Compare commits

..
Author SHA1 Message Date
arcbtc 86a1313258 make cards highlight with neon 2024-12-20 02:02:57 +00:00
arcbtc 0fa8fca70e Merge remote-tracking branch 'origin/backgroundimage' into all_ui_elements 2024-12-20 01:56:18 +00:00
arcbtc b33518f075 Merge remote-tracking branch 'origin/calls_to_action' into all_ui_elements 2024-12-20 01:55:43 +00:00
arcbtc 3dce95968e fix 2024-12-20 01:55:00 +00:00
arcbtc 7c92788cd8 fix 2024-12-20 01:54:06 +00:00
arcbtc 2e31d1fd91 Merge branch 'better_fiat_tracking' into all_ui_elements 2024-12-20 01:50:11 +00:00
arcbtc 6c353f366d Merge branch 'card_flip' into all_ui_elements 2024-12-20 01:43:08 +00:00
Arc 902f3c7c6f format 2024-12-20 01:26:47 +00:00
arcbtc 7fbffa20d7 added to card, bg idea was terrible 2024-12-20 01:25:53 +00:00
Arc 0185f1e465 bundle/format 2024-12-19 23:52:27 +00:00
arcbtc 42b3616b03 Cards working nicely on mobile 2024-12-19 23:43:39 +00:00
arcbtc a446cd6c5b different way to do wallet cards 2024-12-19 22:47:37 +00:00
arcbtc 3c0c1ca930 small bug 2024-12-19 20:25:59 +00:00
arcbtc 1a4c706ed4 fixed merge bug 2024-12-19 20:10:15 +00:00
arcbtc 8969c0235d Merge remote-tracking branch 'origin/dev' into better_fiat_tracking 2024-12-19 20:08:37 +00:00
Arc 34ee5b273a format 2024-12-19 19:30:30 +00:00
arcbtc c7c1af9465 working 2024-12-19 19:29:32 +00:00
ArcandGitHub f7ed194e02 Merge branch 'dev' into backgroundimage 2024-12-19 16:24:44 +00:00
ArcandGitHub fd4b6af1de Merge branch 'dev' into calls_to_action 2024-12-19 16:24:29 +00:00
Arc 342bab5714 format 2024-12-19 16:06:28 +00:00
arcbtc 3d9a882066 More calls to actions 2024-12-19 16:05:37 +00:00
Arc 85b2f30a1a format 2024-12-19 13:38:25 +00:00
arcbtc 07eb0ecdde bug 2024-12-19 13:37:11 +00:00
arcbtc ea0e1e032e Merge branch 'better_fiat_tracking' of https://github.com/lnbits/lnbits into better_fiat_tracking 2024-12-19 13:36:30 +00:00
arcbtc b23e8ea781 bug 2024-12-19 13:35:53 +00:00
Arc fa61016ed1 bundle 2024-12-19 13:31:06 +00:00
arcbtc 6f44b755e4 default in create_wallet 2024-12-19 13:22:06 +00:00
Arc 5997fd74fa format and bundle 2024-12-18 23:28:42 +00:00
arcbtc fb5659de2a working, also fixed the button 2024-12-18 23:19:03 +00:00
arcbtc e13fd39e75 layout bug 2024-12-18 22:15:17 +00:00
arcbtc acd3b598b7 bug fix 2024-12-18 21:55:54 +00:00
arcbtc 1a6671b4b3 bug 2024-12-18 21:38:48 +00:00
arcbtc 7fc723c2dd added js 2024-12-18 21:33:02 +00:00
arcbtc 468a786f78 step3 2024-12-18 21:00:41 +00:00
arcbtc f74a7cbe1f step2 2024-12-18 20:56:28 +00:00
arcbtc ce9b63c54e step1 wallet card adjust 2024-12-18 20:53:55 +00:00
arcbtc 3dd1c6992b Trigger to default fiat conversion on
Like all the other wallets
2024-12-18 20:40:32 +00:00
Arc 1c49430ece fundle 2024-12-18 14:55:47 +00:00
arcbtc b14b3bbaad trans 2024-12-18 14:45:20 +00:00
arcbtc 020f6b6b1b account.html 2024-12-18 14:37:35 +00:00
arcbtc 0314453100 account.js 2024-12-18 14:35:40 +00:00
arcbtc 37dca56ccc base.js 2024-12-18 14:34:14 +00:00
240 changed files with 10821 additions and 28714 deletions
+17 -48
View File
@@ -1,17 +1,5 @@
#For more information on .env files, their content and format: https://pypi.org/project/python-dotenv/
######################################
####### Auth Configurations ##########
######################################
# Secret Key: will default to the hash of the super user.
# !!!!! It is strongly recommended that you set your own strong random value !!!!
AUTH_SECRET_KEY=""
AUTH_TOKEN_EXPIRE_MINUTES=525600
# Possible authorization methods: user-id-only, username-password, nostr-auth-nip98, google-auth, github-auth, keycloak-auth
AUTH_ALLOWED_METHODS="user-id-only, username-password"
# Set this flag if HTTP is used for OAuth
# OAUTHLIB_INSECURE_TRANSPORT="1"
######################################
########### Admin Settings ###########
######################################
@@ -26,7 +14,7 @@ LNBITS_ADMIN_UI=true
# Change theme
LNBITS_SITE_TITLE="LNbits"
LNBITS_SITE_TAGLINE="Open Source Lightning Payments Platform"
LNBITS_SITE_TAGLINE="free and open-source lightning wallet"
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"
@@ -40,7 +28,7 @@ PORT=5000
######################################
# which fundingsources are allowed in the admin ui
# LNBITS_ALLOWED_FUNDING_SOURCES="VoidWallet, FakeWallet, CoreLightningWallet, CoreLightningRestWallet, LndRestWallet, EclairWallet, LndWallet, LnTipsWallet, LNPayWallet, LNbitsWallet, BlinkWallet, AlbyWallet, ZBDWallet, PhoenixdWallet, OpenNodeWallet, NWCWallet, BreezSdkWallet, BoltzWallet, StrikeWallet, CLNRestWallet"
# LNBITS_ALLOWED_FUNDING_SOURCES="VoidWallet, FakeWallet, CoreLightningWallet, CoreLightningRestWallet, LndRestWallet, EclairWallet, LndWallet, LnTipsWallet, LNPayWallet, LNbitsWallet, BlinkWallet, AlbyWallet, ZBDWallet, PhoenixdWallet, OpenNodeWallet, NWCWallet, BreezSdkWallet, BoltzWallet"
LNBITS_BACKEND_WALLET_CLASS=VoidWallet
# VoidWallet is just a fallback that works without any actual Lightning capabilities,
@@ -61,20 +49,6 @@ CLICHE_ENDPOINT=ws://127.0.0.1:12000
SPARK_URL=http://localhost:9737/rpc
SPARK_TOKEN=myaccesstoken
#CLNRest (using runes)
CLNREST_URL=https://127.0.0.1:3010
CLNREST_CA=/home/lightningd/.lightning/bitcoin/ca.pem
CLNREST_CERT=/home/lightningd/.lightning/bitcoin/server.pem
# CLNREST_CA = cat ca.pem | awk '{printf "%s\\n", $0} END {printf "\n"}'
# CLNREST_CERT = cat server.pem | awk '{printf "%s\\n", $0} END {printf "\n"}'
CLNREST_READONLY_RUNE=lightning-cli createrune restrictions='[["method=listfunds", "method=listpays", "method=listinvoices", "method=getinfo", "method=summary", "method=waitanyinvoice"]]' | jq -r .rune
CLNREST_INVOICE_RUNE=lightning-cli createrune restrictions='[["method=invoice"], ["pnameamount_msat<1000001"], ["pnamelabel^LNbits"], ["rate=60"]]' | jq -r .rune
CLNREST_PAY_RUNE=lightning-cli createrune restrictions='[["method=pay"], ["pinvbolt11_amount<1001"], ["pnamelabel^LNbits"], ["rate=1"]]' | jq -r .rune
#CLNREST_RENEPAY_RUNE=lightning-cli createrune restrictions='[["method=renepay"], ["pinvinvstring_amount<1001"], ["pnamelabel^LNbits"], ["rate=1"]]' | jq -r .rune
#CLNREST_LAST_PAY_INDEX='lightning-cli listinvoices | jq -r '.invoices | map(.created_index) | max'
#CLNREST_NODEID=lightning-cli getinfo | jq -r .id # only required for v23.08
# CoreLightningWallet
CORELIGHTNING_RPC="/home/bob/.lightning/bitcoin/lightning-rpc"
@@ -115,16 +89,10 @@ ALBY_ACCESS_TOKEN=ALBY_ACCESS_TOKEN
# BoltzWallet
BOLTZ_CLIENT_ENDPOINT=127.0.0.1:9002
# HEXSTRING instead of path also possible
BOLTZ_CLIENT_MACAROON="/home/bob/.boltz/macaroons/admin.macaroon"
# HEXSTRING instead of path also possible
BOLTZ_CLIENT_CERT="/home/bob/.boltz/tls.cert"
BOLTZ_CLIENT_MACAROON="/home/bob/.boltz/macaroon" # or HEXSTRING
BOLTZ_CLIENT_CERT="/home/bob/.boltz/tls.cert" # or HEXSTRING
BOLTZ_CLIENT_WALLET="lnbits"
# StrikeWallet
STRIKE_API_ENDPOINT=https://api.strike.me/v1
STRIKE_API_KEY=YOUR_STRIKE_API_KEY
# ZBDWallet
ZBD_API_ENDPOINT=https://api.zebedee.io/v0/
ZBD_API_KEY=ZBD_ACCESS_TOKEN
@@ -165,14 +133,17 @@ 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
# BreezLiquidSdkWallet
# get your own api key here https://breez.technology/request-api-key/#contact-us-form-sdk
# or keep the api key empty to use the LNbits key for referrals (API key is not a secret)
# BREEZ_LIQUID_API_KEY=""
BREEZ_LIQUID_SEED="MNEMONIC SEED PHRASE"
# BREEZ_LIQUID_FEE_OFFSET_SAT=50
######################################
####### 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
@@ -189,16 +160,14 @@ GITHUB_CLIENT_SECRET=""
KEYCLOAK_CLIENT_ID=""
KEYCLOAK_CLIENT_SECRET=""
KEYCLOAK_DISCOVERY_URL=""
KEYCLOAK_CLIENT_CUSTOM_ORG=""
KEYCLOAK_CLIENT_CUSTOM_ICON=""
######################################
# uvicorn variable, allow https behind a proxy
# uvicorn variable, uncomment to allow https behind a proxy
# IMPORTANT: this also needs the webserver to be configured to forward the headers
# http://docs.lnbits.org/guide/installation.html#running-behind-an-apache2-reverse-proxy-over-https
FORWARDED_ALLOW_IPS="*"
# FORWARDED_ALLOW_IPS="*"
# Server security, rate limiting ips, blocked ips, allowed ips
LNBITS_RATE_LIMIT_NO="200"
@@ -214,7 +183,7 @@ LNBITS_ADMIN_USERS=""
# SUPER_USER=""
# Extensions only admin can access
LNBITS_ADMIN_EXTENSIONS="ngrok, nostrclient"
LNBITS_ADMIN_EXTENSIONS="ngrok, admin"
# Extensions enabled by default when a user is created
LNBITS_USER_DEFAULT_EXTENSIONS="lnurlp"
+9 -2
View File
@@ -5,6 +5,9 @@ inputs:
description: "Python Version"
required: true
default: "3.10"
poetry-version:
description: "Poetry Version"
default: "1.7.0"
node-version:
description: "Node Version"
default: "20.x"
@@ -24,8 +27,10 @@ runs:
# cache poetry install via pip
cache: "pip"
- name: Set up Poetry
- name: Set up Poetry ${{ inputs.poetry-version }}
uses: abatilo/actions-poetry@v2
with:
poetry-version: ${{ inputs.poetry-version }}
- name: Setup a local virtual environment (if no poetry.toml file)
shell: bash
@@ -43,7 +48,9 @@ runs:
shell: bash
run: |
poetry env use python${{ inputs.python-version }}
poetry install --all-extras
poetry install
# needed for conv tests
poetry add psycopg2-binary
- name: Use Node.js ${{ inputs.node-version }}
if: ${{ (inputs.npm == 'true') }}
-80
View File
@@ -1,80 +0,0 @@
#!/bin/sh
LAUNCH_DIR="$PWD"
# Define persistent storage for extracted LNbits
PERSISTENT_DIR="$HOME/.local/share/lnbits"
# Remove existing LNbits directory before extraction
if [ -d "$PERSISTENT_DIR" ]; then
echo "Removing existing LNbits directory..."
rm -rf "$PERSISTENT_DIR"
fi
# Ensure the persistent directory exists
mkdir -p "$PERSISTENT_DIR"
# Extract LNbits from the AppImage if not already extracted
echo "Extracting LNbits to disk for better performance..."
cp -r "$APPDIR/usr/lnbits"/* "$PERSISTENT_DIR/"
chmod +x "$PERSISTENT_DIR/dist/lnbits"
# Check if the directory exists, and create it if it doesn't
if [ ! -d "$LAUNCH_DIR/lnbits/database" ]; then
mkdir -p "$LAUNCH_DIR/lnbits/database"
echo "Created database directory at $LAUNCH_DIR/lnbits/database"
fi
if [ ! -d "$LAUNCH_DIR/lnbits/extensions" ]; then
mkdir -p "$LAUNCH_DIR/lnbits/extensions"
echo "Created extensions directory at $LAUNCH_DIR/lnbits/extensions"
fi
cd "$PERSISTENT_DIR"
# Export the directory as an environment variable for the app
LNBITS_DATA_FOLDER="${LNBITS_DATA_FOLDER:-$LAUNCH_DIR/lnbits/database}"
LNBITS_EXTENSIONS_PATH="${LNBITS_EXTENSIONS_PATH:-$LAUNCH_DIR/lnbits/extensions}"
export LNBITS_DATA_FOLDER
export LNBITS_EXTENSIONS_PATH
# Define the LNbits URL
URL="http://0.0.0.0:5000"
"./dist/lnbits" "$@" &
LNBITS_PID=$!
# Wait for LNbits to be ready before showing the popup
sleep 3
CLOSED=false
# Function to stop LNbits gracefully
kill_lnbits() {
LN_PIDS=$(lsof -t -i:5000 2>/dev/null) # Capture all PIDs
if [ -n "$LN_PIDS" ]; then
echo "Stopping LNbits (PIDs: $LN_PIDS)..."
kill -2 $LN_PIDS # Send SIGINT to all processes on port 5000
CLOSED=true
fi
}
# Show a GUI with a clickable link to open the browser
if command -v zenity >/dev/null 2>&1; then
while [ "$CLOSED" = false ]; do
zenity --info --title="LNbits" --width=400 --text="<b>LNbits is running.</b>\n\n<a href='$URL'>$URL</a>\n\nClick 'Close Server' to stop LNbits." --ok-label="Close Server"
kill_lnbits
sleep 1
done
elif command -v yad >/dev/null 2>&1; then
while [ "$CLOSED" = false ]; do
yad --title="LNbits" --width=400 --text="<b>LNbits is running.</b>\n\n<a href='$URL'>$URL</a>\n\nClick 'Close Server' to stop LNbits." --button="Close Server":0
kill_lnbits
sleep 1
done
else
echo "No GUI tool found. LNbits is running at $URL"
fi
# Ensure the script doesn't hang after closing
if ps -p $LNBITS_PID >/dev/null 2>&1; then
wait $LNBITS_PID 2>/dev/null || true
fi
@@ -1,6 +0,0 @@
[Desktop Entry]
Name=LNbits
Exec=lnbits
Icon=lnbits
Type=Application
Categories=X-Bitcoin;X-LightningNetwork;X-Finance;Network;
Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

+2
View File
@@ -88,6 +88,8 @@ jobs:
strategy:
matrix:
python-version: ["3.10"]
poetry-version: ["1.5.1"]
uses: ./.github/workflows/jmeter.yml
with:
python-version: ${{ matrix.python-version }}
poetry-version: ${{ matrix.poetry-version }}
-11
View File
@@ -51,14 +51,3 @@ jobs:
platforms: linux/amd64,linux/arm64
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache
- name: Build and push boltz
uses: docker/build-push-action@v5
with:
context: .
file: Dockerfile.boltz
push: true
tags: ${{ secrets.DOCKER_USERNAME }}/lnbits-boltz:${{ inputs.tag }}
platforms: linux/amd64,linux/arm64
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache
+5
View File
@@ -8,6 +8,11 @@ on:
required: true
default: "3.10"
type: string
poetry-version:
description: "Poetry Version"
required: true
default: "1.5.1"
type: string
jobs:
jmeter:
-1
View File
@@ -25,7 +25,6 @@ on:
jobs:
nix:
if: false # temporarly disable nix support until the `poetry2nix` issue is resolved
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
-72
View File
@@ -1,72 +0,0 @@
name: Build LNbits AppImage DMG
on:
release:
types: [published]
jobs:
build-linux-package:
runs-on: ubuntu-latest
steps:
# Step 1: Checkout the repository
- name: Checkout code
uses: actions/checkout@v3
with:
fetch-depth: 0
# Step 2: Install Dependencies
- name: Install Dependencies
run: |
curl -sSL https://install.python-poetry.org | python3 -
echo "$HOME/.local/bin" >> $GITHUB_PATH
sudo apt-get update
sudo apt-get install -y libfuse2
shell: bash
# Step 3: Clone LNbits Repository
- name: Clone LNbits
run: |
mv .github/packaging packaging
mkdir -p packaging/linux/AppDir/usr
git clone https://github.com/lnbits/lnbits.git packaging/linux/AppDir/usr/lnbits
shell: bash
# Step 4: Make the AppImage Asset
- name: Make Asset
run: |
cd packaging/linux/AppDir/usr/lnbits
poetry install
poetry run pip install pyinstaller
# Build the LNbits binary
poetry run pyinstaller --onefile --name lnbits --hidden-import=embit --collect-all embit --collect-all lnbits --collect-all sqlalchemy --collect-all aiosqlite --hidden-import=passlib.handlers.bcrypt $(poetry run which lnbits)
cd ../../../../..
chmod +x packaging/linux/AppDir/AppRun
chmod +x packaging/linux/AppDir/lnbits.desktop
chmod +x packaging/linux/AppDir/usr/lnbits/dist/lnbits
find packaging/linux/AppDir/usr/lnbits -mindepth 1 -maxdepth 1 \
! -name 'dist' \
! -name 'lnbits' \
-exec rm -rf {} +
wget https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage
chmod +x appimagetool-x86_64.AppImage
TAG_NAME=${{ github.event.release.tag_name }}
APPIMAGE_NAME="LNbits-${TAG_NAME}.AppImage"
./appimagetool-x86_64.AppImage --updateinformation "gh-releases-zsync|lnbits|lnbits|latest|*.AppImage.zsync" packaging/linux/AppDir "$APPIMAGE_NAME"
chmod +x "$APPIMAGE_NAME"
echo "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV
shell: bash
# Step 5: Upload Linux Release Asset
- name: Upload Linux Release Asset
uses: actions/upload-release-asset@v1
with:
upload_url: ${{ github.event.release.upload_url }}
asset_path: ${{ env.APPIMAGE_NAME }}
asset_name: ${{ env.APPIMAGE_NAME }}
asset_content_type: application/octet-stream
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-2
View File
@@ -63,8 +63,6 @@ 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
+1 -1
View File
@@ -3,7 +3,7 @@ name: release-rc
on:
push:
tags:
- "*-rc[0-9]+"
- "*-rc[0-9]"
permissions:
contents: write
+1 -4
View File
@@ -7,7 +7,6 @@ __pycache__
.mypy_cache
.vscode
*-lock.json
.python-version
*.egg
*.egg-info
@@ -40,8 +39,6 @@ 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
@@ -54,7 +51,7 @@ lnbits-backup.zip
# Ignore extensions (post installable extension PR)
/lnbits/extensions
/lnbits/upgrades/
/upgrades/
# builded python package
dist
+3 -6
View File
@@ -11,7 +11,6 @@ WORKDIR /app
# Only copy the files required to install the dependencies
COPY pyproject.toml poetry.lock ./
RUN touch README.md
RUN mkdir data
@@ -20,8 +19,7 @@ ENV POETRY_NO_INTERACTION=1 \
POETRY_VIRTUALENVS_CREATE=1 \
POETRY_CACHE_DIR=/tmp/poetry_cache
ARG POETRY_INSTALL_ARGS="--only main"
RUN poetry install --no-root ${POETRY_INSTALL_ARGS}
RUN poetry install --only main
FROM python:3.12-slim-bookworm
@@ -48,12 +46,11 @@ WORKDIR /app
COPY . .
COPY --from=builder /app/.venv .venv
ARG POETRY_INSTALL_ARGS="--only main"
RUN poetry install ${POETRY_INSTALL_ARGS}
RUN poetry install --only main
ENV LNBITS_PORT="5000"
ENV LNBITS_HOST="0.0.0.0"
EXPOSE 5000
CMD ["sh", "-c", "poetry run lnbits --port $LNBITS_PORT --host $LNBITS_HOST --forwarded-allow-ips='*'"]
CMD ["sh", "-c", "poetry run lnbits --port $LNBITS_PORT --host $LNBITS_HOST"]
-30
View File
@@ -1,30 +0,0 @@
FROM boltz/boltz-client:latest AS boltz
FROM lnbits/lnbits:latest
COPY --from=boltz /bin/boltzd /bin/boltzcli /usr/local/bin/
RUN ls -l /usr/local/bin/boltzd
RUN apt-get update && apt-get -y upgrade && \
apt-get install -y netcat-openbsd
# Reinstall dependencies just in case (needed for CMD usage)
ARG POETRY_INSTALL_ARGS="--only main"
RUN poetry install ${POETRY_INSTALL_ARGS}
# LNbits + boltzd configuration
ENV LNBITS_PORT="5000"
ENV LNBITS_HOST="0.0.0.0"
ENV LNBITS_BACKEND_WALLET_CLASS="BoltzWallet"
ENV FUNDING_SOURCE_MAX_RETRIES=10
ENV BOLTZ_CLIENT_ENDPOINT="127.0.0.1:9002"
ENV BOLTZ_CLIENT_MACAROON="/root/.boltz/macaroons/admin.macaroon"
ENV BOLTZ_CLIENT_CERT="/root/.boltz/tls.cert"
ENV BOLTZ_CLIENT_WALLET="lnbits"
EXPOSE 5000
# Entrypoint to start boltzd and LNbits
COPY dockerboltz.sh /dockerboltz.sh
RUN chmod +x /dockerboltz.sh
CMD ["/dockerboltz.sh"]
-26
View File
@@ -1,26 +0,0 @@
#!/bin/bash
set -e
boltzd --standalone --referralId lnbits &
# Capture boltzd PID to monitor if needed
BOLTZ_PID=$!
# Wait for boltzd to start
for i in {1..10}; do
if nc -z localhost 9002; then
echo "boltzd is up!"
break
fi
echo "Waiting for boltzd to start..."
sleep 1
done
# Optional: check if still not up
if ! nc -z localhost 9002; then
echo "boltzd did not start successfully."
exit 1
fi
echo "Starting LNbits on $LNBITS_HOST:$LNBITS_PORT..."
exec poetry run lnbits --port "$LNBITS_PORT" --host "$LNBITS_HOST" --forwarded-allow-ips='*'
-30
View File
@@ -1,30 +0,0 @@
# LNbits Funding Sources Comparison Table
LNbits can use a number of different Lightning Network funding source.
There may be trade-offs between the funding sources used, for example funding LNbits using Strike requires the user to KYC themselves and has some
privacy compromises versus running your own LND node. However the technical barrier to entry of using Strike is lower than using LND.
The table below offers a comparison of the different Lightning Network funding sources that can be used with LNbits.
## LNbits Lightning Network Funding Sources Comparison Table
| **Funding Source** | **Custodial Type** | **KYC Required** | **Technical Knowledge Needed** | **Node Hosting Required** | **Privacy Level** | **Liquidity Management** | **Ease of Setup** | **Maintenance Effort** | **Cost Implications** | **Scalability** | **Notes** |
| -------------------------- | ------------------ | ------------------- | ------------------------------ | ------------------------- | ----------------- | ------------------------ | ----------------- | ---------------------- | -------------------------------------------- | --------------- | ---------------------------------------------------------------- |
| LND (gRPC) | Self-custodial | ❌ | Higher | ✅ | High | Manual | Moderate | High | Infrastructure cost and channel opening fees | High | gRPC interface for LND; suitable for advanced integrations. |
| CoreLightning (CLN) | Self-custodial | ❌ | Higher | ✅ | High | Manual | Moderate | High | Infrastructure cost and channel opening fees | High | Requires setting up and managing your own CLN node. |
| Phoenixd | Self-custodial | ❌ | Medium | ❌ | Medium | Automatic | Moderate | Low | Minimal fees | Medium | Mobile wallet backend; suitable for mobile integrations. |
| Nostr Wallet Connect (NWC) | Custodial | Depends on provider | Low | ❌ | Variable | Provider-managed | Easy | Low | May incur fees | Medium | Connects via Nostr protocol; depends on provider's policies. |
| Boltz | Self-custodial | ❌ | Medium | ❌ | Medium | Provider-managed | Moderate | Moderate | Minimal fees | Medium | Uses submarine swaps; connects to Boltz client. |
| LND (REST) | Self-custodial | ❌ | Higher | ✅ | High | Manual | Moderate | High | Infrastructure cost and channel opening fees | High | REST interface for LND; suitable for web integrations. |
| CoreLightning REST | Self-custodial | ❌ | Higher | ✅ | High | Manual | Moderate | High | Infrastructure cost and channel opening fees | High | REST interface for CLN; suitable for web integrations. |
| LNbits (another instance) | Custodial | Depends on host | Low | ❌ | Variable | Provider-managed | Easy | Low | May incur hosting fees | Medium | Connects to another LNbits instance; depends on host's policies. |
| Alby | Custodial | ✅ | Low | ❌ | Low | Provider-managed | Easy | Low | Transaction fees apply | Medium | Browser extension wallet; suitable for web users. |
| Breez SDK | Self-custodial | ❌ | Medium | ❌ | High | Automatic | Moderate | Low | Minimal fees | Medium | SDK for integrating Breez wallet functionalities. |
| OpenNode | Custodial | ✅ | Low | ❌ | Low | Provider-managed | Easy | Low | Transaction fees apply | Medium | Third-party service; suitable for merchants. |
| Blink | Custodial | ✅ | Low | ❌ | Low | Provider-managed | Easy | Low | Transaction fees apply | Medium | Third-party service; focuses on mobile integrations. |
| ZBD | Custodial | ✅ | Low | ❌ | Low | Provider-managed | Easy | Low | Transaction fees apply | Medium | Gaming-focused payment platform. |
| Spark (CLN) | Self-custodial | ❌ | Higher | ✅ | High | Manual | Moderate | High | Infrastructure cost and channel opening fees | High | Web interface for CLN; requires Spark server setup. |
| Cliche Wallet | Self-custodial | ❌ | Medium | ❌ | Medium | Manual | Moderate | Moderate | Minimal fees | Medium | Lightweight wallet; suitable for embedded systems. |
| Strike | Custodial | ✅ | Low | ❌ | Low | Provider-managed | Easy | Low | Transaction fees apply | Medium | Third-party service; suitable for quick setups. |
| LNPay | Custodial | ✅ | Low | ❌ | Low | Provider-managed | Easy | Low | Transaction fees apply | Medium | Third-party service; suitable for quick setups. |
+18 -68
View File
@@ -6,44 +6,30 @@ 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: AppImage (LInux)
### AppImage (Linux)
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_ADMIN_UI=true HOST=0.0.0.0 PORT=5000 ./LNbits-latest.AppImage # most system settings are now in the admin UI, but pass additional .env variables here
```
LNbits will create a folder for db and extension files in the folder the AppImage runs from.
## Option 2: Poetry (recommended for developers)
## Option 1 (recommended): Poetry
It is recommended to use the latest version of Poetry. Make sure you have Python version `3.12` installed.
### Install Python 3.12
## Option 2 (recommended): Poetry
It is recommended to use the latest version of Poetry. Make sure you have Python version 3.9 or higher installed.
### Verify Python version
```sh
sudo add-apt-repository ppa:deadsnakes/ppa
sudo apt update
sudo apt install python3.12
sudo apt-get install python3.12-dev # ensure correct headers needed for secp256k1
python3 --version
```
### Install Poetry
```sh
# If path 'export PATH="$HOME/.local/bin:$PATH"' fails, use the path echoed by the install
curl -sSL https://install.python-poetry.org | python3 - && export PATH="$HOME/.local/bin:$PATH"
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"
```
### install LNbits
@@ -78,24 +64,11 @@ poetry run lnbits-cli --help
```sh
cd lnbits
# Stop LNbits with `ctrl + x` or with service manager
# sudo systemctl stop lnbits
# Update LNbits
git pull --rebase
# Check your poetry version with
poetry env list
# If version is less 3.12, update it by running
poetry env use python3.12
poetry env remove python3.9
poetry env list
# Run install and start LNbits with
# Stop LNbits with `ctrl + x`
git pull
# Keep your poetry install up to date, this can be done with `poetry self update`
poetry install --only main
poetry run lnbits
# use LNbits admin UI Extensions page function "Update All" do get extensions onto proper level
# Start LNbits with `poetry run lnbits`
```
## Option 2: Install script (on Debian/Ubuntu)
@@ -108,7 +81,7 @@ chmod +x lnbits.sh &&
Now visit `0.0.0.0:5000` to make a super-user account.
`./lnbits.sh` can be used to run, but for more control `cd lnbits` and use `poetry run lnbits` (see previous option).
`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
@@ -150,7 +123,7 @@ SUPER_USER=be54db7f245346c8833eaa430e1e0405 LNBITS_ADMIN_UI=true ./result/bin/ln
## Option 4: Docker
Use latest version from Docker Hub.
use latest version from docker hub
```sh
docker pull lnbits/lnbits
@@ -159,15 +132,7 @@ mkdir data
docker run --detach --publish 5000:5000 --name lnbits --volume ${PWD}/.env:/app/.env --volume ${PWD}/data/:/app/data lnbits/lnbits
```
The LNbits Docker image comes with no extensions installed. User-installed extensions will be stored by default in a container directory.
It is recommended to point the `LNBITS_EXTENSIONS_PATH` environment variable to a directory that is mapped to a Docker volume. This way, the extensions will not be reinstalled when the container is destroyed.
Example:
```sh
docker run ... -e "LNBITS_EXTENSIONS_PATH='/app/data/extensions'" --volume ${PWD}/data/:/app/data ...
```
Build the image yourself.
build the image yourself
```sh
git clone https://github.com/lnbits/lnbits.git
@@ -178,12 +143,6 @@ mkdir data
docker run --detach --publish 5000:5000 --name lnbits --volume ${PWD}/.env:/app/.env --volume ${PWD}/data/:/app/data lnbits/lnbits
```
You can optionally override the arguments that are passed to `poetry install` during the build process by setting the Docker build argument named `POETRY_INSTALL_ARGS`. For example, to enable the Breez funding source, build the Docker image with the command:
```sh
docker build --build-arg POETRY_INSTALL_ARGS="-E breez" -t lnbits/lnbits .
```
## Option 5: Fly.io
Fly.io is a docker container hosting platform that has a generous free tier. You can host LNbits for free on Fly.io for personal use.
@@ -267,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.10-dev gcc build-essential
sudo apt install python3.9-dev gcc build-essential
# if the secp256k1 build fails:
# if you used poetry
@@ -591,12 +550,3 @@ docker run --detach --publish 5000:5000 --name lnbits -e "LNBITS_BACKEND_WALLET_
```
Finally you can access your lnbits on your machine at port 5000.
### FreeBSD notes
Currently there is an issue with secp256k1 0.14.0 on FreeBSD. Thanks to @GitKalle
1. Install package `py311-secp256k1` with `pkg install py311-secp256k1`
2. Change version in `pyproject.toml` from 0.14.0 to 0.13.2
3. Rewrite `poetry.lock` file with command `poetry lock`
4. Follow install instruction with Poetry
+1 -34
View File
@@ -10,25 +10,6 @@ LNbits can run on top of many Lightning Network funding sources with more being
A backend wallet can be configured using the following LNbits environment variables:
You can [compare the LNbits compatible Lightning Network funding sources here](wallets.md).
### CLNRest (using [runes](https://docs.corelightning.org/reference/lightning-createrune))
[Core lightning Rest API docs](https://docs.corelightning.org/docs/rest)
Should also work with the [Rust version of CLNRest](https://github.com/daywalker90/clnrest-rs)
- `LNBITS_BACKEND_WALLET_CLASS`: **CLNRestWallet**
- `CLNREST_URL`: `https://127.0.0.1:3010`
- `CLNREST_CA`: `/home/lightningd/.lightning/bitcoin/ca.pem` (or the content of the `ca.pem` file)
- `CLNREST_CERT`: `/home/lightningd/.lightning/bitcoin/server.pem` (or the content of the `server.pem` file)
- `CLNREST_READONLY_RUNE`: `lightning-cli createrune restrictions='[["method=listfunds", "method=listpays", "method=listinvoices", "method=getinfo", "method=summary", "method=waitanyinvoice"]]' | jq -r .rune`
- `CLNREST_INVOICE_RUNE`: `lightning-cli createrune restrictions='[["method=invoice"], ["pnameamount_msat<1000001"], ["pnamelabel^LNbits"], ["rate=60"]]' | jq -r .rune`
- `CLNREST_PAY_RUNE`: `lightning-cli createrune restrictions='[["method=pay"], ["pinvbolt11_amount<1001"], ["pnamelabel^LNbits"], ["rate=1"]]' | jq -r .rune`
- `CLNREST_RENEPAY_RUNE`: `lightning-cli createrune restrictions='[["method=renepay"], ["pinvinvstring_amount<1001"], ["pnamelabel^LNbits"], ["rate=1"]]' | jq -r .rune`
- `CLNREST_LAST_PAY_INDEX`: `lightning-cli listinvoices | jq -r '.invoices | map(.created_index) | max' `
- `CLNREST_NODEID`: `lightning-cli getinfo | jq -r .id` (only required for v23.08)
### CoreLightning
- `LNBITS_BACKEND_WALLET_CLASS`: **CoreLightningWallet**
@@ -36,8 +17,6 @@ Should also work with the [Rust version of CLNRest](https://github.com/daywalker
### CoreLightning REST
This is the old REST interface that uses [Ride The Lightning/c-lightning-REST](https://github.com/Ride-The-Lightning/c-lightning-REST)
- `LNBITS_BACKEND_WALLET_CLASS`: **CoreLightningRestWallet**
- `CORELIGHTNING_REST_URL`: http://127.0.0.1:8185/
- `CORELIGHTNING_REST_MACAROON`: /file/path/admin.macaroon or Base64/Hex
@@ -72,7 +51,7 @@ You can also use an AES-encrypted macaroon (more info) instead by using
- `LND_GRPC_MACAROON_ENCRYPTED`: eNcRyPtEdMaCaRoOn
To encrypt your macaroon, run `poetry run lnbits-cli encrypt macaroon`.
To encrypt your macaroon, run `poetry run python lnbits/wallets/macaroon/macaroon.py`.
### LNbits
@@ -154,18 +133,6 @@ A Greenlight invite code or Greenlight partner certificate/key can be used to re
- `BREEZ_GREENLIGHT_DEVICE_KEY`: /path/to/breezsdk/device.pem or Base64/Hex
- `BREEZ_GREENLIGHT_DEVICE_CERT`: /path/to/breezsdk/device.crt or Base64/Hex
### Breez Liquid SDK
This funding source leverages the [Breez SDK - Liquid](https://sdk-doc-liquid.breez.technology/) to manage all Lightning payments via submarine swaps on the Liquid network. To get started, simply provide a mnemonic seed phrase. The easiest way to generate one is by using a liquid wallet, such as [Blockstream Green](https://blockstream.com/green/). Once generated, you can copy the seed to your environment variable or enter it in the admin UI.
- `LNBITS_BACKEND_WALLET_CLASS`: **BreezLiquidSdkWallet**
- `BREEZ_LIQUID_SEED`: ...
Each submarine swap incurs service and on-chain fees. To account for these, you may need to increase the reserve fee in the admin UI by navigating to **Settings -> Funding**, or by setting the following environment variables:
- `LNBITS_RESERVE_FEE_MIN`: ...
- `LNBITS_RESERVE_FEE_PERCENT`: ...
### Cliche Wallet
- `CLICHE_ENDPOINT`: ws://127.0.0.1:12000
Generated
+3 -3
View File
@@ -41,11 +41,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1735563628,
"narHash": "sha256-OnSAY7XDSx7CtDoqNh8jwVwh4xNL/2HaJxGjryLWzX8=",
"lastModified": 1723938990,
"narHash": "sha256-9tUadhnZQbWIiYVXH8ncfGXGvkNq3Hag4RCBEMUk7MI=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "b134951a4c9f3c995fd7be05f3243f8ecd65d798",
"rev": "c42fcfbdfeae23e68fc520f9182dde9f38ad1890",
"type": "github"
},
"original": {
-10
View File
@@ -34,16 +34,6 @@
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
];
});
});
};
});
+9 -6
View File
@@ -6,15 +6,15 @@ 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.10 and distutils non-interactively
sudo apt install -y python3.10 python3.10-distutils
# 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.10 -
curl -sSL https://install.python-poetry.org | python3.9 -
# Add Poetry to PATH for the current session
export PATH="/home/$USER/.local/bin:$PATH"
@@ -42,6 +42,9 @@ elif [ ! -d lnbits/wallets ]; then
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
@@ -51,4 +54,4 @@ export LNBITS_ADMIN_UI=true
export HOST=0.0.0.0
# Run LNbits
poetry run lnbits
poetry run lnbits
+8 -5
View File
@@ -9,13 +9,16 @@ from .decorators import (
from .exceptions import InvoiceError, PaymentError
__all__ = [
"InvoiceError",
"PaymentError",
# decorators
"require_admin_key",
"require_invoice_key",
"check_admin",
"check_super_user",
"check_user_exists",
"create_invoice",
# services
"pay_invoice",
"require_admin_key",
"require_invoice_key",
"create_invoice",
# exceptions
"PaymentError",
"InvoiceError",
]
+28 -45
View File
@@ -24,18 +24,14 @@ 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.services.notifications import enqueue_notification
from lnbits.core.services.payments import check_pending_payments
from lnbits.core.tasks import (
from lnbits.core.tasks import ( # watchdog_task
audit_queue,
collect_exchange_rates_data,
killswitch_task,
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
@@ -66,7 +62,12 @@ from .middleware import (
add_ip_block_middleware,
add_ratelimit_middleware,
)
from .tasks import internal_invoice_listener, invoice_listener, run_interval
from .requestvars import g
from .tasks import (
check_pending_payments,
internal_invoice_listener,
invoice_listener,
)
async def startup(app: FastAPI):
@@ -77,7 +78,6 @@ 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
@@ -102,24 +102,9 @@ async def startup(app: FastAPI):
# initialize tasks
register_async_tasks()
enqueue_notification(
NotificationType.server_start_stop,
{
"message": "LNbits server started.",
"up_time": settings.lnbits_server_up_time,
},
)
async def shutdown():
logger.warning("LNbits shutting down...")
enqueue_notification(
NotificationType.server_start_stop,
{
"message": "LNbits server shutting down...",
"up_time": settings.lnbits_server_up_time,
},
)
settings.lnbits_running = False
# shutdown event
@@ -159,13 +144,11 @@ def create_app() -> FastAPI:
core_app_extra.register_new_ratelimiter = register_new_ratelimiter(app)
# register static files
app.mount("/static", StaticFiles(directory=Path("lnbits", "static")), name="static")
Path(settings.lnbits_data_folder, "images").mkdir(parents=True, exist_ok=True)
app.mount(
"/library",
StaticFiles(directory=Path(settings.lnbits_data_folder, "images")),
name="library",
)
static_path = Path("lnbits", "static")
static = StaticFiles(directory=static_path)
app.mount("/static", static, name="static")
g().base_url = f"http://{settings.host}:{settings.port}"
app.add_middleware(
CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]
@@ -274,7 +257,7 @@ async def check_installed_extensions(app: FastAPI):
logger.info(f"{ext.id} ({ext.installed_version})")
async def build_all_installed_extensions_list( # noqa: C901
async def build_all_installed_extensions_list(
include_deactivated: Optional[bool] = True,
) -> list[InstallableExtension]:
"""
@@ -282,14 +265,14 @@ async def build_all_installed_extensions_list( # noqa: C901
MUST be installed by default (see LNBITS_EXTENSIONS_DEFAULT_INSTALL).
"""
installed_extensions = await get_installed_extensions()
settings.lnbits_installed_extensions_ids = {e.id for e in installed_extensions}
settings.lnbits_all_extensions_ids = {e.id for e in installed_extensions}
for ext_dir in Path(settings.lnbits_extensions_path, "extensions").iterdir():
try:
if not ext_dir.is_dir():
continue
ext_id = ext_dir.name
if ext_id in settings.lnbits_installed_extensions_ids:
if ext_id in settings.lnbits_all_extensions_ids:
continue
ext_info = InstallableExtension.from_ext_dir(ext_id)
if not ext_info:
@@ -304,7 +287,7 @@ async def build_all_installed_extensions_list( # noqa: C901
logger.warning(e)
for ext_id in settings.lnbits_extensions_default_install:
if ext_id in settings.lnbits_installed_extensions_ids:
if ext_id in settings.lnbits_all_extensions_ids:
continue
ext_releases = await InstallableExtension.get_extension_releases(ext_id)
@@ -365,11 +348,6 @@ 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")
@@ -388,6 +366,11 @@ 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.
@@ -448,7 +431,7 @@ def register_ext_routes(app: FastAPI, ext: Extension) -> None:
app.include_router(router=ext_route, prefix=prefix)
async def check_and_register_extensions(app: FastAPI) -> None:
async def check_and_register_extensions(app: FastAPI):
await check_installed_extensions(app)
for ext in await get_valid_extensions(False):
try:
@@ -458,12 +441,10 @@ async def check_and_register_extensions(app: FastAPI) -> None:
logger.error(f"Could not load extension `{ext.code}`: {exc!s}")
def register_async_tasks() -> None:
def register_async_tasks():
create_permanent_task(wait_for_audit_data)
create_permanent_task(wait_notification_messages)
create_permanent_task(run_interval(30 * 60, check_pending_payments))
create_permanent_task(check_pending_payments)
create_permanent_task(invoice_listener)
create_permanent_task(internal_invoice_listener)
create_permanent_task(cache.invalidate_forever)
@@ -473,7 +454,9 @@ def register_async_tasks() -> None:
register_invoice_listener(invoice_queue, "core")
create_permanent_task(lambda: wait_for_paid_invoices(invoice_queue))
create_permanent_task(run_by_the_minute_tasks)
# TODO: implement watchdog properly
# create_permanent_task(watchdog_task)
create_permanent_task(killswitch_task)
create_permanent_task(purge_audit_data)
create_permanent_task(collect_exchange_rates_data)
+22 -118
View File
@@ -3,10 +3,8 @@ import importlib
import sys
import time
from functools import wraps
from getpass import getpass
from pathlib import Path
from typing import Optional
from uuid import uuid4
import click
import httpx
@@ -29,21 +27,19 @@ from lnbits.core.crud import (
update_payment,
)
from lnbits.core.helpers import is_valid_url, migrate_databases
from lnbits.core.models import Account, Payment, PaymentState
from lnbits.core.models import Payment, PaymentState
from lnbits.core.models.extensions import (
CreateExtension,
ExtensionRelease,
InstallableExtension,
)
from lnbits.core.services import check_admin_settings, create_user_account_no_ckeck
from lnbits.core.services import check_admin_settings
from lnbits.core.views.extension_api import (
api_install_extension,
api_uninstall_extension,
)
from lnbits.settings import settings
from lnbits.utils.crypto import AESCipher
from lnbits.wallets.base import Wallet
from lnbits.wallets.macaroon import load_macaroon
def coro(f):
@@ -68,13 +64,6 @@ def db():
"""
@lnbits_cli.group()
def users():
"""
Users related commands
"""
@lnbits_cli.group()
def extensions():
"""
@@ -82,20 +71,6 @@ def extensions():
"""
@lnbits_cli.group()
def encrypt():
"""
Encryption commands
"""
@lnbits_cli.group()
def decrypt():
"""
Decryption commands
"""
def get_super_user() -> Optional[str]:
"""Get the superuser"""
superuser_file = Path(settings.lnbits_data_folder, ".super_user")
@@ -134,7 +109,7 @@ async def delete_settings():
"""Deletes the settings"""
async with core_db.connect() as conn:
await conn.execute("DELETE from system_settings")
await conn.execute("DELETE from settings")
@db.command("migrate")
@@ -205,6 +180,17 @@ async def database_revert_payment(checking_id: str):
click.echo(f"Payment '{checking_id}' marked as pending.")
@db.command("cleanup-accounts")
@click.argument("days", type=int, required=False)
@coro
async def database_cleanup_accounts(days: Optional[int] = None):
"""Delete all accounts that have no wallets"""
async with core_db.connect() as conn:
delta = days or settings.cleanup_wallets_days
delta = delta * 24 * 60 * 60
await delete_accounts_no_wallets(delta, conn)
@db.command("check-payments")
@click.option("-d", "--days", help="Maximum age of payments in days.")
@click.option("-l", "--limit", help="Maximum number of payments to be checked.")
@@ -285,32 +271,6 @@ async def check_invalid_payments(
click.echo(" ".join([w, str(data[0]), str(data[1] / 1000).ljust(10)]))
@users.command("new")
@click.option("-u", "--username", required=True, help="Username.")
@click.option("-p", "--password", required=True, help="Password.")
@coro
async def create_user(username: str, password: str):
"""Create a new user bypassing the system 'new_accounts_allowed' rules"""
account = Account(
id=uuid4().hex,
username=username,
)
account.hash_password(password)
user = await create_user_account_no_ckeck(account)
click.echo(f"User '{user.username}' created. Id: '{user.id}'")
@users.command("cleanup-accounts")
@click.argument("days", type=int, required=False)
@coro
async def database_cleanup_accounts(days: Optional[int] = None):
"""Delete all accounts that have no wallets"""
async with core_db.connect() as conn:
delta = days or settings.cleanup_wallets_days
delta = delta * 24 * 60 * 60
await delete_accounts_no_wallets(delta, conn)
@extensions.command("list")
@coro
async def extensions_list():
@@ -320,8 +280,9 @@ async def extensions_list():
from lnbits.app import build_all_installed_extensions_list
for ext in await build_all_installed_extensions_list():
if not ext.meta or not ext.meta.installed_release:
raise ValueError(f"Extension {ext.id} has no installed_release")
assert (
ext.meta and ext.meta.installed_release
), f"Extension {ext.id} has no installed_release"
click.echo(f" - {ext.id} ({ext.meta.installed_release.version})")
@@ -352,7 +313,7 @@ async def extensions_list():
help="Admin user ID (must have permissions to install extensions).",
)
@coro
async def extensions_update( # noqa: C901
async def extensions_update(
extension: Optional[str] = None,
all_extensions: Optional[bool] = False,
repo_index: Optional[str] = None,
@@ -378,7 +339,7 @@ async def extensions_update( # noqa: C901
if not await _can_run_operation(url):
return
upgrades_dir = settings.lnbits_extensions_upgrade_path
upgrades_dir = Path(settings.lnbits_extensions_path, "upgrades")
Path(upgrades_dir).mkdir(parents=True, exist_ok=True)
sys.path.append(str(upgrades_dir))
@@ -495,62 +456,6 @@ async def extensions_uninstall(
return False, str(ex)
@encrypt.command("macaroon")
def encrypt_macaroon():
"""Encrypts a macaroon (LND wallets)"""
_macaroon = getpass("Enter macaroon: ")
try:
macaroon = load_macaroon(_macaroon)
except Exception as ex:
click.echo(f"Error loading macaroon: {ex}")
return
key = getpass("Enter encryption key: ")
aes = AESCipher(key.encode())
try:
encrypted_macaroon = aes.encrypt(bytes.fromhex(macaroon))
except Exception as ex:
click.echo(f"Error encrypting macaroon: {ex}")
return
click.echo("Encrypted macaroon: ")
click.echo(encrypted_macaroon)
@encrypt.command("aes")
@click.option("-p", "--payload", required=True, help="Payload to encrypt.")
@click.option(
"-u", "--urlsafe", is_flag=True, required=False, help="Urlsafe b64encode."
)
def encrypt_aes(payload: str, urlsafe: bool = False):
"""AES encrypts a payload"""
key = getpass("Enter encryption key: ")
aes = AESCipher(key.encode())
try:
encrypted = aes.encrypt(payload.encode(), urlsafe=urlsafe)
except Exception as ex:
click.echo(f"Error encrypting payload: {ex}")
return
click.echo("Encrypted payload: ")
click.echo(encrypted)
@decrypt.command("aes")
@click.option("-p", "--payload", required=True, help="Payload to decrypt.")
@click.option(
"-u", "--urlsafe", is_flag=True, required=False, help="Urlsafe b64decode."
)
def decrypt_aes(payload: str, urlsafe: bool = False):
"""AES decrypts a payload"""
key = getpass("Enter encryption key: ")
aes = AESCipher(key.encode())
try:
decrypted = aes.decrypt(payload, urlsafe=urlsafe)
except Exception as ex:
click.echo(f"Error decrypting payload: {ex}")
return
click.echo("Decrypted payload: ")
click.echo(decrypted)
def main():
"""main function"""
lnbits_cli()
@@ -607,10 +512,9 @@ async def update_extension(
click.echo(f"Current '{extension}' version: {installed_ext.installed_version}.")
if not installed_ext.meta or not installed_ext.meta.installed_release:
raise ValueError(
"Cannot find previously installed release. Please uninstall first."
)
assert (
installed_ext.meta and installed_ext.meta.installed_release
), "Cannot find previously installed release. Please uninstall first."
release = await _select_release(extension, repo_index, source_repo)
if not release:
-4
View File
@@ -5,9 +5,7 @@ from .views.admin_api import admin_router
from .views.api import api_router
from .views.audit_api import audit_router
from .views.auth_api import auth_router
from .views.callback_api import callback_router
from .views.extension_api import extension_router
from .views.fiat_api import fiat_router
# this compat is needed for usermanager extension
from .views.generic import generic_router
@@ -36,12 +34,10 @@ def init_core_routers(app: FastAPI):
app.include_router(wallet_router)
app.include_router(api_router)
app.include_router(websocket_router)
app.include_router(callback_router)
app.include_router(tinyurl_router)
app.include_router(webpush_router)
app.include_router(users_router)
app.include_router(audit_router)
app.include_router(fiat_router)
__all__ = ["core_app", "core_app_extra", "db"]
+62 -57
View File
@@ -43,7 +43,6 @@ from .settings import (
delete_admin_settings,
get_admin_settings,
get_super_settings,
reset_core_settings,
update_admin_settings,
update_super_user,
)
@@ -59,7 +58,6 @@ from .users import (
get_account_by_username_or_email,
get_accounts,
get_user,
get_user_access_control_lists,
get_user_from_account,
update_account,
)
@@ -85,78 +83,85 @@ from .webpush import (
)
__all__ = [
"DateTrunc",
"check_internal",
"create_account",
"create_admin_settings",
# audit
"create_audit_entry",
"create_installed_extension",
"create_payment",
"create_tinyurl",
"create_user_extension",
"create_wallet",
"create_webpush_subscription",
"delete_account",
"delete_accounts_no_wallets",
"delete_admin_settings",
"delete_dbversion",
"delete_expired_invoices",
"delete_installed_extension",
"delete_tinyurl",
"delete_unused_wallets",
"delete_wallet",
"delete_wallet_by_id",
"delete_wallet_payment",
"delete_webpush_subscription",
"delete_webpush_subscriptions",
"drop_extension_db",
"force_delete_wallet",
"get_account",
"get_account_by_email",
"get_account_by_pubkey",
"get_account_by_username",
"get_account_by_username_or_email",
"get_accounts",
"get_admin_settings",
# db_versions
"get_db_version",
"get_db_versions",
"update_migration_version",
"delete_dbversion",
# extensions
"create_installed_extension",
"create_user_extension",
"delete_installed_extension",
"drop_extension_db",
"get_installed_extension",
"get_installed_extensions",
"get_user_active_extensions_ids",
"get_user_extension",
"update_installed_extension",
"update_installed_extension_state",
"update_user_extension",
"get_user_extensions",
# payments
"DateTrunc",
"check_internal",
"create_payment",
"delete_expired_invoices",
"delete_wallet_payment",
"get_latest_payments_by_extension",
"get_payment",
"get_payments",
"get_payments_history",
"get_payments_paginated",
"get_standalone_payment",
"get_super_settings",
"get_tinyurl",
"get_tinyurl_by_url",
"get_total_balance",
"get_user",
"get_user_access_control_lists",
"get_user_active_extensions_ids",
"get_user_extension",
"get_user_extensions",
"get_user_from_account",
"get_wallet",
"get_wallet_for_key",
"get_wallet_payment",
"get_wallets",
"get_webpush_subscription",
"get_webpush_subscriptions_for_user",
"is_internal_status_success",
"mark_webhook_sent",
"remove_deleted_wallets",
"reset_core_settings",
"update_account",
"update_admin_settings",
"update_installed_extension",
"update_installed_extension_state",
"update_migration_version",
"update_payment",
"update_payment_checking_id",
"update_payment_extra",
# settings
"create_admin_settings",
"delete_admin_settings",
"get_admin_settings",
"get_super_settings",
"update_admin_settings",
"update_super_user",
"update_user_extension",
# tinyurl
"create_tinyurl",
"delete_tinyurl",
"get_tinyurl",
"get_tinyurl_by_url",
# users
"create_account",
"delete_account",
"delete_accounts_no_wallets",
"get_account",
"get_account_by_email",
"get_account_by_pubkey",
"get_account_by_username",
"get_account_by_username_or_email",
"get_accounts",
"get_user",
"get_user_from_account",
"update_account",
# wallets
"create_wallet",
"delete_unused_wallets",
"delete_wallet",
"delete_wallet_by_id",
"force_delete_wallet",
"get_total_balance",
"get_wallet",
"get_wallet_for_key",
"get_wallets",
"remove_deleted_wallets",
"update_wallet",
# webpush
"create_webpush_subscription",
"delete_webpush_subscription",
"delete_webpush_subscriptions",
"get_webpush_subscription",
"get_webpush_subscriptions_for_user",
]
+3 -9
View File
@@ -30,11 +30,10 @@ async def delete_expired_audit_entries(
conn: Optional[Connection] = None,
):
await (conn or db).execute(
# Timestamp placeholder is safe from SQL injection (not user input)
f"""
DELETE from audit
WHERE delete_at < {db.timestamp_now}
""", # noqa: S608
""",
)
@@ -49,16 +48,13 @@ async def get_count_stats(
filters = Filters()
clause = filters.where()
data = await (conn or db).fetchall(
# SQL injection vectors safety:
# - `field` is a static string, not user input
# - `clause` is generated from filters, which are validated and sanitized
query=f"""
SELECT {field} as field, count({field}) as total
FROM audit
{clause}
GROUP BY {field}
ORDER BY {field}
""", # noqa: S608
""",
values=filters.values(),
model=AuditCountStat,
)
@@ -74,15 +70,13 @@ async def get_long_duration_stats(
filters = Filters()
clause = filters.where()
long_duration_paths = await (conn or db).fetchall(
# This query is safe from SQL injection
# The `clause` is constructed from sanitized inputs
query=f"""
SELECT path as field, max(duration) as total FROM audit
{clause}
GROUP BY path
ORDER BY total DESC
LIMIT 5
""", # noqa: S608
""",
values=filters.values(),
model=AuditCountStat,
)
+3 -7
View File
@@ -50,8 +50,7 @@ async def drop_extension_db(ext_id: str, conn: Optional[Connection] = None) -> N
{"id": ext_id},
)
# Check that 'ext_id' is a valid extension id and not a malicious string
if not row:
raise Exception(f"Extension '{ext_id}' db version cannot be found")
assert row, f"Extension '{ext_id}' db version cannot be found"
is_file_based_db = await Database.clean_ext_db_files(ext_id)
if is_file_based_db:
@@ -79,13 +78,10 @@ async def get_installed_extensions(
active: Optional[bool] = None,
conn: Optional[Connection] = None,
) -> list[InstallableExtension]:
query = "SELECT * FROM installed_extensions"
if active is not None:
query += " WHERE active = :active"
where = "WHERE active = :active" if active is not None else ""
values = {"active": active} if active is not None else {}
all_extensions = await (conn or db).fetchall(
query,
f"SELECT * FROM installed_extensions {where}",
values,
model=InstallableExtension,
)
+42 -221
View File
@@ -1,31 +1,28 @@
from time import time
from typing import Any, Optional
from typing import Literal, Optional
from lnbits.core.crud.wallets import get_total_balance, get_wallet, get_wallets_ids
from lnbits.core.crud.wallets import get_total_balance, get_wallet
from lnbits.core.db import db
from lnbits.core.models import PaymentState
from lnbits.db import Connection, DateTrunc, Filters, Page
from lnbits.db import DB_TYPE, SQLITE, Connection, 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",
}
async def update_payment_extra(
payment: Payment, conn: Optional[Connection] = None
) -> Payment:
return await (conn or db).execute(
"UPDATE apipayments SET extra = :extra WHERE checking_id = :checking_id",
{"extra": payment.extra, "checking_id": payment.checking_id},
)
def update_payment_extra():
pass
async def get_payment(checking_id: str, conn: Optional[Connection] = None) -> Payment:
@@ -55,13 +52,11 @@ async def get_standalone_payment(
clause = f"({clause}) AND wallet_id = :wallet_id"
row = await (conn or db).fetchone(
# This query is safe from SQL injection
# The `clause` is constructed from sanitized inputs
f"""
SELECT * FROM apipayments
WHERE {clause}
ORDER BY amount LIMIT 1
""", # noqa: S608
""",
values,
Payment,
)
@@ -87,31 +82,23 @@ async def get_latest_payments_by_extension(
ext_name: str, ext_id: str, limit: int = 5
) -> list[Payment]:
return await db.fetchall(
# This query is safe from SQL injection
# The limtit is an integer and not user input
f"""
SELECT * FROM apipayments
WHERE status = :status
WHERE status = '{PaymentState.SUCCESS}'
AND extra LIKE :ext_name
AND extra LIKE :ext_id
ORDER BY time DESC LIMIT {int(limit)}
""", # noqa: S608
{
"status": f"{PaymentState.SUCCESS}",
"ext_name": f"%{ext_name}%",
"ext_id": f"%{ext_id}%",
},
ORDER BY time DESC LIMIT {limit}
""",
{"ext_name": f"%{ext_name}%", "ext_id": f"%{ext_id}%"},
Payment,
)
async def get_payments_paginated( # noqa: C901
async def get_payments_paginated(
*,
wallet_id: Optional[str] = None,
user_id: Optional[str] = None,
complete: bool = False,
pending: bool = False,
failed: bool = False,
outgoing: bool = False,
incoming: bool = False,
since: Optional[int] = None,
@@ -120,11 +107,11 @@ async def get_payments_paginated( # noqa: C901
conn: Optional[Connection] = None,
) -> Page[Payment]:
"""
Filters payments to be returned by:
- complete | pending | failed | outgoing | incoming.
Filters payments to be returned by complete | pending | outgoing | incoming.
"""
values: dict[str, Any] = {
values: dict = {
"wallet_id": wallet_id,
"time": since,
}
clause: list[str] = []
@@ -133,11 +120,7 @@ async def get_payments_paginated( # noqa: C901
clause.append(f"time > {db.timestamp_placeholder('time')}")
if wallet_id:
values["wallet_id"] = wallet_id
clause.append("wallet_id = :wallet_id")
elif user_id:
only_user_wallets = await _only_user_wallets_statement(user_id, conn=conn)
clause.append(only_user_wallets)
if complete and pending:
clause.append(
@@ -154,8 +137,6 @@ async def get_payments_paginated( # noqa: C901
)
elif pending:
clause.append(f"status = '{PaymentState.PENDING}'")
elif failed:
clause.append(f"status = '{PaymentState.FAILED}'")
if outgoing and incoming:
pass
@@ -219,44 +200,27 @@ 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:
# first we delete all invoices older than one month
await (conn or db).execute(
# Timestamp placeholder is safe from SQL injection (not user input)
f"""
DELETE FROM apipayments
WHERE status = :status AND amount > 0
WHERE status = '{PaymentState.PENDING}' AND amount > 0
AND time < {db.timestamp_placeholder("delta")}
""", # noqa: S608
{"status": f"{PaymentState.PENDING}", "delta": int(time() - 2592000)},
""",
{"delta": int(time() - 2592000)},
)
# then we delete all invoices whose expiry date is in the past
await (conn or db).execute(
# Timestamp placeholder is safe from SQL injection (not user input)
f"""
DELETE FROM apipayments
WHERE status = :status AND amount > 0
WHERE status = '{PaymentState.PENDING}' AND amount > 0
AND expiry < {db.timestamp_placeholder("now")}
""", # noqa: S608
{"status": f"{PaymentState.PENDING}", "now": int(time())},
""",
{"now": int(time())},
)
@@ -269,9 +233,7 @@ async def create_payment(
# we don't allow the creation of the same invoice twice
# note: this can be removed if the db uniqueness constraints are set appropriately
previous_payment = await get_standalone_payment(checking_id, conn=conn)
if previous_payment is not None:
raise ValueError("Payment already exists")
extra = data.extra or {}
assert previous_payment is None, "Payment already exists"
payment = Payment(
checking_id=checking_id,
@@ -284,9 +246,8 @@ async def create_payment(
preimage=data.preimage,
expiry=data.expiry,
webhook=data.webhook,
fee=-abs(data.fee),
tag=extra.get("tag", None),
extra=extra,
fee=data.fee,
extra=data.extra or {},
)
await (conn or db).insert("apipayments", payment)
@@ -323,7 +284,12 @@ async def get_payments_history(
if not filters:
filters = Filters()
date_trunc = db.datetime_grouping(group)
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}")
values = {
"wallet_id": wallet_id,
@@ -337,20 +303,16 @@ async def get_payments_history(
)
"""
]
clause = filters.where(where)
transactions: list[dict] = await db.fetchall(
# This query is safe from SQL injection:
# - `date_trunc` is a static string, not user input
# - `clause` is generated from filters, which are validated and sanitized
f"""
SELECT {date_trunc} date,
SUM(CASE WHEN amount > 0 THEN amount ELSE 0 END) income,
SUM(CASE WHEN amount < 0 THEN abs(amount) + abs(fee) ELSE 0 END) spending
FROM apipayments
{clause}
{filters.where(where)}
GROUP BY date
ORDER BY date DESC
""", # noqa: S608
""",
filters.values(values),
)
if wallet_id:
@@ -379,136 +341,6 @@ async def get_payments_history(
return results
async def get_payment_count_stats(
field: PaymentCountField,
filters: Optional[Filters[PaymentFilters]] = None,
user_id: Optional[str] = None,
conn: Optional[Connection] = None,
) -> list[PaymentCountStat]:
if not filters:
filters = Filters()
extra_stmts = []
if user_id:
only_user_wallets = await _only_user_wallets_statement(user_id, conn=conn)
extra_stmts.append(only_user_wallets)
clause = filters.where(extra_stmts)
data = await (conn or db).fetchall(
# SQL injection vectors safety:
# - `field` is a static string, not user input
# - `clause` is generated from filters, which are validated and sanitized
query=f"""
SELECT {field} as field, count(*) as total
FROM apipayments
{clause}
GROUP BY {field}
ORDER BY {field}
""", # noqa: S608
values=filters.values(),
model=PaymentCountStat,
)
return data
async def get_daily_stats(
filters: Optional[Filters[PaymentFilters]] = None,
user_id: Optional[str] = None,
conn: Optional[Connection] = None,
) -> tuple[list[PaymentDailyStats], list[PaymentDailyStats]]:
if not filters:
filters = Filters()
in_where_stmts = ["(apipayments.status = 'success' AND apipayments.amount > 0)"]
out_where_stmts = [
"(apipayments.status IN ('success', 'pending') AND apipayments.amount < 0)"
]
if user_id:
only_user_wallets = await _only_user_wallets_statement(user_id, conn=conn)
in_where_stmts.append(only_user_wallets)
out_where_stmts.append(only_user_wallets)
in_clause = filters.where(in_where_stmts)
out_clause = filters.where(out_where_stmts)
date_trunc = db.datetime_grouping("day")
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,
user_id: Optional[str] = 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
)
)
""",
]
if user_id:
only_user_wallets = await _only_user_wallets_statement(user_id, conn=conn)
where_stmts.append(only_user_wallets)
clauses = filters.where(where_stmts)
data = await (conn or db).fetchall(
# This query is safe from SQL injection
# The `clauses` is constructed from sanitized inputs
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
""", # noqa: S608
values=filters.values(),
model=PaymentWalletStats,
)
return data
async def delete_wallet_payment(
checking_id: str, wallet_id: str, conn: Optional[Connection] = None
) -> None:
@@ -526,11 +358,11 @@ async def check_internal(
otherwise None
"""
return await (conn or db).fetchone(
"""
f"""
SELECT * FROM apipayments
WHERE payment_hash = :hash AND status = :status AND amount > 0
WHERE payment_hash = :hash AND status = '{PaymentState.PENDING}' AND amount > 0
""",
{"status": f"{PaymentState.PENDING}", "hash": payment_hash},
{"hash": payment_hash},
Payment,
)
@@ -554,7 +386,7 @@ async def is_internal_status_success(
return payment.status == PaymentState.SUCCESS.value
async def mark_webhook_sent(payment_hash: str, status: str) -> None:
async def mark_webhook_sent(payment_hash: str, status: int) -> None:
await db.execute(
"""
UPDATE apipayments SET webhook_status = :status
@@ -562,14 +394,3 @@ async def mark_webhook_sent(payment_hash: str, status: str) -> None:
""",
{"status": status, "hash": payment_hash},
)
async def _only_user_wallets_statement(
user_id: str, conn: Optional[Connection] = None
) -> str:
wallet_ids = await get_wallets_ids(user_id=user_id, conn=conn) or [
"no-wallets-for-user"
]
# wallet ids are safe to use in sql queries
wallet_ids_str = [f"'{w}'" for w in wallet_ids]
return f""" wallet_id IN ({", ".join(wallet_ids_str)}) """
+3 -21
View File
@@ -56,29 +56,12 @@ async def update_admin_settings(
async def update_super_user(super_user: str) -> SuperSettings:
await set_settings_field("super_user", super_user)
settings = await get_super_settings()
if not settings:
raise ValueError("updated super_user settings could not be retrieved")
assert settings, "updated super_user settings could not be retrieved"
return settings
async def delete_admin_settings(tag: Optional[str] = "core") -> None:
await db.execute(
"DELETE FROM system_settings WHERE tag = :tag",
{"tag": tag},
)
async def reset_core_settings() -> None:
await db.execute(
"""
DELETE FROM system_settings WHERE tag = 'core'
AND id NOT IN (
'super_user',
'lnbits_webpush_pubkey',
'lnbits_webpush_privkey'
)
""",
)
await db.execute("DELETE FROM settings WHERE tag = :tag", {"tag": tag})
async def create_admin_settings(super_user: str, new_settings: dict) -> SuperSettings:
@@ -87,8 +70,7 @@ async def create_admin_settings(super_user: str, new_settings: dict) -> SuperSet
await set_settings_field(key, value)
settings = await get_super_settings()
if not settings:
raise ValueError("created admin settings could not be retrieved")
assert settings, "created admin settings could not be retrieved"
return settings
+9 -40
View File
@@ -6,7 +6,6 @@ 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 (
@@ -68,7 +67,6 @@ async def get_accounts(
accounts.username,
accounts.email,
accounts.pubkey,
accounts.external_id,
SUM(COALESCE((
SELECT balance FROM balances WHERE wallet_id = wallets.id
), 0)) as balance_msat,
@@ -95,8 +93,6 @@ 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},
@@ -110,7 +106,6 @@ async def delete_accounts_no_wallets(
) -> None:
delta = int(time()) - time_delta
await (conn or db).execute(
# Timestamp placeholder is safe from SQL injection (not user input)
f"""
DELETE FROM accounts
WHERE NOT EXISTS (
@@ -119,7 +114,7 @@ async def delete_accounts_no_wallets(
(updated_at is null AND created_at < :delta)
OR updated_at < {db.timestamp_placeholder("delta")}
)
""", # noqa: S608
""",
{"delta": delta},
)
@@ -127,11 +122,9 @@ 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 LOWER(username) = :username",
{"username": username.lower()},
"SELECT * FROM accounts WHERE username = :username",
{"username": username},
Account,
)
@@ -140,8 +133,8 @@ async def get_account_by_pubkey(
pubkey: str, conn: Optional[Connection] = None
) -> Optional[Account]:
return await (conn or db).fetchone(
"SELECT * FROM accounts WHERE LOWER(pubkey) = :pubkey",
{"pubkey": pubkey.lower()},
"SELECT * FROM accounts WHERE pubkey = :pubkey",
{"pubkey": pubkey},
Account,
)
@@ -149,11 +142,9 @@ 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 LOWER(email) = :email",
{"email": email.lower()},
"SELECT * FROM accounts WHERE email = :email",
{"email": email},
Account,
)
@@ -162,11 +153,8 @@ async def get_account_by_username_or_email(
username_or_email: str, conn: Optional[Connection] = None
) -> Optional[Account]:
return await (conn or db).fetchone(
"""
SELECT * FROM accounts
WHERE LOWER(email) = :value or LOWER(username) = :value
""",
{"value": username_or_email.lower()},
"SELECT * FROM accounts WHERE email = :value or username = :value",
{"value": username_or_email},
Account,
)
@@ -188,7 +176,6 @@ async def get_user_from_account(
email=account.email,
username=account.username,
pubkey=account.pubkey,
external_id=account.external_id,
extra=account.extra,
created_at=account.created_at,
updated_at=account.updated_at,
@@ -196,23 +183,5 @@ async def get_user_from_account(
wallets=wallets,
admin=account.is_admin,
super_user=account.is_super_user,
fiat_providers=account.fiat_providers,
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)
+20 -69
View File
@@ -4,8 +4,7 @@ from typing import Optional
from uuid import uuid4
from lnbits.core.db import db
from lnbits.core.models.wallets import WalletsFilters
from lnbits.db import Connection, Filters, Page
from lnbits.db import Connection
from lnbits.settings import settings
from ..models import Wallet
@@ -24,7 +23,7 @@ async def create_wallet(
user=user_id,
adminkey=uuid4().hex,
inkey=uuid4().hex,
currency=settings.lnbits_default_accounting_currency or "USD",
currency="USD",
)
await (conn or db).insert("wallets", wallet)
return wallet
@@ -48,12 +47,11 @@ async def delete_wallet(
) -> None:
now = int(time())
await (conn or db).execute(
# Timestamp placeholder is safe from SQL injection (not user input)
f"""
UPDATE wallets
SET deleted = :deleted, updated_at = {db.timestamp_placeholder('now')}
WHERE id = :wallet AND "user" = :user
""", # noqa: S608
""",
{"wallet": wallet_id, "user": user_id, "deleted": deleted, "now": now},
)
@@ -72,12 +70,11 @@ async def delete_wallet_by_id(
) -> Optional[int]:
now = int(time())
result = await (conn or db).execute(
# Timestamp placeholder is safe from SQL injection (not user input)
f"""
UPDATE wallets
SET deleted = true, updated_at = {db.timestamp_placeholder('now')}
WHERE id = :wallet
""", # noqa: S608
""",
{"wallet": wallet_id, "now": now},
)
return result.rowcount
@@ -109,16 +106,14 @@ async def delete_unused_wallets(
async def get_wallet(
wallet_id: str, deleted: Optional[bool] = None, conn: Optional[Connection] = None
) -> Optional[Wallet]:
query = """
SELECT *, COALESCE((
SELECT balance FROM balances WHERE wallet_id = wallets.id
), 0) AS balance_msat FROM wallets
WHERE id = :wallet
"""
if deleted is not None:
query += " AND deleted = :deleted "
where = "AND deleted = :deleted" if deleted is not None else ""
return await (conn or db).fetchone(
query,
f"""
SELECT *, COALESCE((
SELECT balance FROM balances WHERE wallet_id = wallets.id
), 0) AS balance_msat FROM wallets
WHERE id = :wallet {where}
""",
{"wallet": wallet_id, "deleted": deleted},
Wallet,
)
@@ -127,63 +122,19 @@ async def get_wallet(
async def get_wallets(
user_id: str, deleted: Optional[bool] = None, conn: Optional[Connection] = None
) -> list[Wallet]:
query = """
SELECT *, COALESCE((
SELECT balance FROM balances WHERE wallet_id = wallets.id
), 0) AS balance_msat FROM wallets
WHERE "user" = :user
"""
if deleted is not None:
query += " AND deleted = :deleted "
where = "AND deleted = :deleted" if deleted is not None else ""
return await (conn or db).fetchall(
query,
f"""
SELECT *, COALESCE((
SELECT balance FROM balances WHERE wallet_id = wallets.id
), 0) AS balance_msat FROM wallets
WHERE "user" = :user {where}
""",
{"user": user_id, "deleted": deleted},
Wallet,
)
async def get_wallets_paginated(
user_id: str,
deleted: Optional[bool] = None,
filters: Optional[Filters[WalletsFilters]] = None,
conn: Optional[Connection] = None,
) -> Page[Wallet]:
if deleted is None:
deleted = False
where: list[str] = [""" "user" = :user AND deleted = :deleted """]
return await (conn or db).fetch_page(
"""
SELECT *, COALESCE((
SELECT balance FROM balances WHERE wallet_id = wallets.id
), 0) AS balance_msat FROM wallets
""",
where=where,
values={"user": user_id, "deleted": deleted},
filters=filters,
model=Wallet,
)
async def get_wallets_ids(
user_id: str, deleted: Optional[bool] = None, conn: Optional[Connection] = None
) -> list[str]:
query = """SELECT id FROM wallets WHERE "user" = :user"""
if deleted is not None:
query += "AND deleted = :deleted"
result: list[dict] = await (conn or db).fetchall(
query,
{"user": user_id, "deleted": deleted},
)
return [row["id"] for row in result]
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,
@@ -202,6 +153,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) as balance FROM balances")
result = await (conn or db).execute("SELECT SUM(balance) FROM balances")
row = result.mappings().first()
return row.get("balance", 0) or 0
return row.get("balance", 0)
+1 -2
View File
@@ -37,8 +37,7 @@ async def create_webpush_subscription(
{"endpoint": endpoint, "user": user, "data": data, "host": host},
)
subscription = await get_webpush_subscription(endpoint, user)
if not subscription:
raise ValueError("Newly created webpush subscription couldn't be retrieved")
assert subscription, "Newly created webpush subscription couldn't be retrieved"
return subscription
+26 -77
View File
@@ -214,7 +214,6 @@ async def m007_set_invoice_expiries(db: Connection):
"""
try:
result = await db.execute(
# Timestamp placeholder is safe from SQL injection (not user input)
f"""
SELECT bolt11, checking_id
FROM apipayments
@@ -223,7 +222,7 @@ async def m007_set_invoice_expiries(db: Connection):
AND bolt11 IS NOT NULL
AND expiry IS NULL
AND time < {db.timestamp_now}
""" # noqa: S608
"""
)
rows = result.mappings().all()
if len(rows):
@@ -243,15 +242,13 @@ async def m007_set_invoice_expiries(db: Connection):
f" {invoice.payment_hash} to {expiration_date}"
)
await db.execute(
# Timestamp placeholder is safe from SQL injection (not user input)
f"""
UPDATE apipayments SET expiry = {db.timestamp_placeholder('expiry')}
WHERE checking_id = :checking_id AND amount > 0
""", # noqa: S608
""",
{"expiry": expiration_date, "checking_id": checking_id},
)
except Exception as exc:
logger.debug(exc)
except Exception:
continue
except OperationalError:
# this is necessary now because it may be the case that this migration will
@@ -374,8 +371,7 @@ async def m014_set_deleted_wallets(db: Connection):
"wallet": row.get("id"),
},
)
except Exception as exc:
logger.debug(exc)
except Exception:
continue
except OperationalError:
# this is necessary now because it may be the case that this migration will
@@ -458,19 +454,17 @@ async def m017_add_timestamp_columns_to_accounts_and_wallets(db: Connection):
# set all to now where they are null
now = int(time())
await db.execute(
# Timestamp placeholder is safe from SQL injection (not user input)
f"""
UPDATE wallets SET created_at = {db.timestamp_placeholder('now')}
WHERE created_at IS NULL
""", # noqa: S608
""",
{"now": now},
)
await db.execute(
# Timestamp placeholder is safe from SQL injection (not user input)
f"""
UPDATE accounts SET created_at = {db.timestamp_placeholder('now')}
WHERE created_at IS NULL
""", # noqa: S608
""",
{"now": now},
)
@@ -608,52 +602,34 @@ async def m026_update_payment_table(db: Connection):
async def m027_update_apipayments_data(db: Connection):
result = None
try:
result = await db.execute("SELECT * FROM apipayments LIMIT 100")
result = await db.execute("SELECT * FROM apipayments")
except Exception as exc:
logger.warning("Could not select, trying again after cache cleared.")
logger.debug(exc)
await db.execute("COMMIT")
offset = 0
limit = 1000
payments: list[dict[Any, Any]] = []
logger.info("Updating payments")
while len(payments) > 0 or offset == 0:
logger.info(f"Updating {offset} to {offset+limit}")
result = await db.execute("SELECT * FROM apipayments")
result = await db.execute(
# Limit and Offset safe from SQL injection
# since they are integers and are not user input
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"""
SELECT * FROM apipayments
ORDER BY time LIMIT {int(limit)} OFFSET {int(offset)}
""" # noqa: S608
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"),
},
)
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(
# Timestamp placeholder is safe from SQL injection (not user input)
f"""
UPDATE apipayments
SET tag = :tag, created_at = {tsph}, updated_at = {tsph}
WHERE checking_id = :checking_id
""", # noqa: S608
{
"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):
@@ -708,30 +684,3 @@ async def m029_create_audit_table(db: Connection):
);
"""
)
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")
async def m032_add_external_id_to_accounts(db: Connection):
"""
Adds external_id column to accounts.
Used for external account linking.
"""
await db.execute("ALTER TABLE accounts ADD COLUMN external_id TEXT")
async def m033_update_payment_table(db: Connection):
await db.execute("ALTER TABLE apipayments ADD COLUMN fiat_provider TEXT")
+27 -31
View File
@@ -14,15 +14,10 @@ from .payments import (
DecodePayment,
PayInvoice,
Payment,
PaymentCountField,
PaymentCountStat,
PaymentDailyStats,
PaymentExtra,
PaymentFilters,
PaymentHistoryPoint,
PaymentsStatusCount,
PaymentState,
PaymentWalletStats,
)
from .tinyurl import TinyURL
from .users import (
@@ -41,61 +36,62 @@ from .users import (
UpdateUserPassword,
UpdateUserPubkey,
User,
UserAcls,
UserExtra,
)
from .wallets import BaseWallet, CreateWallet, KeyType, Wallet, WalletTypeInfo
from .webpush import CreateWebPushSubscription, WebPushSubscription
__all__ = [
"AccessTokenPayload",
"Account",
"AccountFilters",
"AccountOverview",
# audit
"AuditEntry",
"AuditFilters",
# lnurl
"CreateLnurl",
"CreateLnurlAuth",
"PayLnurlWData",
# misc
"BalanceDelta",
"BaseWallet",
"Callback",
"ConversionData",
"CoreAppExtra",
"CreateInvoice",
"CreateLnurl",
"CreateLnurlAuth",
"CreatePayment",
"CreateUser",
"CreateWallet",
"CreateWebPushSubscription",
"DbVersion",
"SimpleStatus",
# payments
"CreateInvoice",
"CreatePayment",
"DecodePayment",
"KeyType",
"LoginUsernamePassword",
"LoginUsr",
"PayInvoice",
"PayLnurlWData",
"Payment",
"PaymentCountField",
"PaymentCountStat",
"PaymentDailyStats",
"PaymentExtra",
"PaymentFilters",
"PaymentHistoryPoint",
"PaymentState",
"PaymentWalletStats",
"PaymentsStatusCount",
"RegisterUser",
"ResetUserPassword",
"SimpleStatus",
# tinyurl
"TinyURL",
# users
"AccessTokenPayload",
"Account",
"AccountFilters",
"AccountOverview",
"CreateUser",
"RegisterUser",
"LoginUsernamePassword",
"LoginUsr",
"ResetUserPassword",
"UpdateBalance",
"UpdateSuperuserPassword",
"UpdateUser",
"UpdateUserPassword",
"UpdateUserPubkey",
"User",
"UserAcls",
"UserExtra",
# wallets
"BaseWallet",
"CreateWallet",
"KeyType",
"Wallet",
"WalletTypeInfo",
# webpush
"CreateWebPushSubscription",
"WebPushSubscription",
]
+16 -15
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from typing import Optional
from pydantic import BaseModel, Field
@@ -9,16 +10,16 @@ from lnbits.settings import settings
class AuditEntry(BaseModel):
component: str | None = None
ip_address: str | None = None
user_id: str | None = None
path: str | None = None
request_type: str | None = None
request_method: str | None = None
request_details: str | None = None
response_code: str | None = None
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
duration: float
delete_at: datetime | None = None
delete_at: Optional[datetime] = None
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
def __init__(self, **data):
@@ -41,12 +42,12 @@ class AuditFilters(FilterModel):
"duration",
]
ip_address: str | None = None
user_id: str | None = None
path: str | None = None
request_method: str | None = None
response_code: str | None = None
component: str | None = None
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
class AuditCountStat(BaseModel):
+82 -70
View File
@@ -7,7 +7,7 @@ import os
import shutil
import zipfile
from pathlib import Path
from typing import Any
from typing import Any, Optional
import httpx
from loguru import logger
@@ -29,17 +29,17 @@ class ExplicitRelease(BaseModel):
archive: str
hash: str
dependencies: list[str] = []
repo: str | None
icon: str | None
short_description: str | None
min_lnbits_version: str | None
max_lnbits_version: str | None
html_url: str | None # todo: release_url
warning: str | None
info_notification: str | None
critical_notification: str | None
details_link: str | None
pay_link: str | None
repo: Optional[str]
icon: Optional[str]
short_description: Optional[str]
min_lnbits_version: Optional[str]
max_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]
def is_version_compatible(self):
return is_lnbits_version_ok(self.min_lnbits_version, self.max_lnbits_version)
@@ -77,9 +77,9 @@ class ExtensionConfig(BaseModel):
name: str
short_description: str
tile: str = ""
warning: str | None = ""
min_lnbits_version: str | None
max_lnbits_version: str | None
warning: Optional[str] = ""
min_lnbits_version: Optional[str]
max_lnbits_version: Optional[str]
def is_version_compatible(self) -> bool:
return is_lnbits_version_ok(self.min_lnbits_version, self.max_lnbits_version)
@@ -87,7 +87,7 @@ class ExtensionConfig(BaseModel):
@classmethod
async def fetch_github_release_config(
cls, org: str, repo: str, tag_name: str
) -> ExtensionConfig | None:
) -> Optional[ExtensionConfig]:
config_url = (
f"https://raw.githubusercontent.com/{org}/{repo}/{tag_name}/config.json"
)
@@ -97,28 +97,28 @@ class ExtensionConfig(BaseModel):
class ReleasePaymentInfo(BaseModel):
amount: int | None = None
pay_link: str | None = None
payment_hash: str | None = None
payment_request: str | None = None
amount: Optional[int] = None
pay_link: Optional[str] = None
payment_hash: Optional[str] = None
payment_request: Optional[str] = None
class PayToEnableInfo(BaseModel):
amount: int = 0
amount: int
required: bool = False
wallet: str | None = None
wallet: Optional[str] = None
class UserExtensionInfo(BaseModel):
paid_to_enable: bool | None = False
payment_hash_to_enable: str | None = None
paid_to_enable: Optional[bool] = False
payment_hash_to_enable: Optional[str] = None
class UserExtension(BaseModel):
user: str
extension: str
active: bool
extra: UserExtensionInfo | None = None
extra: Optional[UserExtensionInfo] = None
@property
def is_paid(self) -> bool:
@@ -140,14 +140,16 @@ class UserExtension(BaseModel):
class Extension(BaseModel):
code: str
is_valid: bool
name: str | None = None
short_description: str | None = None
tile: str | None = None
upgrade_hash: str | None = ""
name: Optional[str] = None
short_description: Optional[str] = None
tile: Optional[str] = None
upgrade_hash: Optional[str] = ""
@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:
@@ -176,21 +178,21 @@ class ExtensionRelease(BaseModel):
archive: str
source_repo: str
is_github_release: bool = False
hash: str | None = None
min_lnbits_version: str | None = None
max_lnbits_version: str | None = None
is_version_compatible: bool | None = True
html_url: str | None = None
description: str | None = None
warning: str | None = None
repo: str | None = None
icon: str | None = None
details_link: str | None = None
hash: Optional[str] = None
min_lnbits_version: Optional[str] = None
max_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
pay_link: str | None = None
cost_sats: int | None = None
paid_sats: int | None = 0
payment_hash: str | None = None
pay_link: Optional[str] = None
cost_sats: Optional[int] = None
paid_sats: Optional[int] = 0
payment_hash: Optional[str] = None
@property
def archive_url(self) -> str:
@@ -208,11 +210,10 @@ class ExtensionRelease(BaseModel):
self.cost_sats = payment_info.amount if payment_info else None
async def fetch_release_payment_info(
self, amount: int | None = None
) -> ReleasePaymentInfo | None:
self, amount: Optional[int] = None
) -> Optional[ReleasePaymentInfo]:
url = f"{self.pay_link}?amount={amount}" if amount else self.pay_link
if not url:
raise ValueError("Missing URL for payment info.")
assert url, "Missing URL for payment info."
try:
async with httpx.AsyncClient() as client:
resp = await client.get(url)
@@ -282,7 +283,7 @@ class ExtensionRelease(BaseModel):
return [GitHubRepoRelease.parse_obj(r) for r in releases]
@classmethod
async def fetch_release_details(cls, details_link: str) -> dict | None:
async def fetch_release_details(cls, details_link: str) -> Optional[dict]:
try:
async with httpx.AsyncClient() as client:
@@ -301,12 +302,12 @@ class ExtensionRelease(BaseModel):
class ExtensionMeta(BaseModel):
installed_release: ExtensionRelease | None = None
latest_release: ExtensionRelease | None = None
pay_to_enable: PayToEnableInfo | None = None
installed_release: Optional[ExtensionRelease] = None
latest_release: Optional[ExtensionRelease] = None
pay_to_enable: Optional[PayToEnableInfo] = None
payments: list[ReleasePaymentInfo] = []
dependencies: list[str] = []
archive: str | None = None
archive: Optional[str] = None
featured: bool = False
@@ -314,11 +315,11 @@ class InstallableExtension(BaseModel):
id: str
name: str
version: str
active: bool | None = False
short_description: str | None = None
icon: str | None = None
active: Optional[bool] = False
short_description: Optional[str] = None
icon: Optional[str] = None
stars: int = 0
meta: ExtensionMeta | None = None
meta: Optional[ExtensionMeta] = None
@property
def hash(self) -> str:
@@ -342,11 +343,15 @@ class InstallableExtension(BaseModel):
@property
def ext_upgrade_dir(self) -> Path:
return Path(settings.lnbits_extensions_upgrade_path, f"{self.id}-{self.hash}")
return Path(
settings.lnbits_extensions_path, "upgrades", f"{self.id}-{self.hash}"
)
@property
def module_name(self) -> str:
if self.ext_upgrade_dir.is_dir():
if settings.has_default_extension_path:
return f"lnbits.upgrades.{self.id}-{self.hash}"
return f"{self.id}-{self.hash}"
if settings.has_default_extension_path:
@@ -377,8 +382,9 @@ class InstallableExtension(BaseModel):
if ext_zip_file.is_file():
os.remove(ext_zip_file)
try:
if not self.meta or not self.meta.installed_release:
raise ValueError("No installed release.")
assert (
self.meta and self.meta.installed_release
), "installed_release is none."
self._restore_payment_info()
@@ -405,7 +411,9 @@ class InstallableExtension(BaseModel):
def extract_archive(self):
logger.info(f"Extracting extension {self.name} ({self.installed_version}).")
Path(settings.lnbits_extensions_upgrade_path).mkdir(parents=True, exist_ok=True)
Path(settings.lnbits_extensions_path, "upgrades").mkdir(
parents=True, exist_ok=True
)
tmp_dir = Path(settings.lnbits_data_folder, "unzip-temp", self.hash)
shutil.rmtree(tmp_dir, True)
@@ -452,7 +460,7 @@ class InstallableExtension(BaseModel):
shutil.rmtree(self.ext_upgrade_dir, True)
def check_latest_version(self, release: ExtensionRelease | None):
def check_latest_version(self, release: Optional[ExtensionRelease]):
if not release:
return
if not self.meta or not self.meta.latest_release:
@@ -465,7 +473,9 @@ class InstallableExtension(BaseModel):
):
self.meta.latest_release = release
def find_existing_payment(self, pay_link: str | None) -> ReleasePaymentInfo | None:
def find_existing_payment(
self, pay_link: Optional[str]
) -> Optional[ReleasePaymentInfo]:
if not pay_link or not self.meta or not self.meta.payments:
return None
return next(
@@ -505,7 +515,7 @@ class InstallableExtension(BaseModel):
@classmethod
async def from_github_release(
cls, github_release: GitHubRelease
) -> InstallableExtension | None:
) -> Optional[InstallableExtension]:
try:
repo, latest_release, config = await cls.fetch_github_repo_info(
github_release.organisation, github_release.repository
@@ -544,7 +554,7 @@ class InstallableExtension(BaseModel):
)
@classmethod
def from_ext_dir(cls, ext_id: str) -> InstallableExtension | None:
def from_ext_dir(cls, ext_id: str) -> Optional[InstallableExtension]:
try:
conf_path = Path(
settings.lnbits_extensions_path, "extensions", ext_id, "config.json"
@@ -584,6 +594,7 @@ class InstallableExtension(BaseModel):
cls,
) -> list[InstallableExtension]:
extension_list: list[InstallableExtension] = []
extension_id_list: list[str] = []
for url in settings.lnbits_extensions_manifests:
try:
@@ -604,6 +615,7 @@ 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)
@@ -619,10 +631,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
@@ -655,7 +667,7 @@ class InstallableExtension(BaseModel):
@classmethod
async def get_extension_release(
cls, ext_id: str, source_repo: str, archive: str, version: str
) -> ExtensionRelease | None:
) -> Optional[ExtensionRelease]:
all_releases: list[ExtensionRelease] = (
await InstallableExtension.get_extension_releases(ext_id)
)
@@ -706,8 +718,8 @@ class CreateExtension(BaseModel):
archive: str
source_repo: str
version: str
cost_sats: int | None = 0
payment_hash: str | None = None
cost_sats: Optional[int] = 0
payment_hash: Optional[str] = None
class ExtensionDetailsRequest(BaseModel):
@@ -716,7 +728,7 @@ class ExtensionDetailsRequest(BaseModel):
version: str
async def github_api_get(url: str, error_msg: str | None) -> Any:
async def github_api_get(url: str, error_msg: Optional[str]) -> Any:
headers = {"User-Agent": settings.user_agent}
if settings.lnbits_ext_github_token:
headers["Authorization"] = f"Bearer {settings.lnbits_ext_github_token}"
@@ -728,7 +740,7 @@ async def github_api_get(url: str, error_msg: str | None) -> Any:
return resp.json()
def icon_to_github_url(source_repo: str, path: str | None) -> str:
def icon_to_github_url(source_repo: str, path: Optional[str]) -> str:
if not path:
return ""
_, _, *rest = path.split("/")
-1
View File
@@ -10,7 +10,6 @@ class CreateLnurl(BaseModel):
comment: Optional[str] = None
description: Optional[str] = None
unit: Optional[str] = None
internal_memo: Optional[str] = None
class CreateLnurlAuth(BaseModel):
+4 -14
View File
@@ -25,12 +25,12 @@ class Callback(BaseModel):
class BalanceDelta(BaseModel):
lnbits_balance_sats: int
node_balance_sats: int
lnbits_balance_msats: int
node_balance_msats: int
@property
def delta_sats(self) -> int:
return int(self.lnbits_balance_sats - self.node_balance_sats)
def delta_msats(self):
return self.node_balance_msats - self.lnbits_balance_msats
class SimpleStatus(BaseModel):
@@ -38,16 +38,6 @@ class SimpleStatus(BaseModel):
message: str
class SimpleItem(BaseModel):
id: str
name: str
class DbVersion(BaseModel):
db: str
version: int
class Image(BaseModel):
filename: str
directory: str = "library"
-65
View File
@@ -1,65 +0,0 @@
from enum import Enum
from pydantic import BaseModel
class NotificationType(Enum):
server_status = "server_status"
settings_update = "settings_update"
balance_update = "balance_update"
watchdog_check = "watchdog_check"
balance_delta = "balance_delta"
server_start_stop = "server_start_stop"
incoming_payment = "incoming_payment"
outgoing_payment = "outgoing_payment"
text_message = "text_message"
class NotificationMessage(BaseModel):
message_type: NotificationType
values: dict
NOTIFICATION_TEMPLATES = {
"text_message": "{message}",
"server_status": """*SERVER STATUS*
*Up time*: `{up_time}`.
*Accounts*: `{accounts_count}`.
*Wallets*: `{wallets_count}`.
*In/Out payments*: `{in_payments_count}`/`{out_payments_count}`.
*Pending payments*: `{pending_payments_count}`.
*Failed payments*: `{failed_payments_count}`.
*LNbits balance*: `{lnbits_balance_sats}` sats.
*Node balance*: `{node_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}).
""",
}
+41 -138
View File
@@ -2,19 +2,12 @@ from __future__ import annotations
from datetime import datetime, timezone
from enum import Enum
from typing import Literal
from typing import Optional
from fastapi import Query
from pydantic import BaseModel, Field, validator
from lnbits.db import FilterModel
from lnbits.fiat import get_fiat_provider
from lnbits.fiat.base import (
FiatPaymentFailedStatus,
FiatPaymentPendingStatus,
FiatPaymentStatus,
FiatPaymentSuccessStatus,
)
from lnbits.utils.exchange_rates import allowed_currencies
from lnbits.wallets import get_funding_source
from lnbits.wallets.base import (
@@ -35,16 +28,16 @@ class PaymentState(str, Enum):
class PaymentExtra(BaseModel):
comment: str | None = None
success_action: str | None = None
lnurl_response: str | None = None
comment: Optional[str] = None
success_action: Optional[str] = None
lnurl_response: Optional[str] = None
class PayInvoice(BaseModel):
payment_request: str
description: str | None = None
max_sat: int | None = None
extra: dict | None = {}
description: Optional[str] = None
max_sat: Optional[int] = None
extra: Optional[dict] = {}
class CreatePayment(BaseModel):
@@ -53,10 +46,10 @@ class CreatePayment(BaseModel):
bolt11: str
amount_msat: int
memo: str
extra: dict | None = {}
preimage: str | None = None
expiry: datetime | None = None
webhook: str | None = None
extra: Optional[dict] = {}
preimage: Optional[str] = None
expiry: Optional[datetime] = None
webhook: Optional[str] = None
fee: int = 0
@@ -67,16 +60,14 @@ class Payment(BaseModel):
amount: int
fee: int
bolt11: str
# payment_request: str | None
fiat_provider: str | None = None
status: str = PaymentState.PENDING
memo: str | None = None
expiry: datetime | None = None
webhook: str | None = None
webhook_status: str | None = None
preimage: str | None = None
tag: str | None = None
extension: str | None = None
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
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))
@@ -116,23 +107,14 @@ class Payment(BaseModel):
@property
def is_internal(self) -> bool:
return self.checking_id.startswith("internal_") or self.checking_id.startswith(
"fiat_"
)
return self.checking_id.startswith("internal_")
async def check_status(
self, skip_internal_payment_notifications: bool | None = False
) -> PaymentStatus:
async def check_status(self) -> PaymentStatus:
if self.is_internal:
if self.success:
return PaymentSuccessStatus()
if self.failed:
return PaymentFailedStatus()
if self.is_in and self.fiat_provider:
fiat_status = await self.check_fiat_status(
skip_internal_payment_notifications
)
return PaymentStatus(paid=fiat_status.paid)
return PaymentPendingStatus()
funding_source = get_funding_source()
if self.is_out:
@@ -141,95 +123,24 @@ class Payment(BaseModel):
status = await funding_source.get_invoice_status(self.checking_id)
return status
async def check_fiat_status(
self, skip_internal_payment_notifications: bool | None = False
) -> FiatPaymentStatus:
if not self.is_internal:
return FiatPaymentPendingStatus()
if self.success:
return FiatPaymentSuccessStatus()
if self.failed:
return FiatPaymentFailedStatus()
if not self.fiat_provider:
return FiatPaymentPendingStatus()
checking_id = self.extra.get("fiat_checking_id")
if not checking_id:
return FiatPaymentPendingStatus()
fiat_provider = await get_fiat_provider(self.fiat_provider)
if not fiat_provider:
return FiatPaymentPendingStatus()
fiat_status = await fiat_provider.get_invoice_status(checking_id)
if skip_internal_payment_notifications:
return fiat_status
if fiat_status.success:
# notify receivers asynchronously
from lnbits.tasks import internal_invoice_queue
await internal_invoice_queue.put(self.checking_id)
return fiat_status
class PaymentFilters(FilterModel):
__search_fields__ = ["memo", "amount", "wallet_id", "tag", "status", "time"]
__search_fields__ = ["memo", "amount"]
__sort_fields__ = ["created_at", "amount", "fee", "memo", "time", "tag"]
status: str | None
tag: str | None
checking_id: str | None
status: str
checking_id: str
amount: int
fee: int
memo: str | None
memo: Optional[str]
time: datetime
preimage: str | None
payment_hash: str | None
wallet_id: str | None
class PaymentDataPoint(BaseModel):
date: datetime
count: int
max_amount: int
min_amount: int
average_amount: int
total_amount: int
max_fee: int
min_fee: int
average_fee: int
total_fee: int
PaymentCountField = Literal["status", "tag", "extension", "wallet_id"]
class PaymentCountStat(BaseModel):
field: str = ""
total: float = 0
class PaymentWalletStats(BaseModel):
wallet_id: str = ""
wallet_name: str = ""
user_id: str = ""
payments_count: int
balance: float = 0
class PaymentDailyStats(BaseModel):
date: datetime
balance: float = 0
balance_in: float | None = 0
balance_out: float | None = 0
payments_count: int = 0
count_in: int | None = 0
count_out: int | None = 0
fee: float = 0
bolt11: str
preimage: str
payment_hash: str
expiry: Optional[datetime]
extra: dict = {}
wallet_id: str
webhook: Optional[str]
webhook_status: Optional[int]
class PaymentHistoryPoint(BaseModel):
@@ -241,7 +152,7 @@ class PaymentHistoryPoint(BaseModel):
class DecodePayment(BaseModel):
data: str
filter_fields: list[str] | None = []
filter_fields: Optional[list[str]] = []
class CreateInvoice(BaseModel):
@@ -249,15 +160,14 @@ class CreateInvoice(BaseModel):
internal: bool = False
out: bool = True
amount: float = Query(None, ge=0)
memo: str | None = Query(None, max_length=640)
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
fiat_provider: str | None = None
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
@validator("unit")
@classmethod
@@ -265,10 +175,3 @@ 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
+2 -1
View File
@@ -1,4 +1,5 @@
"""Keycloak SSO Login Helper"""
"""Keycloak SSO Login Helper
"""
from typing import Optional
+41 -156
View File
@@ -1,122 +1,50 @@
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_external_id,
is_valid_pubkey,
is_valid_username,
)
from lnbits.helpers import is_valid_email_address, is_valid_pubkey, is_valid_username
from lnbits.settings import settings
from .wallets import Wallet
class UserExtra(BaseModel):
email_verified: bool | None = False
first_name: str | None = None
last_name: str | None = None
display_name: str | None = None
picture: str | None = None
email_verified: Optional[bool] = False
first_name: Optional[str] = None
last_name: Optional[str] = None
display_name: Optional[str] = None
picture: Optional[str] = None
# Auth provider, possible values:
# - "env": the user was created automatically by the system
# - "lnbits": the user was created via register form (username/pass or user_id only)
# - "google | github | ...": the user was created using an SSO provider
provider: str | None = "lnbits" # auth provider
# how many wallets are shown in the user interface
visible_wallet_count: int | None = 10
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
provider: Optional[str] = "lnbits" # auth provider
class Account(BaseModel):
id: str
external_id: str | None = None # for external account linking
username: str | None = None
password_hash: str | None = None
pubkey: str | None = None
email: str | None = None
username: Optional[str] = None
password_hash: Optional[str] = None
pubkey: Optional[str] = None
email: Optional[str] = None
extra: UserExtra = UserExtra()
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
is_super_user: bool = Field(default=False, no_database=True)
is_admin: bool = Field(default=False, no_database=True)
fiat_providers: list[str] = Field(default=[], no_database=True)
def __init__(self, **data):
super().__init__(**data)
self.is_super_user = settings.is_super_user(self.id)
self.is_admin = settings.is_admin_user(self.id)
self.fiat_providers = settings.get_fiat_providers_for_user(self.id)
def hash_password(self, password: str) -> str:
"""sets and returns the hashed password"""
@@ -138,32 +66,20 @@ class Account(BaseModel):
raise ValueError("Invalid email.")
if self.pubkey and not is_valid_pubkey(self.pubkey):
raise ValueError("Invalid pubkey.")
if self.external_id and not is_valid_external_id(self.external_id):
raise ValueError(
"Invalid external id. Max length is 256 characters. "
"Space and newlines are not allowed."
)
user_uuid4 = UUID(hex=self.id, version=4)
if user_uuid4.hex != self.id:
raise ValueError("User ID is not valid UUID4 hex string.")
class AccountOverview(Account):
transaction_count: int | None = 0
wallet_count: int | None = 0
balance_msat: int | None = 0
last_payment: datetime | None = None
transaction_count: Optional[int] = 0
wallet_count: Optional[int] = 0
balance_msat: Optional[int] = 0
last_payment: Optional[datetime] = None
class AccountFilters(FilterModel):
__search_fields__ = [
"user",
"email",
"username",
"pubkey",
"external_id",
"wallet_id",
]
__search_fields__ = ["user", "email", "username", "pubkey", "wallet_id"]
__sort_fields__ = [
"balance_msat",
"email",
@@ -173,27 +89,24 @@ class AccountFilters(FilterModel):
"last_payment",
]
email: str | None = None
user: str | None = None
username: str | None = None
pubkey: str | None = None
external_id: str | None = None
wallet_id: str | None = None
email: Optional[str] = None
user: Optional[str] = None
username: Optional[str] = None
pubkey: Optional[str] = None
wallet_id: Optional[str] = None
class User(BaseModel):
id: str
created_at: datetime
updated_at: datetime
email: str | None = None
username: str | None = None
pubkey: str | None = None
external_id: str | None = None # for external account linking
email: Optional[str] = None
username: Optional[str] = None
pubkey: Optional[str] = None
extensions: list[str] = []
wallets: list[Wallet] = []
admin: bool = False
super_user: bool = False
fiat_providers: list[str] = []
has_password: bool = False
extra: UserExtra = UserExtra()
@@ -201,7 +114,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) -> Wallet | None:
def get_wallet(self, wallet_id: str) -> Optional[Wallet]:
w = [wallet for wallet in self.wallets if wallet.id == wallet_id]
return w[0] if w else None
@@ -217,33 +130,33 @@ class User(BaseModel):
class RegisterUser(BaseModel):
email: str | None = Query(default=None)
email: Optional[str] = Query(default=None)
username: str = Query(default=..., min_length=2, max_length=20)
password: str = Query(default=..., min_length=8, max_length=50)
password_repeat: str = Query(default=..., min_length=8, max_length=50)
class CreateUser(BaseModel):
id: str | None = Query(default=None)
email: str | None = Query(default=None)
username: str | None = Query(default=None, min_length=2, max_length=20)
password: str | None = Query(default=None, min_length=8, max_length=50)
password_repeat: str | None = Query(default=None, min_length=8, max_length=50)
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)
pubkey: str = Query(default=None, max_length=64)
external_id: str = Query(default=None, max_length=256)
extensions: list[str] | None = None
extra: UserExtra | None = None
extensions: Optional[list[str]] = None
extra: Optional[UserExtra] = None
class UpdateUser(BaseModel):
user_id: str
username: str | None = Query(default=..., min_length=2, max_length=20)
extra: UserExtra | None = None
email: Optional[str] = Query(default=None)
username: Optional[str] = Query(default=..., min_length=2, max_length=20)
extra: Optional[UserExtra] = None
class UpdateUserPassword(BaseModel):
user_id: str
password_old: str | None = None
password_old: Optional[str] = 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)
@@ -277,39 +190,11 @@ class LoginUsernamePassword(BaseModel):
class AccessTokenPayload(BaseModel):
sub: str
usr: str | None = None
email: str | None = None
auth_time: int | None = 0
api_token_id: str | None = None
usr: Optional[str] = None
email: Optional[str] = None
auth_time: Optional[int] = 0
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
+3 -20
View File
@@ -5,11 +5,11 @@ 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
from lnbits.db import FilterModel
from lnbits.helpers import url_for
from lnbits.lnurl import encode as lnurl_encode
from lnbits.settings import settings
@@ -23,12 +23,6 @@ class BaseWallet(BaseModel):
balance_msat: int
class WalletExtra(BaseModel):
icon: str = "flash_on"
color: str = "primary"
pinned: bool = False
class Wallet(BaseModel):
id: str
user: str
@@ -38,9 +32,8 @@ 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: str | None = None
currency: Optional[str] = None
balance_msat: int = Field(default=0, no_database=True)
extra: WalletExtra = WalletExtra()
@property
def balance(self) -> int:
@@ -68,7 +61,7 @@ class Wallet(BaseModel):
class CreateWallet(BaseModel):
name: str | None = None
name: Optional[str] = None
class KeyType(Enum):
@@ -85,13 +78,3 @@ class KeyType(Enum):
class WalletTypeInfo:
key_type: KeyType
wallet: Wallet
class WalletsFilters(FilterModel):
__search_fields__ = ["id", "name", "currency"]
__sort_fields__ = ["id", "name", "currency", "created_at", "updated_at"]
id: str | None
name: str | None
currency: str | None
+19 -22
View File
@@ -3,21 +3,16 @@ 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,
check_wallet_limits,
create_fiat_invoice,
create_invoice,
create_payment_request,
create_wallet_invoice,
fee_reserve,
fee_reserve_total,
get_payments_daily_stats,
pay_invoice,
send_payment_notification,
service_fee,
update_pending_payment,
update_pending_payments,
update_wallet_balance,
)
@@ -29,40 +24,42 @@ from .users import (
check_admin_settings,
create_user_account,
create_user_account_no_ckeck,
init_admin_settings,
update_user_account,
update_user_extensions,
)
from .websockets import websocket_manager, websocket_updater
__all__ = [
# funding source
"get_balance_delta",
"switch_to_voidwallet",
# lnurl
"redeem_lnurl_withdraw",
"perform_lnurlauth",
# payments
"calculate_fiat_amounts",
"check_admin_settings",
"check_transaction_status",
"check_wallet_limits",
"check_webpush_settings",
"create_fiat_invoice",
"create_invoice",
"create_payment_request",
"create_user_account",
"create_user_account_no_ckeck",
"create_wallet_invoice",
"enqueue_notification",
"fee_reserve",
"fee_reserve_total",
"get_balance_delta",
"get_payments_daily_stats",
"pay_invoice",
"perform_lnurlauth",
"redeem_lnurl_withdraw",
"send_payment_notification",
"service_fee",
"switch_to_voidwallet",
"update_cached_settings",
"update_pending_payment",
"update_pending_payments",
"update_wallet_balance",
# settings
"check_webpush_settings",
"update_cached_settings",
# users
"check_admin_settings",
"create_user_account",
"create_user_account_no_ckeck",
"init_admin_settings",
"update_user_account",
"update_user_extensions",
"update_wallet_balance",
# websockets
"websocket_manager",
"websocket_updater",
]
+7 -7
View File
@@ -26,11 +26,10 @@ async def install_extension(ext_info: InstallableExtension) -> Extension:
ext_info.meta = ext_info.meta or ExtensionMeta()
if (
ext_info.meta.installed_release
and not ext_info.meta.installed_release.is_version_compatible
):
raise ValueError("Incompatible extension version")
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:
@@ -94,8 +93,9 @@ async def stop_extension_background_work(ext_id: str) -> bool:
old_module = importlib.import_module(ext.module_name)
stop_fn_name = f"{ext_id}_stop"
if not hasattr(old_module, stop_fn_name):
raise ValueError(f"No stop function found for '{ext.module_name}'.")
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:
-189
View File
@@ -1,189 +0,0 @@
import hashlib
import hmac
import time
from typing import Optional
from loguru import logger
from lnbits.core.crud import get_wallet
from lnbits.core.crud.payments import create_payment, get_standalone_payment
from lnbits.core.models import CreatePayment, Payment, PaymentState
from lnbits.core.models.misc import SimpleStatus
from lnbits.db import Connection
from lnbits.fiat import get_fiat_provider
from lnbits.settings import settings
async def handle_fiat_payment_confirmation(
payment: Payment, conn: Optional[Connection] = None
):
try:
await _credit_fiat_service_fee_wallet(payment, conn=conn)
except Exception as e:
logger.warning(e)
try:
await _debit_fiat_service_faucet_wallet(payment, conn=conn)
except Exception as e:
logger.warning(e)
async def _credit_fiat_service_fee_wallet(
payment: Payment, conn: Optional[Connection] = None
):
fiat_provider_name = payment.fiat_provider
if not fiat_provider_name:
return
if payment.fee == 0:
return
limits = settings.get_fiat_provider_limits(fiat_provider_name)
if not limits:
return
if not limits.service_fee_wallet_id:
return
memo = (
f"Service fee for fiat payment of "
f"{abs(payment.sat)} sats. "
f"Provider: {fiat_provider_name}. "
f"Wallet: '{payment.wallet_id}'."
)
create_payment_model = CreatePayment(
wallet_id=limits.service_fee_wallet_id,
bolt11=payment.bolt11,
payment_hash=payment.payment_hash,
amount_msat=abs(payment.fee),
memo=memo,
)
await create_payment(
checking_id=f"service_fee_{payment.payment_hash}",
data=create_payment_model,
status=PaymentState.SUCCESS,
conn=conn,
)
async def _debit_fiat_service_faucet_wallet(
payment: Payment, conn: Optional[Connection] = None
):
fiat_provider_name = payment.fiat_provider
if not fiat_provider_name:
return
limits = settings.get_fiat_provider_limits(fiat_provider_name)
if not limits:
return
if not limits.service_faucet_wallet_id:
return
faucet_wallet = await get_wallet(limits.service_faucet_wallet_id, conn=conn)
if not faucet_wallet:
raise ValueError(
f"Fiat provider '{fiat_provider_name}' faucet wallet not found."
)
memo = (
f"Faucet payment of {abs(payment.sat)} sats. "
f"Provider: {fiat_provider_name}. "
f"Wallet: '{payment.wallet_id}'."
)
create_payment_model = CreatePayment(
wallet_id=limits.service_faucet_wallet_id,
bolt11=payment.bolt11,
payment_hash=payment.payment_hash,
amount_msat=-abs(payment.amount),
memo=memo,
extra=payment.extra,
)
await create_payment(
checking_id=f"internal_fiat_{fiat_provider_name}_"
f"faucet_{payment.payment_hash}",
data=create_payment_model,
status=PaymentState.SUCCESS,
conn=conn,
)
async def handle_stripe_event(event: dict):
event_id = event.get("id")
event_object = event.get("data", {}).get("object", {})
object_type = event_object.get("object")
payment_hash = event_object.get("metadata", {}).get("payment_hash")
logger.debug(
f"Handling Stripe event: '{event_id}'. Type: '{object_type}'."
f" Payment hash: '{payment_hash}'."
)
if not payment_hash:
logger.warning("Stripe event does not contain a payment hash.")
return
payment = await get_standalone_payment(payment_hash)
if not payment:
logger.warning(f"No payment found for hash: '{payment_hash}'.")
return
await payment.check_fiat_status()
def check_stripe_signature(
payload: bytes,
sig_header: Optional[str],
secret: Optional[str],
tolerance_seconds=300,
):
if not sig_header:
logger.warning("Stripe-Signature header is missing.")
raise ValueError("Stripe-Signature header is missing.")
if not secret:
logger.warning("Stripe webhook signing secret is not set.")
raise ValueError("Stripe webhook cannot be verified.")
# Split the Stripe-Signature header
items = dict(i.split("=") for i in sig_header.split(","))
timestamp = int(items["t"])
signature = items["v1"]
# Check timestamp tolerance
if abs(time.time() - timestamp) > tolerance_seconds:
logger.warning("Timestamp outside tolerance.")
logger.debug(
f"Current time: {time.time()}, "
f"Timestamp: {timestamp}, "
f"Tolerance: {tolerance_seconds} seconds"
)
raise ValueError("Timestamp outside tolerance." f"Timestamp: {timestamp}")
signed_payload = f"{timestamp}.{payload.decode()}"
# Compute HMAC SHA256 using the webhook secret
computed_signature = hmac.new(
key=secret.encode(), msg=signed_payload.encode(), digestmod=hashlib.sha256
).hexdigest()
# Compare signatures using constant time comparison
if hmac.compare_digest(computed_signature, signature) is not True:
logger.warning("Stripe signature verification failed.")
raise ValueError("Stripe signature verification failed.")
async def test_connection(provider: str) -> SimpleStatus:
"""
Test the connection to Stripe by checking if the API key is valid.
This function should be called when setting up or testing the Stripe integration.
"""
fiat_provider = await get_fiat_provider(provider)
status = await fiat_provider.status()
if status.error_message:
return SimpleStatus(
success=False,
message=f"Cconnection test failed: {status.error_message}",
)
return SimpleStatus(
success=True,
message="Connection test successful." f" Balance: {status.balance}.",
)
+2 -62
View File
@@ -1,7 +1,3 @@
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
@@ -22,62 +18,6 @@ async def get_balance_delta() -> BalanceDelta:
status = await funding_source.status()
lnbits_balance = await get_total_balance()
return BalanceDelta(
lnbits_balance_sats=int(lnbits_balance) // 1000,
node_balance_sats=status.balance_msat // 1000,
lnbits_balance_msats=lnbits_balance,
node_balance_msats=status.balance_msat,
)
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
+6 -10
View File
@@ -13,7 +13,7 @@ from lnbits.decorators import (
WalletTypeInfo,
require_admin_key,
)
from lnbits.helpers import check_callback_url, url_for
from lnbits.helpers import url_for
from lnbits.lnurl import LnurlErrorResponse
from lnbits.lnurl import decode as decode_lnurl
from lnbits.settings import settings
@@ -37,7 +37,6 @@ 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()
@@ -67,16 +66,15 @@ async def redeem_lnurl_withdraw(
external=True,
wal=wallet_id,
)
except Exception as exc:
logger.debug(exc)
except Exception:
pass
headers = {"User-Agent": settings.user_agent}
async with httpx.AsyncClient(headers=headers) as client:
try:
check_callback_url(res["callback"])
await client.get(res["callback"], params=params)
except Exception as exc:
logger.debug(exc)
except Exception:
pass
async def perform_lnurlauth(
@@ -136,9 +134,7 @@ async def perform_lnurlauth(
headers = {"User-Agent": settings.user_agent}
async with httpx.AsyncClient(headers=headers) as client:
if not key.verifying_key:
raise ValueError("LNURLauth verifying_key does not exist")
check_callback_url(callback)
assert key.verifying_key, "LNURLauth verifying_key does not exist"
r = await client.get(
callback,
params={
-72
View File
@@ -1,72 +0,0 @@
import asyncio
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
-332
View File
@@ -1,332 +0,0 @@
import asyncio
import json
import smtplib
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from http import HTTPStatus
from typing import Optional
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, is_valid_email_address
from lnbits.settings import settings
from lnbits.utils.nostr import normalize_private_key
notifications_queue: asyncio.Queue[NotificationMessage] = 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() -> None:
notification_message = 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, subject: str = "LNbits Notification"
) -> dict:
if not settings.lnbits_email_notifications_enabled:
return {"status": "error", "message": "Email notifications are disabled"}
try:
await send_email(
settings.lnbits_email_notifications_server,
settings.lnbits_email_notifications_port,
settings.lnbits_email_notifications_username,
settings.lnbits_email_notifications_password,
settings.lnbits_email_notifications_email,
settings.lnbits_email_notifications_to_emails,
subject,
message,
)
return {"status": "ok"}
except Exception as e:
logger.error(f"Error sending email notification: {e}")
return {"status": "error", "message": str(e)}
async def send_email(
server: str,
port: int,
username: str,
password: str,
from_email: str,
to_emails: list[str],
subject: str,
message: str,
) -> bool:
if not is_valid_email_address(from_email):
raise ValueError(f"Invalid from email address: {from_email}")
if len(to_emails) == 0:
raise ValueError("No email addresses provided")
for email in to_emails:
if not is_valid_email_address(email):
raise ValueError(f"Invalid email address: {email}")
msg = MIMEMultipart()
msg["From"] = from_email
msg["To"] = ", ".join(to_emails)
msg["Subject"] = subject
msg.attach(MIMEText(message, "plain"))
username = username if len(username) > 0 else from_email
with smtplib.SMTP(server, port) as smtp_server:
smtp_server.starttls()
smtp_server.login(username, password)
smtp_server.sendmail(from_email, to_emails, msg.as_string())
return True
def is_message_type_enabled(message_type: NotificationType) -> bool:
if message_type == NotificationType.balance_update:
return settings.lnbits_notification_credit_debit
if message_type == NotificationType.settings_update:
return settings.lnbits_notification_settings_update
if message_type == NotificationType.watchdog_check:
return settings.lnbits_notification_watchdog
if message_type == NotificationType.balance_delta:
return settings.notification_balance_delta_changed
if message_type == NotificationType.server_start_stop:
return settings.lnbits_notification_server_start_stop
if message_type == NotificationType.server_status:
return settings.lnbits_notification_server_status_hours > 0
if message_type == NotificationType.incoming_payment:
return settings.lnbits_notification_incoming_payment_amount_sats > 0
if message_type == NotificationType.outgoing_payment:
return settings.lnbits_notification_outgoing_payment_amount_sats > 0
return False
def _notification_message_to_text(
notification_message: NotificationMessage,
) -> tuple[str, str]:
message_type = notification_message.message_type.value
meesage_value = NOTIFICATION_TEMPLATES.get(message_type, message_type)
try:
text = meesage_value.format(**notification_message.values)
except Exception as e:
logger.warning(f"Error formatting notification message: {e}")
text = meesage_value
text = f"""[{settings.lnbits_site_title}]\n{text}"""
return message_type, text
async def dispatch_webhook(payment: Payment):
"""
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:
try:
check_callback_url(payment.webhook)
r = await client.post(payment.webhook, json=payment.json(), timeout=40)
r.raise_for_status()
await mark_webhook_sent(payment.payment_hash, str(r.status_code))
except httpx.HTTPStatusError as exc:
await mark_webhook_sent(payment.payment_hash, str(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(f"Error sending websocket payment notification {e!s}")
try:
send_chat_payment_notification(wallet, payment)
except Exception as e:
logger.error(f"Error sending chat payment notification {e!s}")
try:
await send_payment_push_notification(wallet, payment)
except Exception as e:
logger.error(f"Error sending push payment notification {e!s}")
try:
if payment.webhook and not payment.webhook_status:
await dispatch_webhook(payment)
except Exception as e:
logger.error(f"Error dispatching webhook: {e!s}")
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, "status": payment.status}),
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}"
)
+113 -441
View File
@@ -1,26 +1,17 @@
import asyncio
import json
import time
from datetime import datetime, timedelta, timezone
from typing import Optional
import httpx
from bolt11 import Bolt11, MilliSatoshi, Tags
from bolt11 import decode as bolt11_decode
from bolt11 import encode as bolt11_encode
from loguru import logger
from lnbits.core.crud.payments import get_daily_stats
from lnbits.core.db import db
from lnbits.core.models import PaymentDailyStats, PaymentFilters
from lnbits.core.models.payments import CreateInvoice
from lnbits.db import Connection, Filters
from lnbits.db import Connection
from lnbits.decorators import check_user_extension_access
from lnbits.exceptions import InvoiceError, PaymentError
from lnbits.fiat import get_fiat_provider
from lnbits.helpers import check_callback_url
from lnbits.settings import settings
from lnbits.tasks import create_task, internal_invoice_queue_put
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
@@ -47,10 +38,7 @@ from ..models import (
PaymentState,
Wallet,
)
from .notifications import send_payment_notification
payment_lock = asyncio.Lock()
wallets_payments_lock: dict[str, asyncio.Lock] = {}
from .websockets import websocket_manager
async def pay_invoice(
@@ -63,17 +51,14 @@ 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)
if not invoice.amount_msat:
raise ValueError("Missig invoice amount.")
assert invoice.amount_msat
async with db.reuse_conn(conn) if conn else db.connect() as new_conn:
async with db.reuse_conn(conn) if conn else db.connect() as conn:
amount_msat = invoice.amount_msat
wallet = await _check_wallet_for_payment(wallet_id, tag, amount_msat, new_conn)
wallet = await _check_wallet_for_payment(wallet_id, tag, amount_msat, conn)
if await is_internal_status_success(invoice.payment_hash, new_conn):
if await is_internal_status_success(invoice.payment_hash, conn):
raise PaymentError("Internal invoice already paid.", status="failed")
_, extra = await calculate_fiat_amounts(amount_msat / 1000, wallet, extra=extra)
@@ -88,144 +73,10 @@ async def pay_invoice(
extra=extra,
)
payment = await _pay_invoice(wallet.id, create_payment_model, conn)
payment = await _pay_invoice(wallet, create_payment_model, conn)
await _credit_service_fee_wallet(payment, conn)
service_fee_memo = f"""
Service fee for payment of {abs(payment.sat)} sats.
Wallet: '{wallet.name}' ({wallet.id})."""
async with db.reuse_conn(conn) if conn else db.connect() as new_conn:
await _credit_service_fee_wallet(payment, service_fee_memo, new_conn)
return payment
async def create_payment_request(
wallet_id: str, invoice_data: CreateInvoice
) -> Payment:
"""
Create a lightning invoice or a fiat payment request.
"""
if invoice_data.fiat_provider:
return await create_fiat_invoice(wallet_id, invoice_data)
return await create_wallet_invoice(wallet_id, invoice_data)
async def create_fiat_invoice(
wallet_id: str, invoice_data: CreateInvoice, conn: Optional[Connection] = None
):
fiat_provider_name = invoice_data.fiat_provider
if not fiat_provider_name:
raise ValueError("Fiat provider is required for fiat invoices.")
if not settings.is_fiat_provider_enabled(fiat_provider_name):
raise ValueError(
f"Fiat provider '{fiat_provider_name}' is not enabled.",
)
if invoice_data.unit == "sat":
raise ValueError("Fiat provider cannot be used with satoshis.")
amount_sat = await fiat_amount_as_satoshis(invoice_data.amount, invoice_data.unit)
await _check_fiat_invoice_limits(amount_sat, fiat_provider_name, conn)
invoice_data.internal = True # use FakeWallet for fiat invoices
if not invoice_data.memo:
invoice_data.memo = settings.lnbits_site_title + f" ({fiat_provider_name})"
internal_payment = await create_wallet_invoice(wallet_id, invoice_data)
fiat_provider = await get_fiat_provider(fiat_provider_name)
fiat_invoice = await fiat_provider.create_invoice(
amount=invoice_data.amount,
payment_hash=internal_payment.payment_hash,
currency=invoice_data.unit,
memo=invoice_data.memo,
)
if fiat_invoice.failed:
logger.warning(fiat_invoice.error_message)
internal_payment.status = PaymentState.FAILED
await update_payment(internal_payment, conn=conn)
raise ValueError(
f"Cannot create payment request for '{fiat_provider_name}'.",
)
internal_payment.fee = -abs(
service_fee_fiat(internal_payment.msat, fiat_provider_name)
)
internal_payment.fiat_provider = fiat_provider_name
internal_payment.extra["fiat_checking_id"] = fiat_invoice.checking_id
# todo: move to payent
internal_payment.extra["fiat_payment_request"] = fiat_invoice.payment_request
new_checking_id = (
f"fiat_{fiat_provider_name}_"
f"{fiat_invoice.checking_id or internal_payment.checking_id}"
)
await update_payment(internal_payment, new_checking_id, conn=conn)
internal_payment.checking_id = new_checking_id
return internal_payment
async def create_wallet_invoice(wallet_id: str, data: CreateInvoice) -> Payment:
description_hash = b""
unhashed_description = b""
memo = data.memo or settings.lnbits_site_title
if data.description_hash or data.unhashed_description:
if data.description_hash:
try:
description_hash = bytes.fromhex(data.description_hash)
except ValueError as exc:
raise ValueError(
"'description_hash' must be a valid hex string"
) from exc
if data.unhashed_description:
try:
unhashed_description = bytes.fromhex(data.unhashed_description)
except ValueError as exc:
raise ValueError(
"'unhashed_description' must be a valid hex string",
) from exc
# do not save memo if description_hash or unhashed_description is set
memo = ""
payment = await create_invoice(
wallet_id=wallet_id,
amount=data.amount,
memo=memo,
currency=data.unit,
description_hash=description_hash,
unhashed_description=unhashed_description,
expiry=data.expiry,
extra=data.extra,
webhook=data.webhook,
internal=data.internal,
)
# lnurl_response is not saved in the database
if data.lnurl_callback:
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},
timeout=10,
)
if r.is_error:
payment.extra["lnurl_response"] = r.text
else:
resp = json.loads(r.text)
if resp["status"] != "OK":
payment.extra["lnurl_response"] = resp["reason"]
else:
payment.extra["lnurl_response"] = True
except (httpx.ConnectError, httpx.RequestError) as ex:
logger.error(ex)
payment.extra["lnurl_response"] = False
return payment
return payment
async def create_invoice(
@@ -249,7 +100,7 @@ async def create_invoice(
if not user_wallet:
raise InvoiceError(f"Could not fetch wallet '{wallet_id}'.", status="failed")
invoice_memo = None if description_hash else memo[:640]
invoice_memo = None if description_hash else memo
# use the fake wallet if the invoice is for internal use only
funding_source = fake_wallet if internal else get_funding_source()
@@ -258,12 +109,6 @@ 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
):
@@ -273,39 +118,38 @@ async def create_invoice(
status="failed",
)
payment_response = await funding_source.create_invoice(
(
ok,
checking_id,
payment_request,
error_message,
) = await funding_source.create_invoice(
amount=amount_sat,
memo=invoice_memo,
description_hash=description_hash,
unhashed_description=unhashed_description,
expiry=expiry or settings.lightning_invoice_expiry,
)
if (
not payment_response.ok
or not payment_response.payment_request
or not payment_response.checking_id
):
if not ok or not payment_request or not checking_id:
raise InvoiceError(
message=payment_response.error_message or "unexpected backend error.",
status="pending",
error_message or "unexpected backend error.", status="pending"
)
invoice = bolt11_decode(payment_response.payment_request)
invoice = bolt11_decode(payment_request)
create_payment_model = CreatePayment(
wallet_id=wallet_id,
bolt11=payment_response.payment_request,
bolt11=payment_request,
payment_hash=invoice.payment_hash,
preimage=payment_response.preimage,
amount_msat=amount_sat * 1000,
expiry=invoice.expiry_date,
memo=memo,
extra=extra,
webhook=webhook,
fee=payment_response.fee_msat or 0,
)
payment = await create_payment(
checking_id=payment_response.checking_id,
checking_id=checking_id,
data=create_payment_model,
conn=conn,
)
@@ -320,48 +164,13 @@ async def update_pending_payments(wallet_id: str):
exclude_uncheckable=True,
)
for payment in pending_payments:
await update_pending_payment(payment)
async def update_pending_payment(payment: Payment) -> Payment:
status = await payment.check_status()
if status.failed:
payment.status = PaymentState.FAILED
await update_payment(payment)
elif status.success:
payment = await update_payment_success_status(payment, status)
return payment
async def check_pending_payments():
"""
check_pending_payments is called during startup to check for pending payments with
the backend and also to delete expired invoices. Incoming payments will be
checked only once, outgoing pending payments will be checked regularly.
"""
funding_source = get_funding_source()
if funding_source.__class__.__name__ == "VoidWallet":
logger.warning("Task: skipping pending check for VoidWallet")
return
start_time = time.time()
pending_payments = await get_payments(
since=(int(time.time()) - 60 * 60 * 24 * 15), # 15 days ago
complete=False,
pending=True,
exclude_uncheckable=True,
)
count = len(pending_payments)
if count > 0:
logger.info(f"Task: checking {count} pending payments of last 15 days...")
for i, payment in enumerate(pending_payments):
payment = await update_pending_payment(payment)
prefix = f"payment ({i+1} / {count})"
logger.debug(f"{prefix} {payment.status} {payment.checking_id}")
await asyncio.sleep(0.01) # to avoid complete blocking
logger.info(
f"Task: pending check finished for {count} payments"
f" (took {time.time() - start_time:0.3f} s)"
)
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)
def fee_reserve_total(amount_msat: int, internal: bool = False) -> int:
@@ -369,7 +178,6 @@ def fee_reserve_total(amount_msat: int, internal: bool = False) -> int:
def fee_reserve(amount_msat: int, internal: bool = False) -> int:
amount_msat = abs(amount_msat)
return settings.fee_reserve(amount_msat, internal)
@@ -389,26 +197,6 @@ def service_fee(amount_msat: int, internal: bool = False) -> int:
return 0
def service_fee_fiat(amount_msat: int, fiat_provider_name: str) -> int:
"""
Calculate the service fee for a fiat provider based on the amount in msat.
Return the fee in msat.
"""
limits = settings.get_fiat_provider_limits(fiat_provider_name)
if not limits:
return 0
amount_msat = abs(amount_msat)
fee_max = limits.service_max_fee_sats * 1000
if not limits.service_fee_wallet_id:
return 0
fee_percentage = int(amount_msat / 100 * limits.service_fee_percent)
if fee_max > 0 and fee_percentage > fee_max:
return fee_max
else:
return fee_percentage
async def update_wallet_balance(
wallet: Wallet,
amount: int,
@@ -467,7 +255,31 @@ async def update_wallet_balance(
)
payment.status = PaymentState.SUCCESS
await update_payment(payment, conn=conn)
await internal_invoice_queue_put(payment.checking_id)
# notify receiver asynchronously
from lnbits.tasks import internal_invoice_queue
await internal_invoice_queue.put(payment.checking_id)
async def 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
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
)
async def check_wallet_limits(
@@ -542,22 +354,17 @@ 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:
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}")
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
logger.debug(
f"Calculated fiat amounts {wallet.id=} {amount=} {currency=}: {fiat_amounts=}"
@@ -581,75 +388,11 @@ async def check_transaction_status(
return await payment.check_status()
async def get_payments_daily_stats(
filters: Filters[PaymentFilters],
user_id: Optional[str] = None,
) -> list[PaymentDailyStats]:
data_in, data_out = await get_daily_stats(filters, user_id=user_id)
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] = []
def _tz(dt: datetime) -> datetime:
return dt.replace(tzinfo=timezone.utc)
start_date = min(_tz(data_in[0].date), _tz(data_out[0].date))
end_date = max(_tz(data_in[-1].date), _tz(data_out[-1].date))
delta = timedelta(days=1)
while start_date <= end_date:
data_in_point = next((x for x in data_in if _tz(x.date) == start_date), _none)
data_out_point = next((x for x in data_out if _tz(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_id: str,
create_payment_model: CreatePayment,
conn: Optional[Connection] = None,
):
async with payment_lock:
if wallet_id not in wallets_payments_lock:
wallets_payments_lock[wallet_id] = asyncio.Lock()
async with wallets_payments_lock[wallet_id]:
# get the wallet again to make sure we have the latest balance
wallet = await get_wallet(wallet_id, conn=conn)
if not wallet:
raise PaymentError(
f"Could not fetch wallet '{wallet_id}'.", status="failed"
)
payment = await _pay_internal_invoice(wallet, create_payment_model, conn)
if not payment:
payment = await _pay_external_invoice(wallet, create_payment_model, conn)
return payment
async def _pay_invoice(wallet, create_payment_model, conn):
payment = await _pay_internal_invoice(wallet, create_payment_model, conn)
if not payment:
payment = await _pay_external_invoice(wallet, create_payment_model, conn)
return payment
async def _pay_internal_invoice(
@@ -684,15 +427,12 @@ async def _pay_internal_invoice(
):
raise PaymentError("Invalid invoice. Bolt11 changed.", status="failed")
fee_reserve_total_msat = fee_reserve_total(amount_msat, internal=True)
fee_reserve_total_msat = fee_reserve_total(abs(amount_msat), internal=True)
create_payment_model.fee = abs(fee_reserve_total_msat)
if wallet.balance_msat < abs(amount_msat) + fee_reserve_total_msat:
raise PaymentError("Insufficient balance.", status="failed")
# release the preimage
create_payment_model.preimage = internal_invoice.preimage
internal_id = f"internal_{create_payment_model.payment_hash}"
logger.debug(f"creating temporary internal payment with id {internal_id}")
payment = await create_payment(
@@ -730,8 +470,6 @@ async def _pay_external_invoice(
fee_reserve_total_msat = fee_reserve_total(amount_msat, internal=False)
if wallet.balance_msat < abs(amount_msat):
raise PaymentError("Insufficient balance.", status="failed")
if wallet.balance_msat < abs(amount_msat) + fee_reserve_total_msat:
raise PaymentError(
f"You must reserve at least ({round(fee_reserve_total_msat/1000)}"
@@ -751,68 +489,51 @@ async def _pay_external_invoice(
)
fee_reserve_msat = fee_reserve(amount_msat, internal=False)
service_fee_msat = service_fee(amount_msat, internal=False)
task = create_task(
_fundingsource_pay_invoice(checking_id, payment.bolt11, fee_reserve_msat)
)
# make sure a hold invoice or deferred payment is not blocking the server
wait_time = max(1, settings.lnbits_funding_source_pay_invoice_wait_seconds)
try:
payment_response = await asyncio.wait_for(task, timeout=wait_time)
except asyncio.TimeoutError:
# return pending payment on timeout
logger.debug(
f"payment timeout after {wait_time}s, {checking_id} is still pending"
)
return payment
# payment failed
if (
payment_response.checking_id is None
or payment_response.ok is False
or payment_response.checking_id != checking_id
):
payment.status = PaymentState.FAILED
await update_payment(payment, conn=conn)
message = payment_response.error_message or "without an error message."
raise PaymentError(f"Payment failed: {message}", status="failed")
if payment_response.success:
payment = await update_payment_success_status(
payment, payment_response, conn=conn
)
await send_payment_notification(wallet, payment)
logger.success(f"payment successful {payment_response.checking_id}")
payment.checking_id = payment_response.checking_id
return payment
async def update_payment_success_status(
payment: Payment,
status: PaymentStatus,
conn: Optional[Connection] = None,
) -> Payment:
if status.success:
service_fee_msat = service_fee(payment.amount, internal=False)
payment.status = PaymentState.SUCCESS
payment.fee = -(abs(status.fee_msat or 0) + abs(service_fee_msat))
payment.preimage = payment.preimage or status.preimage
await update_payment(payment, conn=conn)
return payment
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()
logger.debug(f"fundingsource: sending payment {checking_id}")
payment_response: PaymentResponse = await funding_source.pay_invoice(
bolt11, fee_reserve_msat
create_payment_model.bolt11, fee_reserve_msat
)
logger.debug(f"backend: pay_invoice finished {checking_id}, {payment_response}")
return payment_response
if payment_response.checking_id and payment_response.checking_id != checking_id:
logger.warning(
f"backend sent unexpected checking_id (expected: {checking_id} got:"
f" {payment_response.checking_id})"
)
if payment_response.checking_id and payment_response.ok is not False:
# payment.ok can be True (paid) or None (pending)!
logger.debug(f"updating payment {checking_id}")
payment.status = (
PaymentState.SUCCESS
if payment_response.ok is True
else PaymentState.PENDING
)
payment.fee = -(abs(payment_response.fee_msat or 0) + abs(service_fee_msat))
payment.preimage = payment_response.preimage
await update_payment(payment, payment_response.checking_id, conn=conn)
payment.checking_id = payment_response.checking_id
if payment.success:
await send_payment_notification(wallet, payment)
logger.success(f"payment successful {payment_response.checking_id}")
elif payment_response.checking_id is None and payment_response.ok is False:
# payment failed
logger.debug(f"payment failed {checking_id}, {payment_response.error_message}")
payment.status = PaymentState.FAILED
await update_payment(payment, conn=conn)
raise PaymentError(
f"Payment failed: {payment_response.error_message}"
or "Payment failed, but backend didn't give us an error message.",
status="failed",
)
else:
logger.warning(
"didn't receive checking_id from backend, payment may be stuck in"
f" database: {checking_id}"
)
return payment
async def _verify_external_payment(
@@ -833,7 +554,8 @@ async def _verify_external_payment(
if status.success:
# payment was successful on the fundingsource
await update_payment_success_status(payment, status, conn=conn)
payment.status = PaymentState.SUCCESS
await update_payment(payment, conn=conn)
raise PaymentError(
"Failed payment was already paid on the fundingsource.",
status="success",
@@ -873,20 +595,14 @@ def _validate_payment_request(
if not invoice.amount_msat or not invoice.amount_msat > 0:
raise PaymentError("Amountless invoices not supported.", status="failed")
max_sat = max_sat or settings.lnbits_max_outgoing_payment_amount_sats
max_sat = min(max_sat, settings.lnbits_max_outgoing_payment_amount_sats)
if invoice.amount_msat > max_sat * 1000:
raise PaymentError(
f"Invoice amount {invoice.amount_msat // 1000} sats is too high. "
f"Max allowed: {max_sat} sats.",
status="failed",
)
if max_sat and invoice.amount_msat > max_sat * 1000:
raise PaymentError("Amount in invoice is too high.", status="failed")
return invoice
async def _credit_service_fee_wallet(
payment: Payment, memo: str, conn: Optional[Connection] = None
payment: Payment, conn: Optional[Connection] = None
):
service_fee_msat = service_fee(payment.amount, internal=payment.is_internal)
if not settings.lnbits_service_fee_wallet or not service_fee_msat:
@@ -897,7 +613,7 @@ async def _credit_service_fee_wallet(
bolt11=payment.bolt11,
payment_hash=payment.payment_hash,
amount_msat=abs(service_fee_msat),
memo=memo,
memo="Service fee",
)
await create_payment(
checking_id=f"service_fee_{payment.payment_hash}",
@@ -905,47 +621,3 @@ async def _credit_service_fee_wallet(
status=PaymentState.SUCCESS,
conn=conn,
)
async def _check_fiat_invoice_limits(
amount_sat: int, fiat_provider_name: str, conn: Optional[Connection] = None
):
limits = settings.get_fiat_provider_limits(fiat_provider_name)
if not limits:
raise ValueError(
f"Fiat provider '{fiat_provider_name}' does not have limits configured.",
)
min_amount_sat = limits.service_min_amount_sats
if min_amount_sat and (amount_sat < min_amount_sat):
raise ValueError(
f"Minimum amount is {min_amount_sat} " f"sats for '{fiat_provider_name}'.",
)
max_amount_sats = limits.service_max_amount_sats
if max_amount_sats and (amount_sat > max_amount_sats):
raise ValueError(
f"Maximum amount is {max_amount_sats} " f"sats for '{fiat_provider_name}'.",
)
if limits.service_max_fee_sats > 0 or limits.service_fee_percent > 0:
if not limits.service_fee_wallet_id:
raise ValueError(
f"Fiat provider '{fiat_provider_name}' service fee wallet missing.",
)
fees_wallet = await get_wallet(limits.service_fee_wallet_id, conn=conn)
if not fees_wallet:
raise ValueError(
f"Fiat provider '{fiat_provider_name}' service fee wallet not found.",
)
if limits.service_faucet_wallet_id:
faucet_wallet = await get_wallet(limits.service_faucet_wallet_id, conn=conn)
if not faucet_wallet:
raise ValueError(
f"Fiat provider '{fiat_provider_name}' faucet wallet not found.",
)
if faucet_wallet.balance < amount_sat:
raise ValueError(
f"The amount exceeds the '{fiat_provider_name}'"
"faucet wallet balance.",
)
+2 -8
View File
@@ -6,7 +6,6 @@ from py_vapid.utils import b64urlencode
from lnbits.db import dict_to_model
from lnbits.settings import (
EditableSettings,
UpdateSettings,
readonly_variables,
settings,
)
@@ -19,8 +18,7 @@ async def check_webpush_settings():
vapid = Vapid()
vapid.generate_keys()
privkey = vapid.private_pem()
if not vapid.public_key:
raise ValueError("VAPID public key does not exist")
assert vapid.public_key, "VAPID public key does not exist"
pubkey = b64urlencode(
vapid.public_key.public_bytes(
serialization.Encoding.X962,
@@ -39,12 +37,8 @@ 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):
editable_settings = dict_to_settings(sets_dict)
editable_settings = dict_to_model(sets_dict, EditableSettings)
for key in sets_dict.keys():
if key in readonly_variables:
continue
+14 -12
View File
@@ -8,6 +8,7 @@ from lnbits.core.models.extensions import UserExtension
from lnbits.settings import (
EditableSettings,
SuperSettings,
send_admin_user_to_saas,
settings,
)
@@ -46,9 +47,7 @@ async def create_user_account(
async def create_user_account_no_ckeck(
account: Optional[Account] = None,
wallet_name: Optional[str] = None,
default_exts: Optional[list[str]] = None,
account: Optional[Account] = None, wallet_name: Optional[str] = None
) -> User:
if account:
@@ -71,17 +70,12 @@ async def create_user_account_no_ckeck(
wallet_name=wallet_name or settings.lnbits_default_wallet_name,
)
user_extensions = (default_exts or []) + settings.lnbits_user_default_extensions
for ext_id in user_extensions:
try:
user_ext = UserExtension(user=account.id, extension=ext_id, active=True)
await create_user_extension(user_ext)
except Exception as e:
logger.error(f"Error enabeling default extension {ext_id}: {e}")
for ext_id in settings.lnbits_user_default_extensions:
user_ext = UserExtension(user=account.id, extension=ext_id, active=True)
await update_user_extension(user_ext)
user = await get_user_from_account(account)
if not user:
raise ValueError("Cannot find user for account.")
assert user, "Cannot find user for account."
return user
@@ -160,6 +154,14 @@ async def check_admin_settings():
with open(Path(settings.lnbits_data_folder) / ".super_user", "w") as file:
file.write(settings.super_user)
# callback for saas
if (
settings.lnbits_saas_callback
and settings.lnbits_saas_secret
and settings.lnbits_saas_instance_id
):
send_admin_user_to_saas()
account = await get_account(settings.super_user)
if account and account.extra and account.extra.provider == "env":
settings.first_install = True
+117 -97
View File
@@ -1,96 +1,82 @@
import asyncio
import traceback
from collections.abc import Coroutine
from typing import Callable
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.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.audit import AuditEntry
from lnbits.core.models.extensions import InstallableExtension
from lnbits.core.models.notifications import NotificationType
from lnbits.core.services.funding_source import (
check_balance_delta_changed,
check_server_balance_against_node,
from lnbits.core.models import AuditEntry, Payment
from lnbits.core.services import (
get_balance_delta,
)
from lnbits.core.services.notifications import (
enqueue_notification,
process_next_notification,
send_payment_notification,
switch_to_voidwallet,
)
from lnbits.db import Filters
from lnbits.settings import settings
from lnbits.tasks import create_unique_task
from lnbits.settings import get_funding_source, settings
from lnbits.tasks import send_push_notification
from lnbits.utils.exchange_rates import btc_rates
audit_queue: asyncio.Queue[AuditEntry] = asyncio.Queue()
audit_queue: asyncio.Queue = asyncio.Queue()
async def run_by_the_minute_tasks() -> None:
minute_counter = 0
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.
"""
while settings.lnbits_running:
status_minutes = settings.lnbits_notification_server_status_hours * 60
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)
if settings.notification_balance_delta_changed:
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"
):
try:
# runs by default every minute, the delta should not change that often
await check_balance_delta_changed()
except Exception as ex:
logger.error(ex)
if minute_counter % settings.lnbits_watchdog_interval_minutes == 0:
try:
await check_server_balance_against_node()
except Exception as ex:
logger.error(ex)
if minute_counter % status_minutes == 0:
try:
await _notify_server_status()
except Exception as ex:
logger.error(ex)
if minute_counter % 60 == 0:
try:
# initialize the list of all extensions
await InstallableExtension.get_installable_extensions()
except Exception as ex:
logger.error(ex)
minute_counter += 1
await asyncio.sleep(60)
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)
async def _notify_server_status() -> None:
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) -> None:
async def wait_for_paid_invoices(invoice_paid_queue: asyncio.Queue):
"""
This worker dispatches events to all extensions and dispatches webhooks.
"""
@@ -101,15 +87,70 @@ async def wait_for_paid_invoices(invoice_paid_queue: asyncio.Queue) -> None:
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 wait_for_audit_data() -> None:
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():
"""
Waits for audit entries to be pushed to the queue.
Then it inserts the entries into the DB.
"""
while settings.lnbits_running:
data = await audit_queue.get()
data: AuditEntry = await audit_queue.get()
try:
await create_audit_entry(data)
except Exception as ex:
@@ -117,17 +158,7 @@ async def wait_for_audit_data() -> None:
await asyncio.sleep(3)
async def wait_notification_messages() -> None:
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() -> None:
async def purge_audit_data():
"""
Remove audit entries which have passed their retention period.
"""
@@ -141,7 +172,7 @@ async def purge_audit_data() -> None:
await asyncio.sleep(60 * 60)
async def collect_exchange_rates_data() -> None:
async def collect_exchange_rates_data():
"""
Collect exchange rates data. Used for monitoring only.
"""
@@ -163,14 +194,3 @@ async def collect_exchange_rates_data() -> None:
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())
+29 -45
View File
@@ -15,8 +15,11 @@
/>
</q-item-section>
<q-item-section>
<q-item-label v-text="$t('enable_audit')"></q-item-label>
<q-item-label caption v-text="$t('audit_desc')"></q-item-label>
<q-item-label>Enable audit</q-item-label>
<q-item-label caption
>Record HTTP requests according with the specified
filters</q-item-label
>
</q-item-section>
</q-item>
</div>
@@ -45,16 +48,14 @@
/>
</q-item-section>
<q-item-section>
<q-item-label v-text="$t('audit_record_req')"></q-item-label>
<q-item-label caption>
<span v-text="$t('audit_record_warning')"></span>
<br />
<q-item-label>Record Request Body</q-item-label>
<q-item-label caption
>Warning:
<ul>
<li><span v-text="$t('audit_record_req_warning_1')"></span></li>
<li><span v-text="$t('audit_record_req_warning_2')"></span></li>
<li>confidential data (like passwords) will be logged</li>
<li>the request body can have large size.</li>
</ul>
<br />
<span v-text="$t('audit_record_use')"></span>
Use it with caution.
</q-item-label>
</q-item-section>
</q-item>
@@ -71,12 +72,8 @@
/>
</q-item-section>
<q-item-section>
<q-item-label>
<span v-text="$t('audit_ip')"></span>
</q-item-label>
<q-item-label caption>
<span v-text="$t('audit_ip_desc')"></span>
</q-item-label>
<q-item-label>Record IP Address</q-item-label>
<q-item-label caption>Save the client IP address.</q-item-label>
</q-item-section>
</q-item>
</div>
@@ -92,12 +89,8 @@
/>
</q-item-section>
<q-item-section>
<q-item-label>
<span v-text="$t('audit_path_params')"></span>
</q-item-label>
<q-item-label caption>
<span v-text="$t('recommended')"></span>
</q-item-label>
<q-item-label>Record Path Parameters</q-item-label>
<q-item-label caption>Recommended. </q-item-label>
</q-item-section>
</q-item>
</div>
@@ -113,12 +106,8 @@
/>
</q-item-section>
<q-item-section>
<q-item-label>
<span v-text="$t('audit_query_params')"></span>
</q-item-label>
<q-item-label caption>
<span v-text="$t('recommended')"></span>
</q-item-label>
<q-item-label>Record Query Parameters</q-item-label>
<q-item-label caption>Recommended.</q-item-label>
</q-item-section>
</q-item>
</div>
@@ -126,26 +115,25 @@
<q-separator class="q-mb-xl q-mt-sm"></q-separator>
<div class="row q-mb-lg">
<div class="col-md-6 col-sm-12 q-pr-sm">
<p><span v-text="$t('audit_http_methods')"></span></p>
<p>Include HTTP Methods</p>
<q-select
filled
v-model="formData.lnbits_audit_http_methods"
multiple
:hint="$t('audit_http_methods_hint')"
:label="$t('audit_http_methods_label')"
hint="List of HTTP methods to be included. Empty lists means all."
label="HTTP Methods"
:options="['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS']"
></q-select>
</div>
<div class="col-md-6 col-sm-12 q-pr-sm">
<p><span v-text="$t('audit_resp_codes')"></span></p>
<p>Include HTTP Response Codes</p>
<q-input
class="q-mb-md"
filled
v-model="formAddIncludeResponseCode"
@keydown.enter="addIncludeResponseCode"
type="text"
:label="$t('audit_resp_codes_label')"
:hint="$t('audit_resp_codes_hint')"
label="HTTP Response code (regex)"
hint="List of HTTP codes to be included (regex match). Empty lists means all. Eg: 4.*, 5.*"
>
<q-btn @click="addIncludeResponseCode" dense flat icon="add"></q-btn>
</q-input>
@@ -166,15 +154,14 @@
<div class="row">
<div class="col-md-6 col-sm-12 q-pr-sm">
<p><span v-text="$t('audit_paths')"></span></p>
<p>Include Paths</p>
<q-input
class="q-mb-md"
filled
v-model="formAddIncludePath"
@keydown.enter="addIncludePath"
type="text"
:label="$t('audit_paths_label')"
:hint="$t('audit_paths_hint')"
label="HTTP Path (regex)"
hint="List of paths to be included (regex match). Empty list means all."
>
<q-btn @click="addIncludePath" dense flat icon="add"></q-btn>
</q-input>
@@ -193,17 +180,14 @@
<br />
</div>
<div class="col-md-6 col-sm-12">
<p>
<span v-text="$t('audit_paths_exclude')"></span>
</p>
<p>Exclude Paths</p>
<q-input
class="q-mb-md"
filled
v-model="formAddExcludePath"
@keydown.enter="addExcludePath"
type="text"
:label="$t('audit_paths_exclude_label')"
:hint="$t('audit_paths_exclude_hint')"
label="HTTP Path (regex)"
hint="List of paths to be excluded (regex match). Empty list means none."
>
<q-btn @click="addExcludePath" dense flat icon="add"></q-btn>
</q-input>
@@ -1,7 +1,5 @@
<q-tab-panel name="exchange_providers">
<h6 class="q-my-none q-mb-sm">
<span v-text="$t('exchange_providers')"></span>
</h6>
<h6 class="q-my-none q-mb-sm">Exchange Providers</h6>
<div class="row">
<div class="col-md-8 col-sm-12">
@@ -1,22 +1,17 @@
<q-tab-panel name="extensions">
<q-card-section class="q-pa-none">
<div>
<h6 class="q-my-none">
<span v-text="$t('extensions')"></span>
</h6>
<h6 class="q-my-none">Extensions</h6>
<div class="row q-col-gutter-md">
<div class="col-12 q-mb-md">
<p>
<span v-text="$t('extension_sources')"></span>
</p>
<div class="col-12">
<p>Extension Sources</p>
<q-input
class="q-mb-md"
filled
v-model="formAddExtensionsManifest"
@keydown.enter="addExtensionsManifest"
type="text"
:label="$t('ext_sources_label')"
:hint="$t('ext_sources_hint')"
label="Source URL (only use the official LNbits extension source, and sources you can trust)"
hint="Repositories from where the extensions can be downloaded"
>
<q-btn @click="addExtensionsManifest" dense flat icon="add"></q-btn>
</q-input>
@@ -35,44 +30,34 @@
</div>
<div class="row q-col-gutter-md">
<div class="col-12 col-md-6">
<p>
<span v-text="$t('admin_extensions')"></span>
</p>
<p>Admin Extensions</p>
<q-select
filled
v-model="formData.lnbits_admin_extensions"
multiple
:label="$t('admin_extensions_label')"
:hint="$t('admin_extensions_hint')"
hint="Extensions only user with admin privileges can use"
label="Admin extensions"
:options="g.extensions"
></q-select>
</div>
<div class="col-12 col-md-6">
<p>
<span v-text="$t('user_default_extensions')"></span>
</p>
<p>User Default Extensions</p>
<q-select
filled
v-model="formData.lnbits_user_default_extensions"
multiple
:label="$t('user_default_extensions_label')"
:hint="$t('user_default_extensions_hint')"
hint="Extensions that will be enabled by default for the users."
label="User extensions"
:options="g.extensions"
></q-select>
</div>
<div class="col-12 col-md-6">
<p>
<span v-text="$t('miscellaneous')"></span>
</p>
<p>Miscellaneous</p>
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label>
<span v-text="$t('misc_disable_extensions')"></span>
</q-item-label>
<q-item-label caption>
<span v-text="$t('misc_disable_extensions_label')"></span>
</q-item-label>
<q-item-label>Disable Extensions</q-item-label>
<q-item-label caption>Disables all extensions</q-item-label>
</q-item-section>
<q-item-section avatar>
<q-toggle
@@ -86,12 +71,10 @@
</q-item>
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label>
<span v-text="$t('misc_hide_api')"></span>
</q-item-label>
<q-item-label caption>
<span v-text="$t('misc_hide_api_label')"></span>
</q-item-label>
<q-item-label>Hide API</q-item-label>
<q-item-label caption
>Hides wallet api, extensions can choose to honor</q-item-label
>
</q-item-section>
<q-item-section avatar>
<q-toggle
@@ -1,287 +0,0 @@
<q-tab-panel name="fiat_providers">
<h6 class="q-my-none q-mb-sm">
<span v-text="$t('fiat_providers')"></span>
<q-btn
round
flat
@click="hideInputsToggle()"
:icon="hideInputToggle ? 'visibility_off' : 'visibility'"
></q-btn>
</h6>
<div class="row">
<div class="col">
<q-list bordered class="rounded-borders">
<q-expansion-item header-class="text-primary text-bold">
<template v-slot:header>
<q-item-section avatar>
<q-avatar>
<img
:src="'{{ static_url_for('static', 'images/stripe_logo.ico') }}'"
/>
</q-avatar>
</q-item-section>
<q-item-section> Stripe </q-item-section>
<q-item-section side>
<div class="row items-center">
<q-toggle
size="md"
:label="$t('enabled')"
v-model="formData.stripe_enabled"
color="green"
unchecked-icon="clear"
/>
</div>
</q-item-section>
</template>
<q-card class="q-pb-xl">
<q-expansion-item :label="$t('api')" default-opened>
<q-card-section class="q-pa-md">
<q-input
filled
type="text"
v-model="formData.stripe_api_endpoint"
:label="$t('endpoint')"
></q-input>
<q-input
filled
class="q-mt-md"
:type="hideInputToggle ? 'password' : 'text'"
v-model="formData.stripe_api_secret_key"
:label="$t('secret_key')"
></q-input>
<q-input
filled
class="q-mt-md"
type="text"
v-model="formData.stripe_payment_success_url"
:label="$t('callback_success_url')"
:hint="$t('callback_success_url_hint')"
></q-input>
</q-card-section>
<q-card-section class="q-pa-md">
<div class="row">
<div class="col">
<q-btn
outline
color="grey"
class="float-right"
:label="$t('check_connection')"
@click="checkFiatProvider('stripe')"
></q-btn>
</div>
</div>
</q-card-section>
</q-expansion-item>
<q-expansion-item :label="$t('webhook')" default-opened>
<q-card-section>
<span v-text="$t('webhook_stripe_description')"></span>
</q-card-section>
<q-card-section>
<q-input
filled
class="q-mt-md"
type="text"
disable
v-model="formData.stripe_payment_webhook_url"
:label="$t('webhook_url')"
:hint="$t('webhook_url_hint')"
></q-input>
<q-input
filled
class="q-mt-md"
:type="hideInputToggle ? 'password' : 'text'"
v-model="formData.stripe_webhook_signing_secret"
:label="$t('signing_secret')"
:hint="$t('signing_secret_hint')"
></q-input>
</q-card-section>
<q-card-section>
<span v-text="$t('webhook_events_list')"></span>
<ul>
<li><code>checkout.session.async_payment_failed</code></li>
<li><code>checkout.session.async_payment_succeeded</code></li>
<li><code>checkout.session.completed</code></li>
<li><code>checkout.session.expired</code></li>
</ul>
</q-card-section>
</q-expansion-item>
<q-expansion-item :label="$t('service_fee')">
<q-card-section>
<div class="row">
<div class="col-md-4 col-sm-12">
<q-input
filled
class="q-ma-sm"
type="number"
min="0"
v-model="formData.stripe_limits.service_fee_percent"
@update:model-value="touchSettings()"
:label="$t('service_fee_label')"
:hint="$t('service_fee_hint')"
></q-input>
</div>
<div class="col-md-4 col-sm-12">
<q-input
filled
class="q-ma-sm"
type="number"
min="0"
v-model="formData.stripe_limits.service_max_fee_sats"
@update:model-value="touchSettings()"
:label="$t('service_fee_max')"
:hint="$t('service_fee_max_hint')"
></q-input>
</div>
<div class="col-md-4 col-sm-12">
<q-input
filled
class="q-ma-sm"
type="text"
v-model="formData.stripe_limits.service_fee_wallet_id"
@update:model-value="touchSettings()"
:label="$t('fee_wallet_label')"
:hint="$t('fee_wallet_hint')"
></q-input>
</div>
</div>
</q-card-section>
</q-expansion-item>
<q-expansion-item :label="$t('amount_limits')">
<q-card-section>
<div class="row">
<div class="col-md-4 col-sm-12">
<q-input
filled
class="q-ma-sm"
type="number"
min="0"
v-model="formData.stripe_limits.service_min_amount_sats"
@update:model-value="touchSettings()"
:label="$t('min_incoming_payment_amount')"
:hint="$t('min_incoming_payment_amount_desc')"
></q-input>
</div>
<div class="col-md-4 col-sm-12">
<q-input
filled
class="q-ma-sm"
type="number"
min="0"
v-model="formData.stripe_limits.service_max_amount_sats"
@update:model-value="touchSettings()"
:label="$t('max_incoming_payment_amount')"
:hint="$t('max_incoming_payment_amount_desc')"
></q-input>
</div>
<div class="col-md-4 col-sm-12">
<q-input
filled
class="q-ma-sm"
v-model="formData.stripe_limits.service_faucet_wallet_id"
@update:model-value="touchSettings()"
:label="$t('faucest_wallet_id')"
:hint="$t('faucest_wallet_id_hint')"
></q-input>
</div>
</div>
<q-item>
<q-item-section>
<q-item-label v-text="$t('faucest_wallet')"></q-item-label>
<q-item-label caption>
<ul>
<li>
<span
v-text="$t('faucest_wallet_desc_1', {provider: 'stripe'})"
></span>
</li>
<li>
<span
v-text="$t('faucest_wallet_desc_2', {provider: 'stripe'})"
></span>
</li>
<li>
<span v-text="$t('faucest_wallet_desc_3')"></span>
</li>
<li>
<span
v-text="$t('faucest_wallet_desc_4', {provider: 'stripe'})"
></span>
</li>
<li>
<span v-text="$t('faucest_wallet_desc_5')"></span>
</li>
</ul>
<br />
</q-item-label>
</q-item-section>
</q-item>
</q-card-section>
</q-expansion-item>
<q-expansion-item :label="$t('allowed_users')">
<q-card-section>
<q-input
filled
v-model="formAddStripeUser"
@keydown.enter="addAllowedUser"
type="text"
:label="$t('allowed_users_label')"
:hint="$t('allowed_users_hint_feature', {feature: 'Stripe'})"
>
<q-btn
@click="addStripeAllowedUser"
dense
flat
icon="add"
></q-btn>
</q-input>
<div>
<q-chip
v-for="user in formData.stripe_limits.allowed_users"
@update:model-value="touchSettings()"
:key="user"
removable
@remove="removeStripeAllowedUser(user)"
color="primary"
text-color="white"
:label="user"
class="ellipsis"
>
</q-chip>
</div>
</q-card-section>
</q-expansion-item>
</q-card>
</q-expansion-item>
<q-separator />
<q-expansion-item header-class="text-primary text-bold">
<template v-slot:header>
<q-item-section avatar>
<q-avatar>
<img
:src="'{{ static_url_for('static', 'images/square_logo.png') }}'"
/>
</q-avatar>
</q-item-section>
<q-item-section> Square </q-item-section>
<q-item-section side>
<div class="row items-center">Disabled</div>
</q-item-section>
</template>
<q-card>
<q-card-section> Coming Soon </q-card-section>
</q-card>
</q-expansion-item>
</q-list>
</div>
</div>
</q-tab-panel>
+40 -140
View File
@@ -1,104 +1,88 @@
<q-tab-panel name="funding">
<q-card-section class="q-pa-none">
<h6 class="q-my-none">
<span v-text="$t('wallets_management')"></span>
</h6>
<h6 class="q-my-none">Wallets Management</h6>
<br />
<div>
<div class="row">
<div class="col">
<p>
<span v-text="$t('funding_source_info')"></span>
</p>
<p>Funding Source Info</p>
<ul>
<li
v-text="$t('funding_source', {wallet_class: settings.lnbits_backend_wallet_class})"
v-text="'Funding Source: '+ settings.lnbits_backend_wallet_class"
></li>
<li
v-text="$t('node_balance', {balance: (auditData.node_balance_sats || 0).toLocaleString()})"
v-text="'Node Balance: ' + (auditData.node_balance_msats /
1000).toLocaleString() + ' sats'"
></li>
<li
v-text="$t('lnbits_balance', {balance: (auditData.lnbits_balance_sats || 0).toLocaleString()})"
v-text="'LNbits Balance: ' + (auditData.lnbits_balance_msats /
1000).toLocaleString() + ' sats'"
></li>
<li
v-text="$t('funding_reserve_percent', {
percent: auditData.lnbits_balance_sats > 0
? (auditData.node_balance_sats / auditData.lnbits_balance_sats * 100).toFixed(2)
: 100
})"
v-text="'Reserve Percent: ' + (auditData.node_balance_msats /
auditData.lnbits_balance_msats * 100).toFixed(2) + ' %'"
></li>
</ul>
<br />
</div>
<div class="col">
{% if LNBITS_NODE_UI_AVAILABLE %}
<p><span v-text="$t('node_management')"></span></p>
<p>Node Management</p>
<q-toggle
:label="$t('toggle_node_ui')"
label="Node UI"
v-model="formData.lnbits_node_ui"
></q-toggle>
<q-toggle
v-if="formData.lnbits_node_ui"
:label="$t('toggle_public_node_ui')"
label="Public node UI"
v-model="formData.lnbits_public_node_ui"
></q-toggle>
<br />
<q-toggle
v-if="formData.lnbits_node_ui"
:label="$t('toggle_transactions_node_ui')"
label="Transactions Tab (Disable on large CLN nodes)"
v-model="formData.lnbits_node_ui_transactions"
></q-toggle>
{% else %}
<p><span v-text="$t('node_management_not_supported')"></span></p>
<p>Node Management not supported by active funding source</p>
{% endif %}
</div>
</div>
<div class="row q-col-gutter-md">
<div class="col-12 col-md-3">
<p><span v-text="$t('fee_reserve')"></span></p>
<q-input
type="number"
filled
v-model="formData.lnbits_reserve_fee_min"
:label="$t('fee_reserve_msats')"
>
</q-input>
</div>
<div class="col-12 col-md-3">
<p><span v-text="$t('fee_reserve_percent')"></span></p>
<q-input
type="number"
filled
name="lnbits_reserve_fee_percent"
v-model="formData.lnbits_reserve_fee_percent"
:label="$t('reserve_fee_in_percent')"
step="0.1"
></q-input>
</div>
<div class="col-12 col-md-3">
<p><span v-text="$t('invoice_expiry')"></span></p>
<div class="col-12 col-md-4">
<p>Invoice Expiry</p>
<q-input
filled
v-model.number="formData.lightning_invoice_expiry"
type="number"
:label="$t('invoice_expiry_label')"
label="Invoice expiry (seconds)"
mask="#######"
>
</q-input>
</div>
<div class="col-12 col-md-3">
<p><span v-text="$t('payment_wait_time')"></span></p>
<q-input
type="number"
filled
name="lnbits_funding_source_pay_invoice_wait_seconds"
v-model="formData.lnbits_funding_source_pay_invoice_wait_seconds"
:label="$t('payment_wait_time')"
:hint="$t('payment_wait_time_desc')"
step="1"
min="0"
></q-input>
<div class="col-12 col-md-8">
<p>Fee reserve</p>
<div class="row q-col-gutter-md">
<div class="col-6">
<q-input
type="number"
filled
v-model="formData.lnbits_reserve_fee_min"
label="Reserve fee in msats"
>
</q-input>
</div>
<div class="col-6">
<q-input
type="number"
filled
name="lnbits_reserve_fee_percent"
v-model="formData.lnbits_reserve_fee_percent"
label="Reserve fee in percent"
step="0.1"
></q-input>
</div>
</div>
</div>
</div>
<div v-if="isSuperUser">
@@ -107,90 +91,6 @@
: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,67 +0,0 @@
<q-tab-panel name="library">
<q-card-section class="q-pa-none">
<h6 class="q-my-none q-mb-sm">
<span v-text="$t('image_library')"></span>
</h6>
<q-btn
color="primary"
label="Add Image"
@click="$refs.imageInput.click()"
class="q-mb-md"
/>
<input
type="file"
ref="imageInput"
accept="image/png, image/jpeg, image/gif"
style="display: none"
@change="onImageInput"
/>
</q-card-section>
<div class="row q-col-gutter-sm q-pa-sm">
<div
v-for="image in library_images"
:key="image.filename"
class="col-6 col-sm-4 col-md-3 col-lg-2"
style="max-width: 200px"
>
<q-card class="q-mb-sm">
<q-img :src="image.url" style="height: 150px" />
<q-card-section
class="q-pt-md q-pb-md row items-center justify-between"
>
<small
><div
class="text-caption ellipsis"
style="max-width: 100px"
:title="image.filename"
v-text="image.filename"
></div
></small>
<q-btn
dense
flat
size="sm"
icon="content_copy"
@click="copyText(image.url)"
:title="$t('copy')"
><q-tooltip>Copy image link</q-tooltip></q-btn
>
<q-btn
dense
flat
size="sm"
icon="delete"
color="negative"
@click="deleteImage(image.filename)"
:title="$t('delete')"
><q-tooltip>Delete image</q-tooltip></q-btn
>
</q-card-section>
</q-card>
</div>
</div>
<div v-if="library_images.length === 0" class="q-pa-xl">
<div class="text-subtitle2 text-grey">No images uploaded yet.</div>
</div>
</q-tab-panel>
@@ -1,504 +1,139 @@
<q-tab-panel name="notifications">
<q-card-section class="q-pa-none">
<h6 class="q-my-none q-mb-sm">
<span v-text="$t('notifications_configure')"></span>
<q-btn
round
flat
@click="hideInputsToggle()"
:icon="hideInputToggle ? 'visibility_off' : 'visibility'"
></q-btn>
</h6>
<q-separator class="q-mt-md q-mb-sm"></q-separator>
<div class="row q-col-gutter-md">
<div class="col-sm-12 col-md-6">
<strong v-text="$t('notifications_nostr_config')"></strong>
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label
v-text="$t('notifications_enable_nostr')"
></q-item-label>
<q-item-label
caption
v-text="$t('notifications_enable_nostr_desc')"
></q-item-label>
</q-item-section>
<q-item-section avatar>
<q-toggle
size="md"
v-model="formData.lnbits_nostr_notifications_enabled"
checked-icon="check"
color="green"
unchecked-icon="clear"
/>
</q-item-section>
</q-item>
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label
v-text="$t('notifications_nostr_private_key')"
></q-item-label>
<q-item-label
caption
v-text="$t('notifications_nostr_private_key_desc')"
></q-item-label>
</q-item-section>
<q-item-section>
<q-input
:type="hideInputToggle ? 'password' : 'text'"
filled
v-model="formData.lnbits_nostr_notifications_private_key"
/>
</q-item-section>
</q-item>
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label
v-text="$t('notifications_nostr_identifiers')"
></q-item-label>
<q-item-label
caption
v-text="$t('notifications_nostr_identifiers_desc')"
></q-item-label>
</q-item-section>
<q-item-section>
<q-input
filled
v-model="nostrNotificationIdentifier"
@keydown.enter="addNostrNotificationIdentifier"
>
<q-btn
@click="addNostrNotificationIdentifier()"
dense
flat
icon="add"
></q-btn>
</q-input>
<div>
<q-chip
v-for="identifier in formData.lnbits_nostr_notifications_identifiers"
:key="identifier"
removable
@remove="removeNostrNotificationIdentifier(identifier)"
color="primary"
text-color="white"
><span class="ellipsis" v-text="identifier"></span
></q-chip>
</div>
</q-item-section>
</q-item>
</div>
<div class="col-sm-12 col-md-6">
<strong v-text="$t('notifications_telegram_config')"></strong>
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label
v-text="$t('notifications_enable_telegram')"
></q-item-label>
<q-item-label
caption
v-text="$t('notifications_enable_telegram_desc')"
></q-item-label>
</q-item-section>
<q-item-section avatar>
<q-toggle
size="md"
v-model="formData.lnbits_telegram_notifications_enabled"
checked-icon="check"
color="green"
unchecked-icon="clear"
/>
</q-item-section>
</q-item>
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label
v-text="$t('notifications_telegram_access_token')"
></q-item-label>
<q-item-label
caption
v-text="$t('notifications_telegram_access_token_desc')"
></q-item-label>
</q-item-section>
<q-item-section>
<q-input
:type="hideInputToggle ? 'password' : 'text'"
filled
v-model="formData.lnbits_telegram_notifications_access_token"
/>
</q-item-section>
</q-item>
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label v-text="$t('notifications_chat_id')"></q-item-label>
<q-item-label
caption
v-text="$t('notifications_chat_id_desc')"
></q-item-label>
</q-item-section>
<q-item-section>
<q-input
filled
v-model="formData.lnbits_telegram_notifications_chat_id"
/>
</q-item-section>
</q-item>
</div>
<div class="col-sm-12">
<q-separator></q-separator>
</div>
<div class="col-12">
<strong v-text="$t('notifications_email_config')"></strong>
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label
v-text="$t('notifications_enable_email')"
></q-item-label>
<q-item-label
caption
v-text="$t('notifications_enable_email_desc')"
></q-item-label>
</q-item-section>
<q-item-section avatar>
<q-toggle
size="md"
v-model="formData.lnbits_email_notifications_enabled"
checked-icon="check"
color="green"
unchecked-icon="clear"
/>
</q-item-section>
</q-item>
<div v-if="formData.lnbits_email_notifications_enabled" class="row">
<div class="col">
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label
v-text="$t('notifications_send_email')"
></q-item-label>
<q-item-label
caption
v-text="$t('notifications_send_email_desc')"
></q-item-label>
</q-item-section>
<q-item-section>
<q-input
:type="hideInputToggle ? 'password' : 'text'"
filled
v-model="formData.lnbits_email_notifications_email"
/>
</q-item-section>
</q-item>
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label
v-text="$t('notifications_send_email_username')"
></q-item-label>
<q-item-label
caption
v-text="$t('notifications_send_email_username_desc')"
></q-item-label>
</q-item-section>
<q-item-section>
<q-input
:type="hideInputToggle ? 'password' : 'text'"
filled
v-model="formData.lnbits_email_notifications_username"
/>
</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>
<q-btn
@click="sendTestEmail()"
:label="$t('notifications_send_test_email')"
color="primary"
class="q-mt-md"
></q-btn>
</q-item>
<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>
<div class="col">
<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>
<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>
<br />
<div>
<p v-text="$t('notification_source')"></p>
<q-input
filled
v-model="formData.lnbits_status_manifest"
type="text"
:label="$t('notification_source_label')"
/>
</div>
<br />
</div>
<div class="col-12 col-md-6">
<p v-text="$t('killswitch')"></p>
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label v-text="$t('enable_killswitch')"></q-item-label>
<q-item-label
caption
v-text="$t('enable_killswitch_desc')"
></q-item-label>
</q-item-section>
<q-item-section avatar>
<q-toggle
size="md"
v-model="formData.lnbits_killswitch"
checked-icon="check"
color="green"
unchecked-icon="clear"
/>
</q-item-section>
</q-item>
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label v-text="$t('killswitch_interval')"></q-item-label>
<q-item-label
caption
v-text="$t('killswitch_interval_desc')"
></q-item-label>
</q-item-section>
<q-item-section>
<q-input
filled
v-model="formData.lnbits_killswitch_interval"
type="number"
/>
</q-item-section>
</q-item>
<br />
<p v-text="$t('watchdog')"></p>
<q-item disabled tag="label" v-ripple>
<q-tooltip><span v-text="$t('coming_soon')"></span></q-tooltip>
<q-item-section>
<q-item-label v-text="$t('enable_watchdog')"></q-item-label>
<q-item-label
caption
v-text="$t('enable_watchdog_desc')"
></q-item-label>
</q-item-section>
<q-item-section avatar>
<q-toggle
size="md"
v-model="formData.lnbits_watchdog"
checked-icon="check"
color="green"
unchecked-icon="clear"
/>
</q-item-section>
</q-item>
<q-item disabled tag="label" v-ripple>
<q-tooltip><span v-text="$t('coming_soon')"></span></q-tooltip>
<q-item-section>
<q-item-label v-text="$t('watchdog_interval')"></q-item-label>
<q-item-label
caption
v-text="$t('watchdog_interval_desc')"
></q-item-label>
</q-item-section>
<q-item-section>
<q-input
filled
v-model="formData.lnbits_watchdog_interval"
type="number"
/>
</q-item-section>
</q-item>
<q-item disabled tag="label" v-ripple>
<q-tooltip><span v-text="$t('coming_soon')"></span></q-tooltip>
<q-item-section>
<q-item-label v-text="$t('watchdog_delta')"></q-item-label>
<q-item-label
caption
v-text="$t('watchdog_delta_desc')"
></q-item-label>
</q-item-section>
<q-item-section>
<q-input
filled
v-model="formData.lnbits_watchdog_delta"
type="number"
/>
</q-item-section>
</q-item>
<br />
</div>
</div>
</div>
<q-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>
+45 -69
View File
@@ -1,28 +1,26 @@
<q-tab-panel name="security">
<q-card-section class="q-pa-none">
<h6 class="q-my-none"><span v-text="$t('server_management')"></span></h6>
<h6 class="q-my-none">Server Management</h6>
<div class="row">
<div class="col-md-6">
<p><span v-text="$t('base_url')"></span></p>
<p>Base URL</p>
<q-input
filled
v-model.number="formData.lnbits_baseurl"
:label="$t('base_url_label')"
label="Static/Base url for the server"
></q-input>
<br />
</div>
</div>
<h6 class="q-my-none q-mb-sm">
<span v-text="$t('authentication')"></span>
</h6>
<h6 class="q-my-none q-mb-sm">Authentication</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="$t('auth_token_expiry_label')"
:hint="$t('auth_token_expiry_hint')"
label="Token expire minutes"
hint="Time in minutes until the token expires"
>
</q-input>
</div>
@@ -31,8 +29,8 @@
filled
v-model="formData.auth_allowed_methods"
multiple
:hint="$t('auth_allowed_methods_hint')"
:label="$t('auth_allowed_methods_label')"
hint="Allowed authorization methods"
label="Select authorization methods"
:options="formData.auth_all_methods"
></q-select>
</div>
@@ -52,8 +50,8 @@
v-model="nostrAcceptedUrl"
@keydown.enter="addNostrUrl"
type="text"
:label="$t('auth_nostr_label')"
:hint="$t('auth_nostr_hint')"
label="Nostr Request URL"
hint="Absolute URL that the clients will use to login."
>
<q-btn @click="addNostrUrl" dense flat icon="add"></q-btn>
</q-input>
@@ -83,8 +81,8 @@
<q-input
filled
v-model="formData.google_client_id"
:label="$t('auth_google_ci_label')"
:hint="$t('auth_google_ci_hint')"
label="Google Client ID"
hint="Make sure thant the authorized redirect URIs contain https://{domain}/api/v1/auth/google/token"
>
</q-input>
</div>
@@ -93,7 +91,7 @@
filled
v-model="formData.google_client_secret"
type="password"
:label="$t('auth_google_cs_label')"
label="Google Client Secret"
>
</q-input>
</div>
@@ -110,8 +108,8 @@
<q-input
filled
v-model="formData.github_client_id"
:label="$t('auth_gh_client_id_label')"
:hint="$t('auth_gh_client_id_hint')"
label="GitHub Client ID"
hint="Make sure thant the authorization callback URL is set to https://{domain}/api/v1/auth/github/token"
>
</q-input>
</div>
@@ -120,7 +118,7 @@
filled
v-model="formData.github_client_secret"
type="password"
:label="$t('auth_gh_client_secret_label')"
label="GitHub Client Secret"
>
</q-input>
</div>
@@ -132,21 +130,21 @@
>
<strong class="q-my-none q-mb-sm">Keycloak Auth</strong>
<div class="row q-col-gutter-sm q-col-gutter-y-md">
<div class="col-md-4 col-sm-12">
<div class="row">
<div class="col-md-4 col-sm-12 q-pr-sm">
<q-input
filled
v-model="formData.keycloak_discovery_url"
:label="$t('auth_keycloak_label')"
label="Keycloak Discovey URL"
>
</q-input>
</div>
<div class="col-md-4 col-sm-12">
<div class="col-md-4 col-sm-12 q-pr-sm">
<q-input
filled
v-model="formData.keycloak_client_id"
:label="$t('auth_keycloak_ci_label')"
:hint="$t('auth_keycloak_ci_hint')"
label="Keycloak Client ID"
hint="Make sure thant the authorization callback URL is set to https://{domain}/api/v1/auth/keycloak/token"
>
</q-input>
</div>
@@ -155,23 +153,7 @@
filled
v-model="formData.keycloak_client_secret"
type="password"
:label="$t('auth_keycloak_cs_label')"
>
</q-input>
</div>
<div class="col-md-4 col-sm-12">
<q-input
filled
v-model="formData.keycloak_client_custom_org"
:label="$t('auth_keycloak_custom_org_label')"
>
</q-input>
</div>
<div class="col-md-8 col-sm-12">
<q-input
filled
v-model="formData.keycloak_client_custom_icon"
:label="$t('auth_keycloak_custom_icon_label')"
label="Keycloak Client Secret"
>
</q-input>
</div>
@@ -299,37 +281,31 @@
</div>
<div class="col-12 col-md-12">
<p v-text="$t('callback_url_rules')"></p>
<p v-text="$t('wallet_limiter')"></p>
<div class="row q-col-gutter-md">
<div class="col-12">
<div class="col-4">
<q-input
filled
v-model="formCallbackUrlRule"
@keydown.enter="addCallbackUrlRule"
type="text"
:label="$t('enter_callback_url_rule')"
:hint="$t('callback_url_rule_hint')"
>
<q-btn
@click="addCallbackUrlRule"
dense
flat
icon="add"
></q-btn>
</q-input>
<div>
<q-chip
v-for="rule in formData.lnbits_callback_url_rules"
:key="rule"
removable
@remove="removeCallbackUrlRule(rule)"
color="primary"
text-color="white"
:label="rule"
class="ellipsis"
></q-chip>
</div>
<br />
type="number"
v-model.number="formData.lnbits_wallet_limit_max_balance"
:label="$t('wallet_max_ballance')"
></q-input>
</div>
<div class="col-4">
<q-input
filled
type="number"
v-model.number="formData.lnbits_wallet_limit_daily_max_withdraw"
:label="$t('wallet_limit_max_withdraw_per_day')"
></q-input>
</div>
<div class="col-4">
<q-input
filled
type="number"
v-model.number="formData.lnbits_wallet_limit_secs_between_trans"
:label="$t('wallet_limit_secs_between_trans')"
></q-input>
</div>
</div>
</div>
@@ -0,0 +1,77 @@
<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>
+26 -91
View File
@@ -1,144 +1,79 @@
<q-tab-panel name="server">
<q-card-section class="q-pa-none">
<div>
<h6 class="q-my-none"><span v-text="$t('currency_settings')"></span></h6>
<h6 class="q-my-none">Currency Settings</h6>
<div class="row q-col-gutter-md">
<div class="col-12 col-md-6">
<p><span v-text="$t('allowed_currencies')"></span></p>
<p>Allowed currencies</p>
<q-select
filled
v-model="formData.lnbits_allowed_currencies"
multiple
:hint="$t('allowed_currencies_hint')"
:label="$t('allowed_currencies')"
hint="Limit the number of available fiat currencies"
label="Allowed currencies"
:options="{{ currencies | safe }}"
></q-select>
<br />
</div>
<div class="col-12 col-md-6">
<p><span v-text="$t('default_account_currency')"></span></p>
<p>Default Accounting Currency</p>
<q-select
filled
v-model="formData.lnbits_default_accounting_currency"
clearable
:hint="$t('default_account_currency_hint')"
:label="$t('currency')"
hint="Default currency for accounting"
label="Currency"
:options="formData.lnbits_allowed_currencies?.length ? formData.lnbits_allowed_currencies : {{ currencies }}"
></q-select>
<br />
</div>
</div>
<q-separator class="q-mb-lg q-mt-sm"></q-separator>
<h6 class="q-my-none"><span v-text="$t('payments')"></span></h6>
<div class="row q-col-gutter-md">
<div class="col-12 col-md-3">
<p><span v-text="$t('max_outgoing_payment_amount')"></span></p>
<q-input
filled
type="number"
v-model.number="formData.lnbits_max_outgoing_payment_amount_sats"
:label="$t('max_outgoing_payment_amount')"
step="1"
min="0"
:hint="$t('max_outgoing_payment_amount_desc')"
></q-input>
</div>
<div class="col-12 col-md-3">
<p><span v-text="$t('max_incoming_payment_amount')"></span></p>
<q-input
filled
type="number"
v-model.number="formData.lnbits_max_incoming_payment_amount_sats"
:label="$t('max_incoming_payment_amount')"
step="1"
min="0"
:hint="$t('max_incoming_payment_amount_desc')"
></q-input>
</div>
<div class="col-12 col-md-6"></div>
</div>
<q-separator class="q-mb-lg q-mt-sm"></q-separator>
<h6 class="q-my-none"><span v-text="$t('wallet_limiter')"></span></h6>
<div class="row q-col-gutter-md">
<div class="col-12 col-md-3">
<q-input
filled
type="number"
v-model.number="formData.lnbits_wallet_limit_max_balance"
:label="$t('wallet_max_ballance')"
></q-input>
</div>
<div class="col-12 col-md-3">
<q-input
filled
type="number"
v-model.number="formData.lnbits_wallet_limit_daily_max_withdraw"
:label="$t('wallet_limit_max_withdraw_per_day')"
></q-input>
</div>
<div class="col-12 col-md-3">
<q-input
filled
type="number"
v-model.number="formData.lnbits_wallet_limit_secs_between_trans"
:label="$t('wallet_limit_secs_between_trans')"
></q-input>
</div>
<div class="col-12 col-md-3">
<q-toggle
v-model="formData.lnbits_only_allow_incoming_payments"
:label="$t('only_incoming_payments_allowed')"
><q-tooltip v-text="$t('disable_outgoing_payments')"></q-tooltip
></q-toggle>
</div>
</div>
<q-separator class="q-mb-lg q-mt-sm"></q-separator>
<h6 class="q-my-none"><span v-text="$t('service_fee')"></span></h6>
<br />
<h6 class="q-my-none">Service Fee</h6>
<div class="row q-col-gutter-md">
<div class="col-12 col-md-6">
<p><span v-text="$t('service_fee')"></span></p>
<p>Service Fee</p>
<q-input
filled
type="number"
v-model.number="formData.lnbits_service_fee"
:label="$t('service_fee_label')"
label="Service fee (%)"
step="0.1"
:hint="$t('service_fee_hint')"
hint="Fee charged per tx (%)"
></q-input>
<br />
</div>
<div class="col-12 col-md-6">
<p><span v-text="$t('service_fee_max')"></span></p>
<p>Service fee max</p>
<q-input
filled
type="number"
v-model.number="formData.lnbits_service_fee_max"
:label="$t('service_fee_max_label')"
:hint="$t('service_fee_max_hint')"
label="Service fee max (sats)"
hint="Max service fee to charge in (sats)"
></q-input>
<br />
</div>
<div class="col-12 col-md-6">
<p><span v-text="$t('fee_wallet')"></span></p>
<p>Fee Wallet</p>
<q-input
filled
v-model="formData.lnbits_service_fee_wallet"
:label="$t('fee_wallet_label')"
:hint="$t('fee_wallet_hint')"
label="Fee wallet (wallet ID)"
hint="Wallet ID to send funds to"
></q-input>
<br />
</div>
<div class="col-12 col-md-6">
<p><span v-text="$t('disable_fee_internal')"></span></p>
<p>Disable Service Fee for Internal Payments</p>
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label v-text="$t('disable_fee')"></q-item-label>
<q-item-label
caption
v-text="$t('disable_fee_internal_desc')"
></q-item-label>
<q-item-label>Disable Fee</q-item-label>
<q-item-label caption
>Disable Service Fee for Internal Lightning
Payments</q-item-label
>
</q-item-section>
<q-item-section avatar>
<q-toggle
+40 -92
View File
@@ -1,72 +1,82 @@
<q-tab-panel name="site_customisation">
<q-card-section class="q-pa-none">
<h6 class="q-my-none"><span v-text="$t('ui_management')"></span></h6>
<h6 class="q-my-none">UI Management</h6>
<br />
<div>
<div class="row q-col-gutter-md">
<div class="col-12 col-md-5">
<p><span v-text="$t('ui_site_title')"></span></p>
<p>Site Title</p>
<q-input
filled
type="text"
v-model="formData.lnbits_site_title"
:label="$t('ui_site_title') + $t('ui_changing_remove_lnbits_elements')"
label="Site title"
></q-input>
<br />
</div>
<div class="col-12 col-md-5">
<p><span v-text="$t('ui_site_tagline')"></span></p>
<p>Site Tagline</p>
<q-input
filled
type="text"
v-model="formData.lnbits_site_tagline"
:label="$t('ui_site_tagline')"
label="Site tagline"
></q-input>
<br />
</div>
<div class="col-12 col-md-2 q-mt-xl">
<q-toggle
:tip="$t('ui_toggle_elements_tip')"
tip="Remove homepage elements like 'runs on' etc"
v-model="formData.lnbits_show_home_page_elements"
:label="formData.lnbits_show_home_page_elements ? $t('ui_elements_enable') : $t('ui_elements_disable')"
:label="formData.lnbits_show_home_page_elements ? 'Enable elements on homepage' : 'Disable elements on homepage'"
></q-toggle>
</div>
</div>
<div>
<p><span v-text="$t('ui_site_description')"></span></p>
<p>Site Description</p>
<q-input
v-model="formData.lnbits_site_description"
filled
type="textarea"
:hint="$t('ui_site_description_hint')"
hint="Use plain text, Markdown, or raw HTML"
/>
</div>
<br />
<div class="row q-col-gutter-md">
<div class="col-12 col-md-4">
<p><span v-text="$t('ui_default_wallet_name')"></span></p>
<p>Default Wallet Name</p>
<q-input
filled
type="text"
v-model="formData.lnbits_default_wallet_name"
:label="$t('lnbits_wallet')"
label="LNbits wallet"
></q-input>
</div>
<div class="col-12 col-md-4">
<p><span v-text="$t('ui_qr_code_logo')"></span></p>
<p>Denomination</p>
<q-input
filled
type="text"
v-model="formData.lnbits_denomination"
label="sats"
hint="The name for the FakeWallet token"
></q-input>
</div>
<div class="col-12 col-md-4">
<p>QR code logo</p>
<q-input
filled
type="text"
v-model="formData.lnbits_qr_logo"
label="https://example.com/image.svg"
:hint="$t('ui_qr_code_logo_hint')"
hint="URL to logo image in QR code"
></q-input>
</div>
</div>
<div class="row q-col-gutter-md q-mt-md">
<div class="col-12 col-md-6">
<p><span v-text="$t('ui_custom_badge')"></span></p>
<div class="row q-col-gutter-md">
<div class="col-12 col-md-4">
<p>Custom Badge</p>
<div class="row q-col-gutter-md">
<div class="col-12 col-md-8">
<q-input
@@ -74,7 +84,7 @@
type="text"
tip="Custom Badge"
v-model.trim="formData.lnbits_custom_badge"
:label="$t('ui_custom_badge_label')"
label="Custom Badge 'USE WITH CAUTION - LNbits wallet is still in BETA'"
></q-input>
</div>
<div class="col-12 col-md-4">
@@ -82,129 +92,67 @@
filled
v-model="formData.lnbits_custom_badge_color"
:options="colors"
:label="$t('ui_custom_badge_color_label')"
label="Custom badge color"
></q-select>
</div>
</div>
</div>
<div class="col-12 col-md-6">
<p><span v-text="$t('ui_custom_image')"></span></p>
<q-input
filled
type="text"
tip="Custom Image"
v-model.trim="formData.lnbits_custom_image"
:label="$t('ui_custom_image_label')"
:hint="$t('ui_custom_image_hint')"
></q-input>
</div>
</div>
<br />
<div class="row q-col-gutter-md">
<div class="col-12 col-md-6">
<p><span v-text="$t('themes')"></span></p>
<p>Themes</p>
<q-select
filled
v-model="formData.lnbits_theme_options"
multiple
:hint="$t('themes_hint')"
hint="Choose themes available for users"
:options="lnbits_theme_options"
:label="$t('themes')"
label="Themes"
></q-select>
<br />
</div>
<div class="col-12 col-md-6">
<p><span v-text="$t('custom_logo')"></span></p>
<p>Custom Logo</p>
<q-input
filled
type="text"
v-model="formData.lnbits_custom_logo"
label="https://example.com/image.png"
:hint="$t('custom_logo_hint')"
hint="URL to logo image"
></q-input>
<br />
</div>
</div>
<div class="row q-col-gutter-md">
<div class="col-12 col-md-6">
<p><span v-text="$t('ad_space_title')"></span></p>
<p>Ad Space Title</p>
<q-input
filled
type="text"
v-model="formData.lnbits_ad_space_title"
:label="$t('ad_space_title_label')"
label="Supported by"
></q-input>
<br />
</div>
<div class="col-12 col-md-6">
<p><span v-text="$t('ad_slots')"></span></p>
<p>Advertisement Slots</p>
<q-input
class="q-mb-md"
filled
v-model="formData.lnbits_ad_space"
type="text"
:label="$t('ad_slots_label')"
:hint="$t('ad_slots_hint')"
><q-tooltip>
format {url};{img-light};{img-dark},{url};{img-light};{img-dark}"
</q-tooltip>
label="url;img_light_url;img_dark_url, url..."
hint="Ad url and image filepaths in CSV format, extensions can choose to honor"
>
</q-input>
<q-toggle
v-model="formData.lnbits_ad_space_enabled"
:label="formData.lnbits_ad_space_enabled ? $t('ads_enabled') : $t('ads_disabled')"
:label="formData.lnbits_ad_space_enabled ? 'Ads enabled' : 'Ads disabled'"
/>
<br />
</div>
</div>
<div class="row q-col-gutter-md q-my-md">
<div class="col-12" v-text="$t('ui_default_theme')"></div>
<div class="col-12 col-sm-6 col-lg-3">
<q-select
v-model="formData.lnbits_default_border"
:options="globalBorderOptions"
label="Border"
@update:model-value="applyGlobalBorder"
>
</q-select>
</div>
<div class="col-12 col-sm-6 col-lg-3">
<q-select
v-model="formData.lnbits_default_theme"
:options="lnbits_theme_options"
label="Theme"
@update:model-value="applyGlobalTheme"
>
</q-select>
</div>
<div class="col-12 col-sm-6 col-lg-3">
<q-select
v-model="formData.lnbits_default_reaction"
:options="reactionOptions"
label="Payment reaction"
@update:model-value="applyGlobalReaction"
>
</q-select>
</div>
<div class="col-12 col-sm-6 col-lg-3">
<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-12 col-sm-6 col-lg-3">
<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>
+11 -14
View File
@@ -1,19 +1,17 @@
<q-tab-panel name="users">
<q-card-section class="q-pa-none">
<h6 class="q-my-none q-mb-sm">
<span v-text="$t('user_management')"></span>
</h6>
<h6 class="q-my-none q-mb-sm">User Management</h6>
<div class="row">
<div class="col-12 col-md-6 q-pr-sm">
<p><span v-text="$t('admin_users')"></span></p>
<p>Admin Users</p>
<q-input
filled
v-model="formAddAdmin"
@keydown.enter="addAdminUser"
type="text"
:label="$t('admin_users_label')"
:hint="$t('admin_users_hint')"
label="User ID"
hint="Users with admin privileges"
>
<q-btn @click="addAdminUser" dense flat icon="add"></q-btn>
</q-input>
@@ -33,14 +31,14 @@
<br />
</div>
<div class="col-12 col-md-6">
<p><span v-text="$t('allowed_users')"></span></p>
<p>Allowed Users</p>
<q-input
filled
v-model="formAddUser"
@keydown.enter="addAllowedUser"
type="text"
:label="$t('allowed_users_label')"
:hint="$t('allowed_users_hint')"
label="User ID"
hint="Only these users can use LNbits"
>
<q-btn @click="addAllowedUser" dense flat icon="add"></q-btn>
</q-input>
@@ -60,11 +58,10 @@
<br />
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label v-text="$t('allow_creation_user')"></q-item-label>
<q-item-label
caption
v-text="$t('allow_creation_user_desc')"
></q-item-label>
<q-item-label>Allow creation of new users</q-item-label>
<q-item-label caption
>Allow creation of new users on the index page</q-item-label
>
</q-item-section>
<q-item-section avatar>
<q-toggle
+72 -106
View File
@@ -1,75 +1,60 @@
{% if not ajax %} {% extends "base.html" %} {% endif %} {% from "macros.jinja"
import window_vars with context %} {% block scripts %} {{ window_vars(user) }}
{% endblock %} {% block page %}
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
%} {% block page %}
<div class="row q-col-gutter-md justify-center">
<div class="col q-mb-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>
<div class="row q-col-gutter-md q-mb-md">
<div class="col-12">
<q-card>
<div class="q-pa-sm">
<div class="row items-center justify-between q-gutter-xs">
<div class="col">
<q-btn
:label="$t('save')"
color="primary"
@click="updateSettings"
:disabled="!checkChanges"
>
<q-tooltip v-if="checkChanges">
<span v-text="$t('save_tooltip')"></span>
</q-tooltip>
<q-badge
v-if="checkChanges"
color="red"
rounded
floating
style="padding: 6px; border-radius: 6px"
/>
</q-btn>
<q-badge
v-if="checkChanges"
color="red"
rounded
floating
style="padding: 6px; border-radius: 6px"
/>
</q-btn>
<q-btn
v-if="isSuperUser"
:label="$t('restart')"
color="primary"
@click="restartServer"
class="q-ml-md"
>
<q-tooltip v-if="needsRestart">
<span v-text="$t('restart_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="needsRestart"
color="red"
rounded
floating
style="padding: 6px; border-radius: 6px"
/>
</q-btn>
<q-badge
v-if="needsRestart"
color="red"
rounded
floating
style="padding: 6px; border-radius: 6px"
/>
</q-btn>
<q-btn :label="$t('download_backup')" flat @click="downloadBackup"></q-btn>
<q-btn
:label="$t('download_backup')"
flat
@click="downloadBackup"
></q-btn>
<q-btn
flat
v-if="isSuperUser"
:label="$t('reset_defaults')"
color="primary"
@click="deleteSettings"
class="float-right"
>
<q-tooltip>
<span v-text="$t('reset_defaults_tooltip')"></span>
</q-tooltip>
</q-btn>
</div>
<div></div>
</div>
</div>
</q-card>
<q-btn
flat
v-if="isSuperUser"
:label="$t('reset_defaults')"
color="primary"
@click="deleteSettings"
class="float-right"
>
<q-tooltip>
<span v-text="$t('reset_defaults_tooltip')"></span>
</q-tooltip>
</q-btn>
</div>
</div>
@@ -115,13 +100,6 @@ import window_vars with context %} {% block scripts %} {{ window_vars(user) }}
><q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('exchanges')"></span></q-tooltip
></q-tab>
<q-tab
name="fiat_providers"
icon="credit_score"
:label="$q.screen.gt.sm ? $t('fiat_providers') : null"
><q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('fiat_providers')"></span></q-tooltip
></q-tab>
<q-tab
name="users"
icon="group"
@@ -156,14 +134,6 @@ import window_vars with context %} {% block scripts %} {{ window_vars(user) }}
><q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('audit')"></span></q-tooltip
></q-tab>
<q-tab
name="library"
icon="image"
:label="$q.screen.gt.sm ? $t('library') : null"
@update="val => tab = val.name"
><q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('library')"></span></q-tooltip
></q-tab>
<q-tab
style="word-break: break-all"
name="site_customisation"
@@ -178,27 +148,22 @@ import window_vars with context %} {% block scripts %} {{ window_vars(user) }}
<template v-slot:after>
<q-form name="settings_form" id="settings_form">
<q-scroll-area style="height: 100vh">
<q-tab-panels
v-model="tab"
animated
swipeable
vertical
scroll
transition-prev="jump-up"
transition-next="jump-up"
>
{% include "admin/_tab_funding.html" %} {% include
"admin/_tab_users.html" %} {% include "admin/_tab_server.html"
%} {% include "admin/_tab_exchange_providers.html" %}{% include
"admin/_tab_fiat_providers.html" %} {% include
"admin/_tab_extensions.html" %} {% include
"admin/_tab_notifications.html" %} {% include
"admin/_tab_security.html" %} {% include "admin/_tab_theme.html"
%}{% include "admin/_tab_audit.html"%}{% include
"admin/_tab_library.html"%}
</q-tab-panels>
</q-scroll-area>
<q-tab-panels
v-model="tab"
animated
swipeable
vertical
transition-prev="jump-up"
transition-next="jump-up"
>
{% include "admin/_tab_funding.html" %} {% include
"admin/_tab_users.html" %} {% include "admin/_tab_server.html" %}
{% include "admin/_tab_exchange_providers.html" %} {% include
"admin/_tab_extensions.html" %} {% include
"admin/_tab_notifications.html" %} {% include
"admin/_tab_security.html" %} {% include "admin/_tab_theme.html"
%}{% include "admin/_tab_audit.html"%}
</q-tab-panels>
</q-form>
</template>
</q-splitter>
@@ -208,9 +173,7 @@ import window_vars with context %} {% block scripts %} {{ window_vars(user) }}
<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>
<strong>Create Currecny Ticker Converter</strong>
<div class="row">
<div class="col-12 q-mb-md">
<q-select
@@ -248,4 +211,7 @@ import window_vars with context %} {% block scripts %} {{ window_vars(user) }}
</div>
</q-card>
</q-dialog>
{% endblock %} {% block scripts %} {{ window_vars(user) }}
<script src="{{ static_url_for('static', 'js/admin.js') }}"></script>
{% endblock %}
+19 -41
View File
@@ -1,63 +1,36 @@
{% 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>
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
%} {% block page %}
<div class="row q-col-gutter-md justify-center q-mb-lg">
<div class="col-lg-3 col-md-6 col-sm-12 text-center">
<q-card class="q-pt-sm">
<strong v-text="$t('components')"></strong>
<strong>Components</strong>
<div style="width: 250px" class="q-pa-sm">
<canvas v-if="chartsReady" ref="componentUseChart"></canvas>
<canvas ref="componentUseChart"></canvas>
</div>
</q-card>
</div>
<div class="col-lg-3 col-md-6 col-sm-12 text-center">
<q-card class="q-pt-sm">
<strong v-text="$t('long_running_endpoints')"></strong>
<strong>To 5 Long Running Endpoints</strong>
<div style="width: 250px; height: 250px" class="q-pa-sm">
<canvas v-if="chartsReady" ref="longDurationChart"></canvas>
<canvas ref="longDurationChart"></canvas>
</div>
</q-card>
</div>
<div class="col-lg-3 col-md-6 col-sm-12 text-center">
<q-card class="q-pt-sm">
<strong v-text="$t('http_request_methods')"></strong>
<strong>HTTP Request Methods</strong>
<div style="width: 250px; height: 250px" class="q-pa-sm">
<canvas v-if="chartsReady" ref="requestMethodChart"></canvas>
<canvas ref="requestMethodChart"></canvas>
</div>
</q-card>
</div>
<div class="col-lg-3 col-md-6 col-sm-12 text-center">
<q-card class="q-pt-sm">
<strong v-text="$t('http_response_codes')"></strong>
<strong>HTTP Response Codes</strong>
<div style="width: 250px; height: 250px" class="q-pa-sm">
<canvas v-if="chartsReady" ref="responseCodeChart"></canvas>
<canvas ref="responseCodeChart"></canvas>
</div>
</q-card>
</div>
@@ -110,6 +83,11 @@
<span v-else v-text="col.label"></span>
</q-th>
<q-th>
<q-btn flat round icon="settings" tag="a" href="/admin#audit"
><q-tooltip v-text="$t('admin_settings')"></q-tooltip
></q-btn>
</q-th>
</q-tr>
</template>
<template v-slot:body="props">
@@ -124,9 +102,7 @@
class="cursor-pointer q-mr-xs"
@click="showDetailsDialog(props.row)"
>
<q-tooltip
><span v-text="$t('request_details')"></span
></q-tooltip>
<q-tooltip>Request Details</q-tooltip>
</q-btn>
<span v-text="formatDate(props.row.created_at)"></span>
@@ -170,7 +146,7 @@
<q-dialog v-model="auditDetailsDialog.show" position="top">
<q-card class="q-pa-md q-pt-md lnbits__dialog-card">
<strong v-text="$t('http_request_details')"></strong>
<strong>HTTP Request Details</strong>
<q-input
filled
dense
@@ -198,4 +174,6 @@
</q-card>
</q-dialog>
{% endblock %} {% block scripts %} {{ window_vars(user) }}
<script src="{{ static_url_for('static', 'js/audit.js') }}"></script>
{% endblock %}
+8 -79
View File
@@ -48,17 +48,6 @@
class="cursor-pointer q-ml-sm"
@click="copyText(wallet.adminkey)"
></q-icon>
<q-icon name="qr_code" class="cursor-pointer q-ml-sm">
<q-popup-proxy>
<div class="q-pa-md">
<lnbits-qrcode
:value="wallet.adminkey"
:options="{ width: 250 }"
class="rounded-borders"
></lnbits-qrcode>
</div>
</q-popup-proxy>
</q-icon>
</div>
</q-item-section>
</q-item>
@@ -81,17 +70,6 @@
class="cursor-pointer q-ml-sm"
@click="copyText(wallet.inkey)"
></q-icon>
<q-icon name="qr_code" class="cursor-pointer q-ml-sm">
<q-popup-proxy>
<div class="q-pa-md">
<lnbits-qrcode
:value="wallet.inkey"
:options="{ width: 250 }"
class="rounded-borders"
></lnbits-qrcode>
</div>
</q-popup-proxy>
</q-icon>
</div>
</q-item-section>
</q-item>
@@ -107,12 +85,7 @@
<q-card-section>
<code><span class="text-light-green">GET</span> /api/v1/wallet</code>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code
>{"X-Api-Key": "<i
v-text="inkeyHidden ? '****************' : wallet.inkey"
></i
>"}</code
><br />
<code>{"X-Api-Key": "<i v-text="wallet.inkey"></i>"}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
@@ -123,8 +96,7 @@
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl <span v-text="baseUrl"></span>api/v1/wallet -H "X-Api-Key:
<i v-text="inkeyHidden ? '****************' : wallet.inkey"></i
>"</code
<i v-text="wallet.inkey"></i>"</code
>
</q-card-section>
</q-card>
@@ -140,12 +112,7 @@
<q-card-section>
<code><span class="text-light-green">POST</span> /api/v1/payments</code>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code
>{"X-Api-Key": "<i
v-text="inkeyHidden ? '****************' : wallet.inkey"
></i
>"}</code
><br />
<code>{"X-Api-Key": "<i v-text="wallet.inkey"></i>"}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<code
>{"out": false, "amount": &lt;int&gt;, "memo": &lt;string&gt;,
@@ -163,9 +130,8 @@
<code
>curl -X POST <span v-text="baseUrl"></span>api/v1/payments -d
'{"out": false, "amount": &lt;int&gt;, "memo": &lt;string&gt;}' -H
"X-Api-Key:
<i v-text="inkeyHidden ? '****************' : wallet.inkey"></i>" -H
"Content-type: application/json"</code
"X-Api-Key: <i v-text="wallet.inkey"></i>" -H "Content-type:
application/json"</code
>
</q-card-section>
</q-card>
@@ -252,12 +218,7 @@
/api/v1/payments/&lt;payment_hash&gt;</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code
>{"X-Api-Key": "<i
v-text="inkeyHidden ? '****************' : wallet.inkey"
></i
>"}</code
>
<code>{"X-Api-Key": "{{ wallet.inkey }}"}</code>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
@@ -266,42 +227,10 @@
<code
>curl -X GET
<span v-text="baseUrl"></span>api/v1/payments/&lt;payment_hash&gt; -H
"X-Api-Key:
<i v-text="inkeyHidden ? '****************' : wallet.inkey"></i>" -H
"Content-type: application/json"</code
"X-Api-Key: <i v-text="wallet.inkey"></i>" -H "Content-type:
application/json"</code
>
</q-card-section>
</q-card>
<q-card>
<q-card-section>
<code
><span class="text-pink">WS</span>
/api/v1/ws/&lt;invoice_key&gt;</code
>
<h5
class="text-caption q-mt-sm q-mb-none"
v-text="$t('websocket_example')"
></h5>
<code
>wscat -c <span v-text="websocketUrl"></span>/<span
v-text="inkeyHidden ? '****************' : wallet.inkey"
></span
></code>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)/payments
</h5>
<code>{"balance": &lt;int&gt;, "payment": &lt;object&gt;}</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-separator></q-separator>
<q-card-section>
<p v-text="$t('reset_wallet_keys_desc')"></p>
<q-btn
unelevated
color="red-10"
@click="resetKeys()"
:label="$t('reset_wallet_keys')"
></q-btn>
</q-card-section>
</q-expansion-item>
+57 -447
View File
@@ -1,51 +1,39 @@
{% if not ajax %} {% extends "base.html" %} {% endif %}
{% extends "base.html" %}
<!---->
{% from "macros.jinja" import window_vars with context %}
<!---->
{% block scripts %} {{ window_vars(user) }}{% endblock %} {% block page %}
<div class="row q-col-gutter-md q-mb-md">
<div class="col-12">
<q-card>
<div>
<div class="q-gutter-y-md">
<q-tabs v-model="tab" align="left">
<q-tab
name="user"
:label="$t('account_settings')"
@update="val => tab = val.name"
></q-tab>
<q-tab
name="theme"
:label="$t('look_and_feel')"
@update="val => tab = val.name"
></q-tab>
<q-tab
name="api_acls"
:label="$t('access_control_list')"
@update="val => tab = val.name"
></q-tab>
</q-tabs>
</div>
</div>
</q-card>
</div>
</div>
{% block scripts %} {{ window_vars(user) }}
<script src="{{ static_url_for('static', 'js/account.js') }}"></script>
{% endblock %} {% block page %}
<div class="row q-col-gutter-md">
<div v-if="user" class="col-md-12 col-lg-6 q-gutter-y-md">
<div v-if="user" class="col-12 col-md-6 q-gutter-y-md">
<q-card>
<q-card-section>
<div class="row">
<div class="col">
<q-tabs v-model="tab" align="justify">
<q-tab
name="user"
:label="$t('account_settings')"
@update="val => tab = val.name"
></q-tab>
<q-tab
name="theme"
:label="$t('look_and_feel')"
@update="val => tab = val.name"
></q-tab>
</q-tabs>
<q-tab-panels v-model="tab">
<q-tab-panel name="user">
<div v-if="credentialsData.show">
<q-separator></q-separator>
<q-card-section>
<div class="row">
<div class="col">
<h4 class="q-my-none">
<span v-text="$t('password')"></span>
<span v-text="$t('password_config')"></span>
</h4>
</div>
<div class="col">
@@ -102,7 +90,7 @@
unelevated
color="primary"
class="float-right"
:label="$t('update_password')"
:label="$t('change_password')"
>
</q-btn>
</q-card-section>
@@ -110,7 +98,7 @@
<q-card-section>
<div class="col q-mb-sm">
<h4 class="q-my-none">
Nostr <span v-text="$t('pubkey')"></span>
<span v-text="$t('pubkey')"></span>
</h4>
</div>
<q-input
@@ -141,10 +129,11 @@
</q-card-section>
</div>
<div v-else>
<q-card-section v-if="user.extra.picture">
<q-card-section>
<div class="row">
<div class="col">
<q-img
v-if="user.extra.picture"
style="max-width: 100px"
:src="user.extra.picture"
class="float-right"
@@ -152,6 +141,7 @@
</div>
</div>
</q-card-section>
<q-separator></q-separator>
<q-card-section>
<q-input
@@ -272,21 +262,10 @@
class="q-mb-md"
>
</q-input>
<q-input
v-model="user.external_id"
:label="$t('external_id')"
filled
dense
readonly
class="q-mb-md"
>
</q-input>
<q-input
v-model="user.extra.picture"
:label="$t('picture')"
filled
dense
class="q-mb-md"
>
</q-input>
@@ -298,7 +277,7 @@
</q-btn>
<q-btn
@click="showUpdateCredentials()"
:label="$t('change_password')"
:label="$t('update_credentials')"
filled
color="primary"
class="float-right"
@@ -313,7 +292,8 @@
flat
round
icon="settings"
to="/admin#site_customisation"
tag="a"
href="/admin#site_customisation"
><q-tooltip v-text="$t('admin_settings')"></q-tooltip
></q-btn>
<div class="row q-mb-md">
@@ -346,111 +326,95 @@
</q-btn-dropdown>
</div>
</div>
<div class="row q-mb-md">
<div class="col-4">
<span v-text="$t('visible_wallet_count')"></span>
</div>
<div class="col-8">
<q-input
v-model="user.extra.visible_wallet_count"
:label="$t('visible_wallet_count')"
filled
dense
type="number"
class="q-mb-md"
></q-input>
</div>
</div>
<div class="row q-mb-md">
<div class="col-4">
<span v-text="$t('color_scheme')"></span>
</div>
<div class="col-8">
<q-btn
v-if="allowedThemes.includes('classic')"
v-if="g.allowedThemes.includes('classic')"
dense
flat
@click="changeTheme('classic')"
@click="changeColor('classic')"
icon="circle"
color="deep-purple"
size="md"
><q-tooltip>classic</q-tooltip>
</q-btn>
<q-btn
v-if="allowedThemes.includes('bitcoin')"
v-if="g.allowedThemes.includes('bitcoin')"
dense
flat
@click="changeTheme('bitcoin')"
@click="changeColor('bitcoin')"
icon="circle"
color="deep-orange"
color="orange"
size="md"
><q-tooltip>bitcoin</q-tooltip>
</q-btn>
<q-btn
v-if="allowedThemes.includes('mint')"
v-if="g.allowedThemes.includes('mint')"
dense
flat
@click="changeTheme('mint')"
@click="changeColor('mint')"
icon="circle"
color="green"
size="md"
><q-tooltip>mint</q-tooltip> </q-btn
><q-btn
v-if="allowedThemes.includes('autumn')"
v-if="g.allowedThemes.includes('autumn')"
dense
flat
@click="changeTheme('autumn')"
@click="changeColor('autumn')"
icon="circle"
color="brown"
size="md"
><q-tooltip>autumn</q-tooltip>
</q-btn>
<q-btn
v-if="allowedThemes.includes('monochrome')"
v-if="g.allowedThemes.includes('monochrome')"
dense
flat
@click="changeTheme('monochrome')"
@click="changeColor('monochrome')"
icon="circle"
color="grey"
size="md"
><q-tooltip>monochrome</q-tooltip>
</q-btn>
<q-btn
v-if="allowedThemes.includes('salvador')"
v-if="g.allowedThemes.includes('salvador')"
dense
flat
@click="changeTheme('salvador')"
@click="changeColor('salvador')"
icon="circle"
color="blue-10"
size="md"
><q-tooltip>elSalvador</q-tooltip>
</q-btn>
<q-btn
v-if="allowedThemes.includes('freedom')"
v-if="g.allowedThemes.includes('freedom')"
dense
flat
@click="changeTheme('freedom')"
@click="changeColor('freedom')"
icon="circle"
color="pink-13"
size="md"
><q-tooltip>freedom</q-tooltip>
</q-btn>
<q-btn
v-if="allowedThemes.includes('cyber')"
v-if="g.allowedThemes.includes('cyber')"
dense
flat
@click="changeTheme('cyber')"
@click="changeColor('cyber')"
icon="circle"
color="light-green-9"
size="md"
><q-tooltip>cyber</q-tooltip>
</q-btn>
<q-btn
v-if="allowedThemes.includes('flamingo')"
v-if="g.allowedThemes.includes('flamingo')"
dense
flat
@click="changeTheme('flamingo')"
@click="changeColor('flamingo')"
icon="circle"
color="pink-3"
size="md"
@@ -464,9 +428,9 @@
</div>
<div class="col-8">
<q-input
v-model="bgimageChoice"
v-model="backgroundImage"
:label="$t('background_image')"
@update:model-value="applyBackgroundImage"
@update:model-value="backgroundImageFunc"
>
<q-tooltip
><span v-text="$t('background_image')"></span
@@ -479,18 +443,19 @@
<span v-text="$t('gradient_background')"></span>
</div>
<div class="col-8">
<q-toggle
<q-btn
dense
flat
round
@click="toggleGradient"
icon="gradient"
size="sm"
v-model="gradientChoice"
@update:model-value="applyGradient"
>
<q-tooltip
><span v-text="$t('toggle_gradient')"></span
></q-tooltip>
</q-toggle>
</q-btn>
</div>
</div>
@@ -499,11 +464,10 @@
<span v-text="$t('toggle_darkmode')"></span>
</div>
<div class="col-8">
<q-toggle
<q-btn
dense
flat
round
v-model="darkChoice"
@click="toggleDarkMode"
:icon="($q.dark.isActive) ? 'brightness_3' : 'wb_sunny'"
size="sm"
@@ -511,7 +475,7 @@
<q-tooltip
><span v-text="$t('toggle_darkmode')"></span
></q-tooltip>
</q-toggle>
</q-btn>
</div>
</div>
<div class="row q-mb-md">
@@ -522,7 +486,7 @@
<q-select
v-model="borderChoice"
:options="borderOptions"
label="Borders"
label="Reactions"
@update:model-value="applyBorder"
>
<q-tooltip
@@ -532,9 +496,7 @@
</div>
</div>
<div class="row q-mb-md">
<div class="col-4">
<span v-text="$t('notifications')"></span>
</div>
<div class="col-4">Notifications</div>
<div class="col-8">
<lnbits-notifications-btn
v-if="g.user"
@@ -559,236 +521,6 @@
</q-select>
</div>
</div>
<q-separator></q-separator>
<div class="row q-mb-md q-mt-md">
<q-btn @click="updateAccount" unelevated color="primary">
<span v-text="$t('update_account')"></span>
</q-btn>
</div>
</q-tab-panel>
<q-tab-panel name="api_acls">
<div class="row q-mb-md">
<q-badge v-if="user.admin">
<span
v-text="$t('access_control_list_admin_warning')"
></span>
</q-badge>
</div>
<q-card-section>
<div class="row q-mb-md q-gutter-y-md">
<div class="col-sm-12 col-md-6">
<q-select
v-model="selectedApiAcl.id"
emit-value
map-options
@update:model-value="handleApiACLSelected"
:options="apiAcl.data.map(t => ({label: t.name, value: t.id}))"
:label="$t('access_control_list')"
dense
>
</q-select>
</div>
<div class="col-sm-12 col-md-6">
<q-btn
@click="askPasswordAndRunFunction('newApiAclDialog')"
filled
outline
icon="add"
:label="$t('access_control_list')"
color="grey"
class="float-right"
></q-btn>
</div>
</div>
<div v-if="selectedApiAcl.id">
<div class="row q-mb-md">
<div class="col-sm-12 col-md-6">
<q-select
:options="selectedApiAcl.token_id_list.map(t => ({label: t.name, value: t.id}))"
v-model="apiAcl.selectedTokenId"
emit-value
map-options
:label="$t('api_tokens')"
dense
>
</q-select>
</div>
<div class="col-sm-12 col-md-6 q-pl-sm">
<q-btn
v-if="apiAcl.selectedTokenId"
@click="askPasswordAndRunFunction('deleteToken')"
icon="delete"
filled
color="negative"
class="float-left"
></q-btn>
<q-btn
@click="askPasswordAndRunFunction('newTokenAclDialog')"
outline
icon="add"
:label="$t('api_token')"
filled
color="grey"
class="float-right"
></q-btn>
</div>
</div>
<div v-if="apiAcl.apiToken" class="row q-mb-md">
<div class="col-12">
<q-badge>
<span>Use this token in the HTTP</span>
<strong>
&nbsp;<code>Authorization</code>
&nbsp;
</strong>
<span> header.</span>
</q-badge>
</div>
<div class="col-12">
<table
class="full-width lnbits__table-bordered"
style="
border-collapse: collapse;
background-color: grey;
"
>
<thead>
<tr>
<th>
<span class="float-left">Header Name</span>
</th>
<th>
<span class="float-left">Header Value</span>
</th>
</tr>
</thead>
<tbody>
<tr>
<td>
<strong>Authorization</strong>
</td>
<td>
<div class="row q-pt-sm">
<div class="col-2 q-mt-sm">
<strong>Bearer &nbsp;</strong>
</div>
<div class="col-10">
<q-input
v-model="apiAcl.apiToken"
:label="$t('api_token_id')"
filled
dense
readonly
:type="selectedApiAcl.showId ? 'text': 'password'"
class="q-mb-md"
>
<q-btn
@click="selectedApiAcl.showId = !selectedApiAcl.showId"
dense
flat
:icon="selectedApiAcl.showId ? 'visibility_off' : 'visibility'"
color="black"
></q-btn>
<q-btn
@click="copyText(apiAcl.apiToken)"
icon="content_copy"
color="black"
flat
dense
></q-btn>
</q-input>
</div>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<div class="col-12">
<q-badge>
<span
>Please store this token. It cannot be later
retrieved, only revoked.</span
>
</q-badge>
</div>
</div>
<q-table
row-key="path"
:rows="selectedApiAcl.endpoints"
:columns="apiAcl.columns"
v-model:pagination="apiAcl.pagination"
>
<template v-slot:header="props">
<q-tr :props="props">
<q-th
v-for="col in props.cols"
:key="col.name"
:props="props"
>
<q-toggle
v-if="col.name == 'read'"
v-model="selectedApiAcl.allRead"
@update:model-value="handleAllEndpointsReadAccess()"
:label="$t('read')"
size="sm"
></q-toggle>
<q-toggle
v-else-if="col.name == 'write'"
v-model="selectedApiAcl.allWrite"
@update:model-value="handleAllEndpointsWriteAccess()"
:label="$t('write')"
size="sm"
></q-toggle>
<span v-else v-text="col.label"></span>
</q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td>
<span v-text="props.row.name"></span>
</q-td>
<q-td>
<span v-text="props.row.path"></span>
</q-td>
<q-td>
<q-toggle size="sm" v-model="props.row.read" />
</q-td>
<q-td>
<q-toggle size="sm" v-model="props.row.write" />
</q-td>
</q-tr>
</template>
</q-table>
<q-separator></q-separator>
</div>
<div v-if="selectedApiAcl.id" class="row q-mt-md">
<div class="col-sm-12 col-md-6">
<q-btn
@click="askPasswordAndRunFunction('updateApiACLs')"
:label="$t('update')"
filled
color="primary"
></q-btn>
</div>
<div class="col-sm-12 col-md-6">
<q-btn
@click="askPasswordAndRunFunction('deleteApiACL')"
:label="$t('delete')"
icon="delete"
color="negative"
class="float-right"
>
</q-btn>
</div>
</div>
</q-card-section>
</q-tab-panel>
</q-tab-panels>
</div>
@@ -804,126 +536,4 @@
</q-card>
</div>
</div>
<q-dialog v-model="apiAcl.showPasswordDialog" position="top">
<q-card class="q-pa-md q-pt-md lnbits__dialog-card">
<strong>User Password</strong>
<div class="row q-mt-md q-col-gutter-md">
<div class="col-12">
<q-input
v-model="apiAcl.password"
type="password"
dense
filled
label="Password"
hint="User password is required for this action."
>
</q-input>
</div>
</div>
<div class="row q-mt-lg">
<q-btn
@click="runPasswordGuardedFunction()"
:label="$t('ok')"
color="primary"
></q-btn>
<q-btn
v-close-popup
flat
color="grey"
class="q-ml-auto"
v-text="$t('cancel')"
></q-btn>
</div>
</q-card>
</q-dialog>
<q-dialog v-model="apiAcl.showNewAclDialog" position="top">
<q-card class="q-pa-md q-pt-md lnbits__dialog-card">
<strong>New API Access Control List</strong>
<div class="row q-mt-md q-col-gutter-md">
<div class="col-12">
<q-input v-model="apiAcl.newAclName" dense filled label="ACL Name">
</q-input>
</div>
</div>
<div class="row q-mt-lg">
<q-btn @click="addApiACL()" label="Create" color="primary"></q-btn>
<q-btn
v-close-popup
flat
color="grey"
class="q-ml-auto"
v-text="$t('close')"
></q-btn>
</div>
</q-card>
</q-dialog>
<q-dialog v-model="apiAcl.showNewTokenDialog" position="top">
<q-card class="q-pa-md q-pt-md lnbits__dialog-card">
<strong>New API Token</strong>
<div class="row q-col-gutter-md q-mt-md">
<div class="col-12">
<q-input v-model="apiAcl.newTokenName" dense filled label="Token Name">
</q-input>
</div>
<div class="col-12">
<q-input
v-model="apiAcl.newTokenExpiry"
dense
filled
label="Expiration"
hit="Expiration time in minutes (default xxx)"
>
<template v-slot:prepend>
<q-icon name="event" class="cursor-pointer">
<q-popup-proxy
cover
transition-show="scale"
transition-hide="scale"
>
<q-date v-model="apiAcl.newTokenExpiry" mask="YYYY-MM-DD HH:mm">
<div class="row items-center justify-end">
<q-btn v-close-popup label="Close" color="primary" flat />
</div>
</q-date>
</q-popup-proxy>
</q-icon>
</template>
<template v-slot:append>
<q-icon name="access_time" class="cursor-pointer">
<q-popup-proxy
cover
transition-show="scale"
transition-hide="scale"
>
<q-time
v-model="apiAcl.newTokenExpiry"
mask="YYYY-MM-DD HH:mm"
format24h
>
<div class="row items-center justify-end">
<q-btn v-close-popup label="Close" color="primary" flat />
</div>
</q-time>
</q-popup-proxy>
</q-icon>
</template>
</q-input>
</div>
</div>
<div class="row q-mt-lg">
<q-btn @click="generateApiToken()" label="Create" color="primary"></q-btn>
<q-btn
v-close-popup
flat
color="grey"
class="q-ml-auto"
v-text="$t('close')"
></q-btn>
</div>
</q-card>
</q-dialog>
{% endblock %}
+687 -25
View File
@@ -1,14 +1,10 @@
{% 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 %}
{% 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-12">
<q-card>
<div>
<div class="q-pa-xs">
<div class="q-gutter-y-md">
<q-tabs
:model-value="tab"
@@ -58,7 +54,8 @@
flat
round
icon="settings"
to="/admin#extensions"
tag="a"
href="/admin#extensions"
><q-tooltip v-text="$t('admin_settings')"></q-tooltip
></q-btn>
</q-tabs>
@@ -69,7 +66,6 @@
</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"
@@ -118,7 +114,6 @@
></q-tooltip>
</q-badge>
<div
v-if="extension.name"
class="text-h5"
style="cursor: pointer"
@click="showExtensionDetails(extension.id, extension.details_link)"
@@ -191,7 +186,7 @@
<div class="col-10">
<div v-if="!extension.inProgress">
<q-btn
v-if="g.user.extensions.includes(extension.id) && extension.isActive && extension.isInstalled"
v-if="user.extensions.includes(extension.id) && extension.isActive && extension.isInstalled"
flat
color="primary"
type="a"
@@ -199,20 +194,20 @@
:label="$t('open')"
></q-btn>
<q-btn
v-if="g.user.extensions.includes(extension.id) && extension.isActive && extension.isInstalled"
v-if="user.extensions.includes(extension.id) && extension.isActive && extension.isInstalled"
flat
color="grey-5"
@click="disableExtension(extension)"
:label="$t('disable')"
></q-btn>
<q-badge
v-if="extension.isAdminOnly && !g.user.admin"
v-if="extension.isAdminOnly && !user.admin"
v-text="$t('admin_only')"
>
</q-badge>
<q-btn
v-else-if="extension.isInstalled && extension.isActive && !g.user.extensions.includes(extension.id)"
v-else-if="extension.isInstalled && extension.isActive && !user.extensions.includes(extension.id)"
flat
color="primary"
@click="enableExtensionForUser(extension)"
@@ -235,7 +230,7 @@
</q-btn>
</div>
<div v-else>
<q-spinner-bars color="primary" size="2.55em"></q-spinner-bars>
<q-spinner color="primary" size="2.55em"></q-spinner>
</div>
</div>
@@ -337,7 +332,7 @@
</a>
</div>
<div v-else>
<q-spinner-bars color="primary" size="2.55em"></q-spinner-bars>
<q-spinner color="primary" size="2.55em"></q-spinner>
</div>
</q-card-section>
@@ -436,10 +431,7 @@
:header-class="getReleaseIconColor(release)"
>
<div v-if="release.inProgress">
<q-spinner-bars
color="primary"
size="2.55em"
></q-spinner-bars>
<q-spinner color="primary" size="2.55em"></q-spinner>
</div>
<div v-else-if="release.error">
<q-icon
@@ -450,7 +442,7 @@
></q-icon>
<span v-text="$t('release_details_error')"></span>
</div>
<q-card class="no-border" v-else>
<q-card v-else>
<q-card-section v-if="release.is_version_compatible">
<span
v-if="release.requiresPayment && !release.paid_sats"
@@ -625,7 +617,7 @@
></q-btn>
</div>
</div>
<q-spinner-bars v-else color="primary" size="2.55em"></q-spinner-bars>
<q-spinner v-else color="primary" size="2.55em"></q-spinner>
<div
v-if="selectedExtension"
v-show="manageExtensionTab === 'sell'"
@@ -711,7 +703,7 @@
</a>
</div>
<div v-else class="col">
<q-spinner-bars color="primary" size="2.55em"></q-spinner-bars>
<q-spinner color="primary" size="2.55em"></q-spinner>
</div>
</div>
<div class="row q-mt-lg">
@@ -959,12 +951,12 @@
value="false"
:label="item.name + ` (v${item.latestRelease?.version})`"
>
<q-spinner-bars
<q-spinner
v-if="item.inProgress"
color="primary"
size="1.5em"
class="q-ml-md"
></q-spinner-bars>
></q-spinner>
</q-checkbox>
</div>
</div>
@@ -988,4 +980,674 @@
</div>
</q-card>
</q-dialog>
{% endblock %} {% block scripts %} {{ window_vars(user) }}
<script>
window.app = Vue.createApp({
el: '#vue',
data: function () {
return {
slide: 0,
fullscreen: false,
autoplay: true,
searchTerm: '',
tab: 'all',
manageExtensionTab: 'releases',
filteredExtensions: null,
updatableExtensions: [],
showUninstallDialog: false,
showManageExtensionDialog: false,
showExtensionDetailsDialog: false,
showDropDbDialog: false,
showPayToEnableDialog: false,
showUpdateAllDialog: 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())
)
}
}
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({
timeout: 2000,
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
}
},
selectAllUpdatableExtensionss: async function () {
this.updatableExtensions.forEach(e => (e.selectedForUpdate = true))
},
updateSelectedExtensions: async function () {
let count = 0
for (const ext of this.updatableExtensions) {
try {
if (!ext.selectedForUpdate) {
continue
}
ext.inProgress = true
await LNbits.api.request(
'POST',
`/api/v1/extension`,
this.g.user.wallets[0].adminkey,
{
ext_id: ext.id,
archive: ext.latestRelease.archive,
source_repo: ext.latestRelease.source_repo,
payment_hash: ext.latestRelease.payment_hash,
version: ext.latestRelease.version
}
)
count++
ext.isAvailable = true
ext.isInstalled = true
ext.isUpgraded = true
ext.inProgress = false
ext.installedRelease = ext.latestRelease
this.toggleExtension(ext)
} catch (err) {
console.warn(err)
Quasar.Notify.create({
type: 'negative',
message: `Failed to update ${ext.code}!`
})
} finally {
ext.inProgress = false
}
}
Quasar.Notify.create({
type: 'positive',
message: `${count} extensions updated!`
})
this.showUpdateAllDialog = false
setTimeout(() => {
window.location.reload()
}, 2000)
}
},
created: function () {
this.extensions = JSON.parse('{{extensions | tojson | safe}}').map(e => ({
...e,
inProgress: false,
selectedForUpdate: 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)
}
this.updatableExtensions = this.extensions.filter(ext =>
this.hasNewVersion(ext)
)
},
mixins: [windowMixin]
})
</script>
{% endblock %}
+133 -144
View File
@@ -1,150 +1,139 @@
{% extends "public.html" %} {% block page_container %}
<q-page-container>
<q-page class="q-px-md q-py-lg" :class="{'q-px-lg': $q.screen.gt.xs}">
{% block page %}
<div class="row q-col-gutter-md justify-center main">
<div class="col-10 col-md-8 col-lg-6 q-gutter-y-md">
<q-card>
<q-card-section class="grid">
<div>
<h6 class="q-my-none text-center">
<strong v-text="$t('welcome_lnbits')"></strong>
<p><span v-text="$t('setup_su_account')"></span></p>
</h6>
<br />
<q-form class="q-gutter-md">
<q-input
filled
v-model="loginData.username"
:label="$t('username')"
></q-input>
<q-input
filled
v-model.trim="loginData.password"
:type="loginData.isPwd ? 'password' : 'text'"
autocomplete="off"
:label="$t('password')"
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password')]"
><template v-slot:append>
<q-icon
:name="loginData.isPwd ? 'visibility_off' : 'visibility'"
class="cursor-pointer"
@click="loginData.isPwd = !loginData.isPwd"
/> </template
></q-input>
<q-input
filled
v-model.trim="loginData.passwordRepeat"
:type="loginData.isPwdRepeat ? 'password' : 'text'"
autocomplete="off"
:label="$t('password_repeat')"
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password'), (val) => val === loginData.password || $t('invalid_password_repeat')]"
><template v-slot:append>
<q-icon
:name="loginData.isPwdRepeat ? 'visibility_off' : 'visibility'"
class="cursor-pointer"
@click="loginData.isPwdRepeat = !loginData.isPwdRepeat"
/> </template
></q-input>
<q-btn
@click="setPassword()"
unelevated
color="primary"
:label="$t('login')"
:disable="checkPasswordsMatch || !loginData.username || !loginData.password || !loginData.passwordRepeat"
></q-btn>
</q-form>
</div>
<div class="hero-wrapper">
<div class="hero q-mx-auto"></div>
</div>
</q-card-section>
</q-card>
</div>
</div>
{% endblock %} {% block scripts %}
<style>
main {
display: flex;
flex-direction: column;
justify-content: center;
}
.grid {
display: block;
}
.hero-wrapper {
display: none;
}
.hero {
display: block;
height: 100%;
max-width: 250px;
background-image: url("{{ static_url_for('static', 'images/logos/lnbits.svg') }}");
background-position: center;
background-size: contain;
background-repeat: no-repeat;
}
{% 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;
}
@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;
@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: ''
}
}
</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
}
)
},
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>
{% endblock %}
</q-page>
</q-page-container>
}
}
})
</script>
{% endblock %}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-165
View File
@@ -1,165 +0,0 @@
{% if not ajax %} {% extends "base.html" %} {% endif %}
<!---->
{% from "macros.jinja" import window_vars with context %}
<!---->
{% block scripts %} {{ window_vars(user) }}{% endblock %} {% block page %}
<div class="row q-col-gutter-md q-mb-md">
<div class="col-12">
<q-card>
<div class="q-pa-sm q-pl-lg">
<div class="row items-center justify-between q-gutter-xs">
<div class="col">
<q-btn
@click="showAddWalletDialog.show = true"
:label="$t('add_wallet')"
color="primary"
>
</q-btn>
</div>
<div class="float-left">
<q-input
:label="$t('search_wallets')"
dense
class="float-right q-pr-xl"
v-model="walletsTable.search"
>
<template v-slot:before>
<q-icon name="search"> </q-icon>
</template>
<template v-slot:append>
<q-icon
v-if="walletsTable.search !== ''"
name="close"
@click="walletsTable.search = ''"
class="cursor-pointer"
>
</q-icon>
</template>
</q-input>
</div>
</div>
</div>
</q-card>
</div>
</div>
<div>
<div>
<div>
<q-table
grid
grid-header
flat
bordered
:rows="wallets"
:columns="walletsTable.columns"
v-model:pagination="walletsTable.pagination"
:loading="walletsTable.loading"
@request="getUserWallets"
row-key="id"
:filter="filter"
hide-header
>
<template v-slot:item="props">
<div class="q-pa-xs col-xs-12 col-sm-6 col-md-4">
<q-card
class="q-ma-sm cursor-pointer wallet-list-card"
style="text-decoration: none"
@click="goToWallet(props.row.id)"
>
<q-card-section>
<div class="row items-center">
<q-avatar
size="lg"
:text-color="$q.dark.isActive ? 'black' : 'grey-3'"
:color="props.row.extra.color"
:icon="props.row.extra.icon"
>
</q-avatar>
<div
class="text-h6 q-pl-md ellipsis"
class="text-bold"
v-text="props.row.name"
></div>
<q-space> </q-space>
<q-btn
v-if="props.row.extra.pinned"
round
color="primary"
text-color="black"
size="xs"
icon="push_pin"
class="float-right"
style="transform: rotate(30deg)"
></q-btn>
</div>
<div class="row items-center q-pt-sm">
<h6 class="q-my-none ellipsis full-width">
<strong
v-text="formatBalance(props.row.balance_msat / 1000)"
></strong>
</h6>
</div>
</q-card-section>
<q-separator />
<q-card-section class="text-left">
<small>
<strong>
<span v-text="$t('currency')"></span>
</strong>
<span v-text="props.row.currency || 'sat'"></span>
</small>
<br />
<small>
<strong>
<span v-text="$t('id')"></span>
:
</strong>
<span v-text="props.row.id"></span>
</small>
</q-card-section>
</q-card>
</div>
</template>
</q-table>
</div>
</div>
</div>
<q-dialog
v-model="showAddWalletDialog.show"
persistent
@hide="showAddWalletDialog = {show: false}"
>
<q-card style="min-width: 350px">
<q-card-section>
<div class="text-h6">
<span v-text="$t('wallet_name')"></span>
</div>
</q-card-section>
<q-card-section class="q-pt-none">
<q-input
dense
v-model="showAddWalletDialog.name"
autofocus
@keyup.enter="submitAddWallet()"
></q-input>
</q-card-section>
<q-card-actions align="right" class="text-primary">
<q-btn flat :label="$t('cancel')" v-close-popup></q-btn>
<q-btn
flat
:label="$t('add_wallet')"
v-close-popup
@click="submitAddWallet()"
></q-btn>
</q-card-actions>
</q-card>
</q-dialog>
{% endblock %}
+5 -76
View File
@@ -29,46 +29,7 @@
</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-dialog v-model="openChannelDialog.show" position="top">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form class="q-gutter-md">
<q-input
@@ -210,41 +171,10 @@
<q-tr :props="props">
<div class="q-pb-sm">
<div class="row items-center q-gutter-sm">
<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-subtitle1 col-grow"
v-text="props.row.name"
></div>
<div class="text-caption" v-if="props.row.short_id">
<span v-text="props.row.short_id"></span>
<q-btn
@@ -271,7 +201,6 @@
color="pink"
></q-btn>
</div>
<lnbits-channel-balance
:balance="props.row.balance"
:color="props.row.color"
+334 -5
View File
@@ -1,8 +1,5 @@
{% if not ajax %} {% extends "base.html" %} {% endif %}
<!---->
{% from "macros.jinja" import window_vars with context %}
<!---->
{% block scripts %} {{ window_vars(user) }}{% endblock %} {% block page %}
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
%} {% block page %}
<q-dialog v-model="nodeInfoDialog.show" position="top">
<lnbits-node-qrcode :info="nodeInfoDialog.data"></lnbits-node-qrcode>
@@ -43,4 +40,336 @@
</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 %}
-432
View File
@@ -1,432 +0,0 @@
{% if not ajax %} {% extends "base.html" %} {% endif %}
<!---->
{% from "macros.jinja" import window_vars with context %}
<!---->
{% block scripts %} {{ window_vars(user) }} {% endblock %} {% block page %}
<div class="row q-col-gutter-md q-mb-md">
<div class="col-12">
<q-card>
<div class="q-pa-sm q-pl-lg">
<div class="row items-center justify-between q-gutter-xs">
<div class="col"></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-btn
v-else-if="['status'].includes(col.name)"
flat
dense
:label="$q.screen.gt.md ? 'Status' : null"
icon="filter_alt"
color="grey"
class="text-capitalize"
>
<q-menu anchor="top right" self="top start">
<q-item dense>
<q-checkbox
v-model="statusFilters.success"
@click="handleFilterChanged"
label="Success Payments"
></q-checkbox>
</q-item>
<q-item dense>
<q-checkbox
v-model="statusFilters.pending"
@click="handleFilterChanged"
label="Pending Payments"
></q-checkbox>
</q-item>
<q-item dense>
<q-checkbox
v-model="statusFilters.failed"
@click="handleFilterChanged"
label="Failed Payments"
></q-checkbox>
</q-item>
<q-separator></q-separator>
<q-item dense>
<q-checkbox
v-model="statusFilters.incoming"
@click="handleFilterChanged"
label="Incoming Payments"
></q-checkbox>
</q-item>
<q-item dense>
<q-checkbox
v-model="statusFilters.outgoing"
@click="handleFilterChanged"
label="Outgoing Payments"
></q-checkbox>
</q-item>
</q-menu>
<q-tooltip>
<span v-text="$t('filter_payments')"></span>
</q-tooltip>
</q-btn>
<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 %}
+2 -11
View File
@@ -61,7 +61,7 @@
:label="$t('set_password')"
v-model="activeUser.setPassword"
>
<q-tooltip v-text="$t('set_password_tooltip')"></q-tooltip>
<q-tooltip>Toggle Admin</q-tooltip>
</q-toggle>
<q-input
@@ -105,12 +105,11 @@
<q-input
v-model="activeUser.data.pubkey"
:label="'Nostr '+ $t('pubkey')"
:label="$t('pubkey')"
filled
dense
class="q-mb-md"
>
<q-tooltip v-text="$t('nostr_pubkey_tooltip')"></q-tooltip>
</q-input>
<q-input
v-model="activeUser.data.email"
@@ -147,14 +146,6 @@
class="q-mb-md"
>
</q-input>
<q-input
v-model="activeUser.data.external_id"
:label="$t('external_id')"
filled
dense
class="q-mb-md"
>
</q-input>
<q-input
v-model="activeUser.data.extra.picture"
:label="$t('picture')"
+14 -34
View File
@@ -15,36 +15,19 @@
</q-card>
</div>
<div v-else-if="activeWallet.show">
<div class="row q-col-gutter-md q-mb-md">
<div class="col-12">
<q-card>
<div class="q-pa-sm">
<div class="row">
<div class="q-pa-xs">
<q-btn
icon="arrow_back_ios"
@click="backToUsersPage()"
:label="$t('back')"
></q-btn>
</div>
<div class="q-pa-xs">
<q-btn
@click="createWalletDialog.show = true"
:label="$t('create_new_wallet')"
color="primary"
></q-btn>
</div>
<div class="q-pa-xs">
<q-btn
@click="deleteAllUserWallets(activeWallet.userId)"
:label="$t('delete_all_wallets')"
icon="delete"
color="negative"
></q-btn>
</div>
</div>
</div>
</q-card>
<div class="row q-mb-lg">
<div class="col">
<q-btn
icon="arrow_back_ios"
@click="backToUsersPage()"
:label="$t('back')"
></q-btn>
<q-btn
@click="createWalletDialog.show = true"
:label="$t('create_new_wallet')"
color="primary"
class="q-ml-md"
></q-btn>
</div>
</div>
<q-card class="q-pa-md">
@@ -52,7 +35,6 @@
<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
@@ -65,14 +47,12 @@
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td auto-width v-if="g.user.super_user">
<q-td auto-width>
<lnbits-update-balance
:wallet_id="props.row.id"
@credit-value="handleBalanceUpdate"
class="q-mr-md"
></lnbits-update-balance>
</q-td>
<q-td auto-width>
<q-btn
round
icon="menu"
+12 -45
View File
@@ -1,8 +1,5 @@
{% if not ajax %} {% extends "base.html" %} {% endif %}
<!---->
{% from "macros.jinja" import window_vars with context %}
<!---->
{% block scripts %} {{ window_vars(user) }}{% endblock %} {% block page %}
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
%} {% block page %}
<div class="row q-col-gutter-md justify-center">
<div class="col">
@@ -14,35 +11,13 @@
{%include "users/_createWalletDialog.html" %}
</div>
<div v-else>
<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-btn
@click="showAccountPage()"
:label="$t('create_account')"
color="primary"
class="q-mb-md"
>
</q-btn>
<q-card class="q-pa-md">
<q-table
@@ -127,17 +102,7 @@
:label="props.row.wallet_count"
@click="fetchWallets(props.row.id)"
>
</q-btn>
<q-btn
v-if="(users.length == 1) && searchData.wallet_id"
round
icon="menu"
size="sm"
color="secondary"
class="q-ml-sm"
@click="showWalletPayments(searchData.wallet_id)"
>
<q-tooltip>Show Payments</q-tooltip>
<q-tooltip>Show Wallets</q-tooltip>
</q-btn>
</q-td>
@@ -183,4 +148,6 @@
</div>
</div>
{% endblock %} {% block scripts %} {{ window_vars(user) }}
<script src="{{ static_url_for('static', 'js/users.js') }}"></script>
{% endblock %}
+12 -132
View File
@@ -1,38 +1,28 @@
import os
import time
from http import HTTPStatus
from pathlib import Path
from shutil import make_archive, move
from shutil import make_archive
from subprocess import Popen
from tempfile import NamedTemporaryFile
from typing import IO, Optional
from typing import Optional
from urllib.parse import urlparse
import filetype
from fastapi import APIRouter, Depends, File, Header, HTTPException, UploadFile
from fastapi import APIRouter, Depends
from fastapi.responses import FileResponse
from lnbits.core.models import User
from lnbits.core.models.misc import Image, SimpleStatus
from lnbits.core.models.notifications import NotificationType
from lnbits.core.services import (
enqueue_notification,
get_balance_delta,
update_cached_settings,
)
from lnbits.core.services.notifications import send_email_notification
from lnbits.core.services.settings import dict_to_settings
from lnbits.decorators import check_admin, check_super_user
from lnbits.helpers import safe_upload_file_path
from lnbits.server import server_restart
from lnbits.settings import AdminSettings, Settings, UpdateSettings, settings
from lnbits.tasks import invoice_listeners
from .. import core_app_extra
from ..crud import get_admin_settings, reset_core_settings, update_admin_settings
from ..crud import delete_admin_settings, get_admin_settings, update_admin_settings
admin_router = APIRouter(tags=["Admin UI"], prefix="/admin")
file_upload = File(...)
@admin_router.get(
@@ -57,18 +47,6 @@ async def api_monitor():
}
@admin_router.get(
"/api/v1/testemail",
name="TestEmail",
description="send a test email to the admin",
dependencies=[Depends(check_admin)],
)
async def api_test_email():
return await send_email_notification(
"This is a LNbits test email.", "LNbits Test Email"
)
@admin_router.get("/api/v1/settings", response_model=Optional[AdminSettings])
async def api_get_settings(
user: User = Depends(check_admin),
@@ -82,25 +60,14 @@ 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)
if not admin_settings:
raise ValueError("Updated admin settings not found.")
assert admin_settings, "Updated admin settings not found."
update_cached_settings(admin_settings.dict())
core_app_extra.register_new_ratelimiter()
return {"status": "Success"}
@admin_router.patch(
"/api/v1/settings",
status_code=HTTPStatus.OK,
)
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,
@@ -111,10 +78,13 @@ async def api_reset_settings(field_name: str):
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 reset_core_settings()
@admin_router.delete(
"/api/v1/settings",
status_code=HTTPStatus.OK,
dependencies=[Depends(check_super_user)],
)
async def api_delete_settings() -> None:
await delete_admin_settings()
server_restart.set()
@@ -166,93 +136,3 @@ async def api_download_backup() -> FileResponse:
return FileResponse(
path=f"{last_filename}.zip", filename=filename, media_type="application/zip"
)
@admin_router.post(
"/api/v1/images",
status_code=HTTPStatus.OK,
dependencies=[Depends(check_admin)],
)
async def upload_image(
file: UploadFile = file_upload,
content_length: int = Header(..., le=settings.lnbits_upload_size_bytes),
) -> Image:
if not file.filename:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail="No filename provided."
)
# validate file types
file_info = filetype.guess(file.file)
if file_info is None:
raise HTTPException(
status_code=HTTPStatus.UNSUPPORTED_MEDIA_TYPE,
detail="Unable to determine file type",
)
detected_content_type = file_info.extension.lower()
if (
file.content_type not in settings.lnbits_upload_allowed_types
or detected_content_type not in settings.lnbits_upload_allowed_types
):
raise HTTPException(HTTPStatus.UNSUPPORTED_MEDIA_TYPE, "Unsupported file type")
# validate file name
try:
file_path = safe_upload_file_path(file.filename)
except ValueError as e:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail=f"The requested filename '{file.filename}' is forbidden.",
) from e
# validate file size
real_file_size = 0
temp: IO = NamedTemporaryFile(delete=False)
for chunk in file.file:
real_file_size += len(chunk)
if real_file_size > content_length:
raise HTTPException(
status_code=HTTPStatus.REQUEST_ENTITY_TOO_LARGE,
detail=f"File too large ({content_length / 1000} KB max)",
)
temp.write(chunk)
temp.close()
move(temp.name, file_path)
return Image(filename=file.filename)
@admin_router.get(
"/api/v1/images",
status_code=HTTPStatus.OK,
dependencies=[Depends(check_admin)],
)
async def list_uploaded_images() -> list[Image]:
image_folder = Path(settings.lnbits_data_folder, "images")
files = image_folder.glob("*")
images = []
for file in files:
if file.is_file():
images.append(Image(filename=file.name))
return images
@admin_router.delete(
"/api/v1/images/{filename}",
status_code=HTTPStatus.OK,
dependencies=[Depends(check_admin)],
)
async def delete_uploaded_image(filename: str) -> SimpleStatus:
try:
file_path = safe_upload_file_path(filename)
except ValueError as e:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail=f"The requested filename '{filename}' is forbidden.",
) from e
if not file_path.exists():
raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Image not found.")
file_path.unlink()
return SimpleStatus(success=True, message=f"{filename} deleted")
+17 -22
View File
@@ -15,6 +15,7 @@ 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,
@@ -29,7 +30,6 @@ 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 (
@@ -50,18 +50,21 @@ api_router = APIRouter(tags=["Core"])
async def health() -> dict:
return {
"server_time": int(time()),
"up_time": settings.lnbits_server_up_time,
"up_time": int(time() - settings.server_startup_time),
}
@api_router.get("/api/v1/status", status_code=HTTPStatus.OK)
async def health_check(user: User = Depends(check_user_exists)) -> dict:
async def health_check(wallet: WalletTypeInfo = Depends(require_invoice_key)) -> dict:
stat: dict[str, Any] = {
"server_time": int(time()),
"up_time": settings.lnbits_server_up_time,
"up_time_seconds": int(time() - settings.server_startup_time),
"up_time": 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
@@ -93,7 +96,7 @@ async def api_create_account(data: CreateWallet) -> Wallet:
@api_router.get("/api/v1/lnurlscan/{code}")
async def api_lnurlscan( # noqa: C901
async def api_lnurlscan(
code: str, wallet: WalletTypeInfo = Depends(require_invoice_key)
):
try:
@@ -124,27 +127,19 @@ async def api_lnurlscan( # noqa: C901
params.update(callback=url) # with k1 already in it
lnurlauth_key = wallet.wallet.lnurlauth_key(domain)
if not lnurlauth_key.verifying_key:
raise ValueError("LNURL auth key not found for this domain.")
assert lnurlauth_key.verifying_key
params.update(pubkey=lnurlauth_key.verifying_key.to_string("compressed").hex())
else:
headers = {"User-Agent": settings.user_agent}
async with httpx.AsyncClient(headers=headers, follow_redirects=True) as client:
check_callback_url(url)
try:
r = await client.get(url, timeout=5)
r.raise_for_status()
except httpx.HTTPStatusError as exc:
if exc.response.status_code == 404:
raise HTTPException(HTTPStatus.NOT_FOUND, "Not found") from exc
r = await client.get(url, timeout=5)
r.raise_for_status()
if r.is_error:
raise HTTPException(
status_code=HTTPStatus.SERVICE_UNAVAILABLE,
detail={
"domain": domain,
"message": "failed to get parameters",
},
) from exc
detail={"domain": domain, "message": "failed to get parameters"},
)
try:
data = json.loads(r.text)
except json.decoder.JSONDecodeError as exc:
@@ -232,7 +227,7 @@ async def api_perform_lnurlauth(
@api_router.get(
"/api/v1/rate/history",
dependencies=[Depends(check_user_exists)],
dependencies=[Depends(require_invoice_key)],
)
async def api_exchange_rate_history() -> list[dict]:
return settings.lnbits_exchange_rate_history
+56 -221
View File
@@ -11,26 +11,12 @@ 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,
)
@@ -58,7 +44,6 @@ from ..models import (
UpdateUserPassword,
UpdateUserPubkey,
User,
UserAcls,
UserExtra,
)
@@ -74,7 +59,7 @@ async def get_auth_user(user: User = Depends(check_user_exists)) -> User:
async def login(data: LoginUsernamePassword) -> JSONResponse:
if not settings.is_auth_method_allowed(AuthMethods.username_and_password):
raise HTTPException(
HTTPStatus.FORBIDDEN, "Login by 'Username and Password' not allowed."
HTTPStatus.UNAUTHORIZED, "Login by 'Username and Password' not allowed."
)
account = await get_account_by_username_or_email(data.username)
if not account or not account.verify_password(data.password):
@@ -85,7 +70,9 @@ async def login(data: LoginUsernamePassword) -> JSONResponse:
@auth_router.post("/nostr", description="Login via Nostr")
async def nostr_login(request: Request) -> JSONResponse:
if not settings.is_auth_method_allowed(AuthMethods.nostr_auth_nip98):
raise HTTPException(HTTPStatus.FORBIDDEN, "Login with Nostr Auth not allowed.")
raise HTTPException(
HTTPStatus.UNAUTHORIZED, "Login with Nostr Auth not allowed."
)
event = _nostr_nip98_event(request)
account = await get_account_by_pubkey(event["pubkey"])
if not account:
@@ -102,140 +89,15 @@ async def nostr_login(request: Request) -> JSONResponse:
async def login_usr(data: LoginUsr) -> JSONResponse:
if not settings.is_auth_method_allowed(AuthMethods.user_id_only):
raise HTTPException(
HTTPStatus.FORBIDDEN,
HTTPStatus.UNAUTHORIZED,
"Login by 'User ID' not allowed.",
)
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.FORBIDDEN, "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:
if not data.expiration_time_minutes > 0:
raise ValueError("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.")
if not account.username:
raise ValueError("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.")
if not account.username:
raise ValueError("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
@@ -243,7 +105,7 @@ async def login_with_sso_provider(
provider_sso = _new_sso(provider)
if not provider_sso:
raise HTTPException(
HTTPStatus.FORBIDDEN,
HTTPStatus.UNAUTHORIZED,
f"Login by '{provider}' not allowed.",
)
@@ -258,7 +120,7 @@ async def handle_oauth_token(request: Request, provider: str) -> RedirectRespons
provider_sso = _new_sso(provider)
if not provider_sso:
raise HTTPException(
HTTPStatus.FORBIDDEN,
HTTPStatus.UNAUTHORIZED,
f"Login by '{provider}' not allowed.",
)
@@ -286,7 +148,7 @@ async def logout() -> JSONResponse:
async def register(data: RegisterUser) -> JSONResponse:
if not settings.is_auth_method_allowed(AuthMethods.username_and_password):
raise HTTPException(
HTTPStatus.FORBIDDEN,
HTTPStatus.UNAUTHORIZED,
"Register by 'Username and Password' not allowed.",
)
@@ -321,7 +183,7 @@ async def update_pubkey(
payload: AccessTokenPayload = Depends(access_token_payload),
) -> Optional[User]:
if data.user_id != user.id:
raise ValueError("Invalid user ID.")
raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid user ID.")
_validate_auth_timeout(payload.auth_time)
if (
@@ -329,7 +191,7 @@ async def update_pubkey(
and data.pubkey != user.pubkey
and await get_account_by_pubkey(data.pubkey)
):
raise ValueError("Public key already in use.")
raise HTTPException(HTTPStatus.BAD_REQUEST, "Public key already in use.")
account = await get_account(user.id)
if not account:
@@ -347,8 +209,7 @@ async def update_password(
payload: AccessTokenPayload = Depends(access_token_payload),
) -> Optional[User]:
_validate_auth_timeout(payload.auth_time)
if data.user_id != user.id:
raise ValueError("Invalid user ID.")
assert data.user_id == user.id, "Invalid user ID."
if (
data.username
and user.username != data.username
@@ -357,15 +218,12 @@ async def update_password(
raise HTTPException(HTTPStatus.BAD_REQUEST, "Username already exists.")
account = await get_account(user.id)
if not account:
raise ValueError("Account not found.")
assert account, "Account not found."
# old accounts do not have a password
if account.password_hash:
if not data.password_old:
raise ValueError("Missing old password.")
if not account.verify_password(data.password_old):
raise ValueError("Invalid old password.")
assert data.password_old, "Missing old password."
assert account.verify_password(data.password_old), "Invalid old password."
account.username = data.username
account.hash_password(data.password)
@@ -380,13 +238,11 @@ async def update_password(
async def reset_password(data: ResetUserPassword) -> JSONResponse:
if not settings.is_auth_method_allowed(AuthMethods.username_and_password):
raise HTTPException(
HTTPStatus.FORBIDDEN, "Auth by 'Username and Password' not allowed."
HTTPStatus.UNAUTHORIZED, "Auth by 'Username and Password' not allowed."
)
if data.password != data.password_repeat:
raise ValueError("Passwords do not match.")
if not data.reset_key[:10].startswith("reset_key_"):
raise ValueError("This is not a reset key.")
assert data.password == data.password_repeat, "Passwords do not match."
assert data.reset_key[:10].startswith("reset_key_"), "This is not a reset key."
try:
reset_key = base64.b64decode(data.reset_key[10:]).decode()
@@ -394,16 +250,12 @@ async def reset_password(data: ResetUserPassword) -> JSONResponse:
except Exception as exc:
raise ValueError("Invalid reset key.") from exc
if not reset_data_json:
raise ValueError("Cannot process reset key.")
assert reset_data_json, "Cannot process reset key."
action, user_id, request_time = json.loads(reset_data_json)
if not action:
raise ValueError("Missing action.")
if not user_id:
raise ValueError("Missing user ID.")
if not request_time:
raise ValueError("Missing reset time.")
assert action, "Missing action."
assert user_id, "Missing user ID."
assert request_time, "Missing reset time."
_validate_auth_timeout(request_time)
@@ -424,13 +276,23 @@ async def update(
raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid user ID.")
if data.username and not is_valid_username(data.username):
raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid username.")
if data.email != user.email:
raise HTTPException(
HTTPStatus.BAD_REQUEST,
"Email mismatch.",
)
if (
data.username
and user.username != data.username
and await get_account_by_username(data.username)
):
raise HTTPException(HTTPStatus.BAD_REQUEST, "Username already exists.")
if (
data.email
and data.email != user.email
and await get_account_by_email(data.email)
):
raise HTTPException(HTTPStatus.BAD_REQUEST, "Email already exists.")
account = await get_account(user.id)
if not account:
@@ -438,6 +300,8 @@ async def update(
if data.username:
account.username = data.username
if data.email:
account.email = data.email
if data.extra:
account.extra = data.extra
@@ -448,7 +312,7 @@ async def update(
@auth_router.put("/first_install")
async def first_install(data: UpdateSuperuserPassword) -> JSONResponse:
if not settings.first_install:
raise HTTPException(HTTPStatus.FORBIDDEN, "This is not your first install")
raise HTTPException(HTTPStatus.UNAUTHORIZED, "This is not your first install")
account = await get_account(settings.super_user)
if not account:
raise HTTPException(HTTPStatus.INTERNAL_SERVER_ERROR, "Superuser not found.")
@@ -471,10 +335,10 @@ async def _handle_sso_login(userinfo: OpenID, verified_user_id: Optional[str] =
if verified_user_id:
if account:
raise HTTPException(HTTPStatus.FORBIDDEN, "Email already used.")
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Email already used.")
account = await get_account(verified_user_id)
if not account:
raise HTTPException(HTTPStatus.FORBIDDEN, "Cannot verify user email.")
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Cannot verify user email.")
redirect_path = "/account"
if account:
@@ -498,37 +362,20 @@ def _auth_success_response(
sub=username or "", usr=user_id, email=email, auth_time=int(time())
)
access_token = create_access_token(data=payload.dict())
max_age = settings.auth_token_expire_minutes * 60
response = JSONResponse({"access_token": access_token, "token_type": "bearer"})
response.set_cookie(
"cookie_access_token", access_token, httponly=True, max_age=max_age
)
response.set_cookie("is_lnbits_user_authorized", "true", max_age=max_age)
response.set_cookie("cookie_access_token", access_token, httponly=True)
response.set_cookie("is_lnbits_user_authorized", "true")
response.delete_cookie("is_access_token_expired")
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())
max_age = settings.auth_token_expire_minutes * 60
response = RedirectResponse(path)
response.set_cookie(
"cookie_access_token", access_token, httponly=True, max_age=max_age
)
response.set_cookie("is_lnbits_user_authorized", "true", max_age=max_age)
response.set_cookie("cookie_access_token", access_token, httponly=True)
response.set_cookie("is_lnbits_user_authorized", "true")
response.delete_cookie("is_access_token_expired")
return response
@@ -570,8 +417,8 @@ def _find_auth_provider_class(provider: str) -> Callable:
provider_class = getattr(provider_module, f"{provider.title()}SSO")
if provider_class:
return provider_class
except Exception as exc:
logger.debug(exc)
except Exception:
pass
raise ValueError(f"No SSO provider found for '{provider}'.")
@@ -579,50 +426,38 @@ def _find_auth_provider_class(provider: str) -> Callable:
def _nostr_nip98_event(request: Request) -> dict:
auth_header = request.headers.get("Authorization")
if not auth_header:
raise HTTPException(HTTPStatus.BAD_REQUEST, "Nostr Auth header missing.")
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Nostr Auth header missing.")
scheme, token = auth_header.split()
if scheme.lower() != "nostr":
raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid Authorization scheme.")
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid Authorization scheme.")
event = None
try:
event_json = base64.b64decode(token.encode("ascii"))
event = json.loads(event_json)
except Exception as exc:
logger.warning(exc)
if not event:
raise ValueError("Nostr login event cannot be parsed.")
assert event, "Nostr login event cannot be parsed."
if not verify_event(event):
raise HTTPException(HTTPStatus.BAD_REQUEST, "Nostr login event is not valid.")
if not event["kind"] == 27_235:
raise ValueError("Invalid event kind.")
assert event["kind"] == 27_235, "Invalid event kind."
auth_threshold = settings.auth_credetials_update_threshold
if not (abs(time() - event["created_at"]) < auth_threshold):
raise ValueError(
f"More than {auth_threshold} seconds have passed "
"since the event was signed."
)
assert (
abs(time() - event["created_at"]) < auth_threshold
), f"More than {auth_threshold} seconds have passed since the event was signed."
_check_nostr_event_tags(event)
return event
def _check_nostr_event_tags(event: dict):
method: Optional[str] = next((v for k, v in event["tags"] if k == "method"), None)
if not method:
raise ValueError("Tag 'method' is missing.")
if not method.upper() == "POST":
raise ValueError("Invalid value for tag 'method'.")
assert method, "Tag 'method' is missing."
assert method.upper() == "POST", "Invalid value for tag 'method'."
url = next((v for k, v in event["tags"] if k == "u"), None)
if not url:
raise ValueError("Tag 'u' for URL is missing.")
assert url, "Tag 'u' for URL is missing."
accepted_urls = [f"{u}/nostr" for u in settings.nostr_absolute_request_urls]
if url not in accepted_urls:
raise ValueError(f"Invalid value for tag 'u': '{url}'.")
assert url in accepted_urls, f"Invalid value for tag 'u': '{url}'."
return event
def _validate_auth_timeout(auth_time: Optional[int] = 0):
-35
View File
@@ -1,35 +0,0 @@
from fastapi import APIRouter, Request
from lnbits.core.models.misc import SimpleStatus
from lnbits.core.services.fiat_providers import (
check_stripe_signature,
handle_stripe_event,
)
from lnbits.settings import settings
callback_router = APIRouter(prefix="/api/v1/callback", tags=["callback"])
@callback_router.post("/{provider_name}")
async def api_generic_webhook_handler(
provider_name: str, request: Request
) -> SimpleStatus:
if provider_name.lower() == "stripe":
payload = await request.body()
sig_header = request.headers.get("Stripe-Signature")
check_stripe_signature(
payload, sig_header, settings.stripe_webhook_signing_secret
)
event = await request.json()
await handle_stripe_event(event)
return SimpleStatus(
success=True,
message=f"Callback received successfully from '{provider_name}'.",
)
return SimpleStatus(
success=False,
message=f"Unknown fiat provider '{provider_name}'.",
)
+78 -63
View File
@@ -170,52 +170,64 @@ async def api_enable_extension(
raise HTTPException(
HTTPStatus.NOT_FOUND, f"Extension '{ext_id}' doesn't exist."
)
try:
logger.info(f"Enabling extension: {ext_id}.")
ext = await get_installed_extension(ext_id)
assert ext, f"Extension '{ext_id}' is not installed."
assert ext.active, f"Extension '{ext_id}' is not activated."
logger.info(f"Enabling extension: {ext_id}.")
ext = await get_installed_extension(ext_id)
if not ext:
raise ValueError(f"Extension '{ext_id}' is not installed.")
if not ext.active:
raise ValueError(f"Extension '{ext_id}' is not activated.")
user_ext = await get_user_extension(user.id, ext_id)
if not user_ext:
user_ext = UserExtension(user=user.id, extension=ext_id, active=False)
await create_user_extension(user_ext)
user_ext = await get_user_extension(user.id, ext_id)
if not user_ext:
user_ext = UserExtension(user=user.id, extension=ext_id, active=False)
await create_user_extension(user_ext)
if user.admin or not ext.requires_payment:
user_ext.active = True
await update_user_extension(user_ext)
return SimpleStatus(success=True, message=f"Extension '{ext_id}' enabled.")
if user.admin or not ext.requires_payment:
user_ext.active = True
await update_user_extension(user_ext)
return SimpleStatus(success=True, message=f"Extension '{ext_id}' enabled.")
if not (user_ext.extra and user_ext.extra.payment_hash_to_enable):
raise HTTPException(
HTTPStatus.PAYMENT_REQUIRED, f"Extension '{ext_id}' requires payment."
)
if not (user_ext.extra and user_ext.extra.payment_hash_to_enable):
raise HTTPException(
HTTPStatus.PAYMENT_REQUIRED, f"Extension '{ext_id}' requires payment."
if user_ext.is_paid:
user_ext.active = True
await update_user_extension(user_ext)
return SimpleStatus(
success=True, message=f"Paid extension '{ext_id}' enabled."
)
assert (
ext.meta and ext.meta.pay_to_enable and ext.meta.pay_to_enable.wallet
), f"Extension '{ext_id}' is missing payment wallet."
payment_status = await check_transaction_status(
wallet_id=ext.meta.pay_to_enable.wallet,
payment_hash=user_ext.extra.payment_hash_to_enable,
)
if user_ext.is_paid:
if not payment_status.paid:
raise HTTPException(
HTTPStatus.PAYMENT_REQUIRED,
f"Invoice generated but not paid for enabeling extension '{ext_id}'.",
)
user_ext.active = True
user_ext.extra.paid_to_enable = True
await update_user_extension(user_ext)
return SimpleStatus(success=True, message=f"Paid extension '{ext_id}' enabled.")
if not ext.meta or not ext.meta.pay_to_enable or not ext.meta.pay_to_enable.wallet:
raise ValueError(f"Extension '{ext_id}' is missing payment wallet.")
payment_status = await check_transaction_status(
wallet_id=ext.meta.pay_to_enable.wallet,
payment_hash=user_ext.extra.payment_hash_to_enable,
)
if not payment_status.paid:
except AssertionError as exc:
raise HTTPException(HTTPStatus.BAD_REQUEST, str(exc)) from exc
except HTTPException as exc:
raise exc from exc
except Exception as exc:
logger.warning(exc)
raise HTTPException(
HTTPStatus.PAYMENT_REQUIRED,
f"Invoice generated but not paid for enabeling extension '{ext_id}'.",
)
user_ext.active = True
user_ext.extra.paid_to_enable = True
await update_user_extension(user_ext)
return SimpleStatus(success=True, message=f"Paid extension '{ext_id}' enabled.")
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=(f"Failed to enable '{ext_id}' "),
) from exc
@extension_router.put("/{ext_id}/disable")
@@ -243,8 +255,7 @@ async def api_activate_extension(ext_id: str) -> SimpleStatus:
logger.info(f"Activating extension: '{ext_id}'.")
ext = await get_valid_extension(ext_id)
if not ext:
raise ValueError(f"Extension '{ext_id}' doesn't exist.")
assert ext, f"Extension '{ext_id}' doesn't exist."
await activate_extension(ext)
return SimpleStatus(success=True, message=f"Extension '{ext_id}' activated.")
@@ -263,8 +274,7 @@ async def api_deactivate_extension(ext_id: str) -> SimpleStatus:
logger.info(f"Deactivating extension: '{ext_id}'.")
ext = await get_valid_extension(ext_id)
if not ext:
raise ValueError(f"Extension '{ext_id}' doesn't exist.")
assert ext, f"Extension '{ext_id}' doesn't exist."
await deactivate_extension(ext_id)
return SimpleStatus(success=True, message=f"Extension '{ext_id}' deactivated.")
@@ -344,35 +354,40 @@ async def get_extension_releases(ext_id: str) -> list[ExtensionRelease]:
async def get_pay_to_install_invoice(
ext_id: str, data: CreateExtension
) -> ReleasePaymentInfo:
if ext_id != data.ext_id:
raise ValueError(
f"Wrong extension id. Expected {ext_id}, but got {data.ext_id}"
try:
assert (
ext_id == data.ext_id
), f"Wrong extension id. Expected {ext_id}, but got {data.ext_id}"
assert data.cost_sats, "A non-zero amount must be specified."
release = await InstallableExtension.get_extension_release(
data.ext_id, data.source_repo, data.archive, data.version
)
if not data.cost_sats:
raise ValueError("A non-zero amount must be specified.")
release = await InstallableExtension.get_extension_release(
data.ext_id, data.source_repo, data.archive, data.version
)
if not release:
raise ValueError("Release not found.")
if not release.pay_link:
raise ValueError("Pay link not found for release.")
assert release, "Release not found."
assert release.pay_link, "Pay link not found for release."
payment_info = await release.fetch_release_payment_info(data.cost_sats)
payment_info = await release.fetch_release_payment_info(data.cost_sats)
if not (payment_info and payment_info.payment_request):
raise ValueError("Cannot request invoice.")
invoice = bolt11_decode(payment_info.payment_request)
assert payment_info and payment_info.payment_request, "Cannot request invoice."
invoice = bolt11_decode(payment_info.payment_request)
if invoice.amount_msat is None:
raise ValueError("Invoic amount is missing.")
invoice_amount = int(invoice.amount_msat / 1000)
if invoice_amount != data.cost_sats:
raise ValueError(f"Wrong invoice amount: {invoice_amount}.")
if payment_info.payment_hash != invoice.payment_hash:
raise ValueError("Wrong invoice payment hash.")
assert invoice.amount_msat is not None, "Invoic amount is missing."
invoice_amount = int(invoice.amount_msat / 1000)
assert (
invoice_amount == data.cost_sats
), f"Wrong invoice amount: {invoice_amount}."
assert (
payment_info.payment_hash == invoice.payment_hash
), "Wrong invoice payment hash."
return payment_info
return payment_info
except AssertionError as exc:
raise HTTPException(HTTPStatus.BAD_REQUEST, str(exc)) from exc
except Exception as exc:
logger.warning(exc)
raise HTTPException(
HTTPStatus.INTERNAL_SERVER_ERROR, "Cannot request invoice"
) from exc
@extension_router.put("/{ext_id}/invoice/enable")
-18
View File
@@ -1,18 +0,0 @@
from http import HTTPStatus
from fastapi import APIRouter, Depends
from lnbits.core.models.misc import SimpleStatus
from lnbits.core.services.fiat_providers import test_connection
from lnbits.decorators import check_admin
fiat_router = APIRouter(tags=["Fiat API"], prefix="/api/v1/fiat")
@fiat_router.put(
"/check/{provider}",
status_code=HTTPStatus.OK,
dependencies=[Depends(check_admin)],
)
async def api_test_fiat_provider(provider: str) -> SimpleStatus:
return await test_connection(provider)
+36 -72
View File
@@ -1,5 +1,6 @@
from http import HTTPStatus
from typing import Annotated, Optional, Union
from pathlib import Path
from typing import Annotated, List, Optional, Union
from urllib.parse import urlencode, urlparse
import httpx
@@ -16,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 check_callback_url, template_renderer
from lnbits.helpers import template_renderer
from lnbits.settings import settings
from lnbits.wallets import get_funding_source
@@ -36,7 +37,7 @@ generic_router = APIRouter(
@generic_router.get("/favicon.ico", response_class=FileResponse)
async def favicon():
return RedirectResponse(settings.lnbits_qr_logo)
return FileResponse(Path("lnbits", "static", "favicon.ico"))
@generic_router.get("/", response_class=HTMLResponse)
@@ -49,9 +50,12 @@ 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:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="Super user account has already been configured.",
return template_renderer().TemplateResponse(
request,
"error.html",
{
"err": "Super user account has already been configured.",
},
)
return template_renderer().TemplateResponse(
request,
@@ -70,7 +74,7 @@ async def robots():
@generic_router.get("/extensions", name="extensions", response_class=HTMLResponse)
async def extensions(request: Request, user: User = Depends(check_user_exists)):
installed_exts: list[InstallableExtension] = await get_installed_extensions()
installed_exts: List[InstallableExtension] = await get_installed_extensions()
installed_exts_ids = [e.id for e in installed_exts]
installable_exts = await InstallableExtension.get_installable_extensions()
@@ -103,7 +107,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()
extension_data = [
extensions = [
{
"id": ext.id,
"name": ext.name,
@@ -148,8 +152,7 @@ async def extensions(request: Request, user: User = Depends(check_user_exists)):
"core/extensions.html",
{
"user": user.json(),
"extension_data": extension_data,
"ajax": _is_ajax_request(request),
"extensions": extensions,
},
)
@@ -176,25 +179,25 @@ async def wallet(
wallet = user.wallets[0]
if not wallet or wallet.deleted:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Wallet not found",
return template_renderer().TemplateResponse(
request, "error.html", {"err": "Wallet not found"}, HTTPStatus.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",
}
return template_renderer().TemplateResponse(
resp = template_renderer().TemplateResponse(
request,
"core/wallet.html",
{**context, "ajax": _is_ajax_request(request)},
{
"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.set_cookie("lnbits_last_active_wallet", wallet.id)
return resp
@generic_router.get(
@@ -211,26 +214,6 @@ async def account(
"core/account.html",
{
"user": user.json(),
"ajax": _is_ajax_request(request),
},
)
@generic_router.get(
"/wallets",
response_class=HTMLResponse,
description="show wallets page",
)
async def wallets(
request: Request,
user: User = Depends(check_user_exists),
):
return template_renderer().TemplateResponse(
request,
"core/wallets.html",
{
"user": user.json(),
"ajax": _is_ajax_request(request),
},
)
@@ -344,7 +327,6 @@ 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),
},
)
@@ -383,7 +365,6 @@ 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),
},
)
@@ -400,7 +381,6 @@ 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),
},
)
@@ -415,19 +395,6 @@ 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_user_exists)):
return template_renderer().TemplateResponse(
"payments/index.html",
{
"request": request,
"user": user.json(),
"ajax": _is_ajax_request(request),
},
)
@@ -444,22 +411,23 @@ async def hex_to_uuid4(hex_value: str):
@generic_router.get("/lnurlwallet", response_class=RedirectResponse)
async def lnurlwallet(request: Request, lightning: str = ""):
async def lnurlwallet(request: Request):
"""
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
"""
if not lightning:
lightning_param = request.query_params.get("lightning")
if not lightning_param:
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)
lnurl = lnurl_decode(lightning_param)
async with httpx.AsyncClient() as client:
check_callback_url(lnurl)
res1 = await client.get(lnurl, timeout=2)
res1.raise_for_status()
data1 = res1.json()
@@ -474,23 +442,19 @@ async def lnurlwallet(request: Request, lightning: str = ""):
detail="Invalid lnurl. Expected maxWithdrawable",
)
account = await create_user_account()
wallet = account.wallets[0]
payment = await create_invoice(
wallet = await create_wallet(user_id=account.id)
_, payment_request = 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.bolt11}
params = {"k1": data1.get("k1"), "pr": payment_request}
callback = url + ("&" if urlparse(url).query else "?") + urlencode(params)
res2 = await client.get(callback, timeout=5)
res2 = await client.get(callback, timeout=2)
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"
+8 -29
View File
@@ -1,5 +1,5 @@
from http import HTTPStatus
from typing import Optional
from typing import List, Optional
import httpx
from fastapi import APIRouter, Body, Depends, HTTPException
@@ -91,18 +91,10 @@ async def api_get_info(
@node_router.get("/channels")
async def api_get_channels(
node: Node = Depends(require_node),
) -> Optional[list[NodeChannel]]:
) -> Optional[List[NodeChannel]]:
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),
@@ -121,7 +113,7 @@ async def api_delete_channel(
output_index: Optional[int],
force: bool = False,
node: Node = Depends(require_node),
) -> Optional[list[NodeChannel]]:
) -> Optional[List[NodeChannel]]:
return await node.close_channel(
short_id,
(
@@ -133,17 +125,7 @@ async def api_delete_channel(
)
@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")
@node_router.get("/payments", response_model=Page[NodePayment])
async def api_get_payments(
node: Node = Depends(require_node),
filters: Filters = Depends(parse_filters(NodePaymentsFilters)),
@@ -156,7 +138,7 @@ async def api_get_payments(
return await node.get_payments(filters)
@node_router.get("/invoices")
@node_router.get("/invoices", response_model=Page[NodeInvoice])
async def api_get_invoices(
node: Node = Depends(require_node),
filters: Filters = Depends(parse_filters(NodeInvoiceFilters)),
@@ -169,8 +151,8 @@ async def api_get_invoices(
return await node.get_invoices(filters)
@node_router.get("/peers")
async def api_get_peers(node: Node = Depends(require_node)) -> list[NodePeerInfo]:
@node_router.get("/peers", response_model=List[NodePeerInfo])
async def api_get_peers(node: Node = Depends(require_node)) -> List[NodePeerInfo]:
return await node.get_peers()
@@ -212,10 +194,7 @@ async def api_get_1ml_stats(node: Node = Depends(require_node)) -> Optional[Node
r = await client.get(url=f"https://1ml.com/node/{node_id}/json", timeout=15)
try:
r.raise_for_status()
data = r.json()
if "noderank" not in data:
return None
return data["noderank"]
return r.json()["noderank"]
except httpx.HTTPStatusError as exc:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Node not found on 1ml.com"
+105 -199
View File
@@ -1,8 +1,7 @@
import json
import ssl
from http import HTTPStatus
from math import ceil
from typing import Optional
from typing import List, Optional
from urllib.parse import urlparse
import httpx
@@ -17,10 +16,6 @@ from fastapi.responses import JSONResponse
from loguru import logger
from lnbits import bolt11
from lnbits.core.crud.payments import (
get_payment_count_stats,
get_wallets_stats,
)
from lnbits.core.models import (
CreateInvoice,
CreateLnurl,
@@ -28,27 +23,18 @@ from lnbits.core.models import (
KeyType,
PayLnurlWData,
Payment,
PaymentCountField,
PaymentCountStat,
PaymentDailyStats,
PaymentFilters,
PaymentHistoryPoint,
PaymentWalletStats,
Wallet,
)
from lnbits.core.models.users import User
from lnbits.db import Filters, Page
from lnbits.decorators import (
WalletTypeInfo,
check_user_exists,
parse_filters,
require_admin_key,
require_invoice_key,
)
from lnbits.helpers import (
check_callback_url,
filter_dict_keys,
generate_filter_params_openapi,
)
from lnbits.helpers import 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
@@ -60,14 +46,11 @@ from ..crud import (
get_payments_paginated,
get_standalone_payment,
get_wallet_for_key,
update_payment_extra,
)
from ..services import (
create_payment_request,
create_invoice,
fee_reserve_total,
get_payments_daily_stats,
pay_invoice,
update_pending_payment,
update_pending_payments,
)
@@ -79,7 +62,7 @@ payment_router = APIRouter(prefix="/api/v1/payments", tags=["Payments"])
name="Payment List",
summary="get list of payments",
response_description="list of payments",
response_model=list[Payment],
response_model=List[Payment],
openapi_extra=generate_filter_params_openapi(PaymentFilters),
)
async def api_payments(
@@ -98,7 +81,7 @@ async def api_payments(
@payment_router.get(
"/history",
name="Get payments history",
response_model=list[PaymentHistoryPoint],
response_model=List[PaymentHistoryPoint],
openapi_extra=generate_filter_params_openapi(PaymentFilters),
)
async def api_payments_history(
@@ -110,66 +93,6 @@ 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",
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)),
user: User = Depends(check_user_exists),
):
if user.admin:
# admin user can see payments from all wallets
for_user_id = None
else:
# regular user can only see payments from their wallets
for_user_id = user.id
return await get_payment_count_stats(count_by, filters=filters, user_id=for_user_id)
@payment_router.get(
"/stats/wallets",
name="Get payments history for all users",
response_model=list[PaymentWalletStats],
openapi_extra=generate_filter_params_openapi(PaymentFilters),
)
async def api_payments_wallets_stats(
filters: Filters[PaymentFilters] = Depends(parse_filters(PaymentFilters)),
user: User = Depends(check_user_exists),
):
if user.admin:
# admin user can see payments from all wallets
for_user_id = None
else:
# regular user can only see payments from their wallets
for_user_id = user.id
return await get_wallets_stats(filters, user_id=for_user_id)
@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 user.admin:
# admin user can see payments from all wallets
for_user_id = None
else:
# regular user can only see payments from their wallets
for_user_id = user.id
return await get_payments_daily_stats(filters, user_id=for_user_id)
@payment_router.get(
"/paginated",
name="Payment List",
@@ -182,41 +105,75 @@ 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,
filters=filters,
)
for payment in page.data:
if payment.pending:
await update_pending_payment(payment)
return page
@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),
)
async def api_all_payments_paginated(
filters: Filters = Depends(parse_filters(PaymentFilters)),
user: User = Depends(check_user_exists),
):
if user.admin:
# admin user can see payments from all wallets
for_user_id = None
else:
# regular user can only see payments from their wallets
for_user_id = user.id
async def _api_payments_create_invoice(data: CreateInvoice, wallet: Wallet):
description_hash = b""
unhashed_description = b""
memo = data.memo or settings.lnbits_site_title
if data.description_hash or data.unhashed_description:
if data.description_hash:
try:
description_hash = bytes.fromhex(data.description_hash)
except ValueError as exc:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="'description_hash' must be a valid hex string",
) from exc
if data.unhashed_description:
try:
unhashed_description = bytes.fromhex(data.unhashed_description)
except ValueError as exc:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="'unhashed_description' must be a valid hex string",
) from exc
# do not save memo if description_hash or unhashed_description is set
memo = ""
return await get_payments_paginated(
filters=filters,
user_id=for_user_id,
payment = await create_invoice(
wallet_id=wallet.id,
amount=data.amount,
memo=memo,
currency=data.unit,
description_hash=description_hash,
unhashed_description=unhashed_description,
expiry=data.expiry,
extra=data.extra,
webhook=data.webhook,
internal=data.internal,
)
# lnurl_response is not saved in the database
if data.lnurl_callback:
headers = {"User-Agent": settings.user_agent}
async with httpx.AsyncClient(headers=headers) as client:
try:
r = await client.get(
data.lnurl_callback,
params={"pr": payment.bolt11},
timeout=10,
)
if r.is_error:
payment.extra["lnurl_response"] = r.text
else:
resp = json.loads(r.text)
if resp["status"] != "OK":
payment.extra["lnurl_response"] = resp["reason"]
else:
payment.extra["lnurl_response"] = True
except (httpx.ConnectError, httpx.RequestError) as ex:
logger.error(ex)
payment.extra["lnurl_response"] = False
return payment
@payment_router.post(
"",
@@ -240,7 +197,6 @@ async def api_payments_create(
invoice_data: CreateInvoice,
wallet: WalletTypeInfo = Depends(require_invoice_key),
) -> Payment:
wallet_id = wallet.wallet.id
if invoice_data.out is True and wallet.key_type == KeyType.admin:
if not invoice_data.bolt11:
raise HTTPException(
@@ -248,53 +204,21 @@ async def api_payments_create(
detail="Missing BOLT11 invoice",
)
payment = await pay_invoice(
wallet_id=wallet_id,
wallet_id=wallet.wallet.id,
payment_request=invoice_data.bolt11,
extra=invoice_data.extra,
)
return payment
if invoice_data.out:
elif not invoice_data.out:
# invoice key
return await _api_payments_create_invoice(invoice_data, wallet.wallet)
else:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
status_code=HTTPStatus.UNAUTHORIZED,
detail="Invoice (or Admin) key required.",
)
# If the payment is not outgoing, we can create a new invoice.
return await create_payment_request(wallet_id, invoice_data)
@payment_router.patch(
"/extra/{payment_hash}", description="Update extra data for a payment"
)
async def api_payments_update_extra(
payment_hash: str,
extra: dict,
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> Payment:
payment = await get_standalone_payment(payment_hash, wallet_id=wallet.wallet.id)
if payment is None:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Payment does not exist."
)
if not isinstance(extra, dict):
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="Extra data must be a dictionary.",
)
if payment.extra is None:
payment.extra = {}
for key, value in extra.items():
if key in payment.extra:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"Key '{key}' already exists in extra data.",
)
payment.extra[key] = value
return await update_payment_extra(payment)
@payment_router.get("/fee-reserve")
async def api_payments_fee_reserve(invoice: str = Query("invoice")) -> JSONResponse:
@@ -311,19 +235,48 @@ async def api_payments_fee_reserve(invoice: str = Query("invoice")) -> JSONRespo
)
def _validate_lnurl_response(
params: dict, domain: str, amount_msat: int
) -> bolt11.Invoice:
@payment_router.post("/lnurl")
async def api_payments_pay_lnurl(
data: CreateLnurl, wallet: WalletTypeInfo = Depends(require_admin_key)
) -> Payment:
domain = urlparse(data.callback).netloc
headers = {"User-Agent": settings.user_agent}
async with httpx.AsyncClient(headers=headers, follow_redirects=True) as client:
try:
if data.unit and data.unit != "sat":
amount_msat = await fiat_amount_as_satoshis(data.amount, data.unit)
# no msat precision
amount_msat = ceil(amount_msat // 1000) * 1000
else:
amount_msat = data.amount
r = await client.get(
data.callback,
params={"amount": amount_msat, "comment": data.comment},
timeout=40,
)
if r.is_error:
raise httpx.ConnectError("LNURL callback connection error")
r.raise_for_status()
except (httpx.ConnectError, httpx.RequestError) as exc:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"Failed to connect to {domain}.",
) from exc
params = json.loads(r.text)
if params.get("status") == "ERROR":
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"{domain} said: '{params.get('reason', '')}'",
)
if not params.get("pr"):
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"{domain} did not return a payment request.",
)
invoice = bolt11.decode(params["pr"])
if invoice.amount_msat != amount_msat:
raise HTTPException(
@@ -333,45 +286,6 @@ def _validate_lnurl_response(
f" {amount_msat} msat, got {invoice.amount_msat}."
),
)
return invoice
async def _fetch_lnurl_params(data: CreateLnurl, amount_msat: int, domain: str) -> dict:
headers = {"User-Agent": settings.user_agent}
async with httpx.AsyncClient(headers=headers, follow_redirects=True) as client:
try:
r: httpx.Response = await client.get(
data.callback,
params={"amount": amount_msat, "comment": data.comment},
timeout=40,
)
if r.is_error:
raise httpx.ConnectError("LNURL callback connection error")
r.raise_for_status()
except (httpx.HTTPError, ssl.SSLError) as exc:
logger.warning(exc)
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"Failed to connect to {domain}.",
) from exc
return json.loads(r.text)
@payment_router.post("/lnurl")
async def api_payments_pay_lnurl(
data: CreateLnurl, wallet: WalletTypeInfo = Depends(require_admin_key)
) -> Payment:
domain = urlparse(data.callback).netloc
check_callback_url(data.callback)
if data.unit and data.unit != "sat":
amount_msat = await fiat_amount_as_satoshis(data.amount, data.unit)
amount_msat = ceil(amount_msat // 1000) * 1000
else:
amount_msat = data.amount
params = await _fetch_lnurl_params(data, amount_msat, domain)
_validate_lnurl_response(params, domain, amount_msat)
extra = {}
if params.get("successAction"):
@@ -381,12 +295,7 @@ async def api_payments_pay_lnurl(
if data.unit and data.unit != "sat":
extra["fiat_currency"] = data.unit
extra["fiat_amount"] = data.amount / 1000
if data.internal_memo is not None:
if len(data.internal_memo) > 512:
raise ValueError("Internal memo must be 512 characters or less.")
extra["internal_memo"] = data.internal_memo
if data.description is None:
raise ValueError("Description is required")
assert data.description is not None, "description is required"
payment = await pay_invoice(
wallet_id=wallet.wallet.id,
@@ -417,9 +326,6 @@ async def api_payment(payment_hash, x_api_key: Optional[str] = Header(None)):
return {"paid": True, "preimage": payment.preimage, "details": payment}
return {"paid": True, "preimage": payment.preimage}
if payment.failed:
return {"paid": False, "status": "failed", "details": payment}
try:
status = await payment.check_status()
except Exception:
@@ -460,6 +366,7 @@ 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
@@ -468,7 +375,6 @@ 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(
+7 -61
View File
@@ -2,7 +2,7 @@ import base64
import json
import time
from http import HTTPStatus
from typing import Optional
from typing import List, Optional
from uuid import uuid4
import shortuuid
@@ -31,11 +31,9 @@ from lnbits.core.models import (
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,
@@ -100,29 +98,20 @@ async def api_create_user(data: CreateUser) -> CreateUser:
username=data.username,
email=data.email,
pubkey=data.pubkey,
external_id=data.external_id,
extra=data.extra,
)
account.validate_fields()
account.hash_password(data.password)
user = await create_user_account_no_ckeck(account, default_exts=data.extensions)
user = await create_user_account_no_ckeck(account)
data.id = user.id
return data
@users_router.put("/user/{user_id}", name="Update user")
async def api_update_user(
user_id: str, data: CreateUser, user: User = Depends(check_admin)
) -> CreateUser:
async def api_update_user(user_id: str, data: CreateUser) -> CreateUser:
if user_id != data.id:
raise HTTPException(HTTPStatus.BAD_REQUEST, "User Id missmatch.")
if user_id == settings.super_user and user.id != settings.super_user:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="Action only allowed for super user.",
)
if data.password or data.password_repeat:
raise HTTPException(
HTTPStatus.BAD_REQUEST, "Use 'reset password' functionality."
@@ -133,7 +122,6 @@ async def api_update_user(
username=data.username,
email=data.email,
pubkey=data.pubkey,
external_id=data.external_id,
extra=data.extra or UserExtra(),
)
await update_user_account(account)
@@ -150,7 +138,7 @@ async def api_update_user(
async def api_users_delete_user(
user_id: str, user: User = Depends(check_admin)
) -> SimpleStatus:
wallets = await get_wallets(user_id, deleted=False)
wallets = await get_wallets(user_id)
if len(wallets) > 0:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
@@ -187,8 +175,7 @@ async def api_users_reset_password(user_id: str) -> str:
reset_data = ["reset", user_id, int(time.time())]
reset_data_json = json.dumps(reset_data, separators=(",", ":"), ensure_ascii=False)
reset_key = encrypt_internal_message(reset_data_json)
if not reset_key:
raise ValueError("Cannot generate reset key.")
assert reset_key, "Cannot generate reset key."
reset_key_b64 = base64.b64encode(reset_key.encode()).decode()
return f"reset_key_{reset_key_b64}"
@@ -217,7 +204,7 @@ async def api_users_toggle_admin(user_id: str) -> SimpleStatus:
@users_router.get("/user/{user_id}/wallet", name="Get wallets for user")
async def api_users_get_user_wallet(user_id: str) -> list[Wallet]:
async def api_users_get_user_wallet(user_id: str) -> List[Wallet]:
return await get_wallets(user_id)
@@ -260,50 +247,19 @@ async def api_users_undelete_user_wallet(user_id: str, wallet: str) -> SimpleSta
return SimpleStatus(success=True, message="Wallet is already active.")
@users_router.delete(
"/user/{user_id}/wallets",
name="Delete all wallets for user",
summary="Soft delete (only sets a flag) all user wallets.",
)
async def api_users_delete_all_user_wallet(user_id: str) -> SimpleStatus:
if user_id == settings.super_user:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="Action not allowed.",
)
wallets = await get_wallets(user_id, deleted=False)
for wallet in wallets:
await delete_wallet(user_id=user_id, wallet_id=wallet.id)
return SimpleStatus(
success=True,
message=f"Deleted '{len(wallets)}' wallets. ",
)
@users_router.delete(
"/user/{user_id}/wallet/{wallet}",
name="Delete wallet by id",
summary="First time it is called it does a soft delete (only sets a flag)."
"The second time it is called will delete the entry from the DB",
)
async def api_users_delete_user_wallet(
user_id: str, wallet: str, user: User = Depends(check_admin)
) -> SimpleStatus:
async def api_users_delete_user_wallet(user_id: str, wallet: str) -> SimpleStatus:
wal = await get_wallet(wallet)
if not wal:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Wallet does not exist.",
)
if user_id == settings.super_user and user.id != settings.super_user:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="Action only allowed for super user.",
)
if wal.deleted:
await force_delete_wallet(wallet)
await delete_wallet(user_id=user_id, wallet_id=wallet)
@@ -321,14 +277,4 @@ async def api_update_balance(data: UpdateBalance) -> SimpleStatus:
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,
},
)
return SimpleStatus(success=True, message="Balance updated.")
+9 -56
View File
@@ -1,6 +1,5 @@
from http import HTTPStatus
from typing import Optional
from uuid import uuid4
from fastapi import (
APIRouter,
@@ -9,18 +8,16 @@ from fastapi import (
HTTPException,
)
from lnbits.core.crud.wallets import get_wallets_paginated
from lnbits.core.models import CreateWallet, KeyType, User, Wallet
from lnbits.core.models.wallets import WalletsFilters
from lnbits.db import Filters, Page
from lnbits.core.models import (
CreateWallet,
KeyType,
Wallet,
)
from lnbits.decorators import (
WalletTypeInfo,
check_user_exists,
parse_filters,
require_admin_key,
require_invoice_key,
)
from lnbits.helpers import generate_filter_params_openapi
from ..crud import (
create_wallet,
@@ -43,26 +40,6 @@ async def api_wallet(key_info: WalletTypeInfo = Depends(require_invoice_key)):
return res
@wallet_router.get(
"/paginated",
name="Wallet List",
summary="get paginated list of user wallets",
response_description="list of user wallets",
response_model=Page[Wallet],
openapi_extra=generate_filter_params_openapi(WalletsFilters),
)
async def api_wallets_paginated(
user: User = Depends(check_user_exists),
filters: Filters = Depends(parse_filters(WalletsFilters)),
):
page = await get_wallets_paginated(
user_id=user.id,
filters=filters,
)
return page
@wallet_router.put("/{new_name}")
async def api_update_wallet_name(
new_name: str, key_info: WalletTypeInfo = Depends(require_admin_key)
@@ -79,52 +56,28 @@ 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),
pinned: Optional[bool] = Body(None),
key_info: WalletTypeInfo = Depends(require_admin_key),
) -> Wallet:
wallet = await get_wallet(key_info.wallet.id)
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.extra.pinned = pinned if pinned is not None else wallet.extra.pinned
wallet.currency = currency if currency is not None else wallet.currency
await update_wallet(wallet)
return wallet
@wallet_router.delete("/{wallet_id}")
@wallet_router.delete("")
async def api_delete_wallet(
wallet_id: str, user: User = Depends(check_user_exists)
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> 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.user,
wallet_id=wallet.id,
user_id=wallet.wallet.user,
wallet_id=wallet.wallet.id,
)
+7 -7
View File
@@ -15,9 +15,9 @@ from lnbits.core.models import (
CreateWebPushSubscription,
WebPushSubscription,
)
from lnbits.core.models.users import User
from lnbits.decorators import (
check_user_exists,
WalletTypeInfo,
require_admin_key,
)
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,
user: User = Depends(check_user_exists),
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> WebPushSubscription:
try:
subscription = json.loads(data.subscription)
endpoint = subscription["endpoint"]
host = urlparse(str(request.url)).netloc
subscription = await get_webpush_subscription(endpoint, user.id)
subscription = await get_webpush_subscription(endpoint, wallet.wallet.user)
if subscription:
return subscription
else:
return await create_webpush_subscription(
endpoint,
user.id,
wallet.wallet.user,
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,
user: User = Depends(check_user_exists),
wallet: WalletTypeInfo = Depends(require_admin_key),
):
try:
endpoint = unquote(
base64.b64decode(str(request.query_params.get("endpoint"))).decode("utf-8")
)
count = await delete_webpush_subscription(endpoint, user.id)
count = await delete_webpush_subscription(endpoint, wallet.wallet.user)
return {"count": count}
except Exception as exc:
logger.debug(exc)
+50 -81
View File
@@ -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, TypeVar, get_origin
from typing import Any, Generic, Literal, Optional, TypeVar, Union, get_origin
from loguru import logger
from pydantic import BaseModel, ValidationError, root_validator
@@ -22,13 +22,6 @@ 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://"):
@@ -65,8 +58,8 @@ def get_placeholder(model: Any, field: str) -> str:
class Compat:
type: str | None = "<inherited>"
schema: str | None = "<inherited>"
type: Optional[str] = "<inherited>"
schema: Optional[str] = "<inherited>"
def interval_seconds(self, seconds: int) -> str:
if self.type in {POSTGRES, COCKROACH}:
@@ -82,18 +75,6 @@ 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}:
@@ -167,8 +148,8 @@ class Connection(Compat):
async def fetchall(
self,
query: str,
values: dict | None = None,
model: type[TModel] | None = None,
values: Optional[dict] = None,
model: Optional[type[TModel]] = None,
) -> list[TModel]:
params = self.rewrite_values(values) if values else {}
result = await self.conn.execute(text(self.rewrite_query(query)), params)
@@ -183,8 +164,8 @@ class Connection(Compat):
async def fetchone(
self,
query: str,
values: dict | None = None,
model: type[TModel] | None = None,
values: Optional[dict] = None,
model: Optional[type[TModel]] = None,
) -> TModel:
params = self.rewrite_values(values) if values else {}
result = await self.conn.execute(text(self.rewrite_query(query)), params)
@@ -211,11 +192,11 @@ class Connection(Compat):
async def fetch_page(
self,
query: str,
where: list[str] | None = None,
values: dict | None = None,
filters: Filters | None = None,
model: type[TModel] | None = None,
group_by: list[str] | None = None,
where: Optional[list[str]] = None,
values: Optional[dict] = None,
filters: Optional[Filters] = None,
model: Optional[type[TModel]] = None,
group_by: Optional[list[str]] = None,
) -> Page[TModel]:
if not filters:
filters = Filters()
@@ -252,7 +233,7 @@ class Connection(Compat):
{clause}
{group_by_string}
) as count
""", # noqa: S608
""",
parsed_values,
)
row = result.mappings().first()
@@ -268,7 +249,7 @@ class Connection(Compat):
total=count,
)
async def execute(self, query: str, values: dict | None = None):
async def execute(self, query: str, values: Optional[dict] = None):
params = self.rewrite_values(values) if values else {}
result = await self.conn.execute(text(self.rewrite_query(query)), params)
await self.conn.commit()
@@ -350,8 +331,8 @@ class Database(Compat):
async def fetchall(
self,
query: str,
values: dict | None = None,
model: type[TModel] | None = None,
values: Optional[dict] = None,
model: Optional[type[TModel]] = None,
) -> list[TModel]:
async with self.connect() as conn:
return await conn.fetchall(query, values, model)
@@ -359,8 +340,8 @@ class Database(Compat):
async def fetchone(
self,
query: str,
values: dict | None = None,
model: type[TModel] | None = None,
values: Optional[dict] = None,
model: Optional[type[TModel]] = None,
) -> TModel:
async with self.connect() as conn:
return await conn.fetchone(query, values, model)
@@ -378,16 +359,16 @@ class Database(Compat):
async def fetch_page(
self,
query: str,
where: list[str] | None = None,
values: dict | None = None,
filters: Filters | None = None,
model: type[TModel] | None = None,
group_by: list[str] | None = None,
where: Optional[list[str]] = None,
values: Optional[dict] = None,
filters: Optional[Filters] = None,
model: Optional[type[TModel]] = None,
group_by: Optional[list[str]] = 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: dict | None = None):
async def execute(self, query: str, values: Optional[dict] = None):
async with self.connect() as conn:
return await conn.execute(query, values)
@@ -445,7 +426,7 @@ class Operator(Enum):
class FilterModel(BaseModel):
__search_fields__: list[str] = []
__sort_fields__: list[str] | None = None
__sort_fields__: Optional[list[str]] = None
T = TypeVar("T")
@@ -461,8 +442,8 @@ class Page(BaseModel, Generic[T]):
class Filter(BaseModel, Generic[TFilterModel]):
field: str
op: Operator = Operator.EQ
model: type[TFilterModel] | None
values: dict | None = None
model: Optional[type[TFilterModel]]
values: Optional[dict] = None
@classmethod
def parse_query(
@@ -517,15 +498,15 @@ class Filters(BaseModel, Generic[TFilterModel]):
"""
filters: list[Filter[TFilterModel]] = []
search: str | None = None
search: Optional[str] = None
offset: int | None = None
limit: int | None = None
offset: Optional[int] = None
limit: Optional[int] = None
sortby: str | None = None
direction: Literal["asc", "desc"] | None = None
sortby: Optional[str] = None
direction: Optional[Literal["asc", "desc"]] = None
model: type[TFilterModel] | None = None
model: Optional[type[TFilterModel]] = None
@root_validator(pre=True)
def validate_sortby(cls, values):
@@ -547,21 +528,18 @@ class Filters(BaseModel, Generic[TFilterModel]):
stmt += f"OFFSET {self.offset}"
return stmt
def where(self, where_stmts: list[str] | None = None) -> str:
def where(self, where_stmts: Optional[list[str]] = 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 and self.model.__search_fields__:
# Use `COALESCE` to handle `NULL` values and `||`
# for cross-database compatible string concatenation
_fields = self.model.__search_fields__
search_expr = " || ".join(
f"COALESCE(CAST({field} AS TEXT), '')" for field in _fields
)
where_stmts.append(f"lower({search_expr}) LIKE :search")
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 where_stmts:
return "WHERE " + " AND ".join(where_stmts)
return ""
@@ -571,7 +549,7 @@ class Filters(BaseModel, Generic[TFilterModel]):
return f"ORDER BY {self.sortby} {self.direction or 'asc'}"
return ""
def values(self, values: dict | None = None) -> dict:
def values(self, values: Optional[dict] = None) -> dict:
if not values:
values = {}
if self.filters:
@@ -580,7 +558,7 @@ class Filters(BaseModel, Generic[TFilterModel]):
for key, value in page_filter.values.items():
values[key] = value
if self.search and self.model:
values["search"] = f"%{self.search.lower()}%"
values["search"] = f"%{self.search}%"
return values
@@ -597,7 +575,7 @@ def insert_query(table_name: str, model: BaseModel) -> str:
# add quotes to keys to avoid SQL conflicts (e.g. `user` is a reserved keyword)
fields = ", ".join([f'"{key}"' for key in keys])
values = ", ".join(placeholders)
return f"INSERT INTO {table_name} ({fields}) VALUES ({values})" # noqa: S608
return f"INSERT INTO {table_name} ({fields}) VALUES ({values})"
def update_query(
@@ -615,7 +593,7 @@ def update_query(
# add quotes to keys to avoid SQL conflicts (e.g. `user` is a reserved keyword)
fields.append(f'"{field}" = {placeholder}')
query = ", ".join(fields)
return f"UPDATE {table_name} SET {query} {where}" # noqa: S608
return f"UPDATE {table_name} SET {query} {where}"
def model_to_dict(model: BaseModel) -> dict:
@@ -645,12 +623,12 @@ def model_to_dict(model: BaseModel) -> dict:
return _dict
def dict_to_submodel(model: type[TModel], value: dict | str) -> TModel | None:
def dict_to_submodel(model: type[TModel], value: Union[dict, str]) -> Optional[TModel]:
"""convert a dictionary or JSON string to a Pydantic model"""
if isinstance(value, str):
if value == "null" or value == "":
if value == "null":
return None
_subdict = _safe_load_json(value)
_subdict = json.loads(value)
elif isinstance(value, dict):
_subdict = value
@@ -658,7 +636,7 @@ def dict_to_submodel(model: type[TModel], value: dict | str) -> TModel | None:
return dict_to_model(_subdict, model)
def dict_to_model(_row: dict, model: type[TModel]) -> TModel: # noqa: C901
def dict_to_model(_row: dict, model: type[TModel]) -> TModel:
"""
Convert a dictionary with JSON-encoded nested models to a Pydantic model
:param _dict: Dictionary from database
@@ -674,7 +652,7 @@ def dict_to_model(_row: dict, model: type[TModel]) -> TModel: # noqa: C901
type_ = model.__fields__[key].type_
outertype_ = model.__fields__[key].outer_type_
if get_origin(outertype_) is list:
_items = _safe_load_json(value) if isinstance(value, str) else value
_items = json.loads(value) if isinstance(value, str) else value
_dict[key] = [
dict_to_submodel(type_, v) if issubclass(type_, BaseModel) else v
for v in _items
@@ -695,7 +673,7 @@ def dict_to_model(_row: dict, model: type[TModel]) -> TModel: # noqa: C901
# TODO: remove this when all sub models are migrated to Pydantic
# NOTE: this is for type dict on BaseModel, (used in Payment class)
if type_ is dict and value:
_dict[key] = _safe_load_json(value)
_dict[key] = json.loads(value)
continue
_dict[key] = value
continue
@@ -703,12 +681,3 @@ def dict_to_model(_row: dict, model: type[TModel]) -> TModel: # noqa: C901
if isinstance(_model, BaseModel):
_model.__init__(**_dict) # type: ignore
return _model
def _safe_load_json(value: str) -> dict:
try:
return json.loads(value)
except json.JSONDecodeError:
# DB is corrupted if it gets here
logger.error(f"Failed to decode JSON: '{value}'")
return {}
+33 -96
View File
@@ -1,11 +1,11 @@
from http import HTTPStatus
from typing import Annotated, Literal, Optional, Union
from typing import Annotated, Literal, Optional, Type, Union
import jwt
from fastapi import Cookie, Depends, Query, Request, Security
from fastapi.exceptions import HTTPException
from fastapi.openapi.models import APIKey, APIKeyIn, SecuritySchemeType
from fastapi.security import APIKeyHeader, APIKeyQuery, HTTPBearer, OAuth2PasswordBearer
from fastapi.security import APIKeyHeader, APIKeyQuery, OAuth2PasswordBearer
from fastapi.security.base import SecurityBase
from loguru import logger
from pydantic.types import UUID4
@@ -18,7 +18,6 @@ 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,
@@ -28,18 +27,10 @@ from lnbits.core.models import (
WalletTypeInfo,
)
from lnbits.db import Connection, Filter, Filters, TFilterModel
from lnbits.helpers import normalize_path, path_segments
from lnbits.settings import AuthMethods, settings
oauth2_scheme = OAuth2PasswordBearer(
tokenUrl="api/v1/auth",
auto_error=False,
description="OAuth2 access token for authentication with username and password.",
)
http_bearer = HTTPBearer(
auto_error=False,
description="Bearer Token for custom ACL based access control",
)
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="api/v1/auth", auto_error=False)
api_key_header = APIKeyHeader(
name="X-API-KEY",
auto_error=False,
@@ -102,7 +93,7 @@ class KeyChecker(SecurityBase):
request.scope["user_id"] = wallet.user
if self.expected_key_type is KeyType.admin and wallet.adminkey != key_value:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
status_code=HTTPStatus.UNAUTHORIZED,
detail="Invalid adminkey.",
)
@@ -139,9 +130,8 @@ async def require_invoice_key(
async def check_access_token(
header_access_token: Annotated[Union[str, None], Depends(oauth2_scheme)],
cookie_access_token: Annotated[Union[str, None], Cookie()] = None,
bearer_access_token: Annotated[Union[str, None], Depends(http_bearer)] = None,
) -> Optional[str]:
return header_access_token or cookie_access_token or bearer_access_token
return header_access_token or cookie_access_token
async def check_user_exists(
@@ -150,13 +140,9 @@ async def check_user_exists(
usr: Optional[UUID4] = None,
) -> User:
if access_token:
account = await _get_account_from_token(access_token, r["path"], r["method"])
account = await _get_account_from_token(access_token)
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.")
@@ -165,7 +151,7 @@ async def check_user_exists(
r.scope["user_id"] = account.id
if not settings.is_user_allowed(account.id):
raise HTTPException(HTTPStatus.FORBIDDEN, "User not allowed.")
raise HTTPException(HTTPStatus.UNAUTHORIZED, "User not allowed.")
user = await get_user_from_account(account)
if not user:
@@ -175,14 +161,13 @@ 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, r["path"], r["method"])
account = await _get_account_from_token(access_token)
return account.id if account else None
return None
@@ -201,11 +186,7 @@ async def access_token_payload(
async def check_admin(user: Annotated[User, Depends(check_user_exists)]) -> User:
if user.id != settings.super_user and user.id not in settings.lnbits_admin_users:
raise HTTPException(
HTTPStatus.FORBIDDEN, "User not authorized. No admin privileges."
)
if not user.has_password:
raise HTTPException(
HTTPStatus.FORBIDDEN, "Admin users must have credentials configured."
HTTPStatus.UNAUTHORIZED, "User not authorized. No admin privileges."
)
return user
@@ -214,16 +195,12 @@ async def check_admin(user: Annotated[User, Depends(check_user_exists)]) -> User
async def check_super_user(user: Annotated[User, Depends(check_user_exists)]) -> User:
if user.id != settings.super_user:
raise HTTPException(
HTTPStatus.FORBIDDEN, "User not authorized. No super user privileges."
)
if not user.has_password:
raise HTTPException(
HTTPStatus.FORBIDDEN, "Super user must have credentials configured."
HTTPStatus.UNAUTHORIZED, "User not authorized. No super user privileges."
)
return user
def parse_filters(model: type[TFilterModel]):
def parse_filters(model: Type[TFilterModel]):
"""
Parses the query params as filters.
:param model: model used for validation of filter values
@@ -270,18 +247,19 @@ async def check_user_extension_access(
success=False, message=f"User not authorized for extension '{ext_id}'."
)
if settings.is_installed_extension_id(ext_id):
if settings.is_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"Extension '{ext_id}' not enabled."
success=False, message=f"User extension '{ext_id}' not enabled."
)
return SimpleStatus(success=True, message="OK")
async def _check_user_extension_access(user_id: str, path: str):
ext_id = path_segments(path)[0]
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]
status = await check_user_extension_access(user_id, ext_id)
if not status.success:
raise HTTPException(
@@ -290,14 +268,16 @@ async def _check_user_extension_access(user_id: str, path: str):
)
async def _get_account_from_token(
access_token: str, path: str, method: str
) -> Optional[Account]:
async def _get_account_from_token(access_token) -> Optional[Account]:
try:
payload: dict = jwt.decode(access_token, settings.auth_secret_key, ["HS256"])
return await _get_account_from_jwt_payload(
AccessTokenPayload(**payload), path, method
)
user = await _get_user_from_jwt_payload(payload)
if not user:
raise HTTPException(
HTTPStatus.UNAUTHORIZED, "Data missing for access token."
)
return user
except jwt.ExpiredSignatureError as exc:
raise HTTPException(
@@ -308,54 +288,11 @@ async def _get_account_from_token(
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid access token.") from exc
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.")
def url_for_interceptor(original_method):
def normalize_url(self, *args, **kwargs):
url = original_method(self, *args, **kwargs)
return url.replace(path=normalize_path(url.path))
return normalize_url
# Upgraded extensions modify the path.
# This interceptor ensures that the path is normalized.
Request.url_for = url_for_interceptor(Request.url_for) # type: ignore[method-assign]
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

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