Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
395ba3aae6 |
+61
-118
@@ -1,95 +1,11 @@
|
|||||||
#For more information on .env files, their content and format: https://pypi.org/project/python-dotenv/
|
#For more information on .env files, their content and format: https://pypi.org/project/python-dotenv/
|
||||||
|
|
||||||
######################################
|
|
||||||
###### .env ONLY SETTINGS ############
|
|
||||||
######################################
|
|
||||||
# The following settings are ONLY set in your .env file.
|
|
||||||
# They are NOT managed by the Admin UI and are not stored in the database.
|
|
||||||
|
|
||||||
# === Logging and Development ===
|
|
||||||
|
|
||||||
DEBUG=False
|
|
||||||
DEBUG_DATABASE=False
|
|
||||||
BUNDLE_ASSETS=True
|
|
||||||
|
|
||||||
# logging into LNBITS_DATA_FOLDER/logs/
|
|
||||||
ENABLE_LOG_TO_FILE=true
|
|
||||||
|
|
||||||
# https://loguru.readthedocs.io/en/stable/api/logger.html#file
|
|
||||||
LOG_ROTATION="100 MB"
|
|
||||||
LOG_RETENTION="3 months"
|
|
||||||
|
|
||||||
# for database cleanup commands
|
|
||||||
# CLEANUP_WALLETS_DAYS=90
|
|
||||||
|
|
||||||
# === Admin Settings ===
|
|
||||||
|
|
||||||
# Enable Admin GUI, available for the first user in LNBITS_ADMIN_USERS if available.
|
|
||||||
# Warning: Enabling this will make LNbits ignore most configurations in file. Only the
|
|
||||||
# configurations defined in `ReadOnlySettings` will still be read from the environment variables.
|
|
||||||
# The rest of the settings will be stored in your database and you will be able to change them
|
|
||||||
# only through the Admin UI.
|
|
||||||
# Disable this and clear `settings` table from database to make LNbits use this config file again.
|
|
||||||
LNBITS_ADMIN_UI=true
|
|
||||||
|
|
||||||
HOST=127.0.0.1
|
|
||||||
PORT=5000
|
|
||||||
# VERSION=
|
|
||||||
# USER_AGENT=
|
|
||||||
|
|
||||||
# === LNbits ===
|
|
||||||
|
|
||||||
# Database: to use SQLite, specify LNBITS_DATA_FOLDER
|
|
||||||
# to use PostgreSQL, specify LNBITS_DATABASE_URL=postgres://...
|
|
||||||
# to use CockroachDB, specify LNBITS_DATABASE_URL=cockroachdb://...
|
|
||||||
# for both PostgreSQL and CockroachDB, you'll need to install
|
|
||||||
# psycopg2 as an additional dependency
|
|
||||||
LNBITS_DATA_FOLDER="./data"
|
|
||||||
# LNBITS_DATABASE_URL="postgres://user:password@host:port/databasename"
|
|
||||||
|
|
||||||
# Extensions to be installed by default. If an extension from this list is uninstalled then it will be re-installed on the next restart.
|
|
||||||
# The extension must be removed from this list in order to not be re-installed.
|
|
||||||
LNBITS_EXTENSIONS_DEFAULT_INSTALL="tpos"
|
|
||||||
|
|
||||||
# LNBITS_EXTENSIONS_MANIFESTS="https://raw.githubusercontent.com/lnbits/lnbits-extensions/main/extensions.json,https://raw.githubusercontent.com/lnbits/lnbits-extensions/main/extensions-trial.json"
|
|
||||||
# GitHub has rate-limits for its APIs. The limit can be increased specifying a GITHUB_TOKEN
|
|
||||||
# LNBITS_EXT_GITHUB_TOKEN=github_pat_xxxxxxxxxxxxxxxxxx
|
|
||||||
|
|
||||||
# which fundingsources are allowed in the admin ui
|
|
||||||
# LNBITS_ALLOWED_FUNDING_SOURCES="VoidWallet, FakeWallet, CoreLightningWallet, CoreLightningRestWallet, LndRestWallet, EclairWallet, LndWallet, LnTipsWallet, LNPayWallet, LNbitsWallet, BlinkWallet, AlbyWallet, ZBDWallet, PhoenixdWallet, OpenNodeWallet, NWCWallet, BreezSdkWallet, BoltzWallet, StrikeWallet, CLNRestWallet"
|
|
||||||
|
|
||||||
# uvicorn variable, allow https behind a proxy
|
|
||||||
# IMPORTANT: this also needs the webserver to be configured to forward the headers
|
|
||||||
# http://docs.lnbits.org/guide/installation.html#running-behind-an-apache2-reverse-proxy-over-https
|
|
||||||
FORWARDED_ALLOW_IPS="*"
|
|
||||||
|
|
||||||
# Path where extensions will be installed (defaults to `./lnbits/`).
|
|
||||||
# Inside this directory the `extensions` and `upgrades` sub-directories will be created.
|
|
||||||
# LNBITS_EXTENSIONS_PATH="/path/to/some/dir"
|
|
||||||
|
|
||||||
# ID of the super user. The user ID must exist.
|
|
||||||
# SUPER_USER=""
|
|
||||||
|
|
||||||
# LNBITS_TITLE="LNbits API"
|
|
||||||
# LNBITS_PATH="folder/path"
|
|
||||||
|
|
||||||
# === Auth Configurations ===
|
|
||||||
|
|
||||||
# Secret Key: will default to the hash of the super user.
|
|
||||||
# !!!!! It is strongly recommended that you set your own strong random value !!!!
|
|
||||||
AUTH_SECRET_KEY=""
|
|
||||||
|
|
||||||
# === Funding Source ===# How many times to retry connectiong to the Funding Source before defaulting to the VoidWallet
|
|
||||||
# FUNDING_SOURCE_MAX_RETRIES=4
|
|
||||||
|
|
||||||
######################################
|
|
||||||
###### END .env ONLY SETTINGS ########
|
|
||||||
######################################
|
|
||||||
|
|
||||||
######################################
|
######################################
|
||||||
####### Auth Configurations ##########
|
####### Auth Configurations ##########
|
||||||
######################################
|
######################################
|
||||||
|
# Secret Key: will default to the hash of the super user.
|
||||||
|
# !!!!! It is strongly recommended that you set your own strong random value !!!!
|
||||||
|
AUTH_SECRET_KEY=""
|
||||||
AUTH_TOKEN_EXPIRE_MINUTES=525600
|
AUTH_TOKEN_EXPIRE_MINUTES=525600
|
||||||
# Possible authorization methods: user-id-only, username-password, nostr-auth-nip98, google-auth, github-auth, keycloak-auth
|
# Possible authorization methods: user-id-only, username-password, nostr-auth-nip98, google-auth, github-auth, keycloak-auth
|
||||||
AUTH_ALLOWED_METHODS="user-id-only, username-password"
|
AUTH_ALLOWED_METHODS="user-id-only, username-password"
|
||||||
@@ -100,6 +16,14 @@ AUTH_ALLOWED_METHODS="user-id-only, username-password"
|
|||||||
########### Admin Settings ###########
|
########### Admin Settings ###########
|
||||||
######################################
|
######################################
|
||||||
|
|
||||||
|
# Enable Admin GUI, available for the first user in LNBITS_ADMIN_USERS if available.
|
||||||
|
# Warning: Enabling this will make LNbits ignore most configurations in file. Only the
|
||||||
|
# configurations defined in `ReadOnlySettings` will still be read from the environment variables.
|
||||||
|
# The rest of the settings will be stored in your database and you will be able to change them
|
||||||
|
# only through the Admin UI.
|
||||||
|
# Disable this and clear `settings` table from database to make LNbits use this config file again.
|
||||||
|
LNBITS_ADMIN_UI=true
|
||||||
|
|
||||||
# Change theme
|
# Change theme
|
||||||
LNBITS_SITE_TITLE="LNbits"
|
LNBITS_SITE_TITLE="LNbits"
|
||||||
LNBITS_SITE_TAGLINE="Open Source Lightning Payments Platform"
|
LNBITS_SITE_TAGLINE="Open Source Lightning Payments Platform"
|
||||||
@@ -108,14 +32,22 @@ LNBITS_SITE_DESCRIPTION="The world's most powerful suite of bitcoin tools. Run f
|
|||||||
LNBITS_THEME_OPTIONS="classic, bitcoin, flamingo, freedom, mint, autumn, monochrome, salvador, cyber"
|
LNBITS_THEME_OPTIONS="classic, bitcoin, flamingo, freedom, mint, autumn, monochrome, salvador, cyber"
|
||||||
# LNBITS_CUSTOM_LOGO="https://lnbits.com/assets/images/logo/logo.svg"
|
# LNBITS_CUSTOM_LOGO="https://lnbits.com/assets/images/logo/logo.svg"
|
||||||
|
|
||||||
|
HOST=127.0.0.1
|
||||||
|
PORT=5000
|
||||||
|
|
||||||
######################################
|
######################################
|
||||||
########## Funding Source ############
|
########## Funding Source ############
|
||||||
######################################
|
######################################
|
||||||
|
|
||||||
|
# which fundingsources are allowed in the admin ui
|
||||||
|
# LNBITS_ALLOWED_FUNDING_SOURCES="VoidWallet, FakeWallet, CoreLightningWallet, CoreLightningRestWallet, LndRestWallet, EclairWallet, LndWallet, LnTipsWallet, LNPayWallet, LNbitsWallet, BlinkWallet, AlbyWallet, ZBDWallet, PhoenixdWallet, OpenNodeWallet, NWCWallet, BreezSdkWallet, BoltzWallet"
|
||||||
|
|
||||||
LNBITS_BACKEND_WALLET_CLASS=VoidWallet
|
LNBITS_BACKEND_WALLET_CLASS=VoidWallet
|
||||||
# VoidWallet is just a fallback that works without any actual Lightning capabilities,
|
# VoidWallet is just a fallback that works without any actual Lightning capabilities,
|
||||||
# just so you can see the UI before dealing with this file.
|
# just so you can see the UI before dealing with this file.
|
||||||
|
|
||||||
|
# How many times to retry connectiong to the Funding Source before defaulting to the VoidWallet
|
||||||
|
# FUNDING_SOURCE_MAX_RETRIES=4
|
||||||
|
|
||||||
# Invoice expiry for LND, CLN, Eclair, LNbits funding sources
|
# Invoice expiry for LND, CLN, Eclair, LNbits funding sources
|
||||||
LIGHTNING_INVOICE_EXPIRY=3600
|
LIGHTNING_INVOICE_EXPIRY=3600
|
||||||
@@ -129,20 +61,6 @@ CLICHE_ENDPOINT=ws://127.0.0.1:12000
|
|||||||
SPARK_URL=http://localhost:9737/rpc
|
SPARK_URL=http://localhost:9737/rpc
|
||||||
SPARK_TOKEN=myaccesstoken
|
SPARK_TOKEN=myaccesstoken
|
||||||
|
|
||||||
#CLNRest (using runes)
|
|
||||||
CLNREST_URL=https://127.0.0.1:3010
|
|
||||||
CLNREST_CA=/home/lightningd/.lightning/bitcoin/ca.pem
|
|
||||||
CLNREST_CERT=/home/lightningd/.lightning/bitcoin/server.pem
|
|
||||||
# CLNREST_CA = cat ca.pem | awk '{printf "%s\\n", $0} END {printf "\n"}'
|
|
||||||
# CLNREST_CERT = cat server.pem | awk '{printf "%s\\n", $0} END {printf "\n"}'
|
|
||||||
|
|
||||||
CLNREST_READONLY_RUNE=lightning-cli createrune restrictions='[["method=listfunds", "method=listpays", "method=listinvoices", "method=getinfo", "method=summary", "method=waitanyinvoice"]]' | jq -r .rune
|
|
||||||
CLNREST_INVOICE_RUNE=lightning-cli createrune restrictions='[["method=invoice"], ["pnameamount_msat<1000001"], ["pnamelabel^LNbits"], ["rate=60"]]' | jq -r .rune
|
|
||||||
CLNREST_PAY_RUNE=lightning-cli createrune restrictions='[["method=pay"], ["pinvbolt11_amount<1001"], ["pnamelabel^LNbits"], ["rate=1"]]' | jq -r .rune
|
|
||||||
#CLNREST_RENEPAY_RUNE=lightning-cli createrune restrictions='[["method=renepay"], ["pinvinvstring_amount<1001"], ["pnamelabel^LNbits"], ["rate=1"]]' | jq -r .rune
|
|
||||||
#CLNREST_LAST_PAY_INDEX='lightning-cli listinvoices | jq -r '.invoices | map(.created_index) | max'
|
|
||||||
#CLNREST_NODEID=lightning-cli getinfo | jq -r .id # only required for v23.08
|
|
||||||
|
|
||||||
# CoreLightningWallet
|
# CoreLightningWallet
|
||||||
CORELIGHTNING_RPC="/home/bob/.lightning/bitcoin/lightning-rpc"
|
CORELIGHTNING_RPC="/home/bob/.lightning/bitcoin/lightning-rpc"
|
||||||
|
|
||||||
@@ -183,16 +101,10 @@ ALBY_ACCESS_TOKEN=ALBY_ACCESS_TOKEN
|
|||||||
|
|
||||||
# BoltzWallet
|
# BoltzWallet
|
||||||
BOLTZ_CLIENT_ENDPOINT=127.0.0.1:9002
|
BOLTZ_CLIENT_ENDPOINT=127.0.0.1:9002
|
||||||
# HEXSTRING instead of path also possible
|
BOLTZ_CLIENT_MACAROON="/home/bob/.boltz/macaroon" # or HEXSTRING
|
||||||
BOLTZ_CLIENT_MACAROON="/home/bob/.boltz/macaroons/admin.macaroon"
|
BOLTZ_CLIENT_CERT="/home/bob/.boltz/tls.cert" # or HEXSTRING
|
||||||
# HEXSTRING instead of path also possible
|
|
||||||
BOLTZ_CLIENT_CERT="/home/bob/.boltz/tls.cert"
|
|
||||||
BOLTZ_CLIENT_WALLET="lnbits"
|
BOLTZ_CLIENT_WALLET="lnbits"
|
||||||
|
|
||||||
# StrikeWallet
|
|
||||||
STRIKE_API_ENDPOINT=https://api.strike.me/v1
|
|
||||||
STRIKE_API_KEY=YOUR_STRIKE_API_KEY
|
|
||||||
|
|
||||||
# ZBDWallet
|
# ZBDWallet
|
||||||
ZBD_API_ENDPOINT=https://api.zebedee.io/v0/
|
ZBD_API_ENDPOINT=https://api.zebedee.io/v0/
|
||||||
ZBD_API_KEY=ZBD_ACCESS_TOKEN
|
ZBD_API_KEY=ZBD_ACCESS_TOKEN
|
||||||
@@ -235,12 +147,6 @@ BREEZ_GREENLIGHT_DEVICE_KEY="/path/to/breezsdk/device.pem" # or BASE64/HEXSTRIN
|
|||||||
BREEZ_GREENLIGHT_DEVICE_CERT="/path/to/breezsdk/device.crt" # or BASE64/HEXSTRING
|
BREEZ_GREENLIGHT_DEVICE_CERT="/path/to/breezsdk/device.crt" # or BASE64/HEXSTRING
|
||||||
# BREEZ_USE_TRAMPOLINE=true
|
# BREEZ_USE_TRAMPOLINE=true
|
||||||
|
|
||||||
# BreezLiquidSdkWallet
|
|
||||||
# get your own api key here https://breez.technology/request-api-key/#contact-us-form-sdk
|
|
||||||
# or keep the api key empty to use the LNbits key for referrals (API key is not a secret)
|
|
||||||
# BREEZ_LIQUID_API_KEY=""
|
|
||||||
BREEZ_LIQUID_SEED="MNEMONIC SEED PHRASE"
|
|
||||||
# BREEZ_LIQUID_FEE_OFFSET_SAT=50
|
|
||||||
|
|
||||||
# Google OAuth Config
|
# Google OAuth Config
|
||||||
# Make sure that the authorized redirect URIs contain https://{domain}/api/v1/auth/google/token
|
# Make sure that the authorized redirect URIs contain https://{domain}/api/v1/auth/google/token
|
||||||
@@ -257,12 +163,15 @@ GITHUB_CLIENT_SECRET=""
|
|||||||
KEYCLOAK_CLIENT_ID=""
|
KEYCLOAK_CLIENT_ID=""
|
||||||
KEYCLOAK_CLIENT_SECRET=""
|
KEYCLOAK_CLIENT_SECRET=""
|
||||||
KEYCLOAK_DISCOVERY_URL=""
|
KEYCLOAK_DISCOVERY_URL=""
|
||||||
KEYCLOAK_CLIENT_CUSTOM_ORG=""
|
|
||||||
KEYCLOAK_CLIENT_CUSTOM_ICON=""
|
|
||||||
|
|
||||||
|
|
||||||
######################################
|
######################################
|
||||||
|
|
||||||
|
# uvicorn variable, uncomment to allow https behind a proxy
|
||||||
|
# IMPORTANT: this also needs the webserver to be configured to forward the headers
|
||||||
|
# http://docs.lnbits.org/guide/installation.html#running-behind-an-apache2-reverse-proxy-over-https
|
||||||
|
# FORWARDED_ALLOW_IPS="*"
|
||||||
|
|
||||||
# Server security, rate limiting ips, blocked ips, allowed ips
|
# Server security, rate limiting ips, blocked ips, allowed ips
|
||||||
LNBITS_RATE_LIMIT_NO="200"
|
LNBITS_RATE_LIMIT_NO="200"
|
||||||
LNBITS_RATE_LIMIT_UNIT="minute"
|
LNBITS_RATE_LIMIT_UNIT="minute"
|
||||||
@@ -273,7 +182,8 @@ LNBITS_BLOCKED_IPS=""
|
|||||||
# if set new users will not be able to create accounts
|
# if set new users will not be able to create accounts
|
||||||
LNBITS_ALLOWED_USERS=""
|
LNBITS_ALLOWED_USERS=""
|
||||||
LNBITS_ADMIN_USERS=""
|
LNBITS_ADMIN_USERS=""
|
||||||
|
# ID of the super user. The user ID must exist.
|
||||||
|
# SUPER_USER=""
|
||||||
|
|
||||||
# Extensions only admin can access
|
# Extensions only admin can access
|
||||||
LNBITS_ADMIN_EXTENSIONS="ngrok, nostrclient"
|
LNBITS_ADMIN_EXTENSIONS="ngrok, nostrclient"
|
||||||
@@ -306,6 +216,26 @@ LNBITS_DEFAULT_WALLET_NAME="LNbits wallet"
|
|||||||
# Hides wallet api, extensions can choose to honor
|
# Hides wallet api, extensions can choose to honor
|
||||||
LNBITS_HIDE_API=false
|
LNBITS_HIDE_API=false
|
||||||
|
|
||||||
|
# LNBITS_EXTENSIONS_MANIFESTS="https://raw.githubusercontent.com/lnbits/lnbits-extensions/main/extensions.json,https://raw.githubusercontent.com/lnbits/lnbits-extensions/main/extensions-trial.json"
|
||||||
|
# GitHub has rate-limits for its APIs. The limit can be increased specifying a GITHUB_TOKEN
|
||||||
|
# LNBITS_EXT_GITHUB_TOKEN=github_pat_xxxxxxxxxxxxxxxxxx
|
||||||
|
|
||||||
|
# Path where extensions will be installed (defaults to `./lnbits/`).
|
||||||
|
# Inside this directory the `extensions` and `upgrades` sub-directories will be created.
|
||||||
|
# LNBITS_EXTENSIONS_PATH="/path/to/some/dir"
|
||||||
|
|
||||||
|
# Extensions to be installed by default. If an extension from this list is uninstalled then it will be re-installed on the next restart.
|
||||||
|
# The extension must be removed from this list in order to not be re-installed.
|
||||||
|
LNBITS_EXTENSIONS_DEFAULT_INSTALL="tpos"
|
||||||
|
|
||||||
|
# Database: to use SQLite, specify LNBITS_DATA_FOLDER
|
||||||
|
# to use PostgreSQL, specify LNBITS_DATABASE_URL=postgres://...
|
||||||
|
# to use CockroachDB, specify LNBITS_DATABASE_URL=cockroachdb://...
|
||||||
|
# for both PostgreSQL and CockroachDB, you'll need to install
|
||||||
|
# psycopg2 as an additional dependency
|
||||||
|
LNBITS_DATA_FOLDER="./data"
|
||||||
|
# LNBITS_DATABASE_URL="postgres://user:password@host:port/databasename"
|
||||||
|
|
||||||
# the service fee (in percent)
|
# the service fee (in percent)
|
||||||
LNBITS_SERVICE_FEE=0.0
|
LNBITS_SERVICE_FEE=0.0
|
||||||
# the wallet where fees go to
|
# the wallet where fees go to
|
||||||
@@ -334,3 +264,16 @@ LNBITS_RESERVE_FEE_PERCENT=1.0
|
|||||||
###### Logging and Development #######
|
###### Logging and Development #######
|
||||||
######################################
|
######################################
|
||||||
|
|
||||||
|
DEBUG=false
|
||||||
|
DEBUG_DATABASE=false
|
||||||
|
BUNDLE_ASSETS=true
|
||||||
|
|
||||||
|
# logging into LNBITS_DATA_FOLDER/logs/
|
||||||
|
ENABLE_LOG_TO_FILE=true
|
||||||
|
|
||||||
|
# https://loguru.readthedocs.io/en/stable/api/logger.html#file
|
||||||
|
LOG_ROTATION="100 MB"
|
||||||
|
LOG_RETENTION="3 months"
|
||||||
|
|
||||||
|
# for database cleanup commands
|
||||||
|
# CLEANUP_WALLETS_DAYS=90
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ inputs:
|
|||||||
description: "Python Version"
|
description: "Python Version"
|
||||||
required: true
|
required: true
|
||||||
default: "3.10"
|
default: "3.10"
|
||||||
|
poetry-version:
|
||||||
|
description: "Poetry Version"
|
||||||
|
default: "1.7.0"
|
||||||
node-version:
|
node-version:
|
||||||
description: "Node Version"
|
description: "Node Version"
|
||||||
default: "20.x"
|
default: "20.x"
|
||||||
@@ -21,16 +24,33 @@ runs:
|
|||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: ${{ inputs.python-version }}
|
python-version: ${{ inputs.python-version }}
|
||||||
|
# cache poetry install via pip
|
||||||
|
cache: "pip"
|
||||||
|
|
||||||
- name: Install uv
|
- name: Set up Poetry ${{ inputs.poetry-version }}
|
||||||
uses: astral-sh/setup-uv@v6
|
uses: abatilo/actions-poetry@v2
|
||||||
with:
|
with:
|
||||||
enable-cache: true
|
poetry-version: ${{ inputs.poetry-version }}
|
||||||
python-version: ${{ inputs.python-version }}
|
|
||||||
|
- name: Setup a local virtual environment (if no poetry.toml file)
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
poetry config virtualenvs.create true --local
|
||||||
|
poetry config virtualenvs.in-project true --local
|
||||||
|
|
||||||
|
- uses: actions/cache@v4
|
||||||
|
name: Define a cache for the virtual environment based on the dependencies lock file
|
||||||
|
with:
|
||||||
|
path: ./.venv
|
||||||
|
key: venv-${{ hashFiles('poetry.lock') }}
|
||||||
|
|
||||||
- name: Install the project dependencies
|
- name: Install the project dependencies
|
||||||
shell: bash
|
shell: bash
|
||||||
run: uv sync --locked --all-extras --dev
|
run: |
|
||||||
|
poetry env use python${{ inputs.python-version }}
|
||||||
|
poetry install
|
||||||
|
# needed for conv tests
|
||||||
|
poetry add psycopg2-binary
|
||||||
|
|
||||||
- name: Use Node.js ${{ inputs.node-version }}
|
- name: Use Node.js ${{ inputs.node-version }}
|
||||||
if: ${{ (inputs.npm == 'true') }}
|
if: ${{ (inputs.npm == 'true') }}
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ LNBITS_DATA_FOLDER="${LNBITS_DATA_FOLDER:-$LAUNCH_DIR/lnbits/database}"
|
|||||||
LNBITS_EXTENSIONS_PATH="${LNBITS_EXTENSIONS_PATH:-$LAUNCH_DIR/lnbits/extensions}"
|
LNBITS_EXTENSIONS_PATH="${LNBITS_EXTENSIONS_PATH:-$LAUNCH_DIR/lnbits/extensions}"
|
||||||
export LNBITS_DATA_FOLDER
|
export LNBITS_DATA_FOLDER
|
||||||
export LNBITS_EXTENSIONS_PATH
|
export LNBITS_EXTENSIONS_PATH
|
||||||
|
export LNBITS_ADMIN_UI=true
|
||||||
|
|
||||||
# Define the LNbits URL
|
# Define the LNbits URL
|
||||||
URL="http://0.0.0.0:5000"
|
URL="http://0.0.0.0:5000"
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ jobs:
|
|||||||
db-url: ["", "postgres://lnbits:lnbits@0.0.0.0:5432/lnbits"]
|
db-url: ["", "postgres://lnbits:lnbits@0.0.0.0:5432/lnbits"]
|
||||||
uses: ./.github/workflows/tests.yml
|
uses: ./.github/workflows/tests.yml
|
||||||
with:
|
with:
|
||||||
custom-pytest: "uv run pytest tests/api"
|
custom-pytest: "poetry run pytest tests/api"
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
db-url: ${{ matrix.db-url }}
|
db-url: ${{ matrix.db-url }}
|
||||||
secrets:
|
secrets:
|
||||||
@@ -34,7 +34,7 @@ jobs:
|
|||||||
db-url: ["", "postgres://lnbits:lnbits@0.0.0.0:5432/lnbits"]
|
db-url: ["", "postgres://lnbits:lnbits@0.0.0.0:5432/lnbits"]
|
||||||
uses: ./.github/workflows/tests.yml
|
uses: ./.github/workflows/tests.yml
|
||||||
with:
|
with:
|
||||||
custom-pytest: "uv run pytest tests/wallets"
|
custom-pytest: "poetry run pytest tests/wallets"
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
db-url: ${{ matrix.db-url }}
|
db-url: ${{ matrix.db-url }}
|
||||||
secrets:
|
secrets:
|
||||||
@@ -48,7 +48,7 @@ jobs:
|
|||||||
db-url: ["", "postgres://lnbits:lnbits@0.0.0.0:5432/lnbits"]
|
db-url: ["", "postgres://lnbits:lnbits@0.0.0.0:5432/lnbits"]
|
||||||
uses: ./.github/workflows/tests.yml
|
uses: ./.github/workflows/tests.yml
|
||||||
with:
|
with:
|
||||||
custom-pytest: "uv run pytest tests/unit"
|
custom-pytest: "poetry run pytest tests/unit"
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
db-url: ${{ matrix.db-url }}
|
db-url: ${{ matrix.db-url }}
|
||||||
secrets:
|
secrets:
|
||||||
@@ -77,7 +77,7 @@ jobs:
|
|||||||
python-version: ["3.10"]
|
python-version: ["3.10"]
|
||||||
backend-wallet-class: ["LndRestWallet", "LndWallet", "CoreLightningWallet", "CoreLightningRestWallet", "LNbitsWallet", "EclairWallet"]
|
backend-wallet-class: ["LndRestWallet", "LndWallet", "CoreLightningWallet", "CoreLightningRestWallet", "LNbitsWallet", "EclairWallet"]
|
||||||
with:
|
with:
|
||||||
custom-pytest: "uv run pytest tests/regtest"
|
custom-pytest: "poetry run pytest tests/regtest"
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
backend-wallet-class: ${{ matrix.backend-wallet-class }}
|
backend-wallet-class: ${{ matrix.backend-wallet-class }}
|
||||||
secrets:
|
secrets:
|
||||||
@@ -88,6 +88,8 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
python-version: ["3.10"]
|
python-version: ["3.10"]
|
||||||
|
poetry-version: ["1.5.1"]
|
||||||
uses: ./.github/workflows/jmeter.yml
|
uses: ./.github/workflows/jmeter.yml
|
||||||
with:
|
with:
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
|
poetry-version: ${{ matrix.poetry-version }}
|
||||||
|
|||||||
@@ -51,14 +51,3 @@ jobs:
|
|||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
cache-from: type=local,src=/tmp/.buildx-cache
|
cache-from: type=local,src=/tmp/.buildx-cache
|
||||||
cache-to: type=local,dest=/tmp/.buildx-cache
|
cache-to: type=local,dest=/tmp/.buildx-cache
|
||||||
|
|
||||||
- name: Build and push boltz
|
|
||||||
uses: docker/build-push-action@v5
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
file: Dockerfile.boltz
|
|
||||||
push: true
|
|
||||||
tags: ${{ secrets.DOCKER_USERNAME }}/lnbits-boltz:${{ inputs.tag }}
|
|
||||||
platforms: linux/amd64,linux/arm64
|
|
||||||
cache-from: type=local,src=/tmp/.buildx-cache
|
|
||||||
cache-to: type=local,dest=/tmp/.buildx-cache
|
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ on:
|
|||||||
required: true
|
required: true
|
||||||
default: "3.10"
|
default: "3.10"
|
||||||
type: string
|
type: string
|
||||||
|
poetry-version:
|
||||||
|
description: "Poetry Version"
|
||||||
|
required: true
|
||||||
|
default: "1.5.1"
|
||||||
|
type: string
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
jmeter:
|
jmeter:
|
||||||
@@ -25,7 +30,7 @@ jobs:
|
|||||||
LNBITS_EXTENSIONS_DEFAULT_INSTALL: "watchonly, satspay, tipjar, tpos, lnurlp, withdraw"
|
LNBITS_EXTENSIONS_DEFAULT_INSTALL: "watchonly, satspay, tipjar, tpos, lnurlp, withdraw"
|
||||||
LNBITS_BACKEND_WALLET_CLASS: FakeWallet
|
LNBITS_BACKEND_WALLET_CLASS: FakeWallet
|
||||||
run: |
|
run: |
|
||||||
uv run lnbits &
|
poetry run lnbits &
|
||||||
sleep 10
|
sleep 10
|
||||||
|
|
||||||
- name: setup java version
|
- name: setup java version
|
||||||
|
|||||||
@@ -53,6 +53,3 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
make: checkbundle
|
make: checkbundle
|
||||||
npm: true
|
npm: true
|
||||||
|
|
||||||
poetry:
|
|
||||||
uses: ./.github/workflows/poetry.yml
|
|
||||||
|
|||||||
@@ -14,14 +14,14 @@ on:
|
|||||||
- 'flake.nix'
|
- 'flake.nix'
|
||||||
- 'flake.lock'
|
- 'flake.lock'
|
||||||
- 'pyproject.toml'
|
- 'pyproject.toml'
|
||||||
- 'uv.lock'
|
- 'poetry.lock'
|
||||||
- '.github/workflows/nix.yml'
|
- '.github/workflows/nix.yml'
|
||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
paths:
|
||||||
- 'flake.nix'
|
- 'flake.nix'
|
||||||
- 'flake.lock'
|
- 'flake.lock'
|
||||||
- 'pyproject.toml'
|
- 'pyproject.toml'
|
||||||
- 'uv.lock'
|
- 'poetry.lock'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
nix:
|
nix:
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
name: Build LNbits AppImage
|
name: Build LNbits AppImage DMG
|
||||||
|
|
||||||
on:
|
on:
|
||||||
release:
|
release:
|
||||||
@@ -11,89 +11,56 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
# Step 1: Checkout the repository
|
# Step 1: Checkout the repository
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v3
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
# Step 2: Set up Python (uv will still use this toolchain)
|
# Step 2: Install Dependencies
|
||||||
- name: Set up Python
|
- name: Install Dependencies
|
||||||
uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: "3.12"
|
|
||||||
|
|
||||||
# Step 3: Install system deps (fuse) + uv
|
|
||||||
- name: Install system deps and uv
|
|
||||||
run: |
|
run: |
|
||||||
|
curl -sSL https://install.python-poetry.org | python3 -
|
||||||
|
echo "$HOME/.local/bin" >> $GITHUB_PATH
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get install -y libfuse2
|
sudo apt-get install -y libfuse2
|
||||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
|
||||||
echo "$HOME/.local/bin" >> $GITHUB_PATH
|
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|
||||||
# Optional: Cache uv + venv to speed up CI
|
# Step 3: Clone LNbits Repository
|
||||||
- name: Cache uv and venv
|
- name: Clone LNbits
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
~/.cache/uv
|
|
||||||
.venv
|
|
||||||
key: ${{ runner.os }}-uv-${{ hashFiles('uv.lock', 'pyproject.toml') }}
|
|
||||||
|
|
||||||
# Step 4: Prepare packaging tree and clone LNbits
|
|
||||||
- name: Prepare packaging & clone LNbits
|
|
||||||
run: |
|
run: |
|
||||||
mv .github/packaging packaging
|
mv .github/packaging packaging
|
||||||
mkdir -p packaging/linux/AppDir/usr
|
mkdir -p packaging/linux/AppDir/usr
|
||||||
git clone https://github.com/lnbits/lnbits.git packaging/linux/AppDir/usr/lnbits
|
git clone https://github.com/lnbits/lnbits.git packaging/linux/AppDir/usr/lnbits
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|
||||||
# Step 5: Build the LNbits binary with uv + PyInstaller
|
# Step 4: Make the AppImage Asset
|
||||||
- name: Build LNbits binary (uv + PyInstaller)
|
- name: Make Asset
|
||||||
run: |
|
run: |
|
||||||
cd packaging/linux/AppDir/usr/lnbits
|
cd packaging/linux/AppDir/usr/lnbits
|
||||||
|
poetry install
|
||||||
# Install project deps into .venv using uv
|
poetry run pip install pyinstaller
|
||||||
uv sync
|
|
||||||
|
|
||||||
# Install PyInstaller into the same environment
|
|
||||||
uv pip install pyinstaller
|
|
||||||
|
|
||||||
# Build the LNbits binary
|
# Build the LNbits binary
|
||||||
uv run pyinstaller \
|
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)
|
||||||
--onefile \
|
|
||||||
--name lnbits \
|
|
||||||
--hidden-import=embit \
|
|
||||||
--collect-all embit \
|
|
||||||
--collect-all lnbits \
|
|
||||||
--collect-all sqlalchemy \
|
|
||||||
--collect-all aiosqlite \
|
|
||||||
--hidden-import=passlib.handlers.bcrypt \
|
|
||||||
"$(uv run which lnbits)"
|
|
||||||
|
|
||||||
cd ../../../../..
|
cd ../../../../..
|
||||||
chmod +x packaging/linux/AppDir/AppRun
|
chmod +x packaging/linux/AppDir/AppRun
|
||||||
chmod +x packaging/linux/AppDir/lnbits.desktop
|
chmod +x packaging/linux/AppDir/lnbits.desktop
|
||||||
chmod +x packaging/linux/AppDir/usr/lnbits/dist/lnbits
|
chmod +x packaging/linux/AppDir/usr/lnbits/dist/lnbits
|
||||||
|
|
||||||
# Clean out non-dist content from the app dir to keep AppImage slim
|
|
||||||
find packaging/linux/AppDir/usr/lnbits -mindepth 1 -maxdepth 1 \
|
find packaging/linux/AppDir/usr/lnbits -mindepth 1 -maxdepth 1 \
|
||||||
! -name 'dist' \
|
! -name 'dist' \
|
||||||
! -name 'lnbits' \
|
! -name 'lnbits' \
|
||||||
-exec rm -rf {} +
|
-exec rm -rf {} +
|
||||||
|
|
||||||
# Build AppImage
|
|
||||||
wget https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage
|
wget https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage
|
||||||
chmod +x appimagetool-x86_64.AppImage
|
chmod +x appimagetool-x86_64.AppImage
|
||||||
TAG_NAME=${{ github.event.release.tag_name }}
|
TAG_NAME=${{ github.event.release.tag_name }}
|
||||||
APPIMAGE_NAME="LNbits-${TAG_NAME}.AppImage"
|
APPIMAGE_NAME="LNbits-${TAG_NAME}.AppImage"
|
||||||
./appimagetool-x86_64.AppImage \
|
./appimagetool-x86_64.AppImage --updateinformation "gh-releases-zsync|lnbits|lnbits|latest|*.AppImage.zsync" packaging/linux/AppDir "$APPIMAGE_NAME"
|
||||||
--updateinformation "gh-releases-zsync|lnbits|lnbits|latest|*.AppImage.zsync" \
|
|
||||||
packaging/linux/AppDir "$APPIMAGE_NAME"
|
|
||||||
chmod +x "$APPIMAGE_NAME"
|
chmod +x "$APPIMAGE_NAME"
|
||||||
echo "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV
|
echo "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|
||||||
# Step 6: Upload Linux Release Asset
|
# Step 5: Upload Linux Release Asset
|
||||||
- name: Upload Linux Release Asset
|
- name: Upload Linux Release Asset
|
||||||
uses: actions/upload-release-asset@v1
|
uses: actions/upload-release-asset@v1
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
name: poetry
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_call:
|
|
||||||
inputs:
|
|
||||||
python-version:
|
|
||||||
description: "python version"
|
|
||||||
type: string
|
|
||||||
default: "3.10"
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
poetry:
|
|
||||||
name: run poetry install to check lock file
|
|
||||||
runs-on: "ubuntu-24.04"
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- name: set up python ${{ inputs.python-version }}
|
|
||||||
uses: actions/setup-python@v4
|
|
||||||
with:
|
|
||||||
python-version: ${{ inputs.python-version }}
|
|
||||||
- name: install poetry
|
|
||||||
run: |
|
|
||||||
curl -sSL https://install.python-poetry.org | python3 -
|
|
||||||
poetry install
|
|
||||||
@@ -32,10 +32,6 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
docker build -t lnbits/lnbits .
|
docker build -t lnbits/lnbits .
|
||||||
|
|
||||||
- uses: ./.github/actions/prepare
|
|
||||||
with:
|
|
||||||
python-version: ${{ inputs.python-version }}
|
|
||||||
|
|
||||||
- name: Setup Regtest
|
- name: Setup Regtest
|
||||||
run: |
|
run: |
|
||||||
git clone https://github.com/lnbits/legend-regtest-enviroment.git docker
|
git clone https://github.com/lnbits/legend-regtest-enviroment.git docker
|
||||||
@@ -44,6 +40,10 @@ jobs:
|
|||||||
./tests
|
./tests
|
||||||
sudo chmod -R a+rwx .
|
sudo chmod -R a+rwx .
|
||||||
|
|
||||||
|
- uses: ./.github/actions/prepare
|
||||||
|
with:
|
||||||
|
python-version: ${{ inputs.python-version }}
|
||||||
|
|
||||||
- name: Run pytest
|
- name: Run pytest
|
||||||
uses: pavelzw/pytest-action@v2
|
uses: pavelzw/pytest-action@v2
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ name: release-rc
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- "*-rc[0-9]+"
|
- "*-rc[0-9]"
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ __pycache__
|
|||||||
.mypy_cache
|
.mypy_cache
|
||||||
.vscode
|
.vscode
|
||||||
*-lock.json
|
*-lock.json
|
||||||
.python-version
|
|
||||||
|
|
||||||
*.egg
|
*.egg
|
||||||
*.egg-info
|
*.egg-info
|
||||||
@@ -61,4 +60,3 @@ dist
|
|||||||
|
|
||||||
# jetbrains
|
# jetbrains
|
||||||
.idea
|
.idea
|
||||||
.venv
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ exclude: '^lnbits/static/bundle.*|^docs/.*|^lnbits/static/vendor/.*|^lnbits/exte
|
|||||||
|
|
||||||
repos:
|
repos:
|
||||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||||
rev: v6.0.0
|
rev: v4.3.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: trailing-whitespace
|
- id: trailing-whitespace
|
||||||
- id: end-of-file-fixer
|
- id: end-of-file-fixer
|
||||||
@@ -14,16 +14,16 @@ repos:
|
|||||||
- id: mixed-line-ending
|
- id: mixed-line-ending
|
||||||
- id: check-case-conflict
|
- id: check-case-conflict
|
||||||
- repo: https://github.com/psf/black
|
- repo: https://github.com/psf/black
|
||||||
rev: 25.1.0
|
rev: 24.2.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: black
|
- id: black
|
||||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
rev: v0.12.10
|
rev: v0.3.2
|
||||||
hooks:
|
hooks:
|
||||||
- id: ruff
|
- id: ruff
|
||||||
args: [ --fix, --exit-non-zero-on-fix ]
|
args: [ --fix, --exit-non-zero-on-fix ]
|
||||||
- repo: https://github.com/rbubley/mirrors-prettier
|
- repo: https://github.com/pre-commit/mirrors-prettier
|
||||||
rev: v3.6.2
|
rev: "v4.0.0-alpha.8"
|
||||||
hooks:
|
hooks:
|
||||||
- id: prettier
|
- id: prettier
|
||||||
types_or: [css, javascript, html, json]
|
types_or: [css, javascript, html, json]
|
||||||
|
|||||||
+18
-8
@@ -2,20 +2,24 @@ FROM python:3.12-slim-bookworm AS builder
|
|||||||
|
|
||||||
RUN apt-get clean
|
RUN apt-get clean
|
||||||
RUN apt-get update
|
RUN apt-get update
|
||||||
RUN apt-get install -y curl pkg-config build-essential libnss-myhostname automake
|
RUN apt-get install -y curl pkg-config build-essential libnss-myhostname
|
||||||
|
|
||||||
RUN curl -LsSf https://astral.sh/uv/install.sh | sh
|
RUN curl -sSL https://install.python-poetry.org | python3 - --version 1.8.5
|
||||||
ENV PATH="/root/.local/bin:$PATH"
|
ENV PATH="/root/.local/bin:$PATH"
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Only copy the files required to install the dependencies
|
# Only copy the files required to install the dependencies
|
||||||
COPY pyproject.toml uv.lock ./
|
COPY pyproject.toml poetry.lock ./
|
||||||
RUN touch README.md
|
|
||||||
|
|
||||||
RUN mkdir data
|
RUN mkdir data
|
||||||
|
|
||||||
RUN uv sync --all-extras
|
ENV POETRY_NO_INTERACTION=1 \
|
||||||
|
POETRY_VIRTUALENVS_IN_PROJECT=1 \
|
||||||
|
POETRY_VIRTUALENVS_CREATE=1 \
|
||||||
|
POETRY_CACHE_DIR=/tmp/poetry_cache
|
||||||
|
|
||||||
|
RUN poetry install --only main
|
||||||
|
|
||||||
FROM python:3.12-slim-bookworm
|
FROM python:3.12-slim-bookworm
|
||||||
|
|
||||||
@@ -28,19 +32,25 @@ RUN apt-get update && apt-get -y upgrade && \
|
|||||||
apt-get -y install postgresql-client-14 postgresql-client-common && \
|
apt-get -y install postgresql-client-14 postgresql-client-common && \
|
||||||
apt-get clean all && rm -rf /var/lib/apt/lists/*
|
apt-get clean all && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
RUN curl -LsSf https://astral.sh/uv/install.sh | sh
|
RUN curl -sSL https://install.python-poetry.org | python3 - --version 1.8.5
|
||||||
ENV PATH="/root/.local/bin:$PATH"
|
ENV PATH="/root/.local/bin:$PATH"
|
||||||
|
|
||||||
|
ENV POETRY_NO_INTERACTION=1 \
|
||||||
|
POETRY_VIRTUALENVS_IN_PROJECT=1 \
|
||||||
|
POETRY_VIRTUALENVS_CREATE=1 \
|
||||||
|
VIRTUAL_ENV=/app/.venv \
|
||||||
|
PATH="/app/.venv/bin:$PATH"
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
COPY --from=builder /app/.venv .venv
|
COPY --from=builder /app/.venv .venv
|
||||||
|
|
||||||
RUN uv sync --all-extras
|
RUN poetry install --only main
|
||||||
|
|
||||||
ENV LNBITS_PORT="5000"
|
ENV LNBITS_PORT="5000"
|
||||||
ENV LNBITS_HOST="0.0.0.0"
|
ENV LNBITS_HOST="0.0.0.0"
|
||||||
|
|
||||||
EXPOSE 5000
|
EXPOSE 5000
|
||||||
|
|
||||||
CMD ["sh", "-c", "uv run lnbits --port $LNBITS_PORT --host $LNBITS_HOST --forwarded-allow-ips='*'"]
|
CMD ["sh", "-c", "poetry run lnbits --port $LNBITS_PORT --host $LNBITS_HOST"]
|
||||||
|
|||||||
@@ -1,32 +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
|
|
||||||
|
|
||||||
RUN curl -LsSf https://astral.sh/uv/install.sh | sh
|
|
||||||
ENV PATH="/root/.local/bin:$PATH"
|
|
||||||
|
|
||||||
# Reinstall dependencies just in case (needed for CMD usage)
|
|
||||||
RUN uv sync --all-extras
|
|
||||||
|
|
||||||
# LNbits + boltzd configuration
|
|
||||||
ENV LNBITS_PORT="5000"
|
|
||||||
ENV LNBITS_HOST="0.0.0.0"
|
|
||||||
ENV LNBITS_BACKEND_WALLET_CLASS="BoltzWallet"
|
|
||||||
ENV FUNDING_SOURCE_MAX_RETRIES=10
|
|
||||||
ENV BOLTZ_CLIENT_ENDPOINT="127.0.0.1:9002"
|
|
||||||
ENV BOLTZ_CLIENT_MACAROON="/root/.boltz/macaroons/admin.macaroon"
|
|
||||||
ENV BOLTZ_CLIENT_CERT="/root/.boltz/tls.cert"
|
|
||||||
ENV BOLTZ_CLIENT_WALLET="lnbits"
|
|
||||||
|
|
||||||
EXPOSE 5000
|
|
||||||
|
|
||||||
# Entrypoint to start boltzd and LNbits
|
|
||||||
COPY dockerboltz.sh /dockerboltz.sh
|
|
||||||
RUN chmod +x /dockerboltz.sh
|
|
||||||
|
|
||||||
CMD ["/dockerboltz.sh"]
|
|
||||||
@@ -9,34 +9,34 @@ check: mypy pyright checkblack checkruff checkprettier checkbundle
|
|||||||
test: test-unit test-wallets test-api test-regtest
|
test: test-unit test-wallets test-api test-regtest
|
||||||
|
|
||||||
prettier:
|
prettier:
|
||||||
uv run ./node_modules/.bin/prettier --write .
|
poetry run ./node_modules/.bin/prettier --write .
|
||||||
|
|
||||||
pyright:
|
pyright:
|
||||||
uv run ./node_modules/.bin/pyright
|
poetry run ./node_modules/.bin/pyright
|
||||||
|
|
||||||
mypy:
|
mypy:
|
||||||
uv run mypy
|
poetry run mypy
|
||||||
|
|
||||||
black:
|
black:
|
||||||
uv run black .
|
poetry run black .
|
||||||
|
|
||||||
ruff:
|
ruff:
|
||||||
uv run ruff check . --fix
|
poetry run ruff check . --fix
|
||||||
|
|
||||||
checkruff:
|
checkruff:
|
||||||
uv run ruff check .
|
poetry run ruff check .
|
||||||
|
|
||||||
checkprettier:
|
checkprettier:
|
||||||
uv run ./node_modules/.bin/prettier --check .
|
poetry run ./node_modules/.bin/prettier --check .
|
||||||
|
|
||||||
checkblack:
|
checkblack:
|
||||||
uv run black --check .
|
poetry run black --check .
|
||||||
|
|
||||||
checkeditorconfig:
|
checkeditorconfig:
|
||||||
editorconfig-checker
|
editorconfig-checker
|
||||||
|
|
||||||
dev:
|
dev:
|
||||||
uv run lnbits --reload
|
poetry run lnbits --reload
|
||||||
|
|
||||||
docker:
|
docker:
|
||||||
docker build -t lnbits/lnbits .
|
docker build -t lnbits/lnbits .
|
||||||
@@ -46,27 +46,27 @@ test-wallets:
|
|||||||
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
|
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
|
||||||
PYTHONUNBUFFERED=1 \
|
PYTHONUNBUFFERED=1 \
|
||||||
DEBUG=true \
|
DEBUG=true \
|
||||||
uv run pytest tests/wallets
|
poetry run pytest tests/wallets
|
||||||
|
|
||||||
test-unit:
|
test-unit:
|
||||||
LNBITS_DATA_FOLDER="./tests/data" \
|
LNBITS_DATA_FOLDER="./tests/data" \
|
||||||
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
|
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
|
||||||
PYTHONUNBUFFERED=1 \
|
PYTHONUNBUFFERED=1 \
|
||||||
DEBUG=true \
|
DEBUG=true \
|
||||||
uv run pytest tests/unit
|
poetry run pytest tests/unit
|
||||||
|
|
||||||
test-api:
|
test-api:
|
||||||
LNBITS_DATA_FOLDER="./tests/data" \
|
LNBITS_DATA_FOLDER="./tests/data" \
|
||||||
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
|
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
|
||||||
PYTHONUNBUFFERED=1 \
|
PYTHONUNBUFFERED=1 \
|
||||||
DEBUG=true \
|
DEBUG=true \
|
||||||
uv run pytest tests/api
|
poetry run pytest tests/api
|
||||||
|
|
||||||
test-regtest:
|
test-regtest:
|
||||||
LNBITS_DATA_FOLDER="./tests/data" \
|
LNBITS_DATA_FOLDER="./tests/data" \
|
||||||
PYTHONUNBUFFERED=1 \
|
PYTHONUNBUFFERED=1 \
|
||||||
DEBUG=true \
|
DEBUG=true \
|
||||||
uv run pytest tests/regtest
|
poetry run pytest tests/regtest
|
||||||
|
|
||||||
test-migration:
|
test-migration:
|
||||||
LNBITS_ADMIN_UI=True \
|
LNBITS_ADMIN_UI=True \
|
||||||
@@ -74,18 +74,18 @@ test-migration:
|
|||||||
HOST=0.0.0.0 \
|
HOST=0.0.0.0 \
|
||||||
PORT=5002 \
|
PORT=5002 \
|
||||||
LNBITS_DATA_FOLDER="./tests/data" \
|
LNBITS_DATA_FOLDER="./tests/data" \
|
||||||
timeout 5s uv run lnbits --host 0.0.0.0 --port 5002 || code=$?; if [[ $code -ne 124 && $code -ne 0 ]]; then exit $code; fi
|
timeout 5s poetry run lnbits --host 0.0.0.0 --port 5002 || code=$?; if [[ $code -ne 124 && $code -ne 0 ]]; then exit $code; fi
|
||||||
HOST=0.0.0.0 \
|
HOST=0.0.0.0 \
|
||||||
PORT=5002 \
|
PORT=5002 \
|
||||||
LNBITS_DATABASE_URL="postgres://lnbits:lnbits@localhost:5432/migration" \
|
LNBITS_DATABASE_URL="postgres://lnbits:lnbits@localhost:5432/migration" \
|
||||||
LNBITS_ADMIN_UI=False \
|
LNBITS_ADMIN_UI=False \
|
||||||
timeout 5s uv run lnbits --host 0.0.0.0 --port 5002 || code=$?; if [[ $code -ne 124 && $code -ne 0 ]]; then exit $code; fi
|
timeout 5s poetry run lnbits --host 0.0.0.0 --port 5002 || code=$?; if [[ $code -ne 124 && $code -ne 0 ]]; then exit $code; fi
|
||||||
LNBITS_DATA_FOLDER="./tests/data" \
|
LNBITS_DATA_FOLDER="./tests/data" \
|
||||||
LNBITS_DATABASE_URL="postgres://lnbits:lnbits@localhost:5432/migration" \
|
LNBITS_DATABASE_URL="postgres://lnbits:lnbits@localhost:5432/migration" \
|
||||||
uv run python tools/conv.py
|
poetry run python tools/conv.py
|
||||||
|
|
||||||
migration:
|
migration:
|
||||||
uv run python tools/conv.py
|
poetry run python tools/conv.py
|
||||||
|
|
||||||
openapi:
|
openapi:
|
||||||
LNBITS_ADMIN_UI=False \
|
LNBITS_ADMIN_UI=False \
|
||||||
@@ -94,9 +94,9 @@ openapi:
|
|||||||
PYTHONUNBUFFERED=1 \
|
PYTHONUNBUFFERED=1 \
|
||||||
HOST=0.0.0.0 \
|
HOST=0.0.0.0 \
|
||||||
PORT=5003 \
|
PORT=5003 \
|
||||||
uv run lnbits &
|
poetry run lnbits &
|
||||||
sleep 15
|
sleep 15
|
||||||
curl -s http://0.0.0.0:5003/openapi.json | uv run openapi-spec-validator --errors=all -
|
curl -s http://0.0.0.0:5003/openapi.json | poetry run openapi-spec-validator --errors=all -
|
||||||
# kill -9 %1
|
# kill -9 %1
|
||||||
|
|
||||||
bak:
|
bak:
|
||||||
@@ -109,7 +109,7 @@ sass:
|
|||||||
bundle:
|
bundle:
|
||||||
npm install
|
npm install
|
||||||
npm run bundle
|
npm run bundle
|
||||||
uv run ./node_modules/.bin/prettier -w ./lnbits/static/vendor.json
|
poetry run ./node_modules/.bin/prettier -w ./lnbits/static/vendor.json
|
||||||
|
|
||||||
checkbundle:
|
checkbundle:
|
||||||
cp lnbits/static/bundle.min.js lnbits/static/bundle.min.js.old
|
cp lnbits/static/bundle.min.js lnbits/static/bundle.min.js.old
|
||||||
@@ -126,8 +126,8 @@ checkbundle:
|
|||||||
|
|
||||||
install-pre-commit-hook:
|
install-pre-commit-hook:
|
||||||
@echo "Installing pre-commit hook to git"
|
@echo "Installing pre-commit hook to git"
|
||||||
@echo "Uninstall the hook with uv run pre-commit uninstall"
|
@echo "Uninstall the hook with poetry run pre-commit uninstall"
|
||||||
uv run pre-commit install
|
poetry run pre-commit install
|
||||||
|
|
||||||
pre-commit:
|
pre-commit:
|
||||||
uv run pre-commit run --all-files
|
poetry run pre-commit run --all-files
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
set -e
|
|
||||||
|
|
||||||
boltzd --standalone --referralId lnbits &
|
|
||||||
|
|
||||||
# Capture boltzd PID to monitor if needed
|
|
||||||
BOLTZ_PID=$!
|
|
||||||
|
|
||||||
# Wait for boltzd to start
|
|
||||||
for i in {1..10}; do
|
|
||||||
if nc -z localhost 9002; then
|
|
||||||
echo "boltzd is up!"
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
echo "Waiting for boltzd to start..."
|
|
||||||
sleep 1
|
|
||||||
done
|
|
||||||
|
|
||||||
# Optional: check if still not up
|
|
||||||
if ! nc -z localhost 9002; then
|
|
||||||
echo "boltzd did not start successfully."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Starting LNbits on $LNBITS_HOST:$LNBITS_PORT..."
|
|
||||||
exec uv run lnbits --port "$LNBITS_PORT" --host "$LNBITS_HOST" --forwarded-allow-ips='*'
|
|
||||||
@@ -11,13 +11,13 @@ Thanks for contributing :)
|
|||||||
|
|
||||||
# Run
|
# Run
|
||||||
|
|
||||||
Follow the [Option 2 (recommended): UV](https://docs.lnbits.org/guide/installation.html)
|
Follow the [Basic installation: Option 1 (recommended): poetry](https://docs.lnbits.org/guide/installation.html#option-1-recommended-poetry)
|
||||||
guide to install uv and other dependencies.
|
guide to install poetry and other dependencies.
|
||||||
|
|
||||||
Then you can start LNbits uvicorn server with:
|
Then you can start LNbits uvicorn server with:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv run lnbits
|
poetry run lnbits
|
||||||
```
|
```
|
||||||
|
|
||||||
Or you can use the following to start uvicorn with hot reloading enabled:
|
Or you can use the following to start uvicorn with hot reloading enabled:
|
||||||
@@ -25,7 +25,7 @@ Or you can use the following to start uvicorn with hot reloading enabled:
|
|||||||
```bash
|
```bash
|
||||||
make dev
|
make dev
|
||||||
# or
|
# or
|
||||||
uv run lnbits --reload
|
poetry run lnbits --reload
|
||||||
```
|
```
|
||||||
|
|
||||||
You might need the following extra dependencies on clean installation of Debian:
|
You might need the following extra dependencies on clean installation of Debian:
|
||||||
@@ -50,7 +50,7 @@ make install-pre-commit-hook
|
|||||||
This project has unit tests that help prevent regressions. Before you can run the tests, you must install a few dependencies:
|
This project has unit tests that help prevent regressions. Before you can run the tests, you must install a few dependencies:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv sync --all-extras --dev
|
poetry install
|
||||||
npm i
|
npm i
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -69,7 +69,7 @@ make format
|
|||||||
Run mypy checks:
|
Run mypy checks:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make mypy
|
poetry run mypy
|
||||||
```
|
```
|
||||||
|
|
||||||
Run everything:
|
Run everything:
|
||||||
|
|||||||
@@ -42,10 +42,14 @@ mv templates/example templates/mysuperplugin # Rename templates folder.
|
|||||||
|
|
||||||
DO NOT ADD NEW DEPENDENCIES. Try to use the dependencies that are available in `pyproject.toml`. Getting the LNbits project to accept a new dependency is time consuming and uncertain, and may result in your extension NOT being made available to others.
|
DO NOT ADD NEW DEPENDENCIES. Try to use the dependencies that are available in `pyproject.toml`. Getting the LNbits project to accept a new dependency is time consuming and uncertain, and may result in your extension NOT being made available to others.
|
||||||
|
|
||||||
If for some reason your extensions must have a new python package to work, and its nees are not met in `pyproject.toml`, you can add a new package using `poerty` or `uv`:
|
If for some reason your extensions must have a new python package to work, and its nees are not met in `pyproject.toml`, you can add a new package using `poerty`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
$ poetry add <package>
|
||||||
|
```
|
||||||
|
|
||||||
**But we need an extra step to make sure LNbits doesn't break in production.**
|
**But we need an extra step to make sure LNbits doesn't break in production.**
|
||||||
Dependencies need to be added to `pyproject.toml`, then tested by running on `uv` and `poetry` compatibility can be tested with `nix build .#checks.x86_64-linux.vmTest`.
|
Dependencies need to be added to `pyproject.toml`, then tested by running on `poetry` compatibility can be tested with `nix build .#checks.x86_64-linux.vmTest`.
|
||||||
|
|
||||||
## SQLite to PostgreSQL migration
|
## SQLite to PostgreSQL migration
|
||||||
|
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ $ sudo nano .env
|
|||||||
Now start LNbits once in the terminal window
|
Now start LNbits once in the terminal window
|
||||||
|
|
||||||
```
|
```
|
||||||
$ uv run lnbits
|
$ poetry run lnbits
|
||||||
```
|
```
|
||||||
|
|
||||||
You can now `cat` the Super User ID:
|
You can now `cat` the Super User ID:
|
||||||
|
|||||||
@@ -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. |
|
|
||||||
+29
-105
@@ -15,43 +15,33 @@ Note that by default LNbits uses SQLite as its database, which is simple and eff
|
|||||||
Go to [releases](https://github.com/lnbits/lnbits/releases) and pull latest AppImage, or:
|
Go to [releases](https://github.com/lnbits/lnbits/releases) and pull latest AppImage, or:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
sudo apt-get install jq libfuse2
|
sudo apt-get install libfuse2
|
||||||
wget $(curl -s https://api.github.com/repos/arcbtc/lnbits/releases/latest | jq -r '.assets[] | select(.name | endswith(".AppImage")) | .browser_download_url') -O LNbits-latest.AppImage
|
wget $(curl -s https://api.github.com/repos/lnbits/lnbits/releases/latest | jq -r '.assets[] | select(.name | endswith(".AppImage")) | .browser_download_url') -O LNbits-latest.AppImage
|
||||||
chmod +x LNbits-latest.AppImage
|
chmod +x LNbits-latest.AppImage
|
||||||
LNBITS_ADMIN_UI=true HOST=0.0.0.0 PORT=5000 ./LNbits-latest.AppImage # most system settings are now in the admin UI, but pass additional .env variables here
|
./LNbits-latest.AppImage --host 0.0.0.0 --port 5000
|
||||||
```
|
```
|
||||||
|
|
||||||
LNbits will create a folder for db and extension files in the folder the AppImage runs from.
|
LNbits will create a folder for db and extension files in the folder the AppImage runs from.
|
||||||
|
|
||||||
## Option 2: UV (recommended for developers)
|
## Option 2: Poetry (recommended for developers)
|
||||||
|
|
||||||
It is recommended to use the latest version of UV. Make sure you have Python version `3.12` installed.
|
It is recommended to use the latest version of Poetry. Make sure you have Python version `3.12` installed.
|
||||||
|
|
||||||
### Install Python 3.12
|
### Install Python 3.12
|
||||||
|
|
||||||
## Option 2 (recommended): UV
|
|
||||||
|
|
||||||
It is recommended to use the latest version of UV. Make sure you have Python version 3.10 or higher installed.
|
|
||||||
|
|
||||||
### Verify Python version
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
sudo add-apt-repository -y ppa:deadsnakes/ppa
|
||||||
|
sudo apt update -y
|
||||||
|
sudo apt install -y python3.12 python3.12-dev # ensure correct headers needed for secp256k1
|
||||||
|
sudo apt install -y pkg-config python3-dev build-essential # ensure correct headers
|
||||||
python3 --version
|
python3 --version
|
||||||
```
|
```
|
||||||
|
|
||||||
### Install UV
|
### Install Poetry
|
||||||
|
|
||||||
```sh
|
|
||||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
|
||||||
export PATH="$HOME/.local/bin:$PATH"
|
|
||||||
```
|
|
||||||
|
|
||||||
### (old) Install Poetry
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
# If path 'export PATH="$HOME/.local/bin:$PATH"' fails, use the path echoed by the install
|
# If path 'export PATH="$HOME/.local/bin:$PATH"' fails, use the path echoed by the install
|
||||||
curl -sSL https://install.python-poetry.org | python3 -
|
curl -sSL https://install.python-poetry.org | python3 - && export PATH="$HOME/.local/bin:$PATH"
|
||||||
export PATH="$HOME/.local/bin:$PATH"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### install LNbits
|
### install LNbits
|
||||||
@@ -59,13 +49,9 @@ export PATH="$HOME/.local/bin:$PATH"
|
|||||||
```sh
|
```sh
|
||||||
git clone https://github.com/lnbits/lnbits.git
|
git clone https://github.com/lnbits/lnbits.git
|
||||||
cd lnbits
|
cd lnbits
|
||||||
|
poetry env use 3.12
|
||||||
git checkout main
|
git checkout main
|
||||||
uv sync --all-extras
|
poetry install --only main
|
||||||
|
|
||||||
# or poetry
|
|
||||||
# poetry env use 3.12
|
|
||||||
# poetry install --only main
|
|
||||||
|
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
# Optional: to set funding source amongst other options via the env `nano .env`
|
# Optional: to set funding source amongst other options via the env `nano .env`
|
||||||
```
|
```
|
||||||
@@ -73,11 +59,8 @@ cp .env.example .env
|
|||||||
#### Running the server
|
#### Running the server
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
uv run lnbits
|
poetry run lnbits
|
||||||
# To change port/host pass 'uv run lnbits --port 9000 --host 0.0.0.0'
|
# To change port/host pass 'poetry run lnbits --port 9000 --host 0.0.0.0'
|
||||||
|
|
||||||
# or poetry
|
|
||||||
# poetry run lnbits
|
|
||||||
# adding --debug in the start-up command above to help your troubleshooting and generate a more verbose output
|
# adding --debug in the start-up command above to help your troubleshooting and generate a more verbose output
|
||||||
# Note that you have to add the line DEBUG=true in your .env file, too.
|
# Note that you have to add the line DEBUG=true in your .env file, too.
|
||||||
```
|
```
|
||||||
@@ -86,64 +69,28 @@ uv run lnbits
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
# A very useful terminal client for getting the supersuer ID, updating extensions, etc
|
# A very useful terminal client for getting the supersuer ID, updating extensions, etc
|
||||||
uv run lnbits-cli --help
|
poetry run lnbits-cli --help
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Updating the server
|
#### Updating the server
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cd lnbits
|
cd lnbits
|
||||||
# Stop LNbits with `ctrl + x` or with service manager
|
# Stop LNbits with `ctrl + x`
|
||||||
# sudo systemctl stop lnbits
|
git pull
|
||||||
|
# Keep your poetry install up to date, this can be done with `poetry self update`
|
||||||
# Update LNbits
|
poetry install --only main
|
||||||
git pull --rebase
|
# Start LNbits with `poetry run lnbits`
|
||||||
|
|
||||||
# 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
|
|
||||||
# poetry install --only main
|
|
||||||
# poetry run lnbits
|
|
||||||
|
|
||||||
uv sync --all-extras
|
|
||||||
uv run lnbits
|
|
||||||
|
|
||||||
# use LNbits admin UI Extensions page function "Update All" do get extensions onto proper level
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Option 2: Install script (on Debian/Ubuntu)
|
|
||||||
|
|
||||||
```sh
|
|
||||||
wget https://raw.githubusercontent.com/lnbits/lnbits/main/lnbits.sh &&
|
|
||||||
chmod +x lnbits.sh &&
|
|
||||||
./lnbits.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
Now visit `0.0.0.0:5000` to make a super-user account.
|
|
||||||
|
|
||||||
`./lnbits.sh` can be used to run, but for more control `cd lnbits` and use `uv run lnbits` (see previous option).
|
|
||||||
|
|
||||||
## Option 3: Nix
|
## Option 3: Nix
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
# Install nix. If you have installed via another manager, remove and use this install (from https://nixos.org/download)
|
# Install nix. If you have installed via another manager, remove and use this install (from https://nixos.org/download)
|
||||||
sh <(curl --proto '=https' --tlsv1.2 -L https://nixos.org/nix/install) --daemon
|
sh <(curl -L https://nixos.org/nix/install) --daemon
|
||||||
|
|
||||||
# Enable nix-command and flakes experimental features for nix:
|
# Enable nix-command and flakes experimental features for nix:
|
||||||
grep -qxF 'experimental-features = nix-command flakes' /etc/nix/nix.conf || \
|
echo 'experimental-features = nix-command flakes' >> /etc/nix/nix.conf
|
||||||
echo 'experimental-features = nix-command flakes' | sudo tee -a /etc/nix/nix.conf
|
|
||||||
|
|
||||||
# Add user to Nix
|
|
||||||
grep -qxF "trusted-users = root $USER" /etc/nix/nix.conf || \
|
|
||||||
echo "trusted-users = root $USER" | sudo tee -a /etc/nix/nix.conf
|
|
||||||
|
|
||||||
# Restart daemon so changes apply
|
|
||||||
sudo systemctl restart nix-daemon
|
|
||||||
|
|
||||||
# Add cachix for cached binaries
|
# Add cachix for cached binaries
|
||||||
nix-env -iA cachix -f https://cachix.org/api/v1/install
|
nix-env -iA cachix -f https://cachix.org/api/v1/install
|
||||||
@@ -168,7 +115,7 @@ but you can also set the env variables or pass command line arguments:
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
# .env variables are currently passed when running, but LNbits can be managed with the admin UI.
|
# .env variables are currently passed when running, but LNbits can be managed with the admin UI.
|
||||||
LNBITS_ADMIN_UI=true ./result/bin/lnbits --port 9000 --host 0.0.0.0
|
LNBITS_ADMIN_UI=true ./result/bin/lnbits --port 9000
|
||||||
|
|
||||||
# Once you have created a user, you can set as the super_user
|
# Once you have created a user, you can set as the super_user
|
||||||
SUPER_USER=be54db7f245346c8833eaa430e1e0405 LNBITS_ADMIN_UI=true ./result/bin/lnbits --port 9000
|
SUPER_USER=be54db7f245346c8833eaa430e1e0405 LNBITS_ADMIN_UI=true ./result/bin/lnbits --port 9000
|
||||||
@@ -176,7 +123,7 @@ SUPER_USER=be54db7f245346c8833eaa430e1e0405 LNBITS_ADMIN_UI=true ./result/bin/ln
|
|||||||
|
|
||||||
## Option 4: Docker
|
## Option 4: Docker
|
||||||
|
|
||||||
Use latest version from Docker Hub.
|
use latest version from docker hub
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
docker pull lnbits/lnbits
|
docker pull lnbits/lnbits
|
||||||
@@ -185,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
|
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.
|
build the image yourself
|
||||||
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.
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
git clone https://github.com/lnbits/lnbits.git
|
git clone https://github.com/lnbits/lnbits.git
|
||||||
@@ -204,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
|
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
|
## 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.
|
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.
|
||||||
@@ -358,7 +291,7 @@ LNBITS_DATABASE_URL="postgres://postgres:postgres@localhost/lnbits"
|
|||||||
|
|
||||||
# START LNbits
|
# START LNbits
|
||||||
# STOP LNbits
|
# STOP LNbits
|
||||||
uv run python tools/conv.py
|
poetry run python tools/conv.py
|
||||||
# or
|
# or
|
||||||
make migration
|
make migration
|
||||||
```
|
```
|
||||||
@@ -383,8 +316,8 @@ Description=LNbits
|
|||||||
[Service]
|
[Service]
|
||||||
# replace with the absolute path of your lnbits installation
|
# replace with the absolute path of your lnbits installation
|
||||||
WorkingDirectory=/home/lnbits/lnbits
|
WorkingDirectory=/home/lnbits/lnbits
|
||||||
# same here. run `which uv` if you can't find the poetry binary
|
# same here. run `which poetry` if you can't find the poetry binary
|
||||||
ExecStart=/home/lnbits/.local/bin/uv run lnbits
|
ExecStart=/home/lnbits/.local/bin/poetry run lnbits
|
||||||
# replace with the user that you're running lnbits on
|
# replace with the user that you're running lnbits on
|
||||||
User=lnbits
|
User=lnbits
|
||||||
Restart=always
|
Restart=always
|
||||||
@@ -617,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.
|
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
@@ -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:
|
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
|
### CoreLightning
|
||||||
|
|
||||||
- `LNBITS_BACKEND_WALLET_CLASS`: **CoreLightningWallet**
|
- `LNBITS_BACKEND_WALLET_CLASS`: **CoreLightningWallet**
|
||||||
@@ -36,8 +17,6 @@ Should also work with the [Rust version of CLNRest](https://github.com/daywalker
|
|||||||
|
|
||||||
### CoreLightning REST
|
### 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**
|
- `LNBITS_BACKEND_WALLET_CLASS`: **CoreLightningRestWallet**
|
||||||
- `CORELIGHTNING_REST_URL`: http://127.0.0.1:8185/
|
- `CORELIGHTNING_REST_URL`: http://127.0.0.1:8185/
|
||||||
- `CORELIGHTNING_REST_MACAROON`: /file/path/admin.macaroon or Base64/Hex
|
- `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
|
- `LND_GRPC_MACAROON_ENCRYPTED`: eNcRyPtEdMaCaRoOn
|
||||||
|
|
||||||
To encrypt your macaroon, run `uv run lnbits-cli encrypt macaroon`.
|
To encrypt your macaroon, run `poetry run python lnbits/wallets/macaroon/macaroon.py`.
|
||||||
|
|
||||||
### LNbits
|
### 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_KEY`: /path/to/breezsdk/device.pem or Base64/Hex
|
||||||
- `BREEZ_GREENLIGHT_DEVICE_CERT`: /path/to/breezsdk/device.crt 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 Wallet
|
||||||
|
|
||||||
- `CLICHE_ENDPOINT`: ws://127.0.0.1:12000
|
- `CLICHE_ENDPOINT`: ws://127.0.0.1:12000
|
||||||
|
|||||||
Generated
+61
-76
@@ -1,39 +1,15 @@
|
|||||||
{
|
{
|
||||||
"nodes": {
|
"nodes": {
|
||||||
"build-system-pkgs": {
|
|
||||||
"inputs": {
|
|
||||||
"nixpkgs": [
|
|
||||||
"nixpkgs"
|
|
||||||
],
|
|
||||||
"pyproject-nix": [
|
|
||||||
"pyproject-nix"
|
|
||||||
],
|
|
||||||
"uv2nix": "uv2nix"
|
|
||||||
},
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1755484659,
|
|
||||||
"narHash": "sha256-2FfbqsaHVQd12XFFUAinIMAuGO3853LONmva1gT3vKw=",
|
|
||||||
"owner": "pyproject-nix",
|
|
||||||
"repo": "build-system-pkgs",
|
|
||||||
"rev": "9778e87c2361810ff15e287ca5895c9da4a0e900",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "pyproject-nix",
|
|
||||||
"repo": "build-system-pkgs",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"flake-utils": {
|
"flake-utils": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"systems": "systems"
|
"systems": "systems"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1731533236,
|
"lastModified": 1710146030,
|
||||||
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
"narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=",
|
||||||
"owner": "numtide",
|
"owner": "numtide",
|
||||||
"repo": "flake-utils",
|
"repo": "flake-utils",
|
||||||
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
"rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -42,49 +18,71 @@
|
|||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"nix-github-actions": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": [
|
||||||
|
"poetry2nix",
|
||||||
|
"nixpkgs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1703863825,
|
||||||
|
"narHash": "sha256-rXwqjtwiGKJheXB43ybM8NwWB8rO2dSRrEqes0S7F5Y=",
|
||||||
|
"owner": "nix-community",
|
||||||
|
"repo": "nix-github-actions",
|
||||||
|
"rev": "5163432afc817cf8bd1f031418d1869e4c9d5547",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nix-community",
|
||||||
|
"repo": "nix-github-actions",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1751274312,
|
"lastModified": 1735563628,
|
||||||
"narHash": "sha256-/bVBlRpECLVzjV19t5KMdMFWSwKLtb5RyXdjz3LJT+g=",
|
"narHash": "sha256-OnSAY7XDSx7CtDoqNh8jwVwh4xNL/2HaJxGjryLWzX8=",
|
||||||
"owner": "NixOS",
|
"owner": "nixos",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "50ab793786d9de88ee30ec4e4c24fb4236fc2674",
|
"rev": "b134951a4c9f3c995fd7be05f3243f8ecd65d798",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
"owner": "NixOS",
|
"owner": "nixos",
|
||||||
"ref": "nixos-24.11",
|
"ref": "nixos-24.05",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"pyproject-nix": {
|
"poetry2nix": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
|
"flake-utils": "flake-utils",
|
||||||
|
"nix-github-actions": "nix-github-actions",
|
||||||
"nixpkgs": [
|
"nixpkgs": [
|
||||||
"nixpkgs"
|
"nixpkgs"
|
||||||
]
|
],
|
||||||
|
"systems": "systems_2",
|
||||||
|
"treefmt-nix": "treefmt-nix"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1754923840,
|
"lastModified": 1724134185,
|
||||||
"narHash": "sha256-QSKpYg+Ts9HYF155ltlj40iBex39c05cpOF8gjoE2EM=",
|
"narHash": "sha256-nDqpGjz7cq3ThdC98BPe1ANCNlsJds/LLZ3/MdIXjA0=",
|
||||||
"owner": "pyproject-nix",
|
"owner": "nix-community",
|
||||||
"repo": "pyproject.nix",
|
"repo": "poetry2nix",
|
||||||
"rev": "023cd4be230eacae52635be09eef100c37ef78da",
|
"rev": "5ee730a8752264e463c0eaf06cc060fd07f6dae9",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
"owner": "pyproject-nix",
|
"owner": "nix-community",
|
||||||
"repo": "pyproject.nix",
|
"repo": "poetry2nix",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"root": {
|
"root": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"build-system-pkgs": "build-system-pkgs",
|
|
||||||
"flake-utils": "flake-utils",
|
|
||||||
"nixpkgs": "nixpkgs",
|
"nixpkgs": "nixpkgs",
|
||||||
"pyproject-nix": "pyproject-nix",
|
"poetry2nix": "poetry2nix"
|
||||||
"uv2nix": "uv2nix_2"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"systems": {
|
"systems": {
|
||||||
@@ -102,51 +100,38 @@
|
|||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"uv2nix": {
|
"systems_2": {
|
||||||
"inputs": {
|
|
||||||
"nixpkgs": [
|
|
||||||
"build-system-pkgs",
|
|
||||||
"nixpkgs"
|
|
||||||
],
|
|
||||||
"pyproject-nix": [
|
|
||||||
"build-system-pkgs",
|
|
||||||
"pyproject-nix"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1755210905,
|
"lastModified": 1681028828,
|
||||||
"narHash": "sha256-WnoFEk79ysjL85TNP7bvImzhxvQw9B6uNtnLd4oJntw=",
|
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||||
"owner": "pyproject-nix",
|
"owner": "nix-systems",
|
||||||
"repo": "uv2nix",
|
"repo": "default",
|
||||||
"rev": "87bcba013ef304bbfd67c8e8a257aee634ed5a4c",
|
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
"owner": "pyproject-nix",
|
"id": "systems",
|
||||||
"repo": "uv2nix",
|
"type": "indirect"
|
||||||
"type": "github"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"uv2nix_2": {
|
"treefmt-nix": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"nixpkgs": [
|
"nixpkgs": [
|
||||||
|
"poetry2nix",
|
||||||
"nixpkgs"
|
"nixpkgs"
|
||||||
],
|
|
||||||
"pyproject-nix": [
|
|
||||||
"pyproject-nix"
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1755485731,
|
"lastModified": 1719749022,
|
||||||
"narHash": "sha256-k8kxwVs8Oze6q/jAaRa3RvZbb50I/K0b5uptlsh0HXI=",
|
"narHash": "sha256-ddPKHcqaKCIFSFc/cvxS14goUhCOAwsM1PbMr0ZtHMg=",
|
||||||
"owner": "pyproject-nix",
|
"owner": "numtide",
|
||||||
"repo": "uv2nix",
|
"repo": "treefmt-nix",
|
||||||
"rev": "bebbd80bf56110fcd20b425589814af28f1939eb",
|
"rev": "8df5ff62195d4e67e2264df0b7f5e8c9995fd0bd",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
"owner": "pyproject-nix",
|
"owner": "numtide",
|
||||||
"repo": "uv2nix",
|
"repo": "treefmt-nix",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,150 +1,70 @@
|
|||||||
{
|
{
|
||||||
description = "LNbits, free and open-source Lightning wallet and accounts system (uv2nix)";
|
description = "LNbits, free and open-source Lightning wallet and accounts system";
|
||||||
|
|
||||||
inputs = {
|
inputs = {
|
||||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11";
|
nixpkgs.url = "github:nixos/nixpkgs/nixos-24.05";
|
||||||
flake-utils.url = "github:numtide/flake-utils";
|
poetry2nix = {
|
||||||
|
url = "github:nix-community/poetry2nix";
|
||||||
pyproject-nix.url = "github:pyproject-nix/pyproject.nix";
|
inputs.nixpkgs.follows = "nixpkgs";
|
||||||
uv2nix.url = "github:pyproject-nix/uv2nix";
|
|
||||||
build-system-pkgs.url = "github:pyproject-nix/build-system-pkgs";
|
|
||||||
|
|
||||||
pyproject-nix.inputs.nixpkgs.follows = "nixpkgs";
|
|
||||||
uv2nix.inputs.nixpkgs.follows = "nixpkgs";
|
|
||||||
build-system-pkgs.inputs.nixpkgs.follows = "nixpkgs";
|
|
||||||
uv2nix.inputs.pyproject-nix.follows = "pyproject-nix";
|
|
||||||
build-system-pkgs.inputs.pyproject-nix.follows = "pyproject-nix";
|
|
||||||
};
|
};
|
||||||
|
};
|
||||||
outputs = { self, nixpkgs, flake-utils, uv2nix, pyproject-nix, build-system-pkgs, ... }:
|
outputs = { self, nixpkgs, poetry2nix }@inputs:
|
||||||
flake-utils.lib.eachSystem [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ]
|
|
||||||
(system:
|
|
||||||
let
|
let
|
||||||
pkgs = import nixpkgs { inherit system; };
|
supportedSystems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ];
|
||||||
lib = pkgs.lib;
|
forSystems = systems: f:
|
||||||
|
nixpkgs.lib.genAttrs systems
|
||||||
python = pkgs.python312;
|
(system: f system (import nixpkgs { inherit system; overlays = [ poetry2nix.overlays.default self.overlays.default ]; }));
|
||||||
|
forAllSystems = forSystems supportedSystems;
|
||||||
# Read uv.lock / pyproject via uv2nix
|
|
||||||
workspace = uv2nix.lib.workspace.loadWorkspace { workspaceRoot = ./.; };
|
|
||||||
|
|
||||||
# Prefer wheels when available
|
|
||||||
uvLockedOverlay = workspace.mkPyprojectOverlay { sourcePreference = "wheel"; };
|
|
||||||
|
|
||||||
# Helper for extending lists safely (works if a is null)
|
|
||||||
plus = a: b: lib.unique (((if a == null then [] else a)) ++ b);
|
|
||||||
|
|
||||||
# Extra build inputs for troublesome sdists
|
|
||||||
myOverrides = (final: prev: {
|
|
||||||
# embit needs setuptools at build time
|
|
||||||
embit = prev.embit.overrideAttrs (old: {
|
|
||||||
nativeBuildInputs = plus (old.nativeBuildInputs or []) [ prev.setuptools ];
|
|
||||||
});
|
|
||||||
|
|
||||||
# http-ece (pywebpush dep) needs setuptools
|
|
||||||
"http-ece" = prev."http-ece".overrideAttrs (old: {
|
|
||||||
nativeBuildInputs = plus (old.nativeBuildInputs or []) [ prev.setuptools ];
|
|
||||||
});
|
|
||||||
|
|
||||||
# pyqrcode needs setuptools
|
|
||||||
pyqrcode = prev.pyqrcode.overrideAttrs (old: {
|
|
||||||
nativeBuildInputs = plus (old.nativeBuildInputs or []) [ prev.setuptools ];
|
|
||||||
});
|
|
||||||
|
|
||||||
# tlv8 needs setuptools
|
|
||||||
tlv8 = prev.tlv8.overrideAttrs (old: {
|
|
||||||
nativeBuildInputs = plus (old.nativeBuildInputs or []) [ prev.setuptools ];
|
|
||||||
});
|
|
||||||
|
|
||||||
# secp256k1 Python binding:
|
|
||||||
# - setuptools, pkg-config
|
|
||||||
# - cffi + pycparser
|
|
||||||
# - system libsecp256k1 for headers/libs
|
|
||||||
secp256k1 = prev.secp256k1.overrideAttrs (old: {
|
|
||||||
nativeBuildInputs = plus (old.nativeBuildInputs or []) [
|
|
||||||
prev.setuptools
|
|
||||||
pkgs.pkg-config
|
|
||||||
prev.cffi
|
|
||||||
prev.pycparser
|
|
||||||
];
|
|
||||||
buildInputs = plus (old.buildInputs or []) [ pkgs.secp256k1 ];
|
|
||||||
propagatedBuildInputs = plus (old.propagatedBuildInputs or []) [ prev.cffi prev.pycparser ];
|
|
||||||
env = (old.env or { }) // { PKG_CONFIG = "${pkgs.pkg-config}/bin/pkg-config"; };
|
|
||||||
});
|
|
||||||
|
|
||||||
# pynostr uses setuptools-scm for versioning
|
|
||||||
pynostr = prev.pynostr.overrideAttrs (old: {
|
|
||||||
nativeBuildInputs = plus (old.nativeBuildInputs or []) [ prev.setuptools-scm ];
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
# Compose Python package set honoring uv.lock
|
|
||||||
pythonSet =
|
|
||||||
(pkgs.callPackage pyproject-nix.build.packages { inherit python; })
|
|
||||||
.overrideScope (lib.composeManyExtensions [
|
|
||||||
build-system-pkgs.overlays.default
|
|
||||||
uvLockedOverlay
|
|
||||||
myOverrides
|
|
||||||
]);
|
|
||||||
|
|
||||||
projectName = "lnbits";
|
projectName = "lnbits";
|
||||||
|
|
||||||
# Build a venv from the locked spec (this installs the resolved wheels)
|
|
||||||
runtimeVenv = pythonSet.mkVirtualEnv "${projectName}-env" workspace.deps.default;
|
|
||||||
|
|
||||||
# Wrapper so `nix run` behaves like `uv run` (use local source tree for templates/static/extensions)
|
|
||||||
lnbitsApp = pkgs.writeShellApplication {
|
|
||||||
name = "lnbits";
|
|
||||||
text = ''
|
|
||||||
export SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt
|
|
||||||
export REQUESTS_CA_BUNDLE=$SSL_CERT_FILE
|
|
||||||
export PYTHONPATH="$PWD:${PYTHONPATH:-}"
|
|
||||||
exec ${runtimeVenv}/bin/lnbits "$@"
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
|
|
||||||
lnbitsCliApp = pkgs.writeShellApplication {
|
|
||||||
name = "lnbits-cli";
|
|
||||||
text = ''
|
|
||||||
export PYTHONPATH="$PWD:${PYTHONPATH:-}"
|
|
||||||
exec ${runtimeVenv}/bin/lnbits-cli "$@"
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
# nix build → produces the venv in ./result
|
overlays = {
|
||||||
packages.default = runtimeVenv;
|
default = final: prev: {
|
||||||
packages.${projectName} = runtimeVenv;
|
${projectName} = self.packages.${prev.stdenv.hostPlatform.system}.${projectName};
|
||||||
|
};
|
||||||
# nix run . → launches via wrapper that imports from source tree
|
};
|
||||||
apps.default = { type = "app"; program = "${lnbitsApp}/bin/lnbits"; };
|
packages = forAllSystems (system: pkgs: {
|
||||||
apps.${projectName} = self.apps.${system}.default;
|
default = self.packages.${system}.${projectName};
|
||||||
apps."${projectName}-cli" = { type = "app"; program = "${lnbitsCliApp}/bin/lnbits-cli"; };
|
${projectName} = pkgs.poetry2nix.mkPoetryApplication {
|
||||||
|
projectDir = ./.;
|
||||||
# dev shell with locked deps + tools
|
meta.rev = self.dirtyRev or self.rev;
|
||||||
devShells.default = pkgs.mkShell {
|
meta.mainProgram = projectName;
|
||||||
packages = [
|
overrides = pkgs.poetry2nix.overrides.withDefaults (final: prev: {
|
||||||
runtimeVenv
|
coincurve = prev.coincurve.override { preferWheel = true; };
|
||||||
pkgs.uv
|
protobuf = prev.protobuf.override { preferWheel = true; };
|
||||||
pkgs.ruff
|
ruff = prev.ruff.override { preferWheel = true; };
|
||||||
pkgs.black
|
wallycore = prev.wallycore.override { preferWheel = true; };
|
||||||
pkgs.mypy
|
tlv8 = prev.tlv8.overrideAttrs (old: {
|
||||||
pkgs.pre-commit
|
nativeBuildInputs = (old.nativeBuildInputs or [ ]) ++ [
|
||||||
pkgs.openapi-generator-cli
|
prev.setuptools
|
||||||
];
|
];
|
||||||
};
|
|
||||||
|
|
||||||
overlays.default = final: prev: {
|
|
||||||
${projectName} = self.packages.${final.stdenv.hostPlatform.system}.${projectName};
|
|
||||||
replaceVars = prev.replaceVars or (path: vars: prev.substituteAll ({ src = path; } // vars));
|
|
||||||
};
|
|
||||||
|
|
||||||
# System-specific nixos modules to avoid circular dependency
|
|
||||||
nixosModules.default = { pkgs, lib, config, ... }: {
|
|
||||||
imports = [ "${./nix/modules/lnbits-service.nix}" ];
|
|
||||||
nixpkgs.overlays = [ self.overlays.${system}.default ];
|
|
||||||
};
|
|
||||||
|
|
||||||
checks = { };
|
|
||||||
});
|
});
|
||||||
|
pynostr = prev.pynostr.overrideAttrs (old: {
|
||||||
|
nativeBuildInputs = (old.nativeBuildInputs or [ ]) ++ [
|
||||||
|
prev.setuptools-scm
|
||||||
|
];
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
});
|
||||||
|
nixosModules = {
|
||||||
|
default = { pkgs, lib, config, ... }: {
|
||||||
|
imports = [ "${./nix/modules/${projectName}-service.nix}" ];
|
||||||
|
nixpkgs.overlays = [ self.overlays.default ];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
checks = forAllSystems (system: pkgs:
|
||||||
|
let
|
||||||
|
vmTests = import ./nix/tests {
|
||||||
|
makeTest = (import (nixpkgs + "/nixos/lib/testing-python.nix") { inherit system; }).makeTest;
|
||||||
|
inherit inputs pkgs;
|
||||||
|
};
|
||||||
|
in
|
||||||
|
pkgs.lib.optionalAttrs pkgs.stdenv.isLinux vmTests # vmTests can only be ran on Linux, so append them only if on Linux.
|
||||||
|
//
|
||||||
|
{
|
||||||
|
# Other checks here...
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# Check install has not already run
|
|
||||||
if [ ! -d lnbits/data ]; then
|
|
||||||
|
|
||||||
# Update package list and install prerequisites non-interactively
|
|
||||||
sudo apt update -y
|
|
||||||
sudo apt install -y software-properties-common
|
|
||||||
|
|
||||||
# Add the deadsnakes PPA repository non-interactively
|
|
||||||
sudo add-apt-repository -y ppa:deadsnakes/ppa
|
|
||||||
|
|
||||||
# Install Python 3.10 and distutils non-interactively
|
|
||||||
sudo apt install -y python3.10 python3.10-distutils
|
|
||||||
|
|
||||||
# Install UV
|
|
||||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
|
||||||
|
|
||||||
export PATH="/home/$USER/.local/bin:$PATH"
|
|
||||||
|
|
||||||
if [ ! -d lnbits/wallets ]; then
|
|
||||||
# Clone the LNbits repository
|
|
||||||
git clone https://github.com/lnbits/lnbits.git
|
|
||||||
if [ $? -ne 0 ]; then
|
|
||||||
echo "Failed to clone the repository ... FAIL"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
# Ensure we are in the lnbits directory
|
|
||||||
cd lnbits || { echo "Failed to cd into lnbits ... FAIL"; exit 1; }
|
|
||||||
fi
|
|
||||||
|
|
||||||
git checkout main
|
|
||||||
# Make data folder
|
|
||||||
mkdir data
|
|
||||||
|
|
||||||
# Copy the .env.example to .env
|
|
||||||
cp .env.example .env
|
|
||||||
|
|
||||||
elif [ ! -d lnbits/wallets ]; then
|
|
||||||
# cd into lnbits
|
|
||||||
cd lnbits || { echo "Failed to cd into lnbits ... FAIL"; exit 1; }
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Install the dependencies using UV
|
|
||||||
uv sync --all-extras
|
|
||||||
|
|
||||||
|
|
||||||
# Set environment variables for LNbits
|
|
||||||
export LNBITS_ADMIN_UI=true
|
|
||||||
export HOST=0.0.0.0
|
|
||||||
|
|
||||||
# Run LNbits
|
|
||||||
uv run lnbits
|
|
||||||
+8
-5
@@ -9,13 +9,16 @@ from .decorators import (
|
|||||||
from .exceptions import InvoiceError, PaymentError
|
from .exceptions import InvoiceError, PaymentError
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"InvoiceError",
|
# decorators
|
||||||
"PaymentError",
|
"require_admin_key",
|
||||||
|
"require_invoice_key",
|
||||||
"check_admin",
|
"check_admin",
|
||||||
"check_super_user",
|
"check_super_user",
|
||||||
"check_user_exists",
|
"check_user_exists",
|
||||||
"create_invoice",
|
# services
|
||||||
"pay_invoice",
|
"pay_invoice",
|
||||||
"require_admin_key",
|
"create_invoice",
|
||||||
"require_invoice_key",
|
# exceptions
|
||||||
|
"PaymentError",
|
||||||
|
"InvoiceError",
|
||||||
]
|
]
|
||||||
|
|||||||
+21
-24
@@ -4,10 +4,9 @@ import importlib
|
|||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import sys
|
import sys
|
||||||
import time
|
|
||||||
from collections.abc import Callable
|
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Callable, Optional
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
@@ -27,8 +26,7 @@ from lnbits.core.crud.extensions import create_installed_extension
|
|||||||
from lnbits.core.helpers import migrate_extension_database
|
from lnbits.core.helpers import migrate_extension_database
|
||||||
from lnbits.core.models.notifications import NotificationType
|
from lnbits.core.models.notifications import NotificationType
|
||||||
from lnbits.core.services.extensions import deactivate_extension, get_valid_extensions
|
from lnbits.core.services.extensions import deactivate_extension, get_valid_extensions
|
||||||
from lnbits.core.services.notifications import enqueue_admin_notification
|
from lnbits.core.services.notifications import enqueue_notification
|
||||||
from lnbits.core.services.payments import check_pending_payments
|
|
||||||
from lnbits.core.tasks import (
|
from lnbits.core.tasks import (
|
||||||
audit_queue,
|
audit_queue,
|
||||||
collect_exchange_rates_data,
|
collect_exchange_rates_data,
|
||||||
@@ -67,13 +65,17 @@ from .middleware import (
|
|||||||
add_ip_block_middleware,
|
add_ip_block_middleware,
|
||||||
add_ratelimit_middleware,
|
add_ratelimit_middleware,
|
||||||
)
|
)
|
||||||
from .tasks import internal_invoice_listener, invoice_listener, run_interval
|
from .requestvars import g
|
||||||
|
from .tasks import (
|
||||||
|
check_pending_payments,
|
||||||
|
internal_invoice_listener,
|
||||||
|
invoice_listener,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def startup(app: FastAPI):
|
async def startup(app: FastAPI):
|
||||||
logger.info(f"Starting LNbits Version: {settings.version}")
|
|
||||||
start = time.perf_counter()
|
|
||||||
settings.lnbits_running = True
|
settings.lnbits_running = True
|
||||||
|
|
||||||
# wait till migration is done
|
# wait till migration is done
|
||||||
await migrate_databases()
|
await migrate_databases()
|
||||||
|
|
||||||
@@ -104,7 +106,7 @@ async def startup(app: FastAPI):
|
|||||||
# initialize tasks
|
# initialize tasks
|
||||||
register_async_tasks()
|
register_async_tasks()
|
||||||
|
|
||||||
enqueue_admin_notification(
|
enqueue_notification(
|
||||||
NotificationType.server_start_stop,
|
NotificationType.server_start_stop,
|
||||||
{
|
{
|
||||||
"message": "LNbits server started.",
|
"message": "LNbits server started.",
|
||||||
@@ -112,13 +114,10 @@ async def startup(app: FastAPI):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
end = time.perf_counter()
|
|
||||||
logger.success(f"LNbits started in {end - start:.2f} seconds.")
|
|
||||||
|
|
||||||
|
|
||||||
async def shutdown():
|
async def shutdown():
|
||||||
logger.warning("LNbits shutting down...")
|
logger.warning("LNbits shutting down...")
|
||||||
enqueue_admin_notification(
|
enqueue_notification(
|
||||||
NotificationType.server_start_stop,
|
NotificationType.server_start_stop,
|
||||||
{
|
{
|
||||||
"message": "LNbits server shutting down...",
|
"message": "LNbits server shutting down...",
|
||||||
@@ -164,13 +163,11 @@ def create_app() -> FastAPI:
|
|||||||
core_app_extra.register_new_ratelimiter = register_new_ratelimiter(app)
|
core_app_extra.register_new_ratelimiter = register_new_ratelimiter(app)
|
||||||
|
|
||||||
# register static files
|
# register static files
|
||||||
app.mount("/static", StaticFiles(directory=Path("lnbits", "static")), name="static")
|
static_path = Path("lnbits", "static")
|
||||||
Path(settings.lnbits_data_folder, "images").mkdir(parents=True, exist_ok=True)
|
static = StaticFiles(directory=static_path)
|
||||||
app.mount(
|
app.mount("/static", static, name="static")
|
||||||
"/library",
|
|
||||||
StaticFiles(directory=Path(settings.lnbits_data_folder, "images")),
|
g().base_url = f"http://{settings.host}:{settings.port}"
|
||||||
name="library",
|
|
||||||
)
|
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]
|
CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]
|
||||||
@@ -279,8 +276,8 @@ async def check_installed_extensions(app: FastAPI):
|
|||||||
logger.info(f"{ext.id} ({ext.installed_version})")
|
logger.info(f"{ext.id} ({ext.installed_version})")
|
||||||
|
|
||||||
|
|
||||||
async def build_all_installed_extensions_list( # noqa: C901
|
async def build_all_installed_extensions_list(
|
||||||
include_deactivated: bool | None = True,
|
include_deactivated: Optional[bool] = True,
|
||||||
) -> list[InstallableExtension]:
|
) -> list[InstallableExtension]:
|
||||||
"""
|
"""
|
||||||
Returns a list of all the installed extensions plus the extensions that
|
Returns a list of all the installed extensions plus the extensions that
|
||||||
@@ -453,7 +450,7 @@ def register_ext_routes(app: FastAPI, ext: Extension) -> None:
|
|||||||
app.include_router(router=ext_route, prefix=prefix)
|
app.include_router(router=ext_route, prefix=prefix)
|
||||||
|
|
||||||
|
|
||||||
async def check_and_register_extensions(app: FastAPI) -> None:
|
async def check_and_register_extensions(app: FastAPI):
|
||||||
await check_installed_extensions(app)
|
await check_installed_extensions(app)
|
||||||
for ext in await get_valid_extensions(False):
|
for ext in await get_valid_extensions(False):
|
||||||
try:
|
try:
|
||||||
@@ -463,12 +460,12 @@ async def check_and_register_extensions(app: FastAPI) -> None:
|
|||||||
logger.error(f"Could not load extension `{ext.code}`: {exc!s}")
|
logger.error(f"Could not load extension `{ext.code}`: {exc!s}")
|
||||||
|
|
||||||
|
|
||||||
def register_async_tasks() -> None:
|
def register_async_tasks():
|
||||||
|
|
||||||
create_permanent_task(wait_for_audit_data)
|
create_permanent_task(wait_for_audit_data)
|
||||||
create_permanent_task(wait_notification_messages)
|
create_permanent_task(wait_notification_messages)
|
||||||
|
|
||||||
create_permanent_task(run_interval(30 * 60, check_pending_payments))
|
create_permanent_task(check_pending_payments)
|
||||||
create_permanent_task(invoice_listener)
|
create_permanent_task(invoice_listener)
|
||||||
create_permanent_task(internal_invoice_listener)
|
create_permanent_task(internal_invoice_listener)
|
||||||
create_permanent_task(cache.invalidate_forever)
|
create_permanent_task(cache.invalidate_forever)
|
||||||
|
|||||||
+53
-148
@@ -3,9 +3,8 @@ import importlib
|
|||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
from getpass import getpass
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from uuid import uuid4
|
from typing import Optional
|
||||||
|
|
||||||
import click
|
import click
|
||||||
import httpx
|
import httpx
|
||||||
@@ -28,21 +27,19 @@ from lnbits.core.crud import (
|
|||||||
update_payment,
|
update_payment,
|
||||||
)
|
)
|
||||||
from lnbits.core.helpers import is_valid_url, migrate_databases
|
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 (
|
from lnbits.core.models.extensions import (
|
||||||
CreateExtension,
|
CreateExtension,
|
||||||
ExtensionRelease,
|
ExtensionRelease,
|
||||||
InstallableExtension,
|
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 (
|
from lnbits.core.views.extension_api import (
|
||||||
api_install_extension,
|
api_install_extension,
|
||||||
api_uninstall_extension,
|
api_uninstall_extension,
|
||||||
)
|
)
|
||||||
from lnbits.settings import settings
|
from lnbits.settings import settings
|
||||||
from lnbits.utils.crypto import AESCipher
|
|
||||||
from lnbits.wallets.base import Wallet
|
from lnbits.wallets.base import Wallet
|
||||||
from lnbits.wallets.macaroon import load_macaroon
|
|
||||||
|
|
||||||
|
|
||||||
def coro(f):
|
def coro(f):
|
||||||
@@ -67,13 +64,6 @@ def db():
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
@lnbits_cli.group()
|
|
||||||
def users():
|
|
||||||
"""
|
|
||||||
Users related commands
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
@lnbits_cli.group()
|
@lnbits_cli.group()
|
||||||
def extensions():
|
def extensions():
|
||||||
"""
|
"""
|
||||||
@@ -81,21 +71,7 @@ def extensions():
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
@lnbits_cli.group()
|
def get_super_user() -> Optional[str]:
|
||||||
def encrypt():
|
|
||||||
"""
|
|
||||||
Encryption commands
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
@lnbits_cli.group()
|
|
||||||
def decrypt():
|
|
||||||
"""
|
|
||||||
Decryption commands
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
def get_super_user() -> str | None:
|
|
||||||
"""Get the superuser"""
|
"""Get the superuser"""
|
||||||
superuser_file = Path(settings.lnbits_data_folder, ".super_user")
|
superuser_file = Path(settings.lnbits_data_folder, ".super_user")
|
||||||
if not superuser_file.exists() or not superuser_file.is_file():
|
if not superuser_file.exists() or not superuser_file.is_file():
|
||||||
@@ -133,7 +109,7 @@ async def delete_settings():
|
|||||||
"""Deletes the settings"""
|
"""Deletes the settings"""
|
||||||
|
|
||||||
async with core_db.connect() as conn:
|
async with core_db.connect() as conn:
|
||||||
await conn.execute("DELETE from system_settings")
|
await conn.execute("DELETE from settings")
|
||||||
|
|
||||||
|
|
||||||
@db.command("migrate")
|
@db.command("migrate")
|
||||||
@@ -154,7 +130,7 @@ async def db_versions():
|
|||||||
@db.command("cleanup-wallets")
|
@db.command("cleanup-wallets")
|
||||||
@click.argument("days", type=int, required=False)
|
@click.argument("days", type=int, required=False)
|
||||||
@coro
|
@coro
|
||||||
async def database_cleanup_wallets(days: int | None = None):
|
async def database_cleanup_wallets(days: Optional[int] = None):
|
||||||
"""Delete all wallets that never had any transaction"""
|
"""Delete all wallets that never had any transaction"""
|
||||||
async with core_db.connect() as conn:
|
async with core_db.connect() as conn:
|
||||||
delta = days or settings.cleanup_wallets_days
|
delta = days or settings.cleanup_wallets_days
|
||||||
@@ -204,6 +180,17 @@ async def database_revert_payment(checking_id: str):
|
|||||||
click.echo(f"Payment '{checking_id}' marked as pending.")
|
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")
|
@db.command("check-payments")
|
||||||
@click.option("-d", "--days", help="Maximum age of payments in days.")
|
@click.option("-d", "--days", help="Maximum age of payments in days.")
|
||||||
@click.option("-l", "--limit", help="Maximum number of payments to be checked.")
|
@click.option("-l", "--limit", help="Maximum number of payments to be checked.")
|
||||||
@@ -211,10 +198,10 @@ async def database_revert_payment(checking_id: str):
|
|||||||
@click.option("-v", "--verbose", is_flag=True, help="Detailed log.")
|
@click.option("-v", "--verbose", is_flag=True, help="Detailed log.")
|
||||||
@coro
|
@coro
|
||||||
async def check_invalid_payments(
|
async def check_invalid_payments(
|
||||||
days: int | None = None,
|
days: Optional[int] = None,
|
||||||
limit: int | None = None,
|
limit: Optional[int] = None,
|
||||||
wallet: str | None = None,
|
wallet: Optional[str] = None,
|
||||||
verbose: bool | None = False,
|
verbose: Optional[bool] = False,
|
||||||
):
|
):
|
||||||
"""Check payments that are settled in the DB but pending on the Funding Source"""
|
"""Check payments that are settled in the DB but pending on the Funding Source"""
|
||||||
await check_admin_settings()
|
await check_admin_settings()
|
||||||
@@ -284,32 +271,6 @@ async def check_invalid_payments(
|
|||||||
click.echo(" ".join([w, str(data[0]), str(data[1] / 1000).ljust(10)]))
|
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: int | None = 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")
|
@extensions.command("list")
|
||||||
@coro
|
@coro
|
||||||
async def extensions_list():
|
async def extensions_list():
|
||||||
@@ -319,8 +280,9 @@ async def extensions_list():
|
|||||||
from lnbits.app import build_all_installed_extensions_list
|
from lnbits.app import build_all_installed_extensions_list
|
||||||
|
|
||||||
for ext in await build_all_installed_extensions_list():
|
for ext in await build_all_installed_extensions_list():
|
||||||
if not ext.meta or not ext.meta.installed_release:
|
assert (
|
||||||
raise ValueError(f"Extension {ext.id} has no installed_release")
|
ext.meta and ext.meta.installed_release
|
||||||
|
), f"Extension {ext.id} has no installed_release"
|
||||||
click.echo(f" - {ext.id} ({ext.meta.installed_release.version})")
|
click.echo(f" - {ext.id} ({ext.meta.installed_release.version})")
|
||||||
|
|
||||||
|
|
||||||
@@ -351,13 +313,13 @@ async def extensions_list():
|
|||||||
help="Admin user ID (must have permissions to install extensions).",
|
help="Admin user ID (must have permissions to install extensions).",
|
||||||
)
|
)
|
||||||
@coro
|
@coro
|
||||||
async def extensions_update( # noqa: C901
|
async def extensions_update(
|
||||||
extension: str | None = None,
|
extension: Optional[str] = None,
|
||||||
all_extensions: bool | None = False,
|
all_extensions: Optional[bool] = False,
|
||||||
repo_index: str | None = None,
|
repo_index: Optional[str] = None,
|
||||||
source_repo: str | None = None,
|
source_repo: Optional[str] = None,
|
||||||
url: str | None = None,
|
url: Optional[str] = None,
|
||||||
admin_user: str | None = None,
|
admin_user: Optional[str] = None,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Update extension to the latest version.
|
Update extension to the latest version.
|
||||||
@@ -442,10 +404,10 @@ async def extensions_update( # noqa: C901
|
|||||||
@coro
|
@coro
|
||||||
async def extensions_install(
|
async def extensions_install(
|
||||||
extension: str,
|
extension: str,
|
||||||
repo_index: str | None = None,
|
repo_index: Optional[str] = None,
|
||||||
source_repo: str | None = None,
|
source_repo: Optional[str] = None,
|
||||||
url: str | None = None,
|
url: Optional[str] = None,
|
||||||
admin_user: str | None = None,
|
admin_user: Optional[str] = None,
|
||||||
):
|
):
|
||||||
"""Install a extension"""
|
"""Install a extension"""
|
||||||
click.echo(f"Installing {extension}... {repo_index}")
|
click.echo(f"Installing {extension}... {repo_index}")
|
||||||
@@ -472,7 +434,7 @@ async def extensions_install(
|
|||||||
)
|
)
|
||||||
@coro
|
@coro
|
||||||
async def extensions_uninstall(
|
async def extensions_uninstall(
|
||||||
extension: str, url: str | None = None, admin_user: str | None = None
|
extension: str, url: Optional[str] = None, admin_user: Optional[str] = None
|
||||||
):
|
):
|
||||||
"""Uninstall a extension"""
|
"""Uninstall a extension"""
|
||||||
click.echo(f"Uninstalling '{extension}'...")
|
click.echo(f"Uninstalling '{extension}'...")
|
||||||
@@ -494,62 +456,6 @@ async def extensions_uninstall(
|
|||||||
return False, str(ex)
|
return False, str(ex)
|
||||||
|
|
||||||
|
|
||||||
@encrypt.command("macaroon")
|
|
||||||
def encrypt_macaroon():
|
|
||||||
"""Encrypts a macaroon (LND wallets)"""
|
|
||||||
_macaroon = getpass("Enter macaroon: ")
|
|
||||||
try:
|
|
||||||
macaroon = load_macaroon(_macaroon)
|
|
||||||
except Exception as ex:
|
|
||||||
click.echo(f"Error loading macaroon: {ex}")
|
|
||||||
return
|
|
||||||
key = getpass("Enter encryption key: ")
|
|
||||||
aes = AESCipher(key.encode())
|
|
||||||
try:
|
|
||||||
encrypted_macaroon = aes.encrypt(bytes.fromhex(macaroon))
|
|
||||||
except Exception as ex:
|
|
||||||
click.echo(f"Error encrypting macaroon: {ex}")
|
|
||||||
return
|
|
||||||
click.echo("Encrypted macaroon: ")
|
|
||||||
click.echo(encrypted_macaroon)
|
|
||||||
|
|
||||||
|
|
||||||
@encrypt.command("aes")
|
|
||||||
@click.option("-p", "--payload", required=True, help="Payload to encrypt.")
|
|
||||||
@click.option(
|
|
||||||
"-u", "--urlsafe", is_flag=True, required=False, help="Urlsafe b64encode."
|
|
||||||
)
|
|
||||||
def encrypt_aes(payload: str, urlsafe: bool = False):
|
|
||||||
"""AES encrypts a payload"""
|
|
||||||
key = getpass("Enter encryption key: ")
|
|
||||||
aes = AESCipher(key.encode())
|
|
||||||
try:
|
|
||||||
encrypted = aes.encrypt(payload.encode(), urlsafe=urlsafe)
|
|
||||||
except Exception as ex:
|
|
||||||
click.echo(f"Error encrypting payload: {ex}")
|
|
||||||
return
|
|
||||||
click.echo("Encrypted payload: ")
|
|
||||||
click.echo(encrypted)
|
|
||||||
|
|
||||||
|
|
||||||
@decrypt.command("aes")
|
|
||||||
@click.option("-p", "--payload", required=True, help="Payload to decrypt.")
|
|
||||||
@click.option(
|
|
||||||
"-u", "--urlsafe", is_flag=True, required=False, help="Urlsafe b64decode."
|
|
||||||
)
|
|
||||||
def decrypt_aes(payload: str, urlsafe: bool = False):
|
|
||||||
"""AES decrypts a payload"""
|
|
||||||
key = getpass("Enter encryption key: ")
|
|
||||||
aes = AESCipher(key.encode())
|
|
||||||
try:
|
|
||||||
decrypted = aes.decrypt(payload, urlsafe=urlsafe)
|
|
||||||
except Exception as ex:
|
|
||||||
click.echo(f"Error decrypting payload: {ex}")
|
|
||||||
return
|
|
||||||
click.echo("Decrypted payload: ")
|
|
||||||
click.echo(decrypted)
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
"""main function"""
|
"""main function"""
|
||||||
lnbits_cli()
|
lnbits_cli()
|
||||||
@@ -561,10 +467,10 @@ if __name__ == "__main__":
|
|||||||
|
|
||||||
async def install_extension(
|
async def install_extension(
|
||||||
extension: str,
|
extension: str,
|
||||||
repo_index: str | None = None,
|
repo_index: Optional[str] = None,
|
||||||
source_repo: str | None = None,
|
source_repo: Optional[str] = None,
|
||||||
url: str | None = None,
|
url: Optional[str] = None,
|
||||||
admin_user: str | None = None,
|
admin_user: Optional[str] = None,
|
||||||
) -> tuple[bool, str]:
|
) -> tuple[bool, str]:
|
||||||
try:
|
try:
|
||||||
release = await _select_release(extension, repo_index, source_repo)
|
release = await _select_release(extension, repo_index, source_repo)
|
||||||
@@ -590,10 +496,10 @@ async def install_extension(
|
|||||||
|
|
||||||
async def update_extension(
|
async def update_extension(
|
||||||
extension: str,
|
extension: str,
|
||||||
repo_index: str | None = None,
|
repo_index: Optional[str] = None,
|
||||||
source_repo: str | None = None,
|
source_repo: Optional[str] = None,
|
||||||
url: str | None = None,
|
url: Optional[str] = None,
|
||||||
admin_user: str | None = None,
|
admin_user: Optional[str] = None,
|
||||||
) -> tuple[bool, str]:
|
) -> tuple[bool, str]:
|
||||||
try:
|
try:
|
||||||
click.echo(f"Updating '{extension}' extension.")
|
click.echo(f"Updating '{extension}' extension.")
|
||||||
@@ -606,10 +512,9 @@ async def update_extension(
|
|||||||
|
|
||||||
click.echo(f"Current '{extension}' version: {installed_ext.installed_version}.")
|
click.echo(f"Current '{extension}' version: {installed_ext.installed_version}.")
|
||||||
|
|
||||||
if not installed_ext.meta or not installed_ext.meta.installed_release:
|
assert (
|
||||||
raise ValueError(
|
installed_ext.meta and installed_ext.meta.installed_release
|
||||||
"Cannot find previously installed release. Please uninstall first."
|
), "Cannot find previously installed release. Please uninstall first."
|
||||||
)
|
|
||||||
|
|
||||||
release = await _select_release(extension, repo_index, source_repo)
|
release = await _select_release(extension, repo_index, source_repo)
|
||||||
if not release:
|
if not release:
|
||||||
@@ -643,9 +548,9 @@ async def update_extension(
|
|||||||
|
|
||||||
async def _select_release(
|
async def _select_release(
|
||||||
extension: str,
|
extension: str,
|
||||||
repo_index: str | None = None,
|
repo_index: Optional[str] = None,
|
||||||
source_repo: str | None = None,
|
source_repo: Optional[str] = None,
|
||||||
) -> ExtensionRelease | None:
|
) -> Optional[ExtensionRelease]:
|
||||||
all_releases = await InstallableExtension.get_extension_releases(extension)
|
all_releases = await InstallableExtension.get_extension_releases(extension)
|
||||||
if len(all_releases) == 0:
|
if len(all_releases) == 0:
|
||||||
click.echo(f"No repository found for extension '{extension}'.")
|
click.echo(f"No repository found for extension '{extension}'.")
|
||||||
@@ -705,7 +610,7 @@ def _get_latest_release_per_repo(all_releases):
|
|||||||
|
|
||||||
|
|
||||||
async def _call_install_extension(
|
async def _call_install_extension(
|
||||||
data: CreateExtension, url: str | None, user_id: str | None = None
|
data: CreateExtension, url: Optional[str], user_id: Optional[str] = None
|
||||||
):
|
):
|
||||||
if url:
|
if url:
|
||||||
user_id = user_id or get_super_user()
|
user_id = user_id or get_super_user()
|
||||||
@@ -719,7 +624,7 @@ async def _call_install_extension(
|
|||||||
|
|
||||||
|
|
||||||
async def _call_uninstall_extension(
|
async def _call_uninstall_extension(
|
||||||
extension: str, url: str | None, user_id: str | None = None
|
extension: str, url: Optional[str], user_id: Optional[str] = None
|
||||||
):
|
):
|
||||||
if url:
|
if url:
|
||||||
user_id = user_id or get_super_user()
|
user_id = user_id or get_super_user()
|
||||||
@@ -755,7 +660,7 @@ async def _can_run_operation(url) -> bool:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
async def _is_lnbits_started(url: str | None):
|
async def _is_lnbits_started(url: Optional[str]):
|
||||||
try:
|
try:
|
||||||
url = url or f"http://{settings.host}:{settings.port}/api/v1/health"
|
url = url or f"http://{settings.host}:{settings.port}/api/v1/health"
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
|
|||||||
@@ -5,14 +5,10 @@ from .views.admin_api import admin_router
|
|||||||
from .views.api import api_router
|
from .views.api import api_router
|
||||||
from .views.audit_api import audit_router
|
from .views.audit_api import audit_router
|
||||||
from .views.auth_api import auth_router
|
from .views.auth_api import auth_router
|
||||||
from .views.callback_api import callback_router
|
|
||||||
from .views.extension_api import extension_router
|
from .views.extension_api import extension_router
|
||||||
from .views.extensions_builder_api import extension_builder_router
|
|
||||||
from .views.fiat_api import fiat_router
|
|
||||||
|
|
||||||
# this compat is needed for usermanager extension
|
# this compat is needed for usermanager extension
|
||||||
from .views.generic import generic_router
|
from .views.generic import generic_router
|
||||||
from .views.lnurl_api import lnurl_router
|
|
||||||
from .views.node_api import node_router, public_node_router, super_node_router
|
from .views.node_api import node_router, public_node_router, super_node_router
|
||||||
from .views.payment_api import payment_router
|
from .views.payment_api import payment_router
|
||||||
from .views.tinyurl_api import tinyurl_router
|
from .views.tinyurl_api import tinyurl_router
|
||||||
@@ -32,20 +28,16 @@ def init_core_routers(app: FastAPI):
|
|||||||
app.include_router(admin_router)
|
app.include_router(admin_router)
|
||||||
app.include_router(node_router)
|
app.include_router(node_router)
|
||||||
app.include_router(extension_router)
|
app.include_router(extension_router)
|
||||||
app.include_router(extension_builder_router)
|
|
||||||
app.include_router(super_node_router)
|
app.include_router(super_node_router)
|
||||||
app.include_router(public_node_router)
|
app.include_router(public_node_router)
|
||||||
app.include_router(payment_router)
|
app.include_router(payment_router)
|
||||||
app.include_router(wallet_router)
|
app.include_router(wallet_router)
|
||||||
app.include_router(api_router)
|
app.include_router(api_router)
|
||||||
app.include_router(websocket_router)
|
app.include_router(websocket_router)
|
||||||
app.include_router(callback_router)
|
|
||||||
app.include_router(tinyurl_router)
|
app.include_router(tinyurl_router)
|
||||||
app.include_router(webpush_router)
|
app.include_router(webpush_router)
|
||||||
app.include_router(users_router)
|
app.include_router(users_router)
|
||||||
app.include_router(audit_router)
|
app.include_router(audit_router)
|
||||||
app.include_router(fiat_router)
|
|
||||||
app.include_router(lnurl_router)
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["core_app", "core_app_extra", "db"]
|
__all__ = ["core_app", "core_app_extra", "db"]
|
||||||
|
|||||||
@@ -43,7 +43,6 @@ from .settings import (
|
|||||||
delete_admin_settings,
|
delete_admin_settings,
|
||||||
get_admin_settings,
|
get_admin_settings,
|
||||||
get_super_settings,
|
get_super_settings,
|
||||||
reset_core_settings,
|
|
||||||
update_admin_settings,
|
update_admin_settings,
|
||||||
update_super_user,
|
update_super_user,
|
||||||
)
|
)
|
||||||
@@ -85,78 +84,86 @@ from .webpush import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"DateTrunc",
|
# audit
|
||||||
"check_internal",
|
|
||||||
"create_account",
|
|
||||||
"create_admin_settings",
|
|
||||||
"create_audit_entry",
|
"create_audit_entry",
|
||||||
"create_installed_extension",
|
# db_versions
|
||||||
"create_payment",
|
|
||||||
"create_tinyurl",
|
|
||||||
"create_user_extension",
|
|
||||||
"create_wallet",
|
|
||||||
"create_webpush_subscription",
|
|
||||||
"delete_account",
|
|
||||||
"delete_accounts_no_wallets",
|
|
||||||
"delete_admin_settings",
|
|
||||||
"delete_dbversion",
|
|
||||||
"delete_expired_invoices",
|
|
||||||
"delete_installed_extension",
|
|
||||||
"delete_tinyurl",
|
|
||||||
"delete_unused_wallets",
|
|
||||||
"delete_wallet",
|
|
||||||
"delete_wallet_by_id",
|
|
||||||
"delete_wallet_payment",
|
|
||||||
"delete_webpush_subscription",
|
|
||||||
"delete_webpush_subscriptions",
|
|
||||||
"drop_extension_db",
|
|
||||||
"force_delete_wallet",
|
|
||||||
"get_account",
|
|
||||||
"get_account_by_email",
|
|
||||||
"get_account_by_pubkey",
|
|
||||||
"get_account_by_username",
|
|
||||||
"get_account_by_username_or_email",
|
|
||||||
"get_accounts",
|
|
||||||
"get_admin_settings",
|
|
||||||
"get_db_version",
|
"get_db_version",
|
||||||
"get_db_versions",
|
"get_db_versions",
|
||||||
|
"update_migration_version",
|
||||||
|
"delete_dbversion",
|
||||||
|
# extensions
|
||||||
|
"create_installed_extension",
|
||||||
|
"create_user_extension",
|
||||||
|
"delete_installed_extension",
|
||||||
|
"drop_extension_db",
|
||||||
"get_installed_extension",
|
"get_installed_extension",
|
||||||
"get_installed_extensions",
|
"get_installed_extensions",
|
||||||
|
"get_user_active_extensions_ids",
|
||||||
|
"get_user_extension",
|
||||||
|
"update_installed_extension",
|
||||||
|
"update_installed_extension_state",
|
||||||
|
"update_user_extension",
|
||||||
|
"get_user_extensions",
|
||||||
|
# payments
|
||||||
|
"DateTrunc",
|
||||||
|
"check_internal",
|
||||||
|
"create_payment",
|
||||||
|
"delete_expired_invoices",
|
||||||
|
"delete_wallet_payment",
|
||||||
"get_latest_payments_by_extension",
|
"get_latest_payments_by_extension",
|
||||||
"get_payment",
|
"get_payment",
|
||||||
"get_payments",
|
"get_payments",
|
||||||
"get_payments_history",
|
"get_payments_history",
|
||||||
"get_payments_paginated",
|
"get_payments_paginated",
|
||||||
"get_standalone_payment",
|
"get_standalone_payment",
|
||||||
"get_super_settings",
|
|
||||||
"get_tinyurl",
|
|
||||||
"get_tinyurl_by_url",
|
|
||||||
"get_total_balance",
|
|
||||||
"get_user",
|
|
||||||
"get_user_access_control_lists",
|
|
||||||
"get_user_active_extensions_ids",
|
|
||||||
"get_user_extension",
|
|
||||||
"get_user_extensions",
|
|
||||||
"get_user_from_account",
|
|
||||||
"get_wallet",
|
|
||||||
"get_wallet_for_key",
|
|
||||||
"get_wallet_payment",
|
"get_wallet_payment",
|
||||||
"get_wallets",
|
|
||||||
"get_webpush_subscription",
|
|
||||||
"get_webpush_subscriptions_for_user",
|
|
||||||
"is_internal_status_success",
|
"is_internal_status_success",
|
||||||
"mark_webhook_sent",
|
"mark_webhook_sent",
|
||||||
"remove_deleted_wallets",
|
|
||||||
"reset_core_settings",
|
|
||||||
"update_account",
|
|
||||||
"update_admin_settings",
|
|
||||||
"update_installed_extension",
|
|
||||||
"update_installed_extension_state",
|
|
||||||
"update_migration_version",
|
|
||||||
"update_payment",
|
"update_payment",
|
||||||
"update_payment_checking_id",
|
"update_payment_checking_id",
|
||||||
"update_payment_extra",
|
"update_payment_extra",
|
||||||
|
# settings
|
||||||
|
"create_admin_settings",
|
||||||
|
"delete_admin_settings",
|
||||||
|
"get_admin_settings",
|
||||||
|
"get_super_settings",
|
||||||
|
"update_admin_settings",
|
||||||
"update_super_user",
|
"update_super_user",
|
||||||
"update_user_extension",
|
# tinyurl
|
||||||
|
"create_tinyurl",
|
||||||
|
"delete_tinyurl",
|
||||||
|
"get_tinyurl",
|
||||||
|
"get_tinyurl_by_url",
|
||||||
|
# users
|
||||||
|
"create_account",
|
||||||
|
"delete_account",
|
||||||
|
"delete_accounts_no_wallets",
|
||||||
|
"get_account",
|
||||||
|
"get_account_by_email",
|
||||||
|
"get_account_by_pubkey",
|
||||||
|
"get_account_by_username",
|
||||||
|
"get_account_by_username_or_email",
|
||||||
|
"get_accounts",
|
||||||
|
"get_user",
|
||||||
|
"get_user_from_account",
|
||||||
|
"get_user_access_control_lists",
|
||||||
|
"update_account",
|
||||||
|
# wallets
|
||||||
|
"create_wallet",
|
||||||
|
"delete_unused_wallets",
|
||||||
|
"delete_wallet",
|
||||||
|
"delete_wallet_by_id",
|
||||||
|
"force_delete_wallet",
|
||||||
|
"get_total_balance",
|
||||||
|
"get_wallet",
|
||||||
|
"get_wallet_for_key",
|
||||||
|
"get_wallets",
|
||||||
|
"remove_deleted_wallets",
|
||||||
"update_wallet",
|
"update_wallet",
|
||||||
|
# webpush
|
||||||
|
"create_webpush_subscription",
|
||||||
|
"delete_webpush_subscription",
|
||||||
|
"delete_webpush_subscriptions",
|
||||||
|
"get_webpush_subscription",
|
||||||
|
"get_webpush_subscriptions_for_user",
|
||||||
]
|
]
|
||||||
|
|||||||
+13
-17
@@ -1,3 +1,5 @@
|
|||||||
|
from typing import Optional
|
||||||
|
|
||||||
from lnbits.core.db import db
|
from lnbits.core.db import db
|
||||||
from lnbits.core.models import AuditEntry, AuditFilters
|
from lnbits.core.models import AuditEntry, AuditFilters
|
||||||
from lnbits.core.models.audit import AuditCountStat
|
from lnbits.core.models.audit import AuditCountStat
|
||||||
@@ -6,14 +8,14 @@ from lnbits.db import Connection, Filters, Page
|
|||||||
|
|
||||||
async def create_audit_entry(
|
async def create_audit_entry(
|
||||||
entry: AuditEntry,
|
entry: AuditEntry,
|
||||||
conn: Connection | None = None,
|
conn: Optional[Connection] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
await (conn or db).insert("audit", entry)
|
await (conn or db).insert("audit", entry)
|
||||||
|
|
||||||
|
|
||||||
async def get_audit_entries(
|
async def get_audit_entries(
|
||||||
filters: Filters[AuditFilters] | None = None,
|
filters: Optional[Filters[AuditFilters]] = None,
|
||||||
conn: Connection | None = None,
|
conn: Optional[Connection] = None,
|
||||||
) -> Page[AuditEntry]:
|
) -> Page[AuditEntry]:
|
||||||
return await (conn or db).fetch_page(
|
return await (conn or db).fetch_page(
|
||||||
"SELECT * from audit",
|
"SELECT * from audit",
|
||||||
@@ -25,21 +27,20 @@ async def get_audit_entries(
|
|||||||
|
|
||||||
|
|
||||||
async def delete_expired_audit_entries(
|
async def delete_expired_audit_entries(
|
||||||
conn: Connection | None = None,
|
conn: Optional[Connection] = None,
|
||||||
):
|
):
|
||||||
await (conn or db).execute(
|
await (conn or db).execute(
|
||||||
# Timestamp placeholder is safe from SQL injection (not user input)
|
|
||||||
f"""
|
f"""
|
||||||
DELETE from audit
|
DELETE from audit
|
||||||
WHERE delete_at < {db.timestamp_now}
|
WHERE delete_at < {db.timestamp_now}
|
||||||
""", # noqa: S608
|
""",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def get_count_stats(
|
async def get_count_stats(
|
||||||
field: str,
|
field: str,
|
||||||
filters: Filters[AuditFilters] | None = None,
|
filters: Optional[Filters[AuditFilters]] = None,
|
||||||
conn: Connection | None = None,
|
conn: Optional[Connection] = None,
|
||||||
) -> list[AuditCountStat]:
|
) -> list[AuditCountStat]:
|
||||||
if field not in ["request_method", "component", "response_code"]:
|
if field not in ["request_method", "component", "response_code"]:
|
||||||
return []
|
return []
|
||||||
@@ -47,16 +48,13 @@ async def get_count_stats(
|
|||||||
filters = Filters()
|
filters = Filters()
|
||||||
clause = filters.where()
|
clause = filters.where()
|
||||||
data = await (conn or db).fetchall(
|
data = await (conn or db).fetchall(
|
||||||
# SQL injection vectors safety:
|
|
||||||
# - `field` is a static string, not user input
|
|
||||||
# - `clause` is generated from filters, which are validated and sanitized
|
|
||||||
query=f"""
|
query=f"""
|
||||||
SELECT {field} as field, count({field}) as total
|
SELECT {field} as field, count({field}) as total
|
||||||
FROM audit
|
FROM audit
|
||||||
{clause}
|
{clause}
|
||||||
GROUP BY {field}
|
GROUP BY {field}
|
||||||
ORDER BY {field}
|
ORDER BY {field}
|
||||||
""", # noqa: S608
|
""",
|
||||||
values=filters.values(),
|
values=filters.values(),
|
||||||
model=AuditCountStat,
|
model=AuditCountStat,
|
||||||
)
|
)
|
||||||
@@ -65,22 +63,20 @@ async def get_count_stats(
|
|||||||
|
|
||||||
|
|
||||||
async def get_long_duration_stats(
|
async def get_long_duration_stats(
|
||||||
filters: Filters[AuditFilters] | None = None,
|
filters: Optional[Filters[AuditFilters]] = None,
|
||||||
conn: Connection | None = None,
|
conn: Optional[Connection] = None,
|
||||||
) -> list[AuditCountStat]:
|
) -> list[AuditCountStat]:
|
||||||
if not filters:
|
if not filters:
|
||||||
filters = Filters()
|
filters = Filters()
|
||||||
clause = filters.where()
|
clause = filters.where()
|
||||||
long_duration_paths = await (conn or db).fetchall(
|
long_duration_paths = await (conn or db).fetchall(
|
||||||
# This query is safe from SQL injection
|
|
||||||
# The `clause` is constructed from sanitized inputs
|
|
||||||
query=f"""
|
query=f"""
|
||||||
SELECT path as field, max(duration) as total FROM audit
|
SELECT path as field, max(duration) as total FROM audit
|
||||||
{clause}
|
{clause}
|
||||||
GROUP BY path
|
GROUP BY path
|
||||||
ORDER BY total DESC
|
ORDER BY total DESC
|
||||||
LIMIT 5
|
LIMIT 5
|
||||||
""", # noqa: S608
|
""",
|
||||||
values=filters.values(),
|
values=filters.values(),
|
||||||
model=AuditCountStat,
|
model=AuditCountStat,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
from typing import Optional
|
||||||
|
|
||||||
from lnbits.core.db import db
|
from lnbits.core.db import db
|
||||||
from lnbits.db import Connection
|
from lnbits.db import Connection
|
||||||
|
|
||||||
@@ -5,8 +7,8 @@ from ..models import DbVersion
|
|||||||
|
|
||||||
|
|
||||||
async def get_db_version(
|
async def get_db_version(
|
||||||
ext_id: str, conn: Connection | None = None
|
ext_id: str, conn: Optional[Connection] = None
|
||||||
) -> DbVersion | None:
|
) -> Optional[DbVersion]:
|
||||||
return await (conn or db).fetchone(
|
return await (conn or db).fetchone(
|
||||||
"SELECT * FROM dbversions WHERE db = :ext_id",
|
"SELECT * FROM dbversions WHERE db = :ext_id",
|
||||||
{"ext_id": ext_id},
|
{"ext_id": ext_id},
|
||||||
@@ -14,7 +16,7 @@ async def get_db_version(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def get_db_versions(conn: Connection | None = None) -> list[DbVersion]:
|
async def get_db_versions(conn: Optional[Connection] = None) -> list[DbVersion]:
|
||||||
return await (conn or db).fetchall("SELECT * FROM dbversions", model=DbVersion)
|
return await (conn or db).fetchall("SELECT * FROM dbversions", model=DbVersion)
|
||||||
|
|
||||||
|
|
||||||
@@ -28,7 +30,7 @@ async def update_migration_version(conn, db_name, version):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def delete_dbversion(*, ext_id: str, conn: Connection | None = None) -> None:
|
async def delete_dbversion(*, ext_id: str, conn: Optional[Connection] = None) -> None:
|
||||||
await (conn or db).execute(
|
await (conn or db).execute(
|
||||||
"""
|
"""
|
||||||
DELETE FROM dbversions WHERE db = :ext
|
DELETE FROM dbversions WHERE db = :ext
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
from typing import Optional
|
||||||
|
|
||||||
from lnbits.core.db import db
|
from lnbits.core.db import db
|
||||||
from lnbits.core.models.extensions import (
|
from lnbits.core.models.extensions import (
|
||||||
InstallableExtension,
|
InstallableExtension,
|
||||||
@@ -8,20 +10,20 @@ from lnbits.db import Connection, Database
|
|||||||
|
|
||||||
async def create_installed_extension(
|
async def create_installed_extension(
|
||||||
ext: InstallableExtension,
|
ext: InstallableExtension,
|
||||||
conn: Connection | None = None,
|
conn: Optional[Connection] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
await (conn or db).insert("installed_extensions", ext)
|
await (conn or db).insert("installed_extensions", ext)
|
||||||
|
|
||||||
|
|
||||||
async def update_installed_extension(
|
async def update_installed_extension(
|
||||||
ext: InstallableExtension,
|
ext: InstallableExtension,
|
||||||
conn: Connection | None = None,
|
conn: Optional[Connection] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
await (conn or db).update("installed_extensions", ext)
|
await (conn or db).update("installed_extensions", ext)
|
||||||
|
|
||||||
|
|
||||||
async def update_installed_extension_state(
|
async def update_installed_extension_state(
|
||||||
*, ext_id: str, active: bool, conn: Connection | None = None
|
*, ext_id: str, active: bool, conn: Optional[Connection] = None
|
||||||
) -> None:
|
) -> None:
|
||||||
await (conn or db).execute(
|
await (conn or db).execute(
|
||||||
"""
|
"""
|
||||||
@@ -32,7 +34,7 @@ async def update_installed_extension_state(
|
|||||||
|
|
||||||
|
|
||||||
async def delete_installed_extension(
|
async def delete_installed_extension(
|
||||||
*, ext_id: str, conn: Connection | None = None
|
*, ext_id: str, conn: Optional[Connection] = None
|
||||||
) -> None:
|
) -> None:
|
||||||
await (conn or db).execute(
|
await (conn or db).execute(
|
||||||
"""
|
"""
|
||||||
@@ -42,14 +44,13 @@ async def delete_installed_extension(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def drop_extension_db(ext_id: str, conn: Connection | None = None) -> None:
|
async def drop_extension_db(ext_id: str, conn: Optional[Connection] = None) -> None:
|
||||||
row: dict = await (conn or db).fetchone(
|
row: dict = await (conn or db).fetchone(
|
||||||
"SELECT * FROM dbversions WHERE db = :id",
|
"SELECT * FROM dbversions WHERE db = :id",
|
||||||
{"id": ext_id},
|
{"id": ext_id},
|
||||||
)
|
)
|
||||||
# Check that 'ext_id' is a valid extension id and not a malicious string
|
# Check that 'ext_id' is a valid extension id and not a malicious string
|
||||||
if not row:
|
assert row, f"Extension '{ext_id}' db version cannot be found"
|
||||||
raise Exception(f"Extension '{ext_id}' db version cannot be found")
|
|
||||||
|
|
||||||
is_file_based_db = await Database.clean_ext_db_files(ext_id)
|
is_file_based_db = await Database.clean_ext_db_files(ext_id)
|
||||||
if is_file_based_db:
|
if is_file_based_db:
|
||||||
@@ -63,8 +64,8 @@ async def drop_extension_db(ext_id: str, conn: Connection | None = None) -> None
|
|||||||
|
|
||||||
|
|
||||||
async def get_installed_extension(
|
async def get_installed_extension(
|
||||||
ext_id: str, conn: Connection | None = None
|
ext_id: str, conn: Optional[Connection] = None
|
||||||
) -> InstallableExtension | None:
|
) -> Optional[InstallableExtension]:
|
||||||
extension = await (conn or db).fetchone(
|
extension = await (conn or db).fetchone(
|
||||||
"SELECT * FROM installed_extensions WHERE id = :id",
|
"SELECT * FROM installed_extensions WHERE id = :id",
|
||||||
{"id": ext_id},
|
{"id": ext_id},
|
||||||
@@ -74,16 +75,13 @@ async def get_installed_extension(
|
|||||||
|
|
||||||
|
|
||||||
async def get_installed_extensions(
|
async def get_installed_extensions(
|
||||||
active: bool | None = None,
|
active: Optional[bool] = None,
|
||||||
conn: Connection | None = None,
|
conn: Optional[Connection] = None,
|
||||||
) -> list[InstallableExtension]:
|
) -> list[InstallableExtension]:
|
||||||
query = "SELECT * FROM installed_extensions"
|
where = "WHERE active = :active" if active is not None else ""
|
||||||
if active is not None:
|
|
||||||
query += " WHERE active = :active"
|
|
||||||
|
|
||||||
values = {"active": active} if active is not None else {}
|
values = {"active": active} if active is not None else {}
|
||||||
all_extensions = await (conn or db).fetchall(
|
all_extensions = await (conn or db).fetchall(
|
||||||
query,
|
f"SELECT * FROM installed_extensions {where}",
|
||||||
values,
|
values,
|
||||||
model=InstallableExtension,
|
model=InstallableExtension,
|
||||||
)
|
)
|
||||||
@@ -91,8 +89,8 @@ async def get_installed_extensions(
|
|||||||
|
|
||||||
|
|
||||||
async def get_user_extension(
|
async def get_user_extension(
|
||||||
user_id: str, extension: str, conn: Connection | None = None
|
user_id: str, extension: str, conn: Optional[Connection] = None
|
||||||
) -> UserExtension | None:
|
) -> Optional[UserExtension]:
|
||||||
return await (conn or db).fetchone(
|
return await (conn or db).fetchone(
|
||||||
"""
|
"""
|
||||||
SELECT * FROM extensions
|
SELECT * FROM extensions
|
||||||
@@ -104,7 +102,7 @@ async def get_user_extension(
|
|||||||
|
|
||||||
|
|
||||||
async def get_user_extensions(
|
async def get_user_extensions(
|
||||||
user_id: str, conn: Connection | None = None
|
user_id: str, conn: Optional[Connection] = None
|
||||||
) -> list[UserExtension]:
|
) -> list[UserExtension]:
|
||||||
return await (conn or db).fetchall(
|
return await (conn or db).fetchall(
|
||||||
"""SELECT * FROM extensions WHERE "user" = :user""",
|
"""SELECT * FROM extensions WHERE "user" = :user""",
|
||||||
@@ -114,20 +112,20 @@ async def get_user_extensions(
|
|||||||
|
|
||||||
|
|
||||||
async def create_user_extension(
|
async def create_user_extension(
|
||||||
user_extension: UserExtension, conn: Connection | None = None
|
user_extension: UserExtension, conn: Optional[Connection] = None
|
||||||
) -> None:
|
) -> None:
|
||||||
await (conn or db).insert("extensions", user_extension)
|
await (conn or db).insert("extensions", user_extension)
|
||||||
|
|
||||||
|
|
||||||
async def update_user_extension(
|
async def update_user_extension(
|
||||||
user_extension: UserExtension, conn: Connection | None = None
|
user_extension: UserExtension, conn: Optional[Connection] = None
|
||||||
) -> None:
|
) -> None:
|
||||||
where = """WHERE extension = :extension AND "user" = :user"""
|
where = """WHERE extension = :extension AND "user" = :user"""
|
||||||
await (conn or db).update("extensions", user_extension, where)
|
await (conn or db).update("extensions", user_extension, where)
|
||||||
|
|
||||||
|
|
||||||
async def get_user_active_extensions_ids(
|
async def get_user_active_extensions_ids(
|
||||||
user_id: str, conn: Connection | None = None
|
user_id: str, conn: Optional[Connection] = None
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
exts = await (conn or db).fetchall(
|
exts = await (conn or db).fetchall(
|
||||||
"""
|
"""
|
||||||
|
|||||||
+67
-122
@@ -1,7 +1,7 @@
|
|||||||
from time import time
|
from time import time
|
||||||
from typing import Any
|
from typing import Optional, Tuple
|
||||||
|
|
||||||
from lnbits.core.crud.wallets import get_total_balance, get_wallet, get_wallets_ids
|
from lnbits.core.crud.wallets import get_total_balance, get_wallet
|
||||||
from lnbits.core.db import db
|
from lnbits.core.db import db
|
||||||
from lnbits.core.models import PaymentState
|
from lnbits.core.models import PaymentState
|
||||||
from lnbits.db import Connection, DateTrunc, Filters, Page
|
from lnbits.db import Connection, DateTrunc, Filters, Page
|
||||||
@@ -23,7 +23,7 @@ def update_payment_extra():
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
async def get_payment(checking_id: str, conn: Connection | None = None) -> Payment:
|
async def get_payment(checking_id: str, conn: Optional[Connection] = None) -> Payment:
|
||||||
return await (conn or db).fetchone(
|
return await (conn or db).fetchone(
|
||||||
"SELECT * FROM apipayments WHERE checking_id = :checking_id",
|
"SELECT * FROM apipayments WHERE checking_id = :checking_id",
|
||||||
{"checking_id": checking_id},
|
{"checking_id": checking_id},
|
||||||
@@ -33,10 +33,10 @@ async def get_payment(checking_id: str, conn: Connection | None = None) -> Payme
|
|||||||
|
|
||||||
async def get_standalone_payment(
|
async def get_standalone_payment(
|
||||||
checking_id_or_hash: str,
|
checking_id_or_hash: str,
|
||||||
conn: Connection | None = None,
|
conn: Optional[Connection] = None,
|
||||||
incoming: bool | None = False,
|
incoming: Optional[bool] = False,
|
||||||
wallet_id: str | None = None,
|
wallet_id: Optional[str] = None,
|
||||||
) -> Payment | None:
|
) -> Optional[Payment]:
|
||||||
clause: str = "checking_id = :checking_id OR payment_hash = :hash"
|
clause: str = "checking_id = :checking_id OR payment_hash = :hash"
|
||||||
values = {
|
values = {
|
||||||
"wallet_id": wallet_id,
|
"wallet_id": wallet_id,
|
||||||
@@ -50,13 +50,11 @@ async def get_standalone_payment(
|
|||||||
clause = f"({clause}) AND wallet_id = :wallet_id"
|
clause = f"({clause}) AND wallet_id = :wallet_id"
|
||||||
|
|
||||||
row = await (conn or db).fetchone(
|
row = await (conn or db).fetchone(
|
||||||
# This query is safe from SQL injection
|
|
||||||
# The `clause` is constructed from sanitized inputs
|
|
||||||
f"""
|
f"""
|
||||||
SELECT * FROM apipayments
|
SELECT * FROM apipayments
|
||||||
WHERE {clause}
|
WHERE {clause}
|
||||||
ORDER BY amount LIMIT 1
|
ORDER BY amount LIMIT 1
|
||||||
""", # noqa: S608
|
""",
|
||||||
values,
|
values,
|
||||||
Payment,
|
Payment,
|
||||||
)
|
)
|
||||||
@@ -64,8 +62,8 @@ async def get_standalone_payment(
|
|||||||
|
|
||||||
|
|
||||||
async def get_wallet_payment(
|
async def get_wallet_payment(
|
||||||
wallet_id: str, payment_hash: str, conn: Connection | None = None
|
wallet_id: str, payment_hash: str, conn: Optional[Connection] = None
|
||||||
) -> Payment | None:
|
) -> Optional[Payment]:
|
||||||
payment = await (conn or db).fetchone(
|
payment = await (conn or db).fetchone(
|
||||||
"""
|
"""
|
||||||
SELECT *
|
SELECT *
|
||||||
@@ -82,44 +80,38 @@ async def get_latest_payments_by_extension(
|
|||||||
ext_name: str, ext_id: str, limit: int = 5
|
ext_name: str, ext_id: str, limit: int = 5
|
||||||
) -> list[Payment]:
|
) -> list[Payment]:
|
||||||
return await db.fetchall(
|
return await db.fetchall(
|
||||||
# This query is safe from SQL injection
|
|
||||||
# The limtit is an integer and not user input
|
|
||||||
f"""
|
f"""
|
||||||
SELECT * FROM apipayments
|
SELECT * FROM apipayments
|
||||||
WHERE status = :status
|
WHERE status = '{PaymentState.SUCCESS}'
|
||||||
AND extra LIKE :ext_name
|
AND extra LIKE :ext_name
|
||||||
AND extra LIKE :ext_id
|
AND extra LIKE :ext_id
|
||||||
ORDER BY time DESC LIMIT {int(limit)}
|
ORDER BY time DESC LIMIT {int(limit)}
|
||||||
""", # noqa: S608
|
""",
|
||||||
{
|
{"ext_name": f"%{ext_name}%", "ext_id": f"%{ext_id}%"},
|
||||||
"status": f"{PaymentState.SUCCESS}",
|
|
||||||
"ext_name": f"%{ext_name}%",
|
|
||||||
"ext_id": f"%{ext_id}%",
|
|
||||||
},
|
|
||||||
Payment,
|
Payment,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def get_payments_paginated( # noqa: C901
|
async def get_payments_paginated(
|
||||||
*,
|
*,
|
||||||
wallet_id: str | None = None,
|
wallet_id: Optional[str] = None,
|
||||||
user_id: str | None = None,
|
|
||||||
complete: bool = False,
|
complete: bool = False,
|
||||||
pending: bool = False,
|
pending: bool = False,
|
||||||
failed: bool = False,
|
failed: bool = False,
|
||||||
outgoing: bool = False,
|
outgoing: bool = False,
|
||||||
incoming: bool = False,
|
incoming: bool = False,
|
||||||
since: int | None = None,
|
since: Optional[int] = None,
|
||||||
exclude_uncheckable: bool = False,
|
exclude_uncheckable: bool = False,
|
||||||
filters: Filters[PaymentFilters] | None = None,
|
filters: Optional[Filters[PaymentFilters]] = None,
|
||||||
conn: Connection | None = None,
|
conn: Optional[Connection] = None,
|
||||||
) -> Page[Payment]:
|
) -> Page[Payment]:
|
||||||
"""
|
"""
|
||||||
Filters payments to be returned by:
|
Filters payments to be returned by:
|
||||||
- complete | pending | failed | outgoing | incoming.
|
- complete | pending | failed | outgoing | incoming.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
values: dict[str, Any] = {
|
values: dict = {
|
||||||
|
"wallet_id": wallet_id,
|
||||||
"time": since,
|
"time": since,
|
||||||
}
|
}
|
||||||
clause: list[str] = []
|
clause: list[str] = []
|
||||||
@@ -128,11 +120,7 @@ async def get_payments_paginated( # noqa: C901
|
|||||||
clause.append(f"time > {db.timestamp_placeholder('time')}")
|
clause.append(f"time > {db.timestamp_placeholder('time')}")
|
||||||
|
|
||||||
if wallet_id:
|
if wallet_id:
|
||||||
values["wallet_id"] = wallet_id
|
|
||||||
clause.append("wallet_id = :wallet_id")
|
clause.append("wallet_id = :wallet_id")
|
||||||
elif user_id:
|
|
||||||
only_user_wallets = await _only_user_wallets_statement(user_id, conn=conn)
|
|
||||||
clause.append(only_user_wallets)
|
|
||||||
|
|
||||||
if complete and pending:
|
if complete and pending:
|
||||||
clause.append(
|
clause.append(
|
||||||
@@ -176,17 +164,17 @@ async def get_payments_paginated( # noqa: C901
|
|||||||
|
|
||||||
async def get_payments(
|
async def get_payments(
|
||||||
*,
|
*,
|
||||||
wallet_id: str | None = None,
|
wallet_id: Optional[str] = None,
|
||||||
complete: bool = False,
|
complete: bool = False,
|
||||||
pending: bool = False,
|
pending: bool = False,
|
||||||
outgoing: bool = False,
|
outgoing: bool = False,
|
||||||
incoming: bool = False,
|
incoming: bool = False,
|
||||||
since: int | None = None,
|
since: Optional[int] = None,
|
||||||
exclude_uncheckable: bool = False,
|
exclude_uncheckable: bool = False,
|
||||||
filters: Filters[PaymentFilters] | None = None,
|
filters: Optional[Filters[PaymentFilters]] = None,
|
||||||
conn: Connection | None = None,
|
conn: Optional[Connection] = None,
|
||||||
limit: int | None = None,
|
limit: Optional[int] = None,
|
||||||
offset: int | None = None,
|
offset: Optional[int] = None,
|
||||||
) -> list[Payment]:
|
) -> list[Payment]:
|
||||||
"""
|
"""
|
||||||
Filters payments to be returned by complete | pending | outgoing | incoming.
|
Filters payments to be returned by complete | pending | outgoing | incoming.
|
||||||
@@ -230,28 +218,26 @@ async def get_payments_status_count() -> PaymentsStatusCount:
|
|||||||
|
|
||||||
|
|
||||||
async def delete_expired_invoices(
|
async def delete_expired_invoices(
|
||||||
conn: Connection | None = None,
|
conn: Optional[Connection] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
# first we delete all invoices older than one month
|
# first we delete all invoices older than one month
|
||||||
|
|
||||||
await (conn or db).execute(
|
await (conn or db).execute(
|
||||||
# Timestamp placeholder is safe from SQL injection (not user input)
|
|
||||||
f"""
|
f"""
|
||||||
DELETE FROM apipayments
|
DELETE FROM apipayments
|
||||||
WHERE status = :status AND amount > 0
|
WHERE status = '{PaymentState.PENDING}' AND amount > 0
|
||||||
AND time < {db.timestamp_placeholder("delta")}
|
AND time < {db.timestamp_placeholder("delta")}
|
||||||
""", # noqa: S608
|
""",
|
||||||
{"status": f"{PaymentState.PENDING}", "delta": int(time() - 2592000)},
|
{"delta": int(time() - 2592000)},
|
||||||
)
|
)
|
||||||
# then we delete all invoices whose expiry date is in the past
|
# then we delete all invoices whose expiry date is in the past
|
||||||
await (conn or db).execute(
|
await (conn or db).execute(
|
||||||
# Timestamp placeholder is safe from SQL injection (not user input)
|
|
||||||
f"""
|
f"""
|
||||||
DELETE FROM apipayments
|
DELETE FROM apipayments
|
||||||
WHERE status = :status AND amount > 0
|
WHERE status = '{PaymentState.PENDING}' AND amount > 0
|
||||||
AND expiry < {db.timestamp_placeholder("now")}
|
AND expiry < {db.timestamp_placeholder("now")}
|
||||||
""", # noqa: S608
|
""",
|
||||||
{"status": f"{PaymentState.PENDING}", "now": int(time())},
|
{"now": int(time())},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -259,13 +245,12 @@ async def create_payment(
|
|||||||
checking_id: str,
|
checking_id: str,
|
||||||
data: CreatePayment,
|
data: CreatePayment,
|
||||||
status: PaymentState = PaymentState.PENDING,
|
status: PaymentState = PaymentState.PENDING,
|
||||||
conn: Connection | None = None,
|
conn: Optional[Connection] = None,
|
||||||
) -> Payment:
|
) -> Payment:
|
||||||
# we don't allow the creation of the same invoice twice
|
# we don't allow the creation of the same invoice twice
|
||||||
# note: this can be removed if the db uniqueness constraints are set appropriately
|
# note: this can be removed if the db uniqueness constraints are set appropriately
|
||||||
previous_payment = await get_standalone_payment(checking_id, conn=conn)
|
previous_payment = await get_standalone_payment(checking_id, conn=conn)
|
||||||
if previous_payment is not None:
|
assert previous_payment is None, "Payment already exists"
|
||||||
raise ValueError("Payment already exists")
|
|
||||||
extra = data.extra or {}
|
extra = data.extra or {}
|
||||||
|
|
||||||
payment = Payment(
|
payment = Payment(
|
||||||
@@ -279,7 +264,7 @@ async def create_payment(
|
|||||||
preimage=data.preimage,
|
preimage=data.preimage,
|
||||||
expiry=data.expiry,
|
expiry=data.expiry,
|
||||||
webhook=data.webhook,
|
webhook=data.webhook,
|
||||||
fee=-abs(data.fee),
|
fee=data.fee,
|
||||||
tag=extra.get("tag", None),
|
tag=extra.get("tag", None),
|
||||||
extra=extra,
|
extra=extra,
|
||||||
)
|
)
|
||||||
@@ -290,7 +275,7 @@ async def create_payment(
|
|||||||
|
|
||||||
|
|
||||||
async def update_payment_checking_id(
|
async def update_payment_checking_id(
|
||||||
checking_id: str, new_checking_id: str, conn: Connection | None = None
|
checking_id: str, new_checking_id: str, conn: Optional[Connection] = None
|
||||||
) -> None:
|
) -> None:
|
||||||
await (conn or db).execute(
|
await (conn or db).execute(
|
||||||
"UPDATE apipayments SET checking_id = :new_id WHERE checking_id = :old_id",
|
"UPDATE apipayments SET checking_id = :new_id WHERE checking_id = :old_id",
|
||||||
@@ -300,8 +285,8 @@ async def update_payment_checking_id(
|
|||||||
|
|
||||||
async def update_payment(
|
async def update_payment(
|
||||||
payment: Payment,
|
payment: Payment,
|
||||||
new_checking_id: str | None = None,
|
new_checking_id: Optional[str] = None,
|
||||||
conn: Connection | None = None,
|
conn: Optional[Connection] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
await (conn or db).update(
|
await (conn or db).update(
|
||||||
"apipayments", payment, "WHERE checking_id = :checking_id"
|
"apipayments", payment, "WHERE checking_id = :checking_id"
|
||||||
@@ -311,9 +296,9 @@ async def update_payment(
|
|||||||
|
|
||||||
|
|
||||||
async def get_payments_history(
|
async def get_payments_history(
|
||||||
wallet_id: str | None = None,
|
wallet_id: Optional[str] = None,
|
||||||
group: DateTrunc = "day",
|
group: DateTrunc = "day",
|
||||||
filters: Filters | None = None,
|
filters: Optional[Filters] = None,
|
||||||
) -> list[PaymentHistoryPoint]:
|
) -> list[PaymentHistoryPoint]:
|
||||||
if not filters:
|
if not filters:
|
||||||
filters = Filters()
|
filters = Filters()
|
||||||
@@ -332,20 +317,16 @@ async def get_payments_history(
|
|||||||
)
|
)
|
||||||
"""
|
"""
|
||||||
]
|
]
|
||||||
clause = filters.where(where)
|
|
||||||
transactions: list[dict] = await db.fetchall(
|
transactions: list[dict] = await db.fetchall(
|
||||||
# This query is safe from SQL injection:
|
|
||||||
# - `date_trunc` is a static string, not user input
|
|
||||||
# - `clause` is generated from filters, which are validated and sanitized
|
|
||||||
f"""
|
f"""
|
||||||
SELECT {date_trunc} date,
|
SELECT {date_trunc} date,
|
||||||
SUM(CASE WHEN amount > 0 THEN amount ELSE 0 END) income,
|
SUM(CASE WHEN amount > 0 THEN amount ELSE 0 END) income,
|
||||||
SUM(CASE WHEN amount < 0 THEN abs(amount) + abs(fee) ELSE 0 END) spending
|
SUM(CASE WHEN amount < 0 THEN abs(amount) + abs(fee) ELSE 0 END) spending
|
||||||
FROM apipayments
|
FROM apipayments
|
||||||
{clause}
|
{filters.where(where)}
|
||||||
GROUP BY date
|
GROUP BY date
|
||||||
ORDER BY date DESC
|
ORDER BY date DESC
|
||||||
""", # noqa: S608
|
""",
|
||||||
filters.values(values),
|
filters.values(values),
|
||||||
)
|
)
|
||||||
if wallet_id:
|
if wallet_id:
|
||||||
@@ -376,31 +357,21 @@ async def get_payments_history(
|
|||||||
|
|
||||||
async def get_payment_count_stats(
|
async def get_payment_count_stats(
|
||||||
field: PaymentCountField,
|
field: PaymentCountField,
|
||||||
filters: Filters[PaymentFilters] | None = None,
|
filters: Optional[Filters[PaymentFilters]] = None,
|
||||||
user_id: str | None = None,
|
conn: Optional[Connection] = None,
|
||||||
conn: Connection | None = None,
|
|
||||||
) -> list[PaymentCountStat]:
|
) -> list[PaymentCountStat]:
|
||||||
|
|
||||||
if not filters:
|
if not filters:
|
||||||
filters = Filters()
|
filters = Filters()
|
||||||
extra_stmts = []
|
clause = filters.where()
|
||||||
|
|
||||||
if user_id:
|
|
||||||
only_user_wallets = await _only_user_wallets_statement(user_id, conn=conn)
|
|
||||||
extra_stmts.append(only_user_wallets)
|
|
||||||
|
|
||||||
clause = filters.where(extra_stmts)
|
|
||||||
data = await (conn or db).fetchall(
|
data = await (conn or db).fetchall(
|
||||||
# SQL injection vectors safety:
|
|
||||||
# - `field` is a static string, not user input
|
|
||||||
# - `clause` is generated from filters, which are validated and sanitized
|
|
||||||
query=f"""
|
query=f"""
|
||||||
SELECT {field} as field, count(*) as total
|
SELECT {field} as field, count(*) as total
|
||||||
FROM apipayments
|
FROM apipayments
|
||||||
{clause}
|
{clause}
|
||||||
GROUP BY {field}
|
GROUP BY {field}
|
||||||
ORDER BY {field}
|
ORDER BY {field}
|
||||||
""", # noqa: S608
|
""",
|
||||||
values=filters.values(),
|
values=filters.values(),
|
||||||
model=PaymentCountStat,
|
model=PaymentCountStat,
|
||||||
)
|
)
|
||||||
@@ -409,27 +380,19 @@ async def get_payment_count_stats(
|
|||||||
|
|
||||||
|
|
||||||
async def get_daily_stats(
|
async def get_daily_stats(
|
||||||
filters: Filters[PaymentFilters] | None = None,
|
filters: Optional[Filters[PaymentFilters]] = None,
|
||||||
user_id: str | None = None,
|
conn: Optional[Connection] = None,
|
||||||
conn: Connection | None = None,
|
) -> Tuple[list[PaymentDailyStats], list[PaymentDailyStats]]:
|
||||||
) -> tuple[list[PaymentDailyStats], list[PaymentDailyStats]]:
|
|
||||||
|
|
||||||
if not filters:
|
if not filters:
|
||||||
filters = Filters()
|
filters = Filters()
|
||||||
|
|
||||||
in_where_stmts = ["(apipayments.status = 'success' AND apipayments.amount > 0)"]
|
in_clause = filters.where(
|
||||||
out_where_stmts = [
|
["(apipayments.status = 'success' AND apipayments.amount > 0)"]
|
||||||
"(apipayments.status IN ('success', 'pending') AND apipayments.amount < 0)"
|
)
|
||||||
]
|
out_clause = filters.where(
|
||||||
|
["(apipayments.status IN ('success', 'pending') AND apipayments.amount < 0)"]
|
||||||
if user_id:
|
)
|
||||||
only_user_wallets = await _only_user_wallets_statement(user_id, conn=conn)
|
|
||||||
in_where_stmts.append(only_user_wallets)
|
|
||||||
out_where_stmts.append(only_user_wallets)
|
|
||||||
|
|
||||||
in_clause = filters.where(in_where_stmts)
|
|
||||||
out_clause = filters.where(out_where_stmts)
|
|
||||||
|
|
||||||
date_trunc = db.datetime_grouping("day")
|
date_trunc = db.datetime_grouping("day")
|
||||||
query = """
|
query = """
|
||||||
SELECT {date_trunc} date,
|
SELECT {date_trunc} date,
|
||||||
@@ -459,9 +422,8 @@ async def get_daily_stats(
|
|||||||
|
|
||||||
|
|
||||||
async def get_wallets_stats(
|
async def get_wallets_stats(
|
||||||
filters: Filters[PaymentFilters] | None = None,
|
filters: Optional[Filters[PaymentFilters]] = None,
|
||||||
user_id: str | None = None,
|
conn: Optional[Connection] = None,
|
||||||
conn: Connection | None = None,
|
|
||||||
) -> list[PaymentWalletStats]:
|
) -> list[PaymentWalletStats]:
|
||||||
|
|
||||||
if not filters:
|
if not filters:
|
||||||
@@ -479,15 +441,9 @@ async def get_wallets_stats(
|
|||||||
)
|
)
|
||||||
""",
|
""",
|
||||||
]
|
]
|
||||||
if user_id:
|
|
||||||
only_user_wallets = await _only_user_wallets_statement(user_id, conn=conn)
|
|
||||||
where_stmts.append(only_user_wallets)
|
|
||||||
|
|
||||||
clauses = filters.where(where_stmts)
|
clauses = filters.where(where_stmts)
|
||||||
|
|
||||||
data = await (conn or db).fetchall(
|
data = await (conn or db).fetchall(
|
||||||
# This query is safe from SQL injection
|
|
||||||
# The `clauses` is constructed from sanitized inputs
|
|
||||||
query=f"""
|
query=f"""
|
||||||
SELECT apipayments.wallet_id,
|
SELECT apipayments.wallet_id,
|
||||||
MAX(wallets.name) AS wallet_name,
|
MAX(wallets.name) AS wallet_name,
|
||||||
@@ -499,7 +455,7 @@ async def get_wallets_stats(
|
|||||||
{clauses}
|
{clauses}
|
||||||
GROUP BY apipayments.wallet_id
|
GROUP BY apipayments.wallet_id
|
||||||
ORDER BY payments_count
|
ORDER BY payments_count
|
||||||
""", # noqa: S608
|
""",
|
||||||
values=filters.values(),
|
values=filters.values(),
|
||||||
model=PaymentWalletStats,
|
model=PaymentWalletStats,
|
||||||
)
|
)
|
||||||
@@ -508,7 +464,7 @@ async def get_wallets_stats(
|
|||||||
|
|
||||||
|
|
||||||
async def delete_wallet_payment(
|
async def delete_wallet_payment(
|
||||||
checking_id: str, wallet_id: str, conn: Connection | None = None
|
checking_id: str, wallet_id: str, conn: Optional[Connection] = None
|
||||||
) -> None:
|
) -> None:
|
||||||
await (conn or db).execute(
|
await (conn or db).execute(
|
||||||
"DELETE FROM apipayments WHERE checking_id = :checking_id AND wallet = :wallet",
|
"DELETE FROM apipayments WHERE checking_id = :checking_id AND wallet = :wallet",
|
||||||
@@ -517,24 +473,24 @@ async def delete_wallet_payment(
|
|||||||
|
|
||||||
|
|
||||||
async def check_internal(
|
async def check_internal(
|
||||||
payment_hash: str, conn: Connection | None = None
|
payment_hash: str, conn: Optional[Connection] = None
|
||||||
) -> Payment | None:
|
) -> Optional[Payment]:
|
||||||
"""
|
"""
|
||||||
Returns the checking_id of the internal payment if it exists,
|
Returns the checking_id of the internal payment if it exists,
|
||||||
otherwise None
|
otherwise None
|
||||||
"""
|
"""
|
||||||
return await (conn or db).fetchone(
|
return await (conn or db).fetchone(
|
||||||
"""
|
f"""
|
||||||
SELECT * FROM apipayments
|
SELECT * FROM apipayments
|
||||||
WHERE payment_hash = :hash AND status = :status AND amount > 0
|
WHERE payment_hash = :hash AND status = '{PaymentState.PENDING}' AND amount > 0
|
||||||
""",
|
""",
|
||||||
{"status": f"{PaymentState.PENDING}", "hash": payment_hash},
|
{"hash": payment_hash},
|
||||||
Payment,
|
Payment,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def is_internal_status_success(
|
async def is_internal_status_success(
|
||||||
payment_hash: str, conn: Connection | None = None
|
payment_hash: str, conn: Optional[Connection] = None
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""
|
"""
|
||||||
Returns True if the internal payment was found and is successful,
|
Returns True if the internal payment was found and is successful,
|
||||||
@@ -552,7 +508,7 @@ async def is_internal_status_success(
|
|||||||
return payment.status == PaymentState.SUCCESS.value
|
return payment.status == PaymentState.SUCCESS.value
|
||||||
|
|
||||||
|
|
||||||
async def mark_webhook_sent(payment_hash: str, status: str) -> None:
|
async def mark_webhook_sent(payment_hash: str, status: int) -> None:
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"""
|
"""
|
||||||
UPDATE apipayments SET webhook_status = :status
|
UPDATE apipayments SET webhook_status = :status
|
||||||
@@ -560,14 +516,3 @@ async def mark_webhook_sent(payment_hash: str, status: str) -> None:
|
|||||||
""",
|
""",
|
||||||
{"status": status, "hash": payment_hash},
|
{"status": status, "hash": payment_hash},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _only_user_wallets_statement(
|
|
||||||
user_id: str, conn: Connection | None = None
|
|
||||||
) -> str:
|
|
||||||
wallet_ids = await get_wallets_ids(user_id=user_id, conn=conn) or [
|
|
||||||
"no-wallets-for-user"
|
|
||||||
]
|
|
||||||
# wallet ids are safe to use in sql queries
|
|
||||||
wallet_ids_str = [f"'{w}'" for w in wallet_ids]
|
|
||||||
return f""" wallet_id IN ({", ".join(wallet_ids_str)}) """
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import json
|
import json
|
||||||
from typing import Any
|
from typing import Any, Optional
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ from lnbits.settings import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def get_super_settings() -> SuperSettings | None:
|
async def get_super_settings() -> Optional[SuperSettings]:
|
||||||
data = await get_settings_by_tag("core")
|
data = await get_settings_by_tag("core")
|
||||||
if data:
|
if data:
|
||||||
super_user = await get_settings_field("super_user")
|
super_user = await get_settings_field("super_user")
|
||||||
@@ -24,7 +24,7 @@ async def get_super_settings() -> SuperSettings | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def get_admin_settings(is_super_user: bool = False) -> AdminSettings | None:
|
async def get_admin_settings(is_super_user: bool = False) -> Optional[AdminSettings]:
|
||||||
sets = await get_super_settings()
|
sets = await get_super_settings()
|
||||||
if not sets:
|
if not sets:
|
||||||
return None
|
return None
|
||||||
@@ -41,7 +41,7 @@ async def get_admin_settings(is_super_user: bool = False) -> AdminSettings | Non
|
|||||||
|
|
||||||
|
|
||||||
async def update_admin_settings(
|
async def update_admin_settings(
|
||||||
data: EditableSettings, tag: str | None = "core"
|
data: EditableSettings, tag: Optional[str] = "core"
|
||||||
) -> None:
|
) -> None:
|
||||||
editable_settings = await get_settings_by_tag("core") or {}
|
editable_settings = await get_settings_by_tag("core") or {}
|
||||||
editable_settings.update(data.dict(exclude_unset=True))
|
editable_settings.update(data.dict(exclude_unset=True))
|
||||||
@@ -56,29 +56,12 @@ async def update_admin_settings(
|
|||||||
async def update_super_user(super_user: str) -> SuperSettings:
|
async def update_super_user(super_user: str) -> SuperSettings:
|
||||||
await set_settings_field("super_user", super_user)
|
await set_settings_field("super_user", super_user)
|
||||||
settings = await get_super_settings()
|
settings = await get_super_settings()
|
||||||
if not settings:
|
assert settings, "updated super_user settings could not be retrieved"
|
||||||
raise ValueError("updated super_user settings could not be retrieved")
|
|
||||||
return settings
|
return settings
|
||||||
|
|
||||||
|
|
||||||
async def delete_admin_settings(tag: str | None = "core") -> None:
|
async def delete_admin_settings(tag: Optional[str] = "core") -> None:
|
||||||
await db.execute(
|
await db.execute("DELETE FROM settings WHERE tag = :tag", {"tag": tag})
|
||||||
"DELETE FROM system_settings WHERE tag = :tag",
|
|
||||||
{"tag": tag},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def reset_core_settings() -> None:
|
|
||||||
await db.execute(
|
|
||||||
"""
|
|
||||||
DELETE FROM system_settings WHERE tag = 'core'
|
|
||||||
AND id NOT IN (
|
|
||||||
'super_user',
|
|
||||||
'lnbits_webpush_pubkey',
|
|
||||||
'lnbits_webpush_privkey'
|
|
||||||
)
|
|
||||||
""",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def create_admin_settings(super_user: str, new_settings: dict) -> SuperSettings:
|
async def create_admin_settings(super_user: str, new_settings: dict) -> SuperSettings:
|
||||||
@@ -87,14 +70,13 @@ async def create_admin_settings(super_user: str, new_settings: dict) -> SuperSet
|
|||||||
await set_settings_field(key, value)
|
await set_settings_field(key, value)
|
||||||
|
|
||||||
settings = await get_super_settings()
|
settings = await get_super_settings()
|
||||||
if not settings:
|
assert settings, "created admin settings could not be retrieved"
|
||||||
raise ValueError("created admin settings could not be retrieved")
|
|
||||||
return settings
|
return settings
|
||||||
|
|
||||||
|
|
||||||
async def get_settings_field(
|
async def get_settings_field(
|
||||||
id_: str, tag: str | None = "core"
|
id_: str, tag: Optional[str] = "core"
|
||||||
) -> SettingsField | None:
|
) -> Optional[SettingsField]:
|
||||||
|
|
||||||
row: dict = await db.fetchone(
|
row: dict = await db.fetchone(
|
||||||
"""
|
"""
|
||||||
@@ -108,7 +90,9 @@ async def get_settings_field(
|
|||||||
return SettingsField(id=row["id"], value=json.loads(row["value"]), tag=row["tag"])
|
return SettingsField(id=row["id"], value=json.loads(row["value"]), tag=row["tag"])
|
||||||
|
|
||||||
|
|
||||||
async def set_settings_field(id_: str, value: Any | None, tag: str | None = "core"):
|
async def set_settings_field(
|
||||||
|
id_: str, value: Optional[Any], tag: Optional[str] = "core"
|
||||||
|
):
|
||||||
value = json.dumps(value) if value is not None else None
|
value = json.dumps(value) if value is not None else None
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"""
|
"""
|
||||||
@@ -120,7 +104,7 @@ async def set_settings_field(id_: str, value: Any | None, tag: str | None = "cor
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def get_settings_by_tag(tag: str) -> dict[str, Any] | None:
|
async def get_settings_by_tag(tag: str) -> Optional[dict[str, Any]]:
|
||||||
rows: list[dict] = await db.fetchall(
|
rows: list[dict] = await db.fetchall(
|
||||||
"SELECT * FROM system_settings WHERE tag = :tag", {"tag": tag}
|
"SELECT * FROM system_settings WHERE tag = :tag", {"tag": tag}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
from typing import Optional
|
||||||
|
|
||||||
import shortuuid
|
import shortuuid
|
||||||
|
|
||||||
from lnbits.core.db import db
|
from lnbits.core.db import db
|
||||||
@@ -17,7 +19,7 @@ async def create_tinyurl(domain: str, endless: bool, wallet: str):
|
|||||||
return await get_tinyurl(tinyurl_id)
|
return await get_tinyurl(tinyurl_id)
|
||||||
|
|
||||||
|
|
||||||
async def get_tinyurl(tinyurl_id: str) -> TinyURL | None:
|
async def get_tinyurl(tinyurl_id: str) -> Optional[TinyURL]:
|
||||||
return await db.fetchone(
|
return await db.fetchone(
|
||||||
"SELECT * FROM tiny_url WHERE id = :tinyurl",
|
"SELECT * FROM tiny_url WHERE id = :tinyurl",
|
||||||
{"tinyurl": tinyurl_id},
|
{"tinyurl": tinyurl_id},
|
||||||
|
|||||||
+31
-36
@@ -1,6 +1,6 @@
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from time import time
|
from time import time
|
||||||
from typing import Any
|
from typing import Any, Optional
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from lnbits.core.crud.extensions import get_user_active_extensions_ids
|
from lnbits.core.crud.extensions import get_user_active_extensions_ids
|
||||||
@@ -18,8 +18,8 @@ from ..models import (
|
|||||||
|
|
||||||
|
|
||||||
async def create_account(
|
async def create_account(
|
||||||
account: Account | None = None,
|
account: Optional[Account] = None,
|
||||||
conn: Connection | None = None,
|
conn: Optional[Connection] = None,
|
||||||
) -> Account:
|
) -> Account:
|
||||||
if account:
|
if account:
|
||||||
account.validate_fields()
|
account.validate_fields()
|
||||||
@@ -36,7 +36,7 @@ async def update_account(account: Account) -> Account:
|
|||||||
return account
|
return account
|
||||||
|
|
||||||
|
|
||||||
async def delete_account(user_id: str, conn: Connection | None = None) -> None:
|
async def delete_account(user_id: str, conn: Optional[Connection] = None) -> None:
|
||||||
await (conn or db).execute(
|
await (conn or db).execute(
|
||||||
"DELETE from accounts WHERE id = :user",
|
"DELETE from accounts WHERE id = :user",
|
||||||
{"user": user_id},
|
{"user": user_id},
|
||||||
@@ -44,8 +44,8 @@ async def delete_account(user_id: str, conn: Connection | None = None) -> None:
|
|||||||
|
|
||||||
|
|
||||||
async def get_accounts(
|
async def get_accounts(
|
||||||
filters: Filters[AccountFilters] | None = None,
|
filters: Optional[Filters[AccountFilters]] = None,
|
||||||
conn: Connection | None = None,
|
conn: Optional[Connection] = None,
|
||||||
) -> Page[AccountOverview]:
|
) -> Page[AccountOverview]:
|
||||||
where_clauses = []
|
where_clauses = []
|
||||||
values: dict[str, Any] = {}
|
values: dict[str, Any] = {}
|
||||||
@@ -68,7 +68,6 @@ async def get_accounts(
|
|||||||
accounts.username,
|
accounts.username,
|
||||||
accounts.email,
|
accounts.email,
|
||||||
accounts.pubkey,
|
accounts.pubkey,
|
||||||
accounts.external_id,
|
|
||||||
SUM(COALESCE((
|
SUM(COALESCE((
|
||||||
SELECT balance FROM balances WHERE wallet_id = wallets.id
|
SELECT balance FROM balances WHERE wallet_id = wallets.id
|
||||||
), 0)) as balance_msat,
|
), 0)) as balance_msat,
|
||||||
@@ -92,7 +91,9 @@ async def get_accounts(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def get_account(user_id: str, conn: Connection | None = None) -> Account | None:
|
async def get_account(
|
||||||
|
user_id: str, conn: Optional[Connection] = None
|
||||||
|
) -> Optional[Account]:
|
||||||
if len(user_id) == 0:
|
if len(user_id) == 0:
|
||||||
return None
|
return None
|
||||||
return await (conn or db).fetchone(
|
return await (conn or db).fetchone(
|
||||||
@@ -104,11 +105,10 @@ async def get_account(user_id: str, conn: Connection | None = None) -> Account |
|
|||||||
|
|
||||||
async def delete_accounts_no_wallets(
|
async def delete_accounts_no_wallets(
|
||||||
time_delta: int,
|
time_delta: int,
|
||||||
conn: Connection | None = None,
|
conn: Optional[Connection] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
delta = int(time()) - time_delta
|
delta = int(time()) - time_delta
|
||||||
await (conn or db).execute(
|
await (conn or db).execute(
|
||||||
# Timestamp placeholder is safe from SQL injection (not user input)
|
|
||||||
f"""
|
f"""
|
||||||
DELETE FROM accounts
|
DELETE FROM accounts
|
||||||
WHERE NOT EXISTS (
|
WHERE NOT EXISTS (
|
||||||
@@ -117,59 +117,56 @@ async def delete_accounts_no_wallets(
|
|||||||
(updated_at is null AND created_at < :delta)
|
(updated_at is null AND created_at < :delta)
|
||||||
OR updated_at < {db.timestamp_placeholder("delta")}
|
OR updated_at < {db.timestamp_placeholder("delta")}
|
||||||
)
|
)
|
||||||
""", # noqa: S608
|
""",
|
||||||
{"delta": delta},
|
{"delta": delta},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def get_account_by_username(
|
async def get_account_by_username(
|
||||||
username: str, conn: Connection | None = None
|
username: str, conn: Optional[Connection] = None
|
||||||
) -> Account | None:
|
) -> Optional[Account]:
|
||||||
if len(username) == 0:
|
if len(username) == 0:
|
||||||
return None
|
return None
|
||||||
return await (conn or db).fetchone(
|
return await (conn or db).fetchone(
|
||||||
"SELECT * FROM accounts WHERE LOWER(username) = :username",
|
"SELECT * FROM accounts WHERE username = :username",
|
||||||
{"username": username.lower()},
|
{"username": username},
|
||||||
Account,
|
Account,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def get_account_by_pubkey(
|
async def get_account_by_pubkey(
|
||||||
pubkey: str, conn: Connection | None = None
|
pubkey: str, conn: Optional[Connection] = None
|
||||||
) -> Account | None:
|
) -> Optional[Account]:
|
||||||
return await (conn or db).fetchone(
|
return await (conn or db).fetchone(
|
||||||
"SELECT * FROM accounts WHERE LOWER(pubkey) = :pubkey",
|
"SELECT * FROM accounts WHERE pubkey = :pubkey",
|
||||||
{"pubkey": pubkey.lower()},
|
{"pubkey": pubkey},
|
||||||
Account,
|
Account,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def get_account_by_email(
|
async def get_account_by_email(
|
||||||
email: str, conn: Connection | None = None
|
email: str, conn: Optional[Connection] = None
|
||||||
) -> Account | None:
|
) -> Optional[Account]:
|
||||||
if len(email) == 0:
|
if len(email) == 0:
|
||||||
return None
|
return None
|
||||||
return await (conn or db).fetchone(
|
return await (conn or db).fetchone(
|
||||||
"SELECT * FROM accounts WHERE LOWER(email) = :email",
|
"SELECT * FROM accounts WHERE email = :email",
|
||||||
{"email": email.lower()},
|
{"email": email},
|
||||||
Account,
|
Account,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def get_account_by_username_or_email(
|
async def get_account_by_username_or_email(
|
||||||
username_or_email: str, conn: Connection | None = None
|
username_or_email: str, conn: Optional[Connection] = None
|
||||||
) -> Account | None:
|
) -> Optional[Account]:
|
||||||
return await (conn or db).fetchone(
|
return await (conn or db).fetchone(
|
||||||
"""
|
"SELECT * FROM accounts WHERE email = :value or username = :value",
|
||||||
SELECT * FROM accounts
|
{"value": username_or_email},
|
||||||
WHERE LOWER(email) = :value or LOWER(username) = :value
|
|
||||||
""",
|
|
||||||
{"value": username_or_email.lower()},
|
|
||||||
Account,
|
Account,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def get_user(user_id: str, conn: Connection | None = None) -> User | None:
|
async def get_user(user_id: str, conn: Optional[Connection] = None) -> Optional[User]:
|
||||||
account = await get_account(user_id, conn)
|
account = await get_account(user_id, conn)
|
||||||
if not account:
|
if not account:
|
||||||
return None
|
return None
|
||||||
@@ -177,8 +174,8 @@ async def get_user(user_id: str, conn: Connection | None = None) -> User | None:
|
|||||||
|
|
||||||
|
|
||||||
async def get_user_from_account(
|
async def get_user_from_account(
|
||||||
account: Account, conn: Connection | None = None
|
account: Account, conn: Optional[Connection] = None
|
||||||
) -> User | None:
|
) -> Optional[User]:
|
||||||
extensions = await get_user_active_extensions_ids(account.id, conn)
|
extensions = await get_user_active_extensions_ids(account.id, conn)
|
||||||
wallets = await get_wallets(account.id, False, conn=conn)
|
wallets = await get_wallets(account.id, False, conn=conn)
|
||||||
return User(
|
return User(
|
||||||
@@ -186,7 +183,6 @@ async def get_user_from_account(
|
|||||||
email=account.email,
|
email=account.email,
|
||||||
username=account.username,
|
username=account.username,
|
||||||
pubkey=account.pubkey,
|
pubkey=account.pubkey,
|
||||||
external_id=account.external_id,
|
|
||||||
extra=account.extra,
|
extra=account.extra,
|
||||||
created_at=account.created_at,
|
created_at=account.created_at,
|
||||||
updated_at=account.updated_at,
|
updated_at=account.updated_at,
|
||||||
@@ -194,7 +190,6 @@ async def get_user_from_account(
|
|||||||
wallets=wallets,
|
wallets=wallets,
|
||||||
admin=account.is_admin,
|
admin=account.is_admin,
|
||||||
super_user=account.is_super_user,
|
super_user=account.is_super_user,
|
||||||
fiat_providers=account.fiat_providers,
|
|
||||||
has_password=account.password_hash is not None,
|
has_password=account.password_hash is not None,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -205,7 +200,7 @@ async def update_user_access_control_list(user_acls: UserAcls):
|
|||||||
|
|
||||||
|
|
||||||
async def get_user_access_control_lists(
|
async def get_user_access_control_lists(
|
||||||
user_id: str, conn: Connection | None = None
|
user_id: str, conn: Optional[Connection] = None
|
||||||
) -> UserAcls:
|
) -> UserAcls:
|
||||||
user_acls = await (conn or db).fetchone(
|
user_acls = await (conn or db).fetchone(
|
||||||
"SELECT id, access_control_list FROM accounts WHERE id = :id",
|
"SELECT id, access_control_list FROM accounts WHERE id = :id",
|
||||||
|
|||||||
+32
-72
@@ -1,10 +1,10 @@
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from time import time
|
from time import time
|
||||||
|
from typing import Optional
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from lnbits.core.db import db
|
from lnbits.core.db import db
|
||||||
from lnbits.core.models.wallets import WalletsFilters
|
from lnbits.db import Connection
|
||||||
from lnbits.db import Connection, Filters, Page
|
|
||||||
from lnbits.settings import settings
|
from lnbits.settings import settings
|
||||||
|
|
||||||
from ..models import Wallet
|
from ..models import Wallet
|
||||||
@@ -13,8 +13,8 @@ from ..models import Wallet
|
|||||||
async def create_wallet(
|
async def create_wallet(
|
||||||
*,
|
*,
|
||||||
user_id: str,
|
user_id: str,
|
||||||
wallet_name: str | None = None,
|
wallet_name: Optional[str] = None,
|
||||||
conn: Connection | None = None,
|
conn: Optional[Connection] = None,
|
||||||
) -> Wallet:
|
) -> Wallet:
|
||||||
wallet_id = uuid4().hex
|
wallet_id = uuid4().hex
|
||||||
wallet = Wallet(
|
wallet = Wallet(
|
||||||
@@ -31,8 +31,8 @@ async def create_wallet(
|
|||||||
|
|
||||||
async def update_wallet(
|
async def update_wallet(
|
||||||
wallet: Wallet,
|
wallet: Wallet,
|
||||||
conn: Connection | None = None,
|
conn: Optional[Connection] = None,
|
||||||
) -> Wallet:
|
) -> Optional[Wallet]:
|
||||||
wallet.updated_at = datetime.now(timezone.utc)
|
wallet.updated_at = datetime.now(timezone.utc)
|
||||||
await (conn or db).update("wallets", wallet)
|
await (conn or db).update("wallets", wallet)
|
||||||
return wallet
|
return wallet
|
||||||
@@ -43,21 +43,22 @@ async def delete_wallet(
|
|||||||
user_id: str,
|
user_id: str,
|
||||||
wallet_id: str,
|
wallet_id: str,
|
||||||
deleted: bool = True,
|
deleted: bool = True,
|
||||||
conn: Connection | None = None,
|
conn: Optional[Connection] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
now = int(time())
|
now = int(time())
|
||||||
await (conn or db).execute(
|
await (conn or db).execute(
|
||||||
# Timestamp placeholder is safe from SQL injection (not user input)
|
|
||||||
f"""
|
f"""
|
||||||
UPDATE wallets
|
UPDATE wallets
|
||||||
SET deleted = :deleted, updated_at = {db.timestamp_placeholder('now')}
|
SET deleted = :deleted, updated_at = {db.timestamp_placeholder('now')}
|
||||||
WHERE id = :wallet AND "user" = :user
|
WHERE id = :wallet AND "user" = :user
|
||||||
""", # noqa: S608
|
""",
|
||||||
{"wallet": wallet_id, "user": user_id, "deleted": deleted, "now": now},
|
{"wallet": wallet_id, "user": user_id, "deleted": deleted, "now": now},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def force_delete_wallet(wallet_id: str, conn: Connection | None = None) -> None:
|
async def force_delete_wallet(
|
||||||
|
wallet_id: str, conn: Optional[Connection] = None
|
||||||
|
) -> None:
|
||||||
await (conn or db).execute(
|
await (conn or db).execute(
|
||||||
"DELETE FROM wallets WHERE id = :wallet",
|
"DELETE FROM wallets WHERE id = :wallet",
|
||||||
{"wallet": wallet_id},
|
{"wallet": wallet_id},
|
||||||
@@ -65,28 +66,27 @@ async def force_delete_wallet(wallet_id: str, conn: Connection | None = None) ->
|
|||||||
|
|
||||||
|
|
||||||
async def delete_wallet_by_id(
|
async def delete_wallet_by_id(
|
||||||
wallet_id: str, conn: Connection | None = None
|
wallet_id: str, conn: Optional[Connection] = None
|
||||||
) -> int | None:
|
) -> Optional[int]:
|
||||||
now = int(time())
|
now = int(time())
|
||||||
result = await (conn or db).execute(
|
result = await (conn or db).execute(
|
||||||
# Timestamp placeholder is safe from SQL injection (not user input)
|
|
||||||
f"""
|
f"""
|
||||||
UPDATE wallets
|
UPDATE wallets
|
||||||
SET deleted = true, updated_at = {db.timestamp_placeholder('now')}
|
SET deleted = true, updated_at = {db.timestamp_placeholder('now')}
|
||||||
WHERE id = :wallet
|
WHERE id = :wallet
|
||||||
""", # noqa: S608
|
""",
|
||||||
{"wallet": wallet_id, "now": now},
|
{"wallet": wallet_id, "now": now},
|
||||||
)
|
)
|
||||||
return result.rowcount
|
return result.rowcount
|
||||||
|
|
||||||
|
|
||||||
async def remove_deleted_wallets(conn: Connection | None = None) -> None:
|
async def remove_deleted_wallets(conn: Optional[Connection] = None) -> None:
|
||||||
await (conn or db).execute("DELETE FROM wallets WHERE deleted = true")
|
await (conn or db).execute("DELETE FROM wallets WHERE deleted = true")
|
||||||
|
|
||||||
|
|
||||||
async def delete_unused_wallets(
|
async def delete_unused_wallets(
|
||||||
time_delta: int,
|
time_delta: int,
|
||||||
conn: Connection | None = None,
|
conn: Optional[Connection] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
delta = int(time()) - time_delta
|
delta = int(time()) - time_delta
|
||||||
await (conn or db).execute(
|
await (conn or db).execute(
|
||||||
@@ -104,77 +104,37 @@ async def delete_unused_wallets(
|
|||||||
|
|
||||||
|
|
||||||
async def get_wallet(
|
async def get_wallet(
|
||||||
wallet_id: str, deleted: bool | None = None, conn: Connection | None = None
|
wallet_id: str, deleted: Optional[bool] = None, conn: Optional[Connection] = None
|
||||||
) -> Wallet | None:
|
) -> Optional[Wallet]:
|
||||||
query = """
|
where = "AND deleted = :deleted" if deleted is not None else ""
|
||||||
|
return await (conn or db).fetchone(
|
||||||
|
f"""
|
||||||
SELECT *, COALESCE((
|
SELECT *, COALESCE((
|
||||||
SELECT balance FROM balances WHERE wallet_id = wallets.id
|
SELECT balance FROM balances WHERE wallet_id = wallets.id
|
||||||
), 0) AS balance_msat FROM wallets
|
), 0) AS balance_msat FROM wallets
|
||||||
WHERE id = :wallet
|
WHERE id = :wallet {where}
|
||||||
"""
|
""",
|
||||||
if deleted is not None:
|
|
||||||
query += " AND deleted = :deleted "
|
|
||||||
return await (conn or db).fetchone(
|
|
||||||
query,
|
|
||||||
{"wallet": wallet_id, "deleted": deleted},
|
{"wallet": wallet_id, "deleted": deleted},
|
||||||
Wallet,
|
Wallet,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def get_wallets(
|
async def get_wallets(
|
||||||
user_id: str, deleted: bool | None = None, conn: Connection | None = None
|
user_id: str, deleted: Optional[bool] = None, conn: Optional[Connection] = None
|
||||||
) -> list[Wallet]:
|
) -> list[Wallet]:
|
||||||
query = """
|
where = "AND deleted = :deleted" if deleted is not None else ""
|
||||||
|
return await (conn or db).fetchall(
|
||||||
|
f"""
|
||||||
SELECT *, COALESCE((
|
SELECT *, COALESCE((
|
||||||
SELECT balance FROM balances WHERE wallet_id = wallets.id
|
SELECT balance FROM balances WHERE wallet_id = wallets.id
|
||||||
), 0) AS balance_msat FROM wallets
|
), 0) AS balance_msat FROM wallets
|
||||||
WHERE "user" = :user
|
WHERE "user" = :user {where}
|
||||||
"""
|
""",
|
||||||
if deleted is not None:
|
|
||||||
query += " AND deleted = :deleted "
|
|
||||||
return await (conn or db).fetchall(
|
|
||||||
query,
|
|
||||||
{"user": user_id, "deleted": deleted},
|
{"user": user_id, "deleted": deleted},
|
||||||
Wallet,
|
Wallet,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def get_wallets_paginated(
|
|
||||||
user_id: str,
|
|
||||||
deleted: bool | None = None,
|
|
||||||
filters: Filters[WalletsFilters] | None = None,
|
|
||||||
conn: Connection | None = None,
|
|
||||||
) -> Page[Wallet]:
|
|
||||||
if deleted is None:
|
|
||||||
deleted = False
|
|
||||||
|
|
||||||
where: list[str] = [""" "user" = :user AND deleted = :deleted """]
|
|
||||||
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: bool | None = None, conn: Connection | None = 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():
|
async def get_wallets_count():
|
||||||
result = await db.execute("SELECT COUNT(*) as count FROM wallets")
|
result = await db.execute("SELECT COUNT(*) as count FROM wallets")
|
||||||
row = result.mappings().first()
|
row = result.mappings().first()
|
||||||
@@ -183,8 +143,8 @@ async def get_wallets_count():
|
|||||||
|
|
||||||
async def get_wallet_for_key(
|
async def get_wallet_for_key(
|
||||||
key: str,
|
key: str,
|
||||||
conn: Connection | None = None,
|
conn: Optional[Connection] = None,
|
||||||
) -> Wallet | None:
|
) -> Optional[Wallet]:
|
||||||
return await (conn or db).fetchone(
|
return await (conn or db).fetchone(
|
||||||
"""
|
"""
|
||||||
SELECT *, COALESCE((
|
SELECT *, COALESCE((
|
||||||
@@ -198,7 +158,7 @@ async def get_wallet_for_key(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def get_total_balance(conn: Connection | None = None):
|
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) as balance FROM balances")
|
||||||
row = result.mappings().first()
|
row = result.mappings().first()
|
||||||
return row.get("balance", 0) or 0
|
return row.get("balance", 0) or 0
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
from typing import Optional
|
||||||
|
|
||||||
from lnbits.core.db import db
|
from lnbits.core.db import db
|
||||||
|
|
||||||
from ..models import WebPushSubscription
|
from ..models import WebPushSubscription
|
||||||
@@ -5,7 +7,7 @@ from ..models import WebPushSubscription
|
|||||||
|
|
||||||
async def get_webpush_subscription(
|
async def get_webpush_subscription(
|
||||||
endpoint: str, user: str
|
endpoint: str, user: str
|
||||||
) -> WebPushSubscription | None:
|
) -> Optional[WebPushSubscription]:
|
||||||
return await db.fetchone(
|
return await db.fetchone(
|
||||||
"""
|
"""
|
||||||
SELECT * FROM webpush_subscriptions
|
SELECT * FROM webpush_subscriptions
|
||||||
@@ -35,8 +37,7 @@ async def create_webpush_subscription(
|
|||||||
{"endpoint": endpoint, "user": user, "data": data, "host": host},
|
{"endpoint": endpoint, "user": user, "data": data, "host": host},
|
||||||
)
|
)
|
||||||
subscription = await get_webpush_subscription(endpoint, user)
|
subscription = await get_webpush_subscription(endpoint, user)
|
||||||
if not subscription:
|
assert subscription, "Newly created webpush subscription couldn't be retrieved"
|
||||||
raise ValueError("Newly created webpush subscription couldn't be retrieved")
|
|
||||||
return subscription
|
return subscription
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import importlib
|
import importlib
|
||||||
import re
|
import re
|
||||||
from typing import Any
|
from typing import Any, Optional
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
@@ -20,7 +20,7 @@ from lnbits.settings import settings
|
|||||||
|
|
||||||
|
|
||||||
async def migrate_extension_database(
|
async def migrate_extension_database(
|
||||||
ext: InstallableExtension, current_version: DbVersion | None = None
|
ext: InstallableExtension, current_version: Optional[DbVersion] = None
|
||||||
):
|
):
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -38,7 +38,7 @@ async def run_migration(
|
|||||||
db: Connection,
|
db: Connection,
|
||||||
migrations_module: Any,
|
migrations_module: Any,
|
||||||
db_name: str,
|
db_name: str,
|
||||||
current_version: DbVersion | None = None,
|
current_version: Optional[DbVersion] = None,
|
||||||
):
|
):
|
||||||
matcher = re.compile(r"^m(\d\d\d)_")
|
matcher = re.compile(r"^m(\d\d\d)_")
|
||||||
|
|
||||||
|
|||||||
@@ -214,7 +214,6 @@ async def m007_set_invoice_expiries(db: Connection):
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
# Timestamp placeholder is safe from SQL injection (not user input)
|
|
||||||
f"""
|
f"""
|
||||||
SELECT bolt11, checking_id
|
SELECT bolt11, checking_id
|
||||||
FROM apipayments
|
FROM apipayments
|
||||||
@@ -223,7 +222,7 @@ async def m007_set_invoice_expiries(db: Connection):
|
|||||||
AND bolt11 IS NOT NULL
|
AND bolt11 IS NOT NULL
|
||||||
AND expiry IS NULL
|
AND expiry IS NULL
|
||||||
AND time < {db.timestamp_now}
|
AND time < {db.timestamp_now}
|
||||||
""" # noqa: S608
|
"""
|
||||||
)
|
)
|
||||||
rows = result.mappings().all()
|
rows = result.mappings().all()
|
||||||
if len(rows):
|
if len(rows):
|
||||||
@@ -243,15 +242,13 @@ async def m007_set_invoice_expiries(db: Connection):
|
|||||||
f" {invoice.payment_hash} to {expiration_date}"
|
f" {invoice.payment_hash} to {expiration_date}"
|
||||||
)
|
)
|
||||||
await db.execute(
|
await db.execute(
|
||||||
# Timestamp placeholder is safe from SQL injection (not user input)
|
|
||||||
f"""
|
f"""
|
||||||
UPDATE apipayments SET expiry = {db.timestamp_placeholder('expiry')}
|
UPDATE apipayments SET expiry = {db.timestamp_placeholder('expiry')}
|
||||||
WHERE checking_id = :checking_id AND amount > 0
|
WHERE checking_id = :checking_id AND amount > 0
|
||||||
""", # noqa: S608
|
""",
|
||||||
{"expiry": expiration_date, "checking_id": checking_id},
|
{"expiry": expiration_date, "checking_id": checking_id},
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception:
|
||||||
logger.debug(exc)
|
|
||||||
continue
|
continue
|
||||||
except OperationalError:
|
except OperationalError:
|
||||||
# this is necessary now because it may be the case that this migration will
|
# this is necessary now because it may be the case that this migration will
|
||||||
@@ -374,8 +371,7 @@ async def m014_set_deleted_wallets(db: Connection):
|
|||||||
"wallet": row.get("id"),
|
"wallet": row.get("id"),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception:
|
||||||
logger.debug(exc)
|
|
||||||
continue
|
continue
|
||||||
except OperationalError:
|
except OperationalError:
|
||||||
# this is necessary now because it may be the case that this migration will
|
# this is necessary now because it may be the case that this migration will
|
||||||
@@ -458,19 +454,17 @@ async def m017_add_timestamp_columns_to_accounts_and_wallets(db: Connection):
|
|||||||
# set all to now where they are null
|
# set all to now where they are null
|
||||||
now = int(time())
|
now = int(time())
|
||||||
await db.execute(
|
await db.execute(
|
||||||
# Timestamp placeholder is safe from SQL injection (not user input)
|
|
||||||
f"""
|
f"""
|
||||||
UPDATE wallets SET created_at = {db.timestamp_placeholder('now')}
|
UPDATE wallets SET created_at = {db.timestamp_placeholder('now')}
|
||||||
WHERE created_at IS NULL
|
WHERE created_at IS NULL
|
||||||
""", # noqa: S608
|
""",
|
||||||
{"now": now},
|
{"now": now},
|
||||||
)
|
)
|
||||||
await db.execute(
|
await db.execute(
|
||||||
# Timestamp placeholder is safe from SQL injection (not user input)
|
|
||||||
f"""
|
f"""
|
||||||
UPDATE accounts SET created_at = {db.timestamp_placeholder('now')}
|
UPDATE accounts SET created_at = {db.timestamp_placeholder('now')}
|
||||||
WHERE created_at IS NULL
|
WHERE created_at IS NULL
|
||||||
""", # noqa: S608
|
""",
|
||||||
{"now": now},
|
{"now": now},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -622,12 +616,7 @@ async def m027_update_apipayments_data(db: Connection):
|
|||||||
logger.info(f"Updating {offset} to {offset+limit}")
|
logger.info(f"Updating {offset} to {offset+limit}")
|
||||||
|
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
# Limit and Offset safe from SQL injection
|
f"SELECT * FROM apipayments ORDER BY time LIMIT {limit} OFFSET {offset}"
|
||||||
# since they are integers and are not user input
|
|
||||||
f"""
|
|
||||||
SELECT * FROM apipayments
|
|
||||||
ORDER BY time LIMIT {int(limit)} OFFSET {int(offset)}
|
|
||||||
""" # noqa: S608
|
|
||||||
)
|
)
|
||||||
payments = result.mappings().all()
|
payments = result.mappings().all()
|
||||||
logger.info(f"Payments count: {len(payments)}")
|
logger.info(f"Payments count: {len(payments)}")
|
||||||
@@ -640,12 +629,11 @@ async def m027_update_apipayments_data(db: Connection):
|
|||||||
tag = extra.get("tag")
|
tag = extra.get("tag")
|
||||||
tsph = db.timestamp_placeholder("created_at")
|
tsph = db.timestamp_placeholder("created_at")
|
||||||
await db.execute(
|
await db.execute(
|
||||||
# Timestamp placeholder is safe from SQL injection (not user input)
|
|
||||||
f"""
|
f"""
|
||||||
UPDATE apipayments
|
UPDATE apipayments
|
||||||
SET tag = :tag, created_at = {tsph}, updated_at = {tsph}
|
SET tag = :tag, created_at = {tsph}, updated_at = {tsph}
|
||||||
WHERE checking_id = :checking_id
|
WHERE checking_id = :checking_id
|
||||||
""", # noqa: S608
|
""",
|
||||||
{
|
{
|
||||||
"tag": tag,
|
"tag": tag,
|
||||||
"created_at": created_at,
|
"created_at": created_at,
|
||||||
@@ -723,23 +711,3 @@ async def m031_add_color_and_icon_to_wallets(db: Connection):
|
|||||||
Adds icon and color columns to wallets.
|
Adds icon and color columns to wallets.
|
||||||
"""
|
"""
|
||||||
await db.execute("ALTER TABLE wallets ADD COLUMN extra TEXT")
|
await db.execute("ALTER TABLE wallets ADD COLUMN extra TEXT")
|
||||||
|
|
||||||
|
|
||||||
async def m032_add_external_id_to_accounts(db: Connection):
|
|
||||||
"""
|
|
||||||
Adds external_id column to accounts.
|
|
||||||
Used for external account linking.
|
|
||||||
"""
|
|
||||||
await db.execute("ALTER TABLE accounts ADD COLUMN external_id TEXT")
|
|
||||||
|
|
||||||
|
|
||||||
async def m033_update_payment_table(db: Connection):
|
|
||||||
await db.execute("ALTER TABLE apipayments ADD COLUMN fiat_provider TEXT")
|
|
||||||
|
|
||||||
|
|
||||||
async def m034_add_stored_paylinks_to_wallet(db: Connection):
|
|
||||||
await db.execute(
|
|
||||||
"""
|
|
||||||
ALTER TABLE wallets ADD COLUMN stored_paylinks TEXT
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from .audit import AuditEntry, AuditFilters
|
from .audit import AuditEntry, AuditFilters
|
||||||
from .lnurl import CreateLnurlPayment, CreateLnurlWithdraw
|
from .lnurl import CreateLnurl, CreateLnurlAuth, PayLnurlWData
|
||||||
from .misc import (
|
from .misc import (
|
||||||
BalanceDelta,
|
BalanceDelta,
|
||||||
Callback,
|
Callback,
|
||||||
@@ -9,7 +9,6 @@ from .misc import (
|
|||||||
SimpleStatus,
|
SimpleStatus,
|
||||||
)
|
)
|
||||||
from .payments import (
|
from .payments import (
|
||||||
CancelInvoice,
|
|
||||||
CreateInvoice,
|
CreateInvoice,
|
||||||
CreatePayment,
|
CreatePayment,
|
||||||
DecodePayment,
|
DecodePayment,
|
||||||
@@ -24,7 +23,6 @@ from .payments import (
|
|||||||
PaymentsStatusCount,
|
PaymentsStatusCount,
|
||||||
PaymentState,
|
PaymentState,
|
||||||
PaymentWalletStats,
|
PaymentWalletStats,
|
||||||
SettleInvoice,
|
|
||||||
)
|
)
|
||||||
from .tinyurl import TinyURL
|
from .tinyurl import TinyURL
|
||||||
from .users import (
|
from .users import (
|
||||||
@@ -50,55 +48,62 @@ from .wallets import BaseWallet, CreateWallet, KeyType, Wallet, WalletTypeInfo
|
|||||||
from .webpush import CreateWebPushSubscription, WebPushSubscription
|
from .webpush import CreateWebPushSubscription, WebPushSubscription
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"AccessTokenPayload",
|
# audit
|
||||||
"Account",
|
|
||||||
"AccountFilters",
|
|
||||||
"AccountOverview",
|
|
||||||
"AuditEntry",
|
"AuditEntry",
|
||||||
"AuditFilters",
|
"AuditFilters",
|
||||||
|
# lnurl
|
||||||
|
"CreateLnurl",
|
||||||
|
"CreateLnurlAuth",
|
||||||
|
"PayLnurlWData",
|
||||||
|
# misc
|
||||||
"BalanceDelta",
|
"BalanceDelta",
|
||||||
"BaseWallet",
|
|
||||||
"Callback",
|
"Callback",
|
||||||
"CancelInvoice",
|
|
||||||
"ConversionData",
|
"ConversionData",
|
||||||
"CoreAppExtra",
|
"CoreAppExtra",
|
||||||
"CreateInvoice",
|
|
||||||
"CreateLnurlPayment",
|
|
||||||
"CreateLnurlWithdraw",
|
|
||||||
"CreatePayment",
|
|
||||||
"CreateUser",
|
|
||||||
"CreateWallet",
|
|
||||||
"CreateWebPushSubscription",
|
|
||||||
"DbVersion",
|
"DbVersion",
|
||||||
|
"SimpleStatus",
|
||||||
|
# payments
|
||||||
|
"CreateInvoice",
|
||||||
|
"CreatePayment",
|
||||||
"DecodePayment",
|
"DecodePayment",
|
||||||
"KeyType",
|
|
||||||
"LoginUsernamePassword",
|
|
||||||
"LoginUsr",
|
|
||||||
"PayInvoice",
|
"PayInvoice",
|
||||||
"Payment",
|
"Payment",
|
||||||
"PaymentCountField",
|
"PaymentCountField",
|
||||||
"PaymentCountStat",
|
"PaymentCountStat",
|
||||||
"PaymentDailyStats",
|
"PaymentDailyStats",
|
||||||
|
"PaymentsStatusCount",
|
||||||
|
"PaymentWalletStats",
|
||||||
"PaymentExtra",
|
"PaymentExtra",
|
||||||
"PaymentFilters",
|
"PaymentFilters",
|
||||||
"PaymentHistoryPoint",
|
"PaymentHistoryPoint",
|
||||||
"PaymentState",
|
"PaymentState",
|
||||||
"PaymentWalletStats",
|
# tinyurl
|
||||||
"PaymentsStatusCount",
|
|
||||||
"RegisterUser",
|
|
||||||
"ResetUserPassword",
|
|
||||||
"SettleInvoice",
|
|
||||||
"SimpleStatus",
|
|
||||||
"TinyURL",
|
"TinyURL",
|
||||||
|
# users
|
||||||
|
"AccessTokenPayload",
|
||||||
|
"Account",
|
||||||
|
"AccountFilters",
|
||||||
|
"AccountOverview",
|
||||||
|
"UserAcls",
|
||||||
|
"CreateUser",
|
||||||
|
"RegisterUser",
|
||||||
|
"LoginUsernamePassword",
|
||||||
|
"LoginUsr",
|
||||||
|
"ResetUserPassword",
|
||||||
"UpdateBalance",
|
"UpdateBalance",
|
||||||
"UpdateSuperuserPassword",
|
"UpdateSuperuserPassword",
|
||||||
"UpdateUser",
|
"UpdateUser",
|
||||||
"UpdateUserPassword",
|
"UpdateUserPassword",
|
||||||
"UpdateUserPubkey",
|
"UpdateUserPubkey",
|
||||||
"User",
|
"User",
|
||||||
"UserAcls",
|
|
||||||
"UserExtra",
|
"UserExtra",
|
||||||
|
# wallets
|
||||||
|
"BaseWallet",
|
||||||
|
"CreateWallet",
|
||||||
|
"KeyType",
|
||||||
"Wallet",
|
"Wallet",
|
||||||
"WalletTypeInfo",
|
"WalletTypeInfo",
|
||||||
|
# webpush
|
||||||
|
"CreateWebPushSubscription",
|
||||||
"WebPushSubscription",
|
"WebPushSubscription",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ class ExplicitRelease(BaseModel):
|
|||||||
info_notification: str | None
|
info_notification: str | None
|
||||||
critical_notification: str | None
|
critical_notification: str | None
|
||||||
details_link: str | None
|
details_link: str | None
|
||||||
paid_features: str | None
|
|
||||||
pay_link: str | None
|
pay_link: str | None
|
||||||
|
|
||||||
def is_version_compatible(self):
|
def is_version_compatible(self):
|
||||||
@@ -188,7 +187,6 @@ class ExtensionRelease(BaseModel):
|
|||||||
icon: str | None = None
|
icon: str | None = None
|
||||||
details_link: str | None = None
|
details_link: str | None = None
|
||||||
|
|
||||||
paid_features: str | None = None
|
|
||||||
pay_link: str | None = None
|
pay_link: str | None = None
|
||||||
cost_sats: int | None = None
|
cost_sats: int | None = None
|
||||||
paid_sats: int | None = 0
|
paid_sats: int | None = 0
|
||||||
@@ -213,8 +211,7 @@ class ExtensionRelease(BaseModel):
|
|||||||
self, amount: int | None = None
|
self, amount: int | None = None
|
||||||
) -> ReleasePaymentInfo | None:
|
) -> ReleasePaymentInfo | None:
|
||||||
url = f"{self.pay_link}?amount={amount}" if amount else self.pay_link
|
url = f"{self.pay_link}?amount={amount}" if amount else self.pay_link
|
||||||
if not url:
|
assert url, "Missing URL for payment info."
|
||||||
raise ValueError("Missing URL for payment info.")
|
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
resp = await client.get(url)
|
resp = await client.get(url)
|
||||||
@@ -258,7 +255,6 @@ class ExtensionRelease(BaseModel):
|
|||||||
html_url=e.html_url,
|
html_url=e.html_url,
|
||||||
details_link=e.details_link,
|
details_link=e.details_link,
|
||||||
pay_link=e.pay_link,
|
pay_link=e.pay_link,
|
||||||
paid_features=e.paid_features,
|
|
||||||
repo=e.repo,
|
repo=e.repo,
|
||||||
icon=e.icon,
|
icon=e.icon,
|
||||||
)
|
)
|
||||||
@@ -311,9 +307,6 @@ class ExtensionMeta(BaseModel):
|
|||||||
dependencies: list[str] = []
|
dependencies: list[str] = []
|
||||||
archive: str | None = None
|
archive: str | None = None
|
||||||
featured: bool = False
|
featured: bool = False
|
||||||
paid_features: str | None = None
|
|
||||||
has_paid_release: bool = False
|
|
||||||
has_free_release: bool = False
|
|
||||||
|
|
||||||
|
|
||||||
class InstallableExtension(BaseModel):
|
class InstallableExtension(BaseModel):
|
||||||
@@ -383,8 +376,9 @@ class InstallableExtension(BaseModel):
|
|||||||
if ext_zip_file.is_file():
|
if ext_zip_file.is_file():
|
||||||
os.remove(ext_zip_file)
|
os.remove(ext_zip_file)
|
||||||
try:
|
try:
|
||||||
if not self.meta or not self.meta.installed_release:
|
assert (
|
||||||
raise ValueError("No installed release.")
|
self.meta and self.meta.installed_release
|
||||||
|
), "installed_release is none."
|
||||||
|
|
||||||
self._restore_payment_info()
|
self._restore_payment_info()
|
||||||
|
|
||||||
@@ -415,6 +409,7 @@ class InstallableExtension(BaseModel):
|
|||||||
|
|
||||||
tmp_dir = Path(settings.lnbits_data_folder, "unzip-temp", self.hash)
|
tmp_dir = Path(settings.lnbits_data_folder, "unzip-temp", self.hash)
|
||||||
shutil.rmtree(tmp_dir, True)
|
shutil.rmtree(tmp_dir, True)
|
||||||
|
|
||||||
with zipfile.ZipFile(self.zip_path, "r") as zip_ref:
|
with zipfile.ZipFile(self.zip_path, "r") as zip_ref:
|
||||||
zip_ref.extractall(tmp_dir)
|
zip_ref.extractall(tmp_dir)
|
||||||
generated_dir_name = os.listdir(tmp_dir)[0]
|
generated_dir_name = os.listdir(tmp_dir)[0]
|
||||||
@@ -457,23 +452,9 @@ class InstallableExtension(BaseModel):
|
|||||||
|
|
||||||
shutil.rmtree(self.ext_upgrade_dir, True)
|
shutil.rmtree(self.ext_upgrade_dir, True)
|
||||||
|
|
||||||
def check_release_updates(self, release: ExtensionRelease | None):
|
def check_latest_version(self, release: ExtensionRelease | None):
|
||||||
self._check_latest_version(release)
|
|
||||||
self._check_payment_link(release)
|
|
||||||
|
|
||||||
def find_existing_payment(self, pay_link: str | None) -> ReleasePaymentInfo | None:
|
|
||||||
if not pay_link or not self.meta or not self.meta.payments:
|
|
||||||
return None
|
|
||||||
return next(
|
|
||||||
(p for p in self.meta.payments if p.pay_link == pay_link),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _check_latest_version(self, release: ExtensionRelease | None):
|
|
||||||
if not release:
|
if not release:
|
||||||
return
|
return
|
||||||
if not release.is_version_compatible:
|
|
||||||
return
|
|
||||||
if not self.meta or not self.meta.latest_release:
|
if not self.meta or not self.meta.latest_release:
|
||||||
meta = self.meta or ExtensionMeta()
|
meta = self.meta or ExtensionMeta()
|
||||||
meta.latest_release = release
|
meta.latest_release = release
|
||||||
@@ -484,19 +465,13 @@ class InstallableExtension(BaseModel):
|
|||||||
):
|
):
|
||||||
self.meta.latest_release = release
|
self.meta.latest_release = release
|
||||||
|
|
||||||
def _check_payment_link(self, release: ExtensionRelease | None):
|
def find_existing_payment(self, pay_link: str | None) -> ReleasePaymentInfo | None:
|
||||||
if not release:
|
if not pay_link or not self.meta or not self.meta.payments:
|
||||||
return
|
return None
|
||||||
if not release.is_version_compatible:
|
return next(
|
||||||
return
|
(p for p in self.meta.payments if p.pay_link == pay_link),
|
||||||
if not self.meta:
|
None,
|
||||||
self.meta = ExtensionMeta()
|
)
|
||||||
if release.pay_link:
|
|
||||||
self.meta.has_paid_release = True
|
|
||||||
else:
|
|
||||||
self.meta.has_free_release = True
|
|
||||||
if release.paid_features:
|
|
||||||
self.meta.paid_features = release.paid_features
|
|
||||||
|
|
||||||
def _restore_payment_info(self):
|
def _restore_payment_info(self):
|
||||||
if (
|
if (
|
||||||
@@ -622,7 +597,7 @@ class InstallableExtension(BaseModel):
|
|||||||
(ee for ee in extension_list if ee.id == r.id), None
|
(ee for ee in extension_list if ee.id == r.id), None
|
||||||
)
|
)
|
||||||
if existing_ext and ext.meta:
|
if existing_ext and ext.meta:
|
||||||
existing_ext.check_release_updates(ext.meta.latest_release)
|
existing_ext.check_latest_version(ext.meta.latest_release)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
meta = ext.meta or ExtensionMeta()
|
meta = ext.meta or ExtensionMeta()
|
||||||
@@ -636,10 +611,10 @@ class InstallableExtension(BaseModel):
|
|||||||
(ee for ee in extension_list if ee.id == e.id), None
|
(ee for ee in extension_list if ee.id == e.id), None
|
||||||
)
|
)
|
||||||
if existing_ext:
|
if existing_ext:
|
||||||
existing_ext.check_release_updates(release)
|
existing_ext.check_latest_version(release)
|
||||||
continue
|
continue
|
||||||
ext = InstallableExtension.from_explicit_release(e)
|
ext = InstallableExtension.from_explicit_release(e)
|
||||||
ext.check_release_updates(release)
|
ext.check_latest_version(release)
|
||||||
meta = ext.meta or ExtensionMeta()
|
meta = ext.meta or ExtensionMeta()
|
||||||
meta.featured = ext.id in manifest.featured
|
meta.featured = ext.id in manifest.featured
|
||||||
ext.meta = meta
|
ext.meta = meta
|
||||||
@@ -653,11 +628,8 @@ class InstallableExtension(BaseModel):
|
|||||||
@classmethod
|
@classmethod
|
||||||
async def get_extension_releases(cls, ext_id: str) -> list[ExtensionRelease]:
|
async def get_extension_releases(cls, ext_id: str) -> list[ExtensionRelease]:
|
||||||
extension_releases: list[ExtensionRelease] = []
|
extension_releases: list[ExtensionRelease] = []
|
||||||
all_manifests = [
|
|
||||||
*settings.lnbits_extensions_manifests,
|
for url in settings.lnbits_extensions_manifests:
|
||||||
settings.lnbits_extensions_builder_manifest_url,
|
|
||||||
]
|
|
||||||
for url in all_manifests:
|
|
||||||
try:
|
try:
|
||||||
manifest = await cls.fetch_manifest(url)
|
manifest = await cls.fetch_manifest(url)
|
||||||
for r in manifest.repos:
|
for r in manifest.repos:
|
||||||
|
|||||||
@@ -1,460 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
from typing import Any, Literal
|
|
||||||
|
|
||||||
from pydantic import BaseModel, validator
|
|
||||||
|
|
||||||
from lnbits.helpers import (
|
|
||||||
camel_to_snake,
|
|
||||||
is_camel_case,
|
|
||||||
is_snake_case,
|
|
||||||
urlsafe_short_hash,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class DataField(BaseModel):
|
|
||||||
name: str
|
|
||||||
type: str
|
|
||||||
label: str | None = None
|
|
||||||
hint: str | None = None
|
|
||||||
optional: bool = False
|
|
||||||
editable: bool = False
|
|
||||||
searchable: bool = False
|
|
||||||
sortable: bool = False
|
|
||||||
fields: list[DataField] = []
|
|
||||||
|
|
||||||
def normalize(self) -> None:
|
|
||||||
self.name = self.name.strip()
|
|
||||||
self.type = self.type.strip()
|
|
||||||
if self.label:
|
|
||||||
self.label = self.label.strip()
|
|
||||||
if self.hint:
|
|
||||||
self.hint = self.hint.strip()
|
|
||||||
if self.type == "json":
|
|
||||||
self.editable = False
|
|
||||||
self.searchable = False
|
|
||||||
self.sortable = False
|
|
||||||
else:
|
|
||||||
self.fields = []
|
|
||||||
|
|
||||||
for field in self.fields:
|
|
||||||
field.normalize()
|
|
||||||
|
|
||||||
def field_to_py(self) -> str:
|
|
||||||
field_name = camel_to_snake(self.name)
|
|
||||||
field_type = self.type
|
|
||||||
if self.type == "json":
|
|
||||||
field_type = "dict"
|
|
||||||
elif self.type in ["wallet", "currency", "text"]:
|
|
||||||
field_type = "str"
|
|
||||||
if self.optional:
|
|
||||||
field_type += " | None"
|
|
||||||
if self.type == "currency":
|
|
||||||
field_type += ' = "sat"'
|
|
||||||
return f"{field_name}: {field_type}"
|
|
||||||
|
|
||||||
def field_to_js(self) -> str:
|
|
||||||
field_name = camel_to_snake(self.name)
|
|
||||||
default_value = "null"
|
|
||||||
if self.type == "json":
|
|
||||||
default_value = "{}"
|
|
||||||
if self.type == "currency":
|
|
||||||
default_value = '"sat"'
|
|
||||||
return f"{field_name}: {default_value}"
|
|
||||||
|
|
||||||
def field_to_ui_table_column(self) -> str:
|
|
||||||
column = {
|
|
||||||
"name": self.name,
|
|
||||||
"align": "left",
|
|
||||||
"label": self.label or self.name,
|
|
||||||
"field": self.name,
|
|
||||||
"sortable": self.sortable,
|
|
||||||
}
|
|
||||||
|
|
||||||
return json.dumps(column)
|
|
||||||
|
|
||||||
def field_to_db(self) -> str:
|
|
||||||
field_name = camel_to_snake(self.name)
|
|
||||||
field_type = self.type
|
|
||||||
if field_type == "str":
|
|
||||||
db_type = "TEXT"
|
|
||||||
elif field_type == "int":
|
|
||||||
db_type = "INT"
|
|
||||||
elif field_type == "float":
|
|
||||||
db_type = "REAL"
|
|
||||||
elif field_type == "bool":
|
|
||||||
db_type = "BOOLEAN"
|
|
||||||
elif field_type == "datetime":
|
|
||||||
db_type = "TIMESTAMP"
|
|
||||||
else:
|
|
||||||
db_type = "TEXT"
|
|
||||||
|
|
||||||
db_field = f"{field_name} {db_type}"
|
|
||||||
if not self.optional:
|
|
||||||
db_field += " NOT NULL"
|
|
||||||
if field_type == "json":
|
|
||||||
db_field += " DEFAULT '{empty_dict}'"
|
|
||||||
return db_field
|
|
||||||
|
|
||||||
def field_mock_value(self, index: int) -> Any:
|
|
||||||
if self.name == "id":
|
|
||||||
return urlsafe_short_hash()
|
|
||||||
if self.type == "int":
|
|
||||||
return index
|
|
||||||
elif self.type == "float":
|
|
||||||
return float(f"{index}.0{index * 2}")
|
|
||||||
elif self.type == "bool":
|
|
||||||
return True if index % 2 == 0 else False
|
|
||||||
elif self.type == "datetime":
|
|
||||||
return (datetime.now(timezone.utc) - timedelta(hours=index * 2)).isoformat()
|
|
||||||
elif self.type == "json":
|
|
||||||
return {"key": "value"}
|
|
||||||
elif self.type == "currency":
|
|
||||||
return "USD"
|
|
||||||
else:
|
|
||||||
return f"{self.name} {index}"
|
|
||||||
|
|
||||||
@validator("name")
|
|
||||||
def validate_name(cls, v: str) -> str:
|
|
||||||
if v.strip() == "":
|
|
||||||
raise ValueError("Field name is required.")
|
|
||||||
if not is_snake_case(v):
|
|
||||||
raise ValueError(f"Field Name must be snake_case. Found: {v}")
|
|
||||||
return v
|
|
||||||
|
|
||||||
@validator("type")
|
|
||||||
def validate_type(cls, v: str) -> str:
|
|
||||||
if v.strip() == "":
|
|
||||||
raise ValueError("Owner Data type is required")
|
|
||||||
if v not in [
|
|
||||||
"str",
|
|
||||||
"int",
|
|
||||||
"float",
|
|
||||||
"bool",
|
|
||||||
"datetime",
|
|
||||||
"json",
|
|
||||||
"wallet",
|
|
||||||
"currency",
|
|
||||||
"text",
|
|
||||||
]:
|
|
||||||
raise ValueError(
|
|
||||||
"Field Type must be one of: "
|
|
||||||
"str, int, float, bool, datetime, json, wallet, currency, text."
|
|
||||||
f" Found: {v}"
|
|
||||||
)
|
|
||||||
return v
|
|
||||||
|
|
||||||
@validator("label")
|
|
||||||
def validate_label(cls, v: str | None) -> str | None:
|
|
||||||
if v and '"' in v:
|
|
||||||
raise ValueError(
|
|
||||||
f'Field label cannot contain double quotes ("). Value: {v}'
|
|
||||||
)
|
|
||||||
return v
|
|
||||||
|
|
||||||
@validator("hint")
|
|
||||||
def validate_hint(cls, v: str | None) -> str | None:
|
|
||||||
if v and '"' in v:
|
|
||||||
raise ValueError(f'Field hint cannot contain double quotes ("). Value: {v}')
|
|
||||||
return v
|
|
||||||
|
|
||||||
|
|
||||||
class DataFields(BaseModel):
|
|
||||||
name: str
|
|
||||||
editable: bool = True
|
|
||||||
fields: list[DataField] = []
|
|
||||||
|
|
||||||
def __init__(self, **data):
|
|
||||||
super().__init__(**data)
|
|
||||||
self.normalize()
|
|
||||||
|
|
||||||
def normalize(self) -> None:
|
|
||||||
self.name = self.name.strip()
|
|
||||||
for field in self.fields:
|
|
||||||
field.normalize()
|
|
||||||
if all(not field.editable for field in self.fields):
|
|
||||||
self.editable = False
|
|
||||||
|
|
||||||
def get_field_by_name(self, name: str | None) -> DataField | None:
|
|
||||||
if not name:
|
|
||||||
return None
|
|
||||||
for field in self.fields:
|
|
||||||
if field.name == name:
|
|
||||||
return field
|
|
||||||
return None
|
|
||||||
|
|
||||||
@validator("name")
|
|
||||||
def validate_name(cls, v: str) -> str:
|
|
||||||
if v.strip() == "":
|
|
||||||
raise ValueError("Data fields name is required")
|
|
||||||
if not is_camel_case(v):
|
|
||||||
raise ValueError(f"Data name must be CamelCase. Found: {v}")
|
|
||||||
return v
|
|
||||||
|
|
||||||
|
|
||||||
class SettingsFields(DataFields):
|
|
||||||
enabled: bool = False
|
|
||||||
type: str = "user"
|
|
||||||
|
|
||||||
@validator("type")
|
|
||||||
def validate_type(cls, v: str) -> str:
|
|
||||||
if v.strip() == "":
|
|
||||||
raise ValueError("Settings type is required")
|
|
||||||
if v not in ["user", "admin"]:
|
|
||||||
raise ValueError("Field Type must be one of: user, admin." f" Found: {v}")
|
|
||||||
return v
|
|
||||||
|
|
||||||
|
|
||||||
class ActionFields(BaseModel):
|
|
||||||
generate_action: bool = False
|
|
||||||
generate_payment_logic: bool = False
|
|
||||||
wallet_id: str | None = None
|
|
||||||
currency: str | None = None
|
|
||||||
amount: str | None = None
|
|
||||||
amount_source: Literal["owner_data", "client_data"] | None = None
|
|
||||||
paid_flag: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class OwnerDataFields(BaseModel):
|
|
||||||
name: str | None = None
|
|
||||||
description: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class ClientDataFields(BaseModel):
|
|
||||||
public_inputs: list[str] = []
|
|
||||||
|
|
||||||
|
|
||||||
class PublicPageFields(BaseModel):
|
|
||||||
has_public_page: bool = False
|
|
||||||
owner_data_fields: OwnerDataFields
|
|
||||||
client_data_fields: ClientDataFields
|
|
||||||
action_fields: ActionFields
|
|
||||||
|
|
||||||
|
|
||||||
class PreviewAction(BaseModel):
|
|
||||||
is_preview_mode: bool = False
|
|
||||||
is_settings_preview: bool = False
|
|
||||||
is_owner_data_preview: bool = False
|
|
||||||
is_client_data_preview: bool = False
|
|
||||||
is_public_page_preview: bool = False
|
|
||||||
|
|
||||||
def __init__(self, **data):
|
|
||||||
super().__init__(**data)
|
|
||||||
if not self.is_preview_mode:
|
|
||||||
self.is_settings_preview = False
|
|
||||||
self.is_owner_data_preview = False
|
|
||||||
self.is_client_data_preview = False
|
|
||||||
self.is_public_page_preview = False
|
|
||||||
|
|
||||||
|
|
||||||
class ExtensionData(BaseModel):
|
|
||||||
id: str
|
|
||||||
name: str
|
|
||||||
stub_version: str | None
|
|
||||||
short_description: str | None = None
|
|
||||||
description: str | None = None
|
|
||||||
owner_data: DataFields
|
|
||||||
client_data: DataFields
|
|
||||||
settings_data: SettingsFields
|
|
||||||
public_page: PublicPageFields
|
|
||||||
preview_action: PreviewAction = PreviewAction()
|
|
||||||
|
|
||||||
def __init__(self, **data):
|
|
||||||
super().__init__(**data)
|
|
||||||
self.validate_data()
|
|
||||||
self.normalize()
|
|
||||||
|
|
||||||
def normalize(self) -> None:
|
|
||||||
self.id = self.id.strip()
|
|
||||||
self.name = self.name.strip()
|
|
||||||
if self.stub_version:
|
|
||||||
self.stub_version = self.stub_version.strip()
|
|
||||||
if self.short_description:
|
|
||||||
self.short_description = self.short_description.strip()
|
|
||||||
if self.description:
|
|
||||||
self.description = self.description.strip()
|
|
||||||
if not self.public_page.has_public_page:
|
|
||||||
self.public_page.action_fields.generate_action = False
|
|
||||||
self.public_page.action_fields.generate_payment_logic = False
|
|
||||||
if not self.public_page.action_fields.generate_action:
|
|
||||||
self.public_page.action_fields.generate_payment_logic = False
|
|
||||||
|
|
||||||
def validate_data(self) -> None:
|
|
||||||
self._validate_field_names()
|
|
||||||
self._validate_public_page_fields()
|
|
||||||
self._validate_action_fields()
|
|
||||||
|
|
||||||
def _validate_public_page_fields(self) -> None:
|
|
||||||
if not self.public_page.has_public_page:
|
|
||||||
return
|
|
||||||
|
|
||||||
public_page_name = self.public_page.owner_data_fields.name
|
|
||||||
if public_page_name:
|
|
||||||
public_page_name_field = self.owner_data.get_field_by_name(public_page_name)
|
|
||||||
if not public_page_name_field:
|
|
||||||
raise ValueError(
|
|
||||||
"Public Page Name must be one of the owner data fields."
|
|
||||||
f" Received: {public_page_name}."
|
|
||||||
)
|
|
||||||
|
|
||||||
public_page_description = self.public_page.owner_data_fields.description
|
|
||||||
if public_page_description:
|
|
||||||
public_page_description_field = self.owner_data.get_field_by_name(
|
|
||||||
public_page_description
|
|
||||||
)
|
|
||||||
if not public_page_description_field:
|
|
||||||
raise ValueError(
|
|
||||||
"Public Page Description must be one of the owner data fields."
|
|
||||||
f" Received: {public_page_description}."
|
|
||||||
)
|
|
||||||
|
|
||||||
public_page_inputs = self.public_page.client_data_fields.public_inputs
|
|
||||||
if public_page_inputs:
|
|
||||||
for input_field in public_page_inputs:
|
|
||||||
input_field_obj = self.client_data.get_field_by_name(input_field)
|
|
||||||
if not input_field_obj:
|
|
||||||
raise ValueError(
|
|
||||||
"Public Page Input fields"
|
|
||||||
" must be one of the client data fields."
|
|
||||||
f" Received: {input_field}."
|
|
||||||
)
|
|
||||||
|
|
||||||
def _validate_action_fields(self) -> None:
|
|
||||||
if not self.public_page.action_fields.generate_action:
|
|
||||||
return
|
|
||||||
if not self.public_page.action_fields.generate_payment_logic:
|
|
||||||
return
|
|
||||||
|
|
||||||
self._validate_owner_data_fields()
|
|
||||||
self._validate_client_data_fields()
|
|
||||||
|
|
||||||
def _validate_owner_data_fields(self) -> None:
|
|
||||||
wallet_id = self.public_page.action_fields.wallet_id
|
|
||||||
if wallet_id:
|
|
||||||
wallet_id_field = self.owner_data.get_field_by_name(wallet_id)
|
|
||||||
if not wallet_id_field:
|
|
||||||
raise ValueError(
|
|
||||||
"Action Wallet ID must be one of the owner data fields."
|
|
||||||
f" Received: {wallet_id}."
|
|
||||||
)
|
|
||||||
if wallet_id_field.type != "wallet":
|
|
||||||
raise ValueError(
|
|
||||||
"Action Wallet ID field type must be 'wallet'."
|
|
||||||
f" Received: {wallet_id_field.type}."
|
|
||||||
)
|
|
||||||
currency = self.public_page.action_fields.currency
|
|
||||||
if currency:
|
|
||||||
currency_field = self.owner_data.get_field_by_name(currency)
|
|
||||||
if not currency_field:
|
|
||||||
raise ValueError(
|
|
||||||
"Action Currency must be one of the owner data fields."
|
|
||||||
f" Received: {currency}."
|
|
||||||
)
|
|
||||||
if currency_field.type != "currency":
|
|
||||||
raise ValueError(
|
|
||||||
"Action Currency field type must be 'currency'."
|
|
||||||
f" Received: {currency_field.type}."
|
|
||||||
)
|
|
||||||
|
|
||||||
def _validate_field_names(self) -> None:
|
|
||||||
reserved_names = {"id", "created_at", "updated_at"}
|
|
||||||
nok = {f.name for f in self.owner_data.fields}.intersection(reserved_names)
|
|
||||||
if nok:
|
|
||||||
raise ValueError(
|
|
||||||
f"Owner Data fields cannot have reserved names: '{', '.join(nok)}.'"
|
|
||||||
)
|
|
||||||
nok = {f.name for f in self.client_data.fields}.intersection(reserved_names)
|
|
||||||
if nok:
|
|
||||||
raise ValueError(
|
|
||||||
f"Client Data fields cannot have reserved names: '{', '.join(nok)}.'"
|
|
||||||
)
|
|
||||||
nok = {f.name for f in self.settings_data.fields}.intersection(reserved_names)
|
|
||||||
if nok:
|
|
||||||
raise ValueError(
|
|
||||||
f"Settings fields cannot have reserved names: '{', '.join(nok)}.'"
|
|
||||||
)
|
|
||||||
|
|
||||||
def _validate_client_data_fields(self) -> None:
|
|
||||||
amount = self.public_page.action_fields.amount
|
|
||||||
amount_source = self.public_page.action_fields.amount_source
|
|
||||||
if amount_source and amount:
|
|
||||||
if amount_source == "owner_data":
|
|
||||||
amount_field = self.owner_data.get_field_by_name(amount)
|
|
||||||
else:
|
|
||||||
amount_field = self.client_data.get_field_by_name(amount)
|
|
||||||
if not amount_field:
|
|
||||||
raise ValueError(
|
|
||||||
"Action Amount must be one of the "
|
|
||||||
"client data or owner data fields."
|
|
||||||
f" Received: {amount}."
|
|
||||||
)
|
|
||||||
if amount_field.type not in ["int", "float"]:
|
|
||||||
raise ValueError(
|
|
||||||
"Action Amount field type must be 'int' or 'float'."
|
|
||||||
f" Received: {amount_field.type}."
|
|
||||||
)
|
|
||||||
paid_flag = self.public_page.action_fields.paid_flag
|
|
||||||
if paid_flag:
|
|
||||||
paid_flag_field = self.client_data.get_field_by_name(paid_flag)
|
|
||||||
if not paid_flag_field:
|
|
||||||
raise ValueError(
|
|
||||||
"Action Paid Flag must be one of the client data fields."
|
|
||||||
f" Received: {paid_flag}."
|
|
||||||
)
|
|
||||||
if paid_flag_field.type != "bool":
|
|
||||||
raise ValueError(
|
|
||||||
"Action Paid Flag field type must be 'bool'."
|
|
||||||
f" Received: {paid_flag_field.type}."
|
|
||||||
)
|
|
||||||
|
|
||||||
@validator("id")
|
|
||||||
def validate_id(cls, v: str) -> str:
|
|
||||||
if v.strip() == "":
|
|
||||||
raise ValueError("Extension ID is required")
|
|
||||||
if not is_snake_case(v):
|
|
||||||
raise ValueError(f"Extension Id must be snake_case. Found: {v}")
|
|
||||||
return v
|
|
||||||
|
|
||||||
@validator("name")
|
|
||||||
def validate_name(cls, v: str) -> str:
|
|
||||||
if v.strip() == "":
|
|
||||||
raise ValueError("Extension name is required")
|
|
||||||
return v
|
|
||||||
|
|
||||||
@validator("stub_version")
|
|
||||||
def validate_stub_version(cls, v: str | None) -> str | None:
|
|
||||||
if v and '"' in v:
|
|
||||||
raise ValueError(
|
|
||||||
f'Extension stub version cannot contain double quotes ("). Value: {v}'
|
|
||||||
)
|
|
||||||
return v
|
|
||||||
|
|
||||||
@validator("short_description")
|
|
||||||
def validate_short_description(cls, v: str | None) -> str | None:
|
|
||||||
if v and '"' in v:
|
|
||||||
raise ValueError(
|
|
||||||
f'Field short description cannot contain double quotes ("). Value: {v}'
|
|
||||||
)
|
|
||||||
return v
|
|
||||||
|
|
||||||
@validator("description")
|
|
||||||
def validate_description(cls, v: str | None) -> str | None:
|
|
||||||
if v and '"' in v:
|
|
||||||
raise ValueError(
|
|
||||||
f'Field description cannot contain double quotes ("). Value: {v}'
|
|
||||||
)
|
|
||||||
return v
|
|
||||||
|
|
||||||
@validator("owner_data")
|
|
||||||
def validate_owner_data(cls, v: DataFields) -> DataFields:
|
|
||||||
if len(v.fields) == 0:
|
|
||||||
raise ValueError("At least one owner data field is required")
|
|
||||||
return v
|
|
||||||
|
|
||||||
@validator("client_data")
|
|
||||||
def validate_client_data(cls, v: DataFields) -> DataFields:
|
|
||||||
if len(v.fields) == 0:
|
|
||||||
raise ValueError("At least one client data field is required")
|
|
||||||
return v
|
|
||||||
+12
-23
@@ -1,31 +1,20 @@
|
|||||||
from time import time
|
from typing import Optional
|
||||||
|
|
||||||
from lnurl import LnAddress, Lnurl, LnurlPayResponse
|
from pydantic import BaseModel
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
|
|
||||||
|
|
||||||
class CreateLnurlPayment(BaseModel):
|
class CreateLnurl(BaseModel):
|
||||||
res: LnurlPayResponse | None = None
|
description_hash: str
|
||||||
lnurl: Lnurl | LnAddress | None = None
|
callback: str
|
||||||
amount: int
|
amount: int
|
||||||
comment: str | None = None
|
comment: Optional[str] = None
|
||||||
unit: str | None = None
|
description: Optional[str] = None
|
||||||
internal_memo: str | None = None
|
unit: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class CreateLnurlWithdraw(BaseModel):
|
class CreateLnurlAuth(BaseModel):
|
||||||
lnurl_w: Lnurl
|
callback: str
|
||||||
|
|
||||||
|
|
||||||
class LnurlScan(BaseModel):
|
class PayLnurlWData(BaseModel):
|
||||||
lnurl: Lnurl | LnAddress
|
lnurl_w: str
|
||||||
|
|
||||||
|
|
||||||
class StoredPayLink(BaseModel):
|
|
||||||
lnurl: str
|
|
||||||
label: str
|
|
||||||
last_used: int = Field(default_factory=lambda: int(time()))
|
|
||||||
|
|
||||||
|
|
||||||
class StoredPayLinks(BaseModel):
|
|
||||||
links: list[StoredPayLink] = []
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Callable
|
from typing import Callable
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
@@ -46,8 +46,3 @@ class SimpleItem(BaseModel):
|
|||||||
class DbVersion(BaseModel):
|
class DbVersion(BaseModel):
|
||||||
db: str
|
db: str
|
||||||
version: int
|
version: int
|
||||||
|
|
||||||
|
|
||||||
class Image(BaseModel):
|
|
||||||
filename: str
|
|
||||||
directory: str = "library"
|
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ from enum import Enum
|
|||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from lnbits.core.models.users import UserNotifications
|
|
||||||
|
|
||||||
|
|
||||||
class NotificationType(Enum):
|
class NotificationType(Enum):
|
||||||
server_status = "server_status"
|
server_status = "server_status"
|
||||||
@@ -20,7 +18,6 @@ class NotificationType(Enum):
|
|||||||
class NotificationMessage(BaseModel):
|
class NotificationMessage(BaseModel):
|
||||||
message_type: NotificationType
|
message_type: NotificationType
|
||||||
values: dict
|
values: dict
|
||||||
user_notifications: UserNotifications | None = None
|
|
||||||
|
|
||||||
|
|
||||||
NOTIFICATION_TEMPLATES = {
|
NOTIFICATION_TEMPLATES = {
|
||||||
@@ -32,8 +29,7 @@ NOTIFICATION_TEMPLATES = {
|
|||||||
*In/Out payments*: `{in_payments_count}`/`{out_payments_count}`.
|
*In/Out payments*: `{in_payments_count}`/`{out_payments_count}`.
|
||||||
*Pending payments*: `{pending_payments_count}`.
|
*Pending payments*: `{pending_payments_count}`.
|
||||||
*Failed payments*: `{failed_payments_count}`.
|
*Failed payments*: `{failed_payments_count}`.
|
||||||
*LNbits balance*: `{lnbits_balance_sats}` sats.
|
*LNbits balance*: `{lnbits_balance_sats}` sats.""",
|
||||||
*Node balance*: `{node_balance_sats}` sats.""",
|
|
||||||
"server_start_stop": """*SERVER*
|
"server_start_stop": """*SERVER*
|
||||||
{message}
|
{message}
|
||||||
*Time*: `{up_time}` seconds.
|
*Time*: `{up_time}` seconds.
|
||||||
|
|||||||
@@ -5,17 +5,9 @@ from enum import Enum
|
|||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
from fastapi import Query
|
from fastapi import Query
|
||||||
from lnurl import LnurlWithdrawResponse
|
|
||||||
from pydantic import BaseModel, Field, validator
|
from pydantic import BaseModel, Field, validator
|
||||||
|
|
||||||
from lnbits.db import FilterModel
|
from lnbits.db import FilterModel
|
||||||
from lnbits.fiat import get_fiat_provider
|
|
||||||
from lnbits.fiat.base import (
|
|
||||||
FiatPaymentFailedStatus,
|
|
||||||
FiatPaymentPendingStatus,
|
|
||||||
FiatPaymentStatus,
|
|
||||||
FiatPaymentSuccessStatus,
|
|
||||||
)
|
|
||||||
from lnbits.utils.exchange_rates import allowed_currencies
|
from lnbits.utils.exchange_rates import allowed_currencies
|
||||||
from lnbits.wallets import get_funding_source
|
from lnbits.wallets import get_funding_source
|
||||||
from lnbits.wallets.base import (
|
from lnbits.wallets.base import (
|
||||||
@@ -68,13 +60,11 @@ class Payment(BaseModel):
|
|||||||
amount: int
|
amount: int
|
||||||
fee: int
|
fee: int
|
||||||
bolt11: str
|
bolt11: str
|
||||||
# payment_request: str | None
|
|
||||||
fiat_provider: str | None = None
|
|
||||||
status: str = PaymentState.PENDING
|
status: str = PaymentState.PENDING
|
||||||
memo: str | None = None
|
memo: str | None = None
|
||||||
expiry: datetime | None = None
|
expiry: datetime | None = None
|
||||||
webhook: str | None = None
|
webhook: str | None = None
|
||||||
webhook_status: str | None = None
|
webhook_status: int | None = None
|
||||||
preimage: str | None = None
|
preimage: str | None = None
|
||||||
tag: str | None = None
|
tag: str | None = None
|
||||||
extension: str | None = None
|
extension: str | None = None
|
||||||
@@ -117,23 +107,14 @@ class Payment(BaseModel):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def is_internal(self) -> bool:
|
def is_internal(self) -> bool:
|
||||||
return self.checking_id.startswith("internal_") or self.checking_id.startswith(
|
return self.checking_id.startswith("internal_")
|
||||||
"fiat_"
|
|
||||||
)
|
|
||||||
|
|
||||||
async def check_status(
|
async def check_status(self) -> PaymentStatus:
|
||||||
self, skip_internal_payment_notifications: bool | None = False
|
|
||||||
) -> PaymentStatus:
|
|
||||||
if self.is_internal:
|
if self.is_internal:
|
||||||
if self.success:
|
if self.success:
|
||||||
return PaymentSuccessStatus()
|
return PaymentSuccessStatus()
|
||||||
if self.failed:
|
if self.failed:
|
||||||
return PaymentFailedStatus()
|
return PaymentFailedStatus()
|
||||||
if self.is_in and self.fiat_provider:
|
|
||||||
fiat_status = await self.check_fiat_status(
|
|
||||||
skip_internal_payment_notifications
|
|
||||||
)
|
|
||||||
return PaymentStatus(paid=fiat_status.paid)
|
|
||||||
return PaymentPendingStatus()
|
return PaymentPendingStatus()
|
||||||
funding_source = get_funding_source()
|
funding_source = get_funding_source()
|
||||||
if self.is_out:
|
if self.is_out:
|
||||||
@@ -142,39 +123,6 @@ class Payment(BaseModel):
|
|||||||
status = await funding_source.get_invoice_status(self.checking_id)
|
status = await funding_source.get_invoice_status(self.checking_id)
|
||||||
return status
|
return status
|
||||||
|
|
||||||
async def check_fiat_status(
|
|
||||||
self, skip_internal_payment_notifications: bool | None = False
|
|
||||||
) -> FiatPaymentStatus:
|
|
||||||
if not self.is_internal:
|
|
||||||
return FiatPaymentPendingStatus()
|
|
||||||
if self.success:
|
|
||||||
return FiatPaymentSuccessStatus()
|
|
||||||
if self.failed:
|
|
||||||
return FiatPaymentFailedStatus()
|
|
||||||
|
|
||||||
if not self.fiat_provider:
|
|
||||||
return FiatPaymentPendingStatus()
|
|
||||||
|
|
||||||
checking_id = self.extra.get("fiat_checking_id")
|
|
||||||
if not checking_id:
|
|
||||||
return FiatPaymentPendingStatus()
|
|
||||||
|
|
||||||
fiat_provider = await get_fiat_provider(self.fiat_provider)
|
|
||||||
if not fiat_provider:
|
|
||||||
return FiatPaymentPendingStatus()
|
|
||||||
fiat_status = await fiat_provider.get_invoice_status(checking_id)
|
|
||||||
|
|
||||||
if skip_internal_payment_notifications:
|
|
||||||
return fiat_status
|
|
||||||
|
|
||||||
if fiat_status.success:
|
|
||||||
# notify receivers asynchronously
|
|
||||||
from lnbits.tasks import internal_invoice_queue
|
|
||||||
|
|
||||||
await internal_invoice_queue.put(self.checking_id)
|
|
||||||
|
|
||||||
return fiat_status
|
|
||||||
|
|
||||||
|
|
||||||
class PaymentFilters(FilterModel):
|
class PaymentFilters(FilterModel):
|
||||||
__search_fields__ = ["memo", "amount", "wallet_id", "tag", "status", "time"]
|
__search_fields__ = ["memo", "amount", "wallet_id", "tag", "status", "time"]
|
||||||
@@ -250,27 +198,14 @@ class CreateInvoice(BaseModel):
|
|||||||
internal: bool = False
|
internal: bool = False
|
||||||
out: bool = True
|
out: bool = True
|
||||||
amount: float = Query(None, ge=0)
|
amount: float = Query(None, ge=0)
|
||||||
memo: str | None = Query(None, max_length=640)
|
memo: str | None = None
|
||||||
description_hash: str | None = None
|
description_hash: str | None = None
|
||||||
unhashed_description: str | None = None
|
unhashed_description: str | None = None
|
||||||
payment_hash: str | None = Query(
|
|
||||||
None,
|
|
||||||
description="The payment hash of the hold invoice.",
|
|
||||||
min_length=64,
|
|
||||||
max_length=64,
|
|
||||||
)
|
|
||||||
expiry: int | None = None
|
expiry: int | None = None
|
||||||
extra: dict | None = None
|
extra: dict | None = None
|
||||||
webhook: str | None = None
|
webhook: str | None = None
|
||||||
bolt11: str | None = None
|
bolt11: str | None = None
|
||||||
lnurl_withdraw: LnurlWithdrawResponse | None = None
|
lnurl_callback: str | None = None
|
||||||
fiat_provider: str | None = None
|
|
||||||
|
|
||||||
@validator("payment_hash")
|
|
||||||
def check_hex(cls, v):
|
|
||||||
if v:
|
|
||||||
_ = bytes.fromhex(v)
|
|
||||||
return v
|
|
||||||
|
|
||||||
@validator("unit")
|
@validator("unit")
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -285,31 +220,3 @@ class PaymentsStatusCount(BaseModel):
|
|||||||
outgoing: int = 0
|
outgoing: int = 0
|
||||||
failed: int = 0
|
failed: int = 0
|
||||||
pending: int = 0
|
pending: int = 0
|
||||||
|
|
||||||
|
|
||||||
class SettleInvoice(BaseModel):
|
|
||||||
preimage: str = Field(
|
|
||||||
...,
|
|
||||||
description="The preimage of the payment hash to settle the invoice.",
|
|
||||||
min_length=64,
|
|
||||||
max_length=64,
|
|
||||||
)
|
|
||||||
|
|
||||||
@validator("preimage")
|
|
||||||
def check_hex(cls, v):
|
|
||||||
_ = bytes.fromhex(v)
|
|
||||||
return v
|
|
||||||
|
|
||||||
|
|
||||||
class CancelInvoice(BaseModel):
|
|
||||||
payment_hash: str = Field(
|
|
||||||
...,
|
|
||||||
description="The payment hash of the invoice to cancel.",
|
|
||||||
min_length=64,
|
|
||||||
max_length=64,
|
|
||||||
)
|
|
||||||
|
|
||||||
@validator("payment_hash")
|
|
||||||
def check_hex(cls, v):
|
|
||||||
_ = bytes.fromhex(v)
|
|
||||||
return v
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
"""Keycloak SSO Login Helper"""
|
"""Keycloak SSO Login Helper
|
||||||
|
"""
|
||||||
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
|||||||
@@ -3,32 +3,18 @@ from __future__ import annotations
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from bcrypt import checkpw, gensalt, hashpw
|
|
||||||
from fastapi import Query
|
from fastapi import Query
|
||||||
|
from passlib.context import CryptContext
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from lnbits.core.models.misc import SimpleItem
|
from lnbits.core.models.misc import SimpleItem
|
||||||
from lnbits.db import FilterModel
|
from lnbits.db import FilterModel
|
||||||
from lnbits.helpers import (
|
from lnbits.helpers import is_valid_email_address, is_valid_pubkey, is_valid_username
|
||||||
is_valid_email_address,
|
|
||||||
is_valid_external_id,
|
|
||||||
is_valid_pubkey,
|
|
||||||
is_valid_username,
|
|
||||||
)
|
|
||||||
from lnbits.settings import settings
|
from lnbits.settings import settings
|
||||||
|
|
||||||
from .wallets import Wallet
|
from .wallets import Wallet
|
||||||
|
|
||||||
|
|
||||||
class UserNotifications(BaseModel):
|
|
||||||
nostr_identifier: str | None = None
|
|
||||||
telegram_chat_id: str | None = None
|
|
||||||
email_address: str | None = None
|
|
||||||
excluded_wallets: list[str] = []
|
|
||||||
outgoing_payments_sats: int = 0
|
|
||||||
incoming_payments_sats: int = 0
|
|
||||||
|
|
||||||
|
|
||||||
class UserExtra(BaseModel):
|
class UserExtra(BaseModel):
|
||||||
email_verified: bool | None = False
|
email_verified: bool | None = False
|
||||||
first_name: str | None = None
|
first_name: str | None = None
|
||||||
@@ -41,11 +27,6 @@ class UserExtra(BaseModel):
|
|||||||
# - "google | github | ...": the user was created using an SSO provider
|
# - "google | github | ...": the user was created using an SSO provider
|
||||||
provider: str | None = "lnbits" # auth provider
|
provider: str | None = "lnbits" # auth provider
|
||||||
|
|
||||||
# how many wallets are shown in the user interface
|
|
||||||
visible_wallet_count: int | None = 10
|
|
||||||
|
|
||||||
notifications: UserNotifications = UserNotifications()
|
|
||||||
|
|
||||||
|
|
||||||
class EndpointAccess(BaseModel):
|
class EndpointAccess(BaseModel):
|
||||||
path: str
|
path: str
|
||||||
@@ -109,7 +90,6 @@ class UserAcls(BaseModel):
|
|||||||
|
|
||||||
class Account(BaseModel):
|
class Account(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
external_id: str | None = None # for external account linking
|
|
||||||
username: str | None = None
|
username: str | None = None
|
||||||
password_hash: str | None = None
|
password_hash: str | None = None
|
||||||
pubkey: str | None = None
|
pubkey: str | None = None
|
||||||
@@ -121,28 +101,24 @@ class Account(BaseModel):
|
|||||||
|
|
||||||
is_super_user: bool = Field(default=False, no_database=True)
|
is_super_user: bool = Field(default=False, no_database=True)
|
||||||
is_admin: bool = Field(default=False, no_database=True)
|
is_admin: bool = Field(default=False, no_database=True)
|
||||||
fiat_providers: list[str] = Field(default=[], no_database=True)
|
|
||||||
|
|
||||||
def __init__(self, **data):
|
def __init__(self, **data):
|
||||||
super().__init__(**data)
|
super().__init__(**data)
|
||||||
self.is_super_user = settings.is_super_user(self.id)
|
self.is_super_user = settings.is_super_user(self.id)
|
||||||
self.is_admin = settings.is_admin_user(self.id)
|
self.is_admin = settings.is_admin_user(self.id)
|
||||||
self.fiat_providers = settings.get_fiat_providers_for_user(self.id)
|
|
||||||
|
|
||||||
def hash_password(self, password: str) -> str:
|
def hash_password(self, password: str) -> str:
|
||||||
"""sets and returns the hashed password"""
|
"""sets and returns the hashed password"""
|
||||||
salt = gensalt()
|
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
hashed_pw = hashpw(password.encode(), salt)
|
self.password_hash = pwd_context.hash(password)
|
||||||
if not hashed_pw:
|
|
||||||
raise ValueError("Password hashing failed.")
|
|
||||||
self.password_hash = hashed_pw.decode()
|
|
||||||
return self.password_hash
|
return self.password_hash
|
||||||
|
|
||||||
def verify_password(self, password: str) -> bool:
|
def verify_password(self, password: str) -> bool:
|
||||||
"""returns True if the password matches the hash"""
|
"""returns True if the password matches the hash"""
|
||||||
if not self.password_hash:
|
if not self.password_hash:
|
||||||
return False
|
return False
|
||||||
return checkpw(password.encode(), self.password_hash.encode())
|
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
|
return pwd_context.verify(password, self.password_hash)
|
||||||
|
|
||||||
def validate_fields(self):
|
def validate_fields(self):
|
||||||
if self.username and not is_valid_username(self.username):
|
if self.username and not is_valid_username(self.username):
|
||||||
@@ -151,11 +127,6 @@ class Account(BaseModel):
|
|||||||
raise ValueError("Invalid email.")
|
raise ValueError("Invalid email.")
|
||||||
if self.pubkey and not is_valid_pubkey(self.pubkey):
|
if self.pubkey and not is_valid_pubkey(self.pubkey):
|
||||||
raise ValueError("Invalid pubkey.")
|
raise ValueError("Invalid pubkey.")
|
||||||
if self.external_id and not is_valid_external_id(self.external_id):
|
|
||||||
raise ValueError(
|
|
||||||
"Invalid external id. Max length is 256 characters. "
|
|
||||||
"Space and newlines are not allowed."
|
|
||||||
)
|
|
||||||
user_uuid4 = UUID(hex=self.id, version=4)
|
user_uuid4 = UUID(hex=self.id, version=4)
|
||||||
if user_uuid4.hex != self.id:
|
if user_uuid4.hex != self.id:
|
||||||
raise ValueError("User ID is not valid UUID4 hex string.")
|
raise ValueError("User ID is not valid UUID4 hex string.")
|
||||||
@@ -169,14 +140,7 @@ class AccountOverview(Account):
|
|||||||
|
|
||||||
|
|
||||||
class AccountFilters(FilterModel):
|
class AccountFilters(FilterModel):
|
||||||
__search_fields__ = [
|
__search_fields__ = ["user", "email", "username", "pubkey", "wallet_id"]
|
||||||
"user",
|
|
||||||
"email",
|
|
||||||
"username",
|
|
||||||
"pubkey",
|
|
||||||
"external_id",
|
|
||||||
"wallet_id",
|
|
||||||
]
|
|
||||||
__sort_fields__ = [
|
__sort_fields__ = [
|
||||||
"balance_msat",
|
"balance_msat",
|
||||||
"email",
|
"email",
|
||||||
@@ -190,7 +154,6 @@ class AccountFilters(FilterModel):
|
|||||||
user: str | None = None
|
user: str | None = None
|
||||||
username: str | None = None
|
username: str | None = None
|
||||||
pubkey: str | None = None
|
pubkey: str | None = None
|
||||||
external_id: str | None = None
|
|
||||||
wallet_id: str | None = None
|
wallet_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@@ -201,12 +164,10 @@ class User(BaseModel):
|
|||||||
email: str | None = None
|
email: str | None = None
|
||||||
username: str | None = None
|
username: str | None = None
|
||||||
pubkey: str | None = None
|
pubkey: str | None = None
|
||||||
external_id: str | None = None # for external account linking
|
|
||||||
extensions: list[str] = []
|
extensions: list[str] = []
|
||||||
wallets: list[Wallet] = []
|
wallets: list[Wallet] = []
|
||||||
admin: bool = False
|
admin: bool = False
|
||||||
super_user: bool = False
|
super_user: bool = False
|
||||||
fiat_providers: list[str] = []
|
|
||||||
has_password: bool = False
|
has_password: bool = False
|
||||||
extra: UserExtra = UserExtra()
|
extra: UserExtra = UserExtra()
|
||||||
|
|
||||||
@@ -243,13 +204,13 @@ class CreateUser(BaseModel):
|
|||||||
password: str | None = Query(default=None, min_length=8, max_length=50)
|
password: str | None = Query(default=None, min_length=8, max_length=50)
|
||||||
password_repeat: str | None = Query(default=None, min_length=8, max_length=50)
|
password_repeat: str | None = Query(default=None, min_length=8, max_length=50)
|
||||||
pubkey: str = Query(default=None, max_length=64)
|
pubkey: str = Query(default=None, max_length=64)
|
||||||
external_id: str = Query(default=None, max_length=256)
|
|
||||||
extensions: list[str] | None = None
|
extensions: list[str] | None = None
|
||||||
extra: UserExtra | None = None
|
extra: UserExtra | None = None
|
||||||
|
|
||||||
|
|
||||||
class UpdateUser(BaseModel):
|
class UpdateUser(BaseModel):
|
||||||
user_id: str
|
user_id: str
|
||||||
|
email: str | None = Query(default=None)
|
||||||
username: str | None = Query(default=..., min_length=2, max_length=20)
|
username: str | None = Query(default=..., min_length=2, max_length=20)
|
||||||
extra: UserExtra | None = None
|
extra: UserExtra | None = None
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
|
||||||
from lnurl import encode as lnurl_encode
|
from ecdsa import SECP256k1, SigningKey
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from lnbits.core.models.lnurl import StoredPayLinks
|
|
||||||
from lnbits.db import FilterModel
|
|
||||||
from lnbits.helpers import url_for
|
from lnbits.helpers import url_for
|
||||||
|
from lnbits.lnurl import encode as lnurl_encode
|
||||||
from lnbits.settings import settings
|
from lnbits.settings import settings
|
||||||
|
|
||||||
|
|
||||||
@@ -24,7 +25,6 @@ class BaseWallet(BaseModel):
|
|||||||
class WalletExtra(BaseModel):
|
class WalletExtra(BaseModel):
|
||||||
icon: str = "flash_on"
|
icon: str = "flash_on"
|
||||||
color: str = "primary"
|
color: str = "primary"
|
||||||
pinned: bool = False
|
|
||||||
|
|
||||||
|
|
||||||
class Wallet(BaseModel):
|
class Wallet(BaseModel):
|
||||||
@@ -39,7 +39,6 @@ class Wallet(BaseModel):
|
|||||||
currency: str | None = None
|
currency: str | None = None
|
||||||
balance_msat: int = Field(default=0, no_database=True)
|
balance_msat: int = Field(default=0, no_database=True)
|
||||||
extra: WalletExtra = WalletExtra()
|
extra: WalletExtra = WalletExtra()
|
||||||
stored_paylinks: StoredPayLinks = StoredPayLinks()
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def balance(self) -> int:
|
def balance(self) -> int:
|
||||||
@@ -57,6 +56,14 @@ class Wallet(BaseModel):
|
|||||||
except Exception:
|
except Exception:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
def lnurlauth_key(self, domain: str) -> SigningKey:
|
||||||
|
hashing_key = hashlib.sha256(self.id.encode()).digest()
|
||||||
|
linking_key = hmac.digest(hashing_key, domain.encode(), "sha256")
|
||||||
|
|
||||||
|
return SigningKey.from_string(
|
||||||
|
linking_key, curve=SECP256k1, hashfunc=hashlib.sha256
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class CreateWallet(BaseModel):
|
class CreateWallet(BaseModel):
|
||||||
name: str | None = None
|
name: str | None = None
|
||||||
@@ -76,13 +83,3 @@ class KeyType(Enum):
|
|||||||
class WalletTypeInfo:
|
class WalletTypeInfo:
|
||||||
key_type: KeyType
|
key_type: KeyType
|
||||||
wallet: Wallet
|
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
|
|
||||||
|
|||||||
@@ -2,24 +2,17 @@ from .funding_source import (
|
|||||||
get_balance_delta,
|
get_balance_delta,
|
||||||
switch_to_voidwallet,
|
switch_to_voidwallet,
|
||||||
)
|
)
|
||||||
from .lnurl import fetch_lnurl_pay_request, get_pr_from_lnurl, perform_withdraw
|
from .lnurl import perform_lnurlauth, redeem_lnurl_withdraw
|
||||||
from .notifications import enqueue_admin_notification, send_payment_notification
|
from .notifications import enqueue_notification, send_payment_notification
|
||||||
from .payments import (
|
from .payments import (
|
||||||
calculate_fiat_amounts,
|
calculate_fiat_amounts,
|
||||||
cancel_hold_invoice,
|
|
||||||
check_transaction_status,
|
check_transaction_status,
|
||||||
check_wallet_limits,
|
check_wallet_limits,
|
||||||
create_fiat_invoice,
|
|
||||||
create_invoice,
|
create_invoice,
|
||||||
create_payment_request,
|
|
||||||
create_wallet_invoice,
|
|
||||||
fee_reserve,
|
fee_reserve,
|
||||||
fee_reserve_total,
|
fee_reserve_total,
|
||||||
get_payments_daily_stats,
|
|
||||||
pay_invoice,
|
pay_invoice,
|
||||||
service_fee,
|
service_fee,
|
||||||
settle_hold_invoice,
|
|
||||||
update_pending_payment,
|
|
||||||
update_pending_payments,
|
update_pending_payments,
|
||||||
update_wallet_balance,
|
update_wallet_balance,
|
||||||
)
|
)
|
||||||
@@ -31,43 +24,44 @@ from .users import (
|
|||||||
check_admin_settings,
|
check_admin_settings,
|
||||||
create_user_account,
|
create_user_account,
|
||||||
create_user_account_no_ckeck,
|
create_user_account_no_ckeck,
|
||||||
|
init_admin_settings,
|
||||||
update_user_account,
|
update_user_account,
|
||||||
update_user_extensions,
|
update_user_extensions,
|
||||||
)
|
)
|
||||||
from .websockets import websocket_manager, websocket_updater
|
from .websockets import websocket_manager, websocket_updater
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
# funding source
|
||||||
|
"get_balance_delta",
|
||||||
|
"switch_to_voidwallet",
|
||||||
|
# lnurl
|
||||||
|
"redeem_lnurl_withdraw",
|
||||||
|
"perform_lnurlauth",
|
||||||
|
# notifications
|
||||||
|
"enqueue_notification",
|
||||||
|
"send_payment_notification",
|
||||||
|
# payments
|
||||||
"calculate_fiat_amounts",
|
"calculate_fiat_amounts",
|
||||||
"cancel_hold_invoice",
|
|
||||||
"check_admin_settings",
|
|
||||||
"check_transaction_status",
|
"check_transaction_status",
|
||||||
"check_wallet_limits",
|
"check_wallet_limits",
|
||||||
"check_webpush_settings",
|
|
||||||
"create_fiat_invoice",
|
|
||||||
"create_invoice",
|
"create_invoice",
|
||||||
"create_payment_request",
|
|
||||||
"create_user_account",
|
|
||||||
"create_user_account_no_ckeck",
|
|
||||||
"create_wallet_invoice",
|
|
||||||
"enqueue_admin_notification",
|
|
||||||
"fee_reserve",
|
"fee_reserve",
|
||||||
"fee_reserve_total",
|
"fee_reserve_total",
|
||||||
"fetch_lnurl_pay_request",
|
|
||||||
"get_balance_delta",
|
|
||||||
"get_payments_daily_stats",
|
|
||||||
"get_pr_from_lnurl",
|
|
||||||
"pay_invoice",
|
"pay_invoice",
|
||||||
"perform_withdraw",
|
|
||||||
"send_payment_notification",
|
|
||||||
"service_fee",
|
"service_fee",
|
||||||
"settle_hold_invoice",
|
|
||||||
"switch_to_voidwallet",
|
|
||||||
"update_cached_settings",
|
|
||||||
"update_pending_payment",
|
|
||||||
"update_pending_payments",
|
"update_pending_payments",
|
||||||
|
"update_wallet_balance",
|
||||||
|
# settings
|
||||||
|
"check_webpush_settings",
|
||||||
|
"update_cached_settings",
|
||||||
|
# users
|
||||||
|
"check_admin_settings",
|
||||||
|
"create_user_account",
|
||||||
|
"create_user_account_no_ckeck",
|
||||||
|
"init_admin_settings",
|
||||||
"update_user_account",
|
"update_user_account",
|
||||||
"update_user_extensions",
|
"update_user_extensions",
|
||||||
"update_wallet_balance",
|
# websockets
|
||||||
"websocket_manager",
|
"websocket_manager",
|
||||||
"websocket_updater",
|
"websocket_updater",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import importlib
|
import importlib
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
@@ -21,23 +22,19 @@ from lnbits.settings import settings
|
|||||||
from ..models.extensions import Extension, ExtensionMeta, InstallableExtension
|
from ..models.extensions import Extension, ExtensionMeta, InstallableExtension
|
||||||
|
|
||||||
|
|
||||||
async def install_extension(
|
async def install_extension(ext_info: InstallableExtension) -> Extension:
|
||||||
ext_info: InstallableExtension, skip_download: bool | None = False
|
|
||||||
) -> Extension:
|
|
||||||
|
|
||||||
ext_info.meta = ext_info.meta or ExtensionMeta()
|
ext_info.meta = ext_info.meta or ExtensionMeta()
|
||||||
|
|
||||||
if (
|
if ext_info.meta.installed_release:
|
||||||
ext_info.meta.installed_release
|
assert (
|
||||||
and not ext_info.meta.installed_release.is_version_compatible
|
ext_info.meta.installed_release.is_version_compatible
|
||||||
):
|
), "Incompatible extension version"
|
||||||
raise ValueError("Incompatible extension version")
|
|
||||||
|
|
||||||
installed_ext = await get_installed_extension(ext_info.id)
|
installed_ext = await get_installed_extension(ext_info.id)
|
||||||
if installed_ext and installed_ext.meta:
|
if installed_ext and installed_ext.meta:
|
||||||
ext_info.meta.payments = installed_ext.meta.payments
|
ext_info.meta.payments = installed_ext.meta.payments
|
||||||
|
|
||||||
if not skip_download:
|
|
||||||
await ext_info.download_archive()
|
await ext_info.download_archive()
|
||||||
|
|
||||||
ext_info.extract_archive()
|
ext_info.extract_archive()
|
||||||
@@ -76,13 +73,11 @@ async def uninstall_extension(ext_id: str):
|
|||||||
async def activate_extension(ext: Extension):
|
async def activate_extension(ext: Extension):
|
||||||
core_app_extra.register_new_ext_routes(ext)
|
core_app_extra.register_new_ext_routes(ext)
|
||||||
await update_installed_extension_state(ext_id=ext.code, active=True)
|
await update_installed_extension_state(ext_id=ext.code, active=True)
|
||||||
await start_extension_background_work(ext.code)
|
|
||||||
|
|
||||||
|
|
||||||
async def deactivate_extension(ext_id: str):
|
async def deactivate_extension(ext_id: str):
|
||||||
settings.deactivate_extension_paths(ext_id)
|
settings.deactivate_extension_paths(ext_id)
|
||||||
await update_installed_extension_state(ext_id=ext_id, active=False)
|
await update_installed_extension_state(ext_id=ext_id, active=False)
|
||||||
await stop_extension_background_work(ext_id)
|
|
||||||
|
|
||||||
|
|
||||||
async def stop_extension_background_work(ext_id: str) -> bool:
|
async def stop_extension_background_work(ext_id: str) -> bool:
|
||||||
@@ -98,8 +93,9 @@ async def stop_extension_background_work(ext_id: str) -> bool:
|
|||||||
old_module = importlib.import_module(ext.module_name)
|
old_module = importlib.import_module(ext.module_name)
|
||||||
|
|
||||||
stop_fn_name = f"{ext_id}_stop"
|
stop_fn_name = f"{ext_id}_stop"
|
||||||
if not hasattr(old_module, stop_fn_name):
|
assert hasattr(
|
||||||
raise ValueError(f"No stop function found for '{ext.module_name}'.")
|
old_module, stop_fn_name
|
||||||
|
), f"No stop function found for '{ext.module_name}'."
|
||||||
|
|
||||||
stop_fn = getattr(old_module, stop_fn_name)
|
stop_fn = getattr(old_module, stop_fn_name)
|
||||||
if stop_fn:
|
if stop_fn:
|
||||||
@@ -149,7 +145,7 @@ async def start_extension_background_work(ext_id: str) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
async def get_valid_extensions(
|
async def get_valid_extensions(
|
||||||
include_deactivated: bool | None = True,
|
include_deactivated: Optional[bool] = True,
|
||||||
) -> list[Extension]:
|
) -> list[Extension]:
|
||||||
installed_extensions = await get_installed_extensions()
|
installed_extensions = await get_installed_extensions()
|
||||||
valid_extensions = [Extension.from_installable_ext(e) for e in installed_extensions]
|
valid_extensions = [Extension.from_installable_ext(e) for e in installed_extensions]
|
||||||
@@ -168,8 +164,8 @@ async def get_valid_extensions(
|
|||||||
|
|
||||||
|
|
||||||
async def get_valid_extension(
|
async def get_valid_extension(
|
||||||
ext_id: str, include_deactivated: bool | None = True
|
ext_id: str, include_deactivated: Optional[bool] = True
|
||||||
) -> Extension | None:
|
) -> Optional[Extension]:
|
||||||
ext = await get_installed_extension(ext_id)
|
ext = await get_installed_extension(ext_id)
|
||||||
if not ext:
|
if not ext:
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -1,543 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import shutil
|
|
||||||
import zipfile
|
|
||||||
from hashlib import sha256
|
|
||||||
from pathlib import Path
|
|
||||||
from time import time
|
|
||||||
from uuid import uuid4
|
|
||||||
|
|
||||||
import shortuuid
|
|
||||||
from jinja2 import Environment, FileSystemLoader
|
|
||||||
from loguru import logger
|
|
||||||
|
|
||||||
from lnbits.core.models.extensions import ExtensionRelease, InstallableExtension
|
|
||||||
from lnbits.core.models.extensions_builder import DataField, ExtensionData
|
|
||||||
from lnbits.db import dict_to_model
|
|
||||||
from lnbits.helpers import (
|
|
||||||
camel_to_snake,
|
|
||||||
camel_to_words,
|
|
||||||
download_url,
|
|
||||||
lowercase_first_letter,
|
|
||||||
)
|
|
||||||
from lnbits.settings import settings
|
|
||||||
|
|
||||||
py_files = [
|
|
||||||
"__init__.py",
|
|
||||||
"models.py",
|
|
||||||
"migrations.py",
|
|
||||||
"views_api.py",
|
|
||||||
"crud.py",
|
|
||||||
"views.py",
|
|
||||||
"tasks.py",
|
|
||||||
"services.py",
|
|
||||||
]
|
|
||||||
|
|
||||||
remove_line_marker = "{remove_line_marker}}"
|
|
||||||
|
|
||||||
ui_table_columns = [
|
|
||||||
DataField(
|
|
||||||
name="updated_at",
|
|
||||||
type="datetime",
|
|
||||||
label="Updated At",
|
|
||||||
hint="Timestamp of the last update",
|
|
||||||
optional=False,
|
|
||||||
editable=False,
|
|
||||||
searchable=False,
|
|
||||||
sortable=True,
|
|
||||||
),
|
|
||||||
DataField(
|
|
||||||
name="id",
|
|
||||||
type="str",
|
|
||||||
label="ID",
|
|
||||||
hint="Unique identifier",
|
|
||||||
optional=False,
|
|
||||||
editable=False,
|
|
||||||
searchable=False,
|
|
||||||
sortable=True,
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
excluded_dirs = {"./.", "./__pycache__", "./node_modules", "./transform"}
|
|
||||||
|
|
||||||
|
|
||||||
async def build_extension_from_data(
|
|
||||||
data: ExtensionData, stub_ext_id: str, working_dir_name: str | None = None
|
|
||||||
):
|
|
||||||
release = await _get_extension_stub_release(stub_ext_id, data.stub_version)
|
|
||||||
release.hash = sha256(uuid4().hex.encode("utf-8")).hexdigest()
|
|
||||||
release.icon = f"/{data.id}/static/image/{data.id}.png"
|
|
||||||
release.is_github_release = False
|
|
||||||
await _fetch_extension_builder_stub(stub_ext_id, release)
|
|
||||||
build_dir = _copy_ext_stub_to_build_dir(
|
|
||||||
stub_ext_id=stub_ext_id,
|
|
||||||
stub_version=release.version,
|
|
||||||
new_ext_id=data.id,
|
|
||||||
working_dir_name=working_dir_name,
|
|
||||||
)
|
|
||||||
_transform_extension_builder_stub(data, build_dir)
|
|
||||||
_export_extension_data_json(data, build_dir)
|
|
||||||
return release, build_dir
|
|
||||||
|
|
||||||
|
|
||||||
def clean_extension_builder_data() -> None:
|
|
||||||
working_dir = Path(settings.extension_builder_working_dir_path)
|
|
||||||
if working_dir.is_dir():
|
|
||||||
shutil.rmtree(working_dir, True)
|
|
||||||
working_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
|
|
||||||
def _transform_extension_builder_stub(data: ExtensionData, extension_dir: Path) -> None:
|
|
||||||
_replace_jinja_placeholders(data, extension_dir)
|
|
||||||
_rename_extension_builder_stub(data, extension_dir)
|
|
||||||
|
|
||||||
|
|
||||||
def _export_extension_data_json(data: ExtensionData, build_dir: Path):
|
|
||||||
json.dump(
|
|
||||||
data.dict(),
|
|
||||||
open(Path(build_dir, "builder.json"), "w", encoding="utf-8"),
|
|
||||||
indent=4,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def _get_extension_stub_release(
|
|
||||||
stub_ext_id: str, stub_version: str | None = None
|
|
||||||
) -> ExtensionRelease:
|
|
||||||
working_dir = Path(settings.extension_builder_working_dir_path, stub_ext_id)
|
|
||||||
cache_dir = Path(working_dir, f"cache-{stub_version}")
|
|
||||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
release_cache_file = Path(cache_dir, "release.json")
|
|
||||||
|
|
||||||
if stub_version:
|
|
||||||
cached_release = _load_extension_stub_release_from_cache(
|
|
||||||
stub_ext_id, stub_version
|
|
||||||
)
|
|
||||||
if cached_release:
|
|
||||||
logger.debug(f"Loading release from cache {stub_ext_id} ({stub_version}).")
|
|
||||||
return cached_release
|
|
||||||
|
|
||||||
releases: list[ExtensionRelease] = (
|
|
||||||
await InstallableExtension.get_extension_releases(stub_ext_id)
|
|
||||||
)
|
|
||||||
|
|
||||||
release = next((r for r in releases if r.version == stub_version), None)
|
|
||||||
|
|
||||||
if not release and len(releases) > 0:
|
|
||||||
release = releases[0]
|
|
||||||
|
|
||||||
if not release:
|
|
||||||
raise ValueError(f"Release {stub_ext_id} ({stub_version}) not found.")
|
|
||||||
|
|
||||||
logger.debug(f"Save release cache {stub_ext_id} ({stub_version}).")
|
|
||||||
with open(release_cache_file, "w", encoding="utf-8") as f:
|
|
||||||
f.write(json.dumps(release.dict(), indent=4))
|
|
||||||
|
|
||||||
return release
|
|
||||||
|
|
||||||
|
|
||||||
def _load_extension_stub_release_from_cache(
|
|
||||||
stub_ext_id: str, stub_version: str
|
|
||||||
) -> ExtensionRelease | None:
|
|
||||||
working_dir = Path(settings.extension_builder_working_dir_path, stub_ext_id)
|
|
||||||
cache_dir = Path(working_dir, f"cache-{stub_version}")
|
|
||||||
release_cache_file = Path(cache_dir, "release.json")
|
|
||||||
if release_cache_file.is_file():
|
|
||||||
with open(release_cache_file, encoding="utf-8") as f:
|
|
||||||
return dict_to_model(json.load(f), ExtensionRelease)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
async def _fetch_extension_builder_stub(
|
|
||||||
stub_ext_id: str, release: ExtensionRelease
|
|
||||||
) -> Path:
|
|
||||||
working_dir = Path(settings.extension_builder_working_dir_path, stub_ext_id)
|
|
||||||
cache_dir = Path(working_dir, f"cache-{release.version}")
|
|
||||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
stub_ext_zip_path = Path(cache_dir, release.version + ".zip")
|
|
||||||
ext_stub_cache_dir = Path(cache_dir, stub_ext_id)
|
|
||||||
|
|
||||||
if not stub_ext_zip_path.is_file():
|
|
||||||
await asyncio.to_thread(download_url, release.archive_url, stub_ext_zip_path)
|
|
||||||
shutil.rmtree(ext_stub_cache_dir, True)
|
|
||||||
|
|
||||||
if not ext_stub_cache_dir.is_dir():
|
|
||||||
tmp_dir = Path(cache_dir, "tmp")
|
|
||||||
shutil.rmtree(tmp_dir, True)
|
|
||||||
tmp_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
with zipfile.ZipFile(stub_ext_zip_path, "r") as zip_ref:
|
|
||||||
zip_ref.extractall(tmp_dir)
|
|
||||||
generated_dir = Path(tmp_dir, os.listdir(tmp_dir)[0])
|
|
||||||
shutil.copytree(generated_dir, Path(ext_stub_cache_dir))
|
|
||||||
shutil.rmtree(tmp_dir, True)
|
|
||||||
|
|
||||||
return ext_stub_cache_dir
|
|
||||||
|
|
||||||
|
|
||||||
def _copy_ext_stub_to_build_dir(
|
|
||||||
stub_ext_id: str,
|
|
||||||
stub_version: str,
|
|
||||||
new_ext_id: str,
|
|
||||||
working_dir_name: str | None = None,
|
|
||||||
) -> Path:
|
|
||||||
working_dir = Path(settings.extension_builder_working_dir_path, stub_ext_id)
|
|
||||||
cache_dir = Path(working_dir, f"cache-{stub_version}")
|
|
||||||
|
|
||||||
ext_stub_cache_dir = Path(cache_dir, stub_ext_id)
|
|
||||||
if not ext_stub_cache_dir.is_dir():
|
|
||||||
raise ValueError(
|
|
||||||
f"Extension stub cache dir not found: {stub_ext_id} ({stub_version})"
|
|
||||||
)
|
|
||||||
|
|
||||||
working_dir_name = working_dir_name or f"ext-{int(time())}-{shortuuid.uuid()}"
|
|
||||||
ext_build_dir = Path(working_dir, new_ext_id, working_dir_name, new_ext_id)
|
|
||||||
shutil.rmtree(ext_build_dir, True)
|
|
||||||
|
|
||||||
shutil.copytree(ext_stub_cache_dir, ext_build_dir)
|
|
||||||
return ext_build_dir
|
|
||||||
|
|
||||||
|
|
||||||
def _replace_jinja_placeholders(data: ExtensionData, ext_stub_dir: Path) -> None:
|
|
||||||
parsed_data = _parse_extension_data(data)
|
|
||||||
for py_file in py_files:
|
|
||||||
template_path = Path(ext_stub_dir, py_file).as_posix()
|
|
||||||
rederer = _render_file(template_path, parsed_data)
|
|
||||||
with open(template_path, "w", encoding="utf-8") as f:
|
|
||||||
f.write(rederer)
|
|
||||||
|
|
||||||
_remove_lines_with_string(template_path, remove_line_marker)
|
|
||||||
|
|
||||||
template_path = Path(ext_stub_dir, "static", "js", "index.js").as_posix()
|
|
||||||
rederer = _render_file(
|
|
||||||
template_path, {"preview": data.preview_action, **parsed_data}
|
|
||||||
)
|
|
||||||
embeded_index_js = rederer
|
|
||||||
with open(template_path, "w", encoding="utf-8") as f:
|
|
||||||
f.write(rederer)
|
|
||||||
|
|
||||||
_remove_lines_with_string(template_path, remove_line_marker)
|
|
||||||
|
|
||||||
owner_inputs = _fields_to_html_input(
|
|
||||||
[f for f in data.owner_data.fields if f.editable],
|
|
||||||
"ownerDataFormDialog.data",
|
|
||||||
ext_stub_dir,
|
|
||||||
)
|
|
||||||
client_inputs = _fields_to_html_input(
|
|
||||||
[f for f in data.client_data.fields if f.editable],
|
|
||||||
"clientDataFormDialog.data",
|
|
||||||
ext_stub_dir,
|
|
||||||
)
|
|
||||||
settings_inputs = _fields_to_html_input(
|
|
||||||
[f for f in data.settings_data.fields if f.editable],
|
|
||||||
"settingsFormDialog.data",
|
|
||||||
ext_stub_dir,
|
|
||||||
)
|
|
||||||
template_path = Path(
|
|
||||||
ext_stub_dir, "templates", "extension_builder_stub", "index.html"
|
|
||||||
).as_posix()
|
|
||||||
rederer = _render_file(
|
|
||||||
template_path,
|
|
||||||
{
|
|
||||||
"embeded_index_js": embeded_index_js,
|
|
||||||
"extension_builder_stub_owner_inputs": owner_inputs,
|
|
||||||
"extension_builder_stub_settings_inputs": settings_inputs,
|
|
||||||
"extension_builder_stub_client_inputs": client_inputs,
|
|
||||||
"preview": data.preview_action,
|
|
||||||
"cancel_comment": remove_line_marker,
|
|
||||||
**parsed_data,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
with open(template_path, "w", encoding="utf-8") as f:
|
|
||||||
f.write(rederer)
|
|
||||||
|
|
||||||
_remove_lines_with_string(template_path, remove_line_marker)
|
|
||||||
|
|
||||||
public_client_inputs = _fields_to_html_input(
|
|
||||||
[
|
|
||||||
f
|
|
||||||
for f in data.client_data.fields
|
|
||||||
if f.name in data.public_page.client_data_fields.public_inputs
|
|
||||||
],
|
|
||||||
"publicClientData",
|
|
||||||
ext_stub_dir,
|
|
||||||
)
|
|
||||||
public_template_path = Path(
|
|
||||||
ext_stub_dir, "templates", "extension_builder_stub", "public_page.html"
|
|
||||||
)
|
|
||||||
template_path = public_template_path.as_posix()
|
|
||||||
if not data.public_page.has_public_page:
|
|
||||||
public_template_path.unlink(missing_ok=True)
|
|
||||||
else:
|
|
||||||
rederer = _render_file(
|
|
||||||
template_path,
|
|
||||||
{
|
|
||||||
"extension_builder_stub_public_client_inputs": public_client_inputs,
|
|
||||||
"preview": data.preview_action,
|
|
||||||
**data.public_page.action_fields.dict(),
|
|
||||||
"cancel_comment": remove_line_marker,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
with open(template_path, "w", encoding="utf-8") as f:
|
|
||||||
f.write(rederer)
|
|
||||||
|
|
||||||
_remove_lines_with_string(template_path, remove_line_marker)
|
|
||||||
|
|
||||||
|
|
||||||
def zip_directory(source_dir, zip_path):
|
|
||||||
"""
|
|
||||||
Zips the contents of a directory (including subdirectories and files).
|
|
||||||
|
|
||||||
Parameters:
|
|
||||||
- source_dir (str): The path of the directory to zip.
|
|
||||||
- zip_path (str): The path where the .zip file will be saved.
|
|
||||||
"""
|
|
||||||
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zipf:
|
|
||||||
for root, _, files in os.walk(source_dir):
|
|
||||||
if _is_excluded_dir(root):
|
|
||||||
continue
|
|
||||||
|
|
||||||
for file in files:
|
|
||||||
full_path = os.path.join(root, file)
|
|
||||||
# Add file with a relative path inside the zip
|
|
||||||
relative_path = os.path.relpath(full_path, start=source_dir)
|
|
||||||
zipf.write(full_path, arcname=relative_path)
|
|
||||||
|
|
||||||
|
|
||||||
def _rename_extension_builder_stub(data: ExtensionData, extension_dir: Path) -> None:
|
|
||||||
extension_dir_path = extension_dir.as_posix()
|
|
||||||
rename_values = {
|
|
||||||
"extension_builder_stub_name": data.name,
|
|
||||||
"extension_builder_stub_short_description": data.short_description or "",
|
|
||||||
"extension_builder_stub": data.id,
|
|
||||||
"OwnerData": data.owner_data.name,
|
|
||||||
"ownerData": lowercase_first_letter(data.owner_data.name),
|
|
||||||
"Owner Data": camel_to_words(data.owner_data.name),
|
|
||||||
"owner data": camel_to_words(data.owner_data.name).lower(),
|
|
||||||
"owner_data": camel_to_snake(data.owner_data.name),
|
|
||||||
"ClientData": data.client_data.name,
|
|
||||||
"clientData": lowercase_first_letter(data.client_data.name),
|
|
||||||
"Client Data": camel_to_words(data.client_data.name),
|
|
||||||
"client data": camel_to_words(data.client_data.name).lower(),
|
|
||||||
"client_data": camel_to_snake(data.client_data.name),
|
|
||||||
}
|
|
||||||
for old_text, new_text in rename_values.items():
|
|
||||||
_replace_text_in_files(
|
|
||||||
directory=extension_dir_path,
|
|
||||||
old_text=old_text,
|
|
||||||
new_text=new_text,
|
|
||||||
file_extensions=[".py", ".js", ".html", ".md", ".json", ".toml"],
|
|
||||||
)
|
|
||||||
|
|
||||||
_rename_files_and_dirs_in_directory(
|
|
||||||
directory=extension_dir_path,
|
|
||||||
old_text="extension_builder_stub",
|
|
||||||
new_text=data.id,
|
|
||||||
)
|
|
||||||
_rename_files_and_dirs_in_directory(
|
|
||||||
directory=extension_dir_path,
|
|
||||||
old_text="owner_data",
|
|
||||||
new_text=camel_to_snake(data.owner_data.name),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _replace_text_in_files(
|
|
||||||
directory: str,
|
|
||||||
old_text: str,
|
|
||||||
new_text: str,
|
|
||||||
file_extensions: list[str] | None = None,
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Recursively replaces text in all files under the given directory.
|
|
||||||
"""
|
|
||||||
for root, _, files in os.walk(directory):
|
|
||||||
if _is_excluded_dir(root):
|
|
||||||
continue
|
|
||||||
|
|
||||||
for filename in files:
|
|
||||||
if file_extensions:
|
|
||||||
if not any(filename.endswith(ext) for ext in file_extensions):
|
|
||||||
continue
|
|
||||||
|
|
||||||
file_path = os.path.join(root, filename)
|
|
||||||
try:
|
|
||||||
with open(file_path, encoding="utf-8") as f:
|
|
||||||
content = f.read()
|
|
||||||
if old_text in content:
|
|
||||||
new_content = content.replace(old_text, new_text)
|
|
||||||
with open(file_path, "w", encoding="utf-8") as f:
|
|
||||||
f.write(new_content)
|
|
||||||
logger.trace(f"Updated: {file_path}")
|
|
||||||
except (UnicodeDecodeError, PermissionError, FileNotFoundError) as e:
|
|
||||||
logger.debug(f"Skipped {file_path}: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
def _render_file(template_path: str, data: dict) -> str:
|
|
||||||
# Extract directory and file name
|
|
||||||
template_dir = os.path.dirname(template_path)
|
|
||||||
template_file = os.path.basename(template_path)
|
|
||||||
|
|
||||||
# Create Jinja environment
|
|
||||||
# env = Environment(loader=FileSystemLoader(template_dir))
|
|
||||||
env = _jinja_env(template_dir)
|
|
||||||
template = env.get_template(template_file)
|
|
||||||
|
|
||||||
# Render the template with data
|
|
||||||
return template.render(**data)
|
|
||||||
|
|
||||||
|
|
||||||
def _jinja_env(template_dir: str) -> Environment:
|
|
||||||
return Environment(
|
|
||||||
loader=FileSystemLoader(template_dir),
|
|
||||||
variable_start_string="<<",
|
|
||||||
variable_end_string=">>",
|
|
||||||
block_start_string="<%",
|
|
||||||
block_end_string="%>",
|
|
||||||
comment_start_string="<#",
|
|
||||||
comment_end_string="#>",
|
|
||||||
autoescape=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_extension_data(data: ExtensionData) -> dict:
|
|
||||||
return {
|
|
||||||
"owner_data": {
|
|
||||||
"name": data.owner_data.name,
|
|
||||||
"editable": data.owner_data.editable,
|
|
||||||
"js_fields": [
|
|
||||||
field.field_to_js()
|
|
||||||
for field in data.owner_data.fields
|
|
||||||
if field.editable
|
|
||||||
],
|
|
||||||
"search_fields": [
|
|
||||||
camel_to_snake(field.name)
|
|
||||||
for field in data.owner_data.fields
|
|
||||||
if field.searchable
|
|
||||||
],
|
|
||||||
"ui_table_columns": [
|
|
||||||
field.field_to_ui_table_column()
|
|
||||||
for field in data.owner_data.fields + ui_table_columns
|
|
||||||
if field.sortable
|
|
||||||
],
|
|
||||||
"ui_mock_data": [
|
|
||||||
json.dumps(
|
|
||||||
{
|
|
||||||
field.name: field.field_mock_value(index=index)
|
|
||||||
for field in data.owner_data.fields + ui_table_columns
|
|
||||||
}
|
|
||||||
)
|
|
||||||
for index in range(1, 5)
|
|
||||||
],
|
|
||||||
"db_fields": [field.field_to_db() for field in data.owner_data.fields],
|
|
||||||
"all_fields": [field.field_to_py() for field in data.owner_data.fields],
|
|
||||||
},
|
|
||||||
"client_data": {
|
|
||||||
"name": data.client_data.name,
|
|
||||||
"editable": data.client_data.editable,
|
|
||||||
"search_fields": [
|
|
||||||
camel_to_snake(field.name)
|
|
||||||
for field in data.client_data.fields
|
|
||||||
if field.searchable
|
|
||||||
],
|
|
||||||
"ui_table_columns": [
|
|
||||||
field.field_to_ui_table_column()
|
|
||||||
for field in data.client_data.fields + ui_table_columns
|
|
||||||
if field.sortable
|
|
||||||
],
|
|
||||||
"ui_mock_data": [
|
|
||||||
json.dumps(
|
|
||||||
{
|
|
||||||
field.name: field.field_mock_value(index=index)
|
|
||||||
for field in data.client_data.fields + ui_table_columns
|
|
||||||
}
|
|
||||||
)
|
|
||||||
for index in range(1, 7)
|
|
||||||
],
|
|
||||||
"db_fields": [field.field_to_db() for field in data.client_data.fields],
|
|
||||||
"all_fields": [field.field_to_py() for field in data.client_data.fields],
|
|
||||||
},
|
|
||||||
"settings_data": {
|
|
||||||
"enabled": data.settings_data.enabled,
|
|
||||||
"is_admin_settings_only": data.settings_data.type == "admin",
|
|
||||||
"db_fields": [field.field_to_db() for field in data.settings_data.fields],
|
|
||||||
"all_fields": [field.field_to_py() for field in data.settings_data.fields],
|
|
||||||
},
|
|
||||||
"public_page": data.public_page,
|
|
||||||
"cancel_comment": remove_line_marker,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _fields_to_html_input(
|
|
||||||
fields: list[DataField], model_name: str, ext_stub_dir: Path
|
|
||||||
) -> str:
|
|
||||||
template_path = Path(
|
|
||||||
ext_stub_dir, "templates", "extension_builder_stub", "_input_fields.html"
|
|
||||||
).as_posix()
|
|
||||||
|
|
||||||
rederer = _render_file(
|
|
||||||
template_path,
|
|
||||||
{
|
|
||||||
"fields": fields,
|
|
||||||
"model_name": model_name,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return rederer
|
|
||||||
|
|
||||||
|
|
||||||
def _remove_lines_with_string(file_path: str, target: str) -> None:
|
|
||||||
"""
|
|
||||||
Removes lines from a file that contain the given target string.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
file_path (str): Path to the file.
|
|
||||||
target (str): Substring to search for in lines to remove.
|
|
||||||
"""
|
|
||||||
with open(file_path, encoding="utf-8") as f:
|
|
||||||
lines = f.readlines()
|
|
||||||
|
|
||||||
filtered_lines = [line for line in lines if target not in line]
|
|
||||||
|
|
||||||
with open(file_path, "w", encoding="utf-8") as f:
|
|
||||||
f.writelines(filtered_lines)
|
|
||||||
|
|
||||||
|
|
||||||
def _rename_files_and_dirs_in_directory(directory, old_text, new_text):
|
|
||||||
"""
|
|
||||||
Recursively renames files and directories by replacing part of their names.
|
|
||||||
"""
|
|
||||||
# First rename directories (bottom-up) so we don't lose paths while renaming
|
|
||||||
for root, dirs, files in os.walk(directory, topdown=False):
|
|
||||||
if _is_excluded_dir(root):
|
|
||||||
continue
|
|
||||||
# Rename files
|
|
||||||
for filename in files:
|
|
||||||
if old_text in filename:
|
|
||||||
old_path = os.path.join(root, filename)
|
|
||||||
new_filename = filename.replace(old_text, new_text)
|
|
||||||
new_path = os.path.join(root, new_filename)
|
|
||||||
try:
|
|
||||||
os.rename(old_path, new_path)
|
|
||||||
logger.trace(f"Renamed file: {old_path} -> {new_path}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Failed to rename file {old_path}: {e}")
|
|
||||||
|
|
||||||
# Rename directories
|
|
||||||
for dirname in dirs:
|
|
||||||
if old_text in dirname:
|
|
||||||
old_dir_path = os.path.join(root, dirname)
|
|
||||||
new_dir_name = dirname.replace(old_text, new_text)
|
|
||||||
new_dir_path = os.path.join(root, new_dir_name)
|
|
||||||
try:
|
|
||||||
os.rename(old_dir_path, new_dir_path)
|
|
||||||
logger.trace(f"Renamed directory: {old_dir_path} -> {new_dir_path}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Failed to rename directory {old_dir_path}: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
def _is_excluded_dir(path):
|
|
||||||
for excluded_dir in excluded_dirs:
|
|
||||||
if path.startswith(excluded_dir):
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
@@ -1,193 +0,0 @@
|
|||||||
import hashlib
|
|
||||||
import hmac
|
|
||||||
import time
|
|
||||||
|
|
||||||
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: Connection | None = None
|
|
||||||
):
|
|
||||||
try:
|
|
||||||
await _credit_fiat_service_fee_wallet(payment, conn=conn)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(e)
|
|
||||||
|
|
||||||
try:
|
|
||||||
await _debit_fiat_service_faucet_wallet(payment, conn=conn)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(e)
|
|
||||||
|
|
||||||
|
|
||||||
async def _credit_fiat_service_fee_wallet(
|
|
||||||
payment: Payment, conn: Connection | None = None
|
|
||||||
):
|
|
||||||
fiat_provider_name = payment.fiat_provider
|
|
||||||
if not fiat_provider_name:
|
|
||||||
return
|
|
||||||
if payment.fee == 0:
|
|
||||||
return
|
|
||||||
|
|
||||||
limits = settings.get_fiat_provider_limits(fiat_provider_name)
|
|
||||||
if not limits:
|
|
||||||
return
|
|
||||||
|
|
||||||
if not limits.service_fee_wallet_id:
|
|
||||||
return
|
|
||||||
|
|
||||||
memo = (
|
|
||||||
f"Service fee for fiat payment of "
|
|
||||||
f"{abs(payment.sat)} sats. "
|
|
||||||
f"Provider: {fiat_provider_name}. "
|
|
||||||
f"Wallet: '{payment.wallet_id}'."
|
|
||||||
)
|
|
||||||
create_payment_model = CreatePayment(
|
|
||||||
wallet_id=limits.service_fee_wallet_id,
|
|
||||||
bolt11=payment.bolt11,
|
|
||||||
payment_hash=payment.payment_hash,
|
|
||||||
amount_msat=abs(payment.fee),
|
|
||||||
memo=memo,
|
|
||||||
)
|
|
||||||
await create_payment(
|
|
||||||
checking_id=f"service_fee_{payment.payment_hash}",
|
|
||||||
data=create_payment_model,
|
|
||||||
status=PaymentState.SUCCESS,
|
|
||||||
conn=conn,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def _debit_fiat_service_faucet_wallet(
|
|
||||||
payment: Payment, conn: Connection | None = None
|
|
||||||
):
|
|
||||||
fiat_provider_name = payment.fiat_provider
|
|
||||||
if not fiat_provider_name:
|
|
||||||
return
|
|
||||||
|
|
||||||
limits = settings.get_fiat_provider_limits(fiat_provider_name)
|
|
||||||
if not limits:
|
|
||||||
return
|
|
||||||
|
|
||||||
if not limits.service_faucet_wallet_id:
|
|
||||||
return
|
|
||||||
|
|
||||||
faucet_wallet = await get_wallet(limits.service_faucet_wallet_id, conn=conn)
|
|
||||||
if not faucet_wallet:
|
|
||||||
raise ValueError(
|
|
||||||
f"Fiat provider '{fiat_provider_name}' faucet wallet not found."
|
|
||||||
)
|
|
||||||
|
|
||||||
memo = (
|
|
||||||
f"Faucet payment of {abs(payment.sat)} sats. "
|
|
||||||
f"Provider: {fiat_provider_name}. "
|
|
||||||
f"Wallet: '{payment.wallet_id}'."
|
|
||||||
)
|
|
||||||
create_payment_model = CreatePayment(
|
|
||||||
wallet_id=limits.service_faucet_wallet_id,
|
|
||||||
bolt11=payment.bolt11,
|
|
||||||
payment_hash=payment.payment_hash,
|
|
||||||
amount_msat=-abs(payment.amount),
|
|
||||||
memo=memo,
|
|
||||||
extra=payment.extra,
|
|
||||||
)
|
|
||||||
await create_payment(
|
|
||||||
checking_id=f"internal_fiat_{fiat_provider_name}_"
|
|
||||||
f"faucet_{payment.payment_hash}",
|
|
||||||
data=create_payment_model,
|
|
||||||
status=PaymentState.SUCCESS,
|
|
||||||
conn=conn,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
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: str | None,
|
|
||||||
secret: str | None,
|
|
||||||
tolerance_seconds=300,
|
|
||||||
):
|
|
||||||
if not sig_header:
|
|
||||||
logger.warning("Stripe-Signature header is missing.")
|
|
||||||
raise ValueError("Stripe-Signature header is missing.")
|
|
||||||
|
|
||||||
if not secret:
|
|
||||||
logger.warning("Stripe webhook signing secret is not set.")
|
|
||||||
raise ValueError("Stripe webhook cannot be verified.")
|
|
||||||
|
|
||||||
# Split the Stripe-Signature header
|
|
||||||
items = dict(i.split("=") for i in sig_header.split(","))
|
|
||||||
timestamp = int(items["t"])
|
|
||||||
signature = items["v1"]
|
|
||||||
|
|
||||||
# Check timestamp tolerance
|
|
||||||
if abs(time.time() - timestamp) > tolerance_seconds:
|
|
||||||
logger.warning("Timestamp outside tolerance.")
|
|
||||||
logger.debug(
|
|
||||||
f"Current time: {time.time()}, "
|
|
||||||
f"Timestamp: {timestamp}, "
|
|
||||||
f"Tolerance: {tolerance_seconds} seconds"
|
|
||||||
)
|
|
||||||
|
|
||||||
raise ValueError("Timestamp outside tolerance." f"Timestamp: {timestamp}")
|
|
||||||
|
|
||||||
signed_payload = f"{timestamp}.{payload.decode()}"
|
|
||||||
|
|
||||||
# Compute HMAC SHA256 using the webhook secret
|
|
||||||
computed_signature = hmac.new(
|
|
||||||
key=secret.encode(), msg=signed_payload.encode(), digestmod=hashlib.sha256
|
|
||||||
).hexdigest()
|
|
||||||
|
|
||||||
# Compare signatures using constant time comparison
|
|
||||||
if hmac.compare_digest(computed_signature, signature) is not True:
|
|
||||||
logger.warning("Stripe signature verification failed.")
|
|
||||||
raise ValueError("Stripe signature verification failed.")
|
|
||||||
|
|
||||||
|
|
||||||
async def test_connection(provider: str) -> SimpleStatus:
|
|
||||||
"""
|
|
||||||
Test the connection to Stripe by checking if the API key is valid.
|
|
||||||
This function should be called when setting up or testing the Stripe integration.
|
|
||||||
"""
|
|
||||||
fiat_provider = await get_fiat_provider(provider)
|
|
||||||
if not fiat_provider:
|
|
||||||
return SimpleStatus(
|
|
||||||
success=False,
|
|
||||||
message=f"Fiat provider '{provider}' not found.",
|
|
||||||
)
|
|
||||||
status = await fiat_provider.status()
|
|
||||||
if status.error_message:
|
|
||||||
return SimpleStatus(
|
|
||||||
success=False,
|
|
||||||
message=f"Cconnection test failed: {status.error_message}",
|
|
||||||
)
|
|
||||||
|
|
||||||
return SimpleStatus(
|
|
||||||
success=True,
|
|
||||||
message="Connection test successful." f" Balance: {status.balance}.",
|
|
||||||
)
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from lnbits.core.models.notifications import NotificationType
|
from lnbits.core.models.notifications import NotificationType
|
||||||
from lnbits.core.services.notifications import enqueue_admin_notification
|
from lnbits.core.services.notifications import enqueue_notification
|
||||||
from lnbits.settings import settings
|
from lnbits.settings import settings
|
||||||
from lnbits.wallets import get_funding_source, set_funding_source
|
from lnbits.wallets import get_funding_source, set_funding_source
|
||||||
|
|
||||||
@@ -51,7 +51,7 @@ async def check_server_balance_against_node():
|
|||||||
f"Balance delta reached: {status.delta_sats} sats."
|
f"Balance delta reached: {status.delta_sats} sats."
|
||||||
f" Switch to void wallet: {use_voidwallet}."
|
f" Switch to void wallet: {use_voidwallet}."
|
||||||
)
|
)
|
||||||
enqueue_admin_notification(
|
enqueue_notification(
|
||||||
NotificationType.watchdog_check,
|
NotificationType.watchdog_check,
|
||||||
{
|
{
|
||||||
"delta_sats": status.delta_sats,
|
"delta_sats": status.delta_sats,
|
||||||
@@ -71,7 +71,7 @@ async def check_balance_delta_changed():
|
|||||||
settings.latest_balance_delta_sats = status.delta_sats
|
settings.latest_balance_delta_sats = status.delta_sats
|
||||||
return
|
return
|
||||||
if status.delta_sats != settings.latest_balance_delta_sats:
|
if status.delta_sats != settings.latest_balance_delta_sats:
|
||||||
enqueue_admin_notification(
|
enqueue_notification(
|
||||||
NotificationType.balance_delta,
|
NotificationType.balance_delta,
|
||||||
{
|
{
|
||||||
"delta_sats": status.delta_sats,
|
"delta_sats": status.delta_sats,
|
||||||
|
|||||||
+142
-151
@@ -1,167 +1,158 @@
|
|||||||
from time import time
|
import asyncio
|
||||||
|
import json
|
||||||
|
from io import BytesIO
|
||||||
|
from typing import Optional
|
||||||
|
from urllib.parse import parse_qs, urlparse
|
||||||
|
|
||||||
from lnurl import (
|
import httpx
|
||||||
LnAddress,
|
from fastapi import Depends
|
||||||
Lnurl,
|
|
||||||
LnurlErrorResponse,
|
|
||||||
LnurlPayActionResponse,
|
|
||||||
LnurlPayResponse,
|
|
||||||
LnurlResponseException,
|
|
||||||
LnurlSuccessResponse,
|
|
||||||
LnurlWithdrawResponse,
|
|
||||||
execute_pay_request,
|
|
||||||
execute_withdraw,
|
|
||||||
handle,
|
|
||||||
)
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from lnbits.core.crud import update_wallet
|
from lnbits.db import Connection
|
||||||
from lnbits.core.models import CreateLnurlPayment, Wallet
|
from lnbits.decorators import (
|
||||||
from lnbits.core.models.lnurl import StoredPayLink
|
WalletTypeInfo,
|
||||||
from lnbits.helpers import check_callback_url
|
require_admin_key,
|
||||||
|
)
|
||||||
|
from lnbits.helpers import check_callback_url, url_for
|
||||||
|
from lnbits.lnurl import LnurlErrorResponse
|
||||||
|
from lnbits.lnurl import decode as decode_lnurl
|
||||||
from lnbits.settings import settings
|
from lnbits.settings import settings
|
||||||
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis
|
|
||||||
|
from .payments import create_invoice
|
||||||
|
|
||||||
|
|
||||||
async def perform_withdraw(lnurl: str, payment_request: str) -> None:
|
async def redeem_lnurl_withdraw(
|
||||||
"""
|
wallet_id: str,
|
||||||
Perform an LNURL withdraw to the given LNURL-withdraw link.
|
lnurl_request: str,
|
||||||
:param lnurl: The LNURL-withdraw link. bech32 or lud17 format.
|
memo: Optional[str] = None,
|
||||||
:param payment_request: The BOLT11 payment request to pay.
|
extra: Optional[dict] = None,
|
||||||
:raises LnurlResponseException: If the LNURL-withdraw process fails.
|
wait_seconds: int = 0,
|
||||||
"""
|
conn: Optional[Connection] = None,
|
||||||
res = await handle(lnurl, user_agent=settings.user_agent, timeout=10)
|
|
||||||
if isinstance(res, LnurlErrorResponse):
|
|
||||||
raise LnurlResponseException(res.reason)
|
|
||||||
if not isinstance(res, LnurlWithdrawResponse):
|
|
||||||
raise LnurlResponseException("Invalid LNURL-withdraw response.")
|
|
||||||
try:
|
|
||||||
check_callback_url(res.callback)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise LnurlResponseException(f"Invalid callback URL: {exc!s}") from exc
|
|
||||||
res2 = await execute_withdraw(
|
|
||||||
res, payment_request, user_agent=settings.user_agent, timeout=10
|
|
||||||
)
|
|
||||||
if isinstance(res2, LnurlErrorResponse):
|
|
||||||
raise LnurlResponseException(res2.reason)
|
|
||||||
if not isinstance(res2, LnurlSuccessResponse):
|
|
||||||
raise LnurlResponseException("Invalid LNURL-withdraw success response.")
|
|
||||||
|
|
||||||
|
|
||||||
async def get_pr_from_lnurl(
|
|
||||||
lnurl: str, amount_msat: int, comment: str | None = None
|
|
||||||
) -> str:
|
|
||||||
res = await handle(lnurl, user_agent=settings.user_agent, timeout=10)
|
|
||||||
if isinstance(res, LnurlErrorResponse):
|
|
||||||
raise LnurlResponseException(res.reason)
|
|
||||||
if not isinstance(res, LnurlPayResponse):
|
|
||||||
raise LnurlResponseException(
|
|
||||||
"Invalid LNURL response. Expected LnurlPayResponse."
|
|
||||||
)
|
|
||||||
res2 = await execute_pay_request(
|
|
||||||
res,
|
|
||||||
msat=amount_msat,
|
|
||||||
comment=comment,
|
|
||||||
user_agent=settings.user_agent,
|
|
||||||
timeout=10,
|
|
||||||
)
|
|
||||||
if isinstance(res2, LnurlErrorResponse):
|
|
||||||
raise LnurlResponseException(res2.reason)
|
|
||||||
return res2.pr
|
|
||||||
|
|
||||||
|
|
||||||
async def fetch_lnurl_pay_request(
|
|
||||||
data: CreateLnurlPayment, wallet: Wallet | None = None
|
|
||||||
) -> tuple[LnurlPayResponse, LnurlPayActionResponse]:
|
|
||||||
"""
|
|
||||||
Pay an LNURL payment request.
|
|
||||||
optional `wallet` is used to store the pay link in the wallet's stored links.
|
|
||||||
|
|
||||||
raises `LnurlResponseException` if pay request fails
|
|
||||||
"""
|
|
||||||
if not data.res and data.lnurl:
|
|
||||||
res = await handle(data.lnurl, user_agent=settings.user_agent, timeout=5)
|
|
||||||
if isinstance(res, LnurlErrorResponse):
|
|
||||||
raise LnurlResponseException(res.reason)
|
|
||||||
if not isinstance(res, LnurlPayResponse):
|
|
||||||
raise LnurlResponseException(
|
|
||||||
"Invalid LNURL response. Expected LnurlPayResponse."
|
|
||||||
)
|
|
||||||
data.res = res
|
|
||||||
if not data.res:
|
|
||||||
raise LnurlResponseException("No LNURL pay request provided.")
|
|
||||||
|
|
||||||
if data.unit and data.unit != "sat":
|
|
||||||
# shift to float with 2 decimal places
|
|
||||||
amount = round(data.amount / 1000, 2)
|
|
||||||
amount_msat = await fiat_amount_as_satoshis(amount, data.unit)
|
|
||||||
amount_msat *= 1000
|
|
||||||
else:
|
|
||||||
amount_msat = data.amount
|
|
||||||
|
|
||||||
res2 = await execute_pay_request(
|
|
||||||
data.res,
|
|
||||||
msat=amount_msat,
|
|
||||||
comment=data.comment,
|
|
||||||
user_agent=settings.user_agent,
|
|
||||||
timeout=10,
|
|
||||||
)
|
|
||||||
|
|
||||||
if wallet:
|
|
||||||
await store_paylink(data.res, res2, wallet, data.lnurl)
|
|
||||||
|
|
||||||
return data.res, res2
|
|
||||||
|
|
||||||
|
|
||||||
async def store_paylink(
|
|
||||||
res: LnurlPayResponse,
|
|
||||||
res2: LnurlPayActionResponse,
|
|
||||||
wallet: Wallet,
|
|
||||||
lnurl: LnAddress | Lnurl | None = None,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
|
if not lnurl_request:
|
||||||
|
return None
|
||||||
|
|
||||||
if res2.disposable is not False:
|
res = {}
|
||||||
return # do not store disposable LNURL pay links
|
|
||||||
|
|
||||||
logger.debug(f"storing lnurl pay link for wallet {wallet.id}. ")
|
headers = {"User-Agent": settings.user_agent}
|
||||||
|
async with httpx.AsyncClient(headers=headers) as client:
|
||||||
|
lnurl = decode_lnurl(lnurl_request)
|
||||||
|
check_callback_url(str(lnurl))
|
||||||
|
r = await client.get(str(lnurl))
|
||||||
|
res = r.json()
|
||||||
|
|
||||||
stored_paylink = None
|
try:
|
||||||
# If we have only a LnurlPayResponse, we can use its lnaddress
|
_, payment_request = await create_invoice(
|
||||||
# because the lnurl is not available.
|
wallet_id=wallet_id,
|
||||||
if not lnurl:
|
amount=int(res["maxWithdrawable"] / 1000),
|
||||||
for _data in res.metadata.list():
|
memo=memo or res["defaultDescription"] or "",
|
||||||
if _data[0] == "text/identifier":
|
extra=extra,
|
||||||
stored_paylink = StoredPayLink(
|
conn=conn,
|
||||||
lnurl=LnAddress(_data[1]), label=res.metadata.text
|
|
||||||
)
|
)
|
||||||
if not stored_paylink:
|
except Exception:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"No lnaddress found in metadata for LNURL pay link. "
|
f"failed to create invoice on redeem_lnurl_withdraw "
|
||||||
"Skipping storage."
|
f"from {lnurl}. params: {res}"
|
||||||
)
|
)
|
||||||
return # skip if lnaddress not found in metadata
|
return None
|
||||||
else:
|
|
||||||
if isinstance(lnurl, Lnurl):
|
|
||||||
_lnurl = str(lnurl.lud17 or lnurl.bech32)
|
|
||||||
else:
|
|
||||||
_lnurl = str(lnurl)
|
|
||||||
stored_paylink = StoredPayLink(lnurl=_lnurl, label=res.metadata.text)
|
|
||||||
|
|
||||||
# update last_used if its already stored
|
if wait_seconds:
|
||||||
for pl in wallet.stored_paylinks.links:
|
await asyncio.sleep(wait_seconds)
|
||||||
if pl.lnurl == stored_paylink.lnurl:
|
|
||||||
pl.last_used = int(time())
|
|
||||||
await update_wallet(wallet)
|
|
||||||
logger.debug(
|
|
||||||
"Updated last used time for LNURL "
|
|
||||||
f"pay link {stored_paylink.lnurl} in wallet {wallet.id}."
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
# if not already stored, append it
|
params = {"k1": res["k1"], "pr": payment_request}
|
||||||
if not any(stored_paylink.lnurl == pl.lnurl for pl in wallet.stored_paylinks.links):
|
|
||||||
wallet.stored_paylinks.links.append(stored_paylink)
|
try:
|
||||||
await update_wallet(wallet)
|
params["balanceNotify"] = url_for(
|
||||||
logger.debug(
|
f"/withdraw/notify/{urlparse(lnurl_request).netloc}",
|
||||||
f"Stored LNURL pay link {stored_paylink.lnurl} for wallet {wallet.id}."
|
external=True,
|
||||||
|
wal=wallet_id,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
headers = {"User-Agent": settings.user_agent}
|
||||||
|
async with httpx.AsyncClient(headers=headers) as client:
|
||||||
|
try:
|
||||||
|
check_callback_url(res["callback"])
|
||||||
|
await client.get(res["callback"], params=params)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def perform_lnurlauth(
|
||||||
|
callback: str,
|
||||||
|
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||||
|
) -> Optional[LnurlErrorResponse]:
|
||||||
|
cb = urlparse(callback)
|
||||||
|
|
||||||
|
k1 = bytes.fromhex(parse_qs(cb.query)["k1"][0])
|
||||||
|
|
||||||
|
key = wallet.wallet.lnurlauth_key(cb.netloc)
|
||||||
|
|
||||||
|
def int_to_bytes_suitable_der(x: int) -> bytes:
|
||||||
|
"""for strict DER we need to encode the integer with some quirks"""
|
||||||
|
b = x.to_bytes((x.bit_length() + 7) // 8, "big")
|
||||||
|
|
||||||
|
if len(b) == 0:
|
||||||
|
# ensure there's at least one byte when the int is zero
|
||||||
|
return bytes([0])
|
||||||
|
|
||||||
|
if b[0] & 0x80 != 0:
|
||||||
|
# ensure it doesn't start with a 0x80 and so it isn't
|
||||||
|
# interpreted as a negative number
|
||||||
|
return bytes([0]) + b
|
||||||
|
|
||||||
|
return b
|
||||||
|
|
||||||
|
def encode_strict_der(r: int, s: int, order: int):
|
||||||
|
# if s > order/2 verification will fail sometimes
|
||||||
|
# so we must fix it here see:
|
||||||
|
# https://github.com/indutny/elliptic/blob/e71b2d9359c5fe9437fbf46f1f05096de447de57/lib/elliptic/ec/index.js#L146-L147
|
||||||
|
if s > order // 2:
|
||||||
|
s = order - s
|
||||||
|
|
||||||
|
# now we do the strict DER encoding copied from
|
||||||
|
# https://github.com/KiriKiri/bip66 (without any checks)
|
||||||
|
r_temp = int_to_bytes_suitable_der(r)
|
||||||
|
s_temp = int_to_bytes_suitable_der(s)
|
||||||
|
|
||||||
|
r_len = len(r_temp)
|
||||||
|
s_len = len(s_temp)
|
||||||
|
sign_len = 6 + r_len + s_len
|
||||||
|
|
||||||
|
signature = BytesIO()
|
||||||
|
signature.write(0x30.to_bytes(1, "big", signed=False))
|
||||||
|
signature.write((sign_len - 2).to_bytes(1, "big", signed=False))
|
||||||
|
signature.write(0x02.to_bytes(1, "big", signed=False))
|
||||||
|
signature.write(r_len.to_bytes(1, "big", signed=False))
|
||||||
|
signature.write(r_temp)
|
||||||
|
signature.write(0x02.to_bytes(1, "big", signed=False))
|
||||||
|
signature.write(s_len.to_bytes(1, "big", signed=False))
|
||||||
|
signature.write(s_temp)
|
||||||
|
|
||||||
|
return signature.getvalue()
|
||||||
|
|
||||||
|
sig = key.sign_digest_deterministic(k1, sigencode=encode_strict_der)
|
||||||
|
|
||||||
|
headers = {"User-Agent": settings.user_agent}
|
||||||
|
async with httpx.AsyncClient(headers=headers) as client:
|
||||||
|
assert key.verifying_key, "LNURLauth verifying_key does not exist"
|
||||||
|
check_callback_url(callback)
|
||||||
|
r = await client.get(
|
||||||
|
callback,
|
||||||
|
params={
|
||||||
|
"k1": k1.hex(),
|
||||||
|
"key": key.verifying_key.to_string("compressed").hex(),
|
||||||
|
"sig": sig.hex(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
resp = json.loads(r.text)
|
||||||
|
if resp["status"] == "OK":
|
||||||
|
return None
|
||||||
|
|
||||||
|
return LnurlErrorResponse(reason=resp["reason"])
|
||||||
|
except (KeyError, json.decoder.JSONDecodeError):
|
||||||
|
return LnurlErrorResponse(
|
||||||
|
reason=r.text[:200] + "..." if len(r.text) > 200 else r.text
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
from typing import Tuple
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
@@ -47,7 +48,7 @@ async def send_nostr_dm(
|
|||||||
return dm_event.to_dict()
|
return dm_event.to_dict()
|
||||||
|
|
||||||
|
|
||||||
async def fetch_nip5_details(identifier: str) -> tuple[str, list[str]]:
|
async def fetch_nip5_details(identifier: str) -> Tuple[str, list[str]]:
|
||||||
identifier, domain = identifier.split("@")
|
identifier, domain = identifier.split("@")
|
||||||
if not identifier or not domain:
|
if not identifier or not domain:
|
||||||
raise ValueError("Invalid NIP5 identifier")
|
raise ValueError("Invalid NIP5 identifier")
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import smtplib
|
|||||||
from email.mime.multipart import MIMEMultipart
|
from email.mime.multipart import MIMEMultipart
|
||||||
from email.mime.text import MIMEText
|
from email.mime.text import MIMEText
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
|
from typing import Optional, Tuple
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
@@ -15,25 +16,23 @@ from lnbits.core.crud import (
|
|||||||
get_webpush_subscriptions_for_user,
|
get_webpush_subscriptions_for_user,
|
||||||
mark_webhook_sent,
|
mark_webhook_sent,
|
||||||
)
|
)
|
||||||
from lnbits.core.crud.users import get_user
|
|
||||||
from lnbits.core.models import Payment, Wallet
|
from lnbits.core.models import Payment, Wallet
|
||||||
from lnbits.core.models.notifications import (
|
from lnbits.core.models.notifications import (
|
||||||
NOTIFICATION_TEMPLATES,
|
NOTIFICATION_TEMPLATES,
|
||||||
NotificationMessage,
|
NotificationMessage,
|
||||||
NotificationType,
|
NotificationType,
|
||||||
)
|
)
|
||||||
from lnbits.core.models.users import UserNotifications
|
|
||||||
from lnbits.core.services.nostr import fetch_nip5_details, send_nostr_dm
|
from lnbits.core.services.nostr import fetch_nip5_details, send_nostr_dm
|
||||||
from lnbits.core.services.websockets import websocket_manager
|
from lnbits.core.services.websockets import websocket_manager
|
||||||
from lnbits.helpers import check_callback_url, is_valid_email_address
|
from lnbits.helpers import check_callback_url
|
||||||
from lnbits.settings import settings
|
from lnbits.settings import settings
|
||||||
from lnbits.utils.nostr import normalize_private_key
|
from lnbits.utils.nostr import normalize_private_key
|
||||||
|
|
||||||
notifications_queue: asyncio.Queue[NotificationMessage] = asyncio.Queue()
|
notifications_queue: asyncio.Queue = asyncio.Queue()
|
||||||
|
|
||||||
|
|
||||||
def enqueue_admin_notification(message_type: NotificationType, values: dict) -> None:
|
def enqueue_notification(message_type: NotificationType, values: dict) -> None:
|
||||||
if not _is_message_type_enabled(message_type):
|
if not is_message_type_enabled(message_type):
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
notifications_queue.put_nowait(
|
notifications_queue.put_nowait(
|
||||||
@@ -43,110 +42,43 @@ def enqueue_admin_notification(message_type: NotificationType, values: dict) ->
|
|||||||
logger.error(f"Error enqueuing notification: {e}")
|
logger.error(f"Error enqueuing notification: {e}")
|
||||||
|
|
||||||
|
|
||||||
def enqueue_user_notification(
|
async def process_next_notification():
|
||||||
message_type: NotificationType, values: dict, user_notifications: UserNotifications
|
notification_message: NotificationMessage = await notifications_queue.get()
|
||||||
) -> None:
|
|
||||||
try:
|
|
||||||
notifications_queue.put_nowait(
|
|
||||||
NotificationMessage(
|
|
||||||
message_type=message_type,
|
|
||||||
values=values,
|
|
||||||
user_notifications=user_notifications,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error enqueuing notification: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
async def process_next_notification() -> None:
|
|
||||||
notification_message = await notifications_queue.get()
|
|
||||||
message_type, text = _notification_message_to_text(notification_message)
|
message_type, text = _notification_message_to_text(notification_message)
|
||||||
user_notifications = notification_message.user_notifications
|
await send_notification(text, message_type)
|
||||||
if user_notifications:
|
|
||||||
await send_user_notification(user_notifications, text, message_type)
|
|
||||||
else:
|
|
||||||
await send_admin_notification(text, message_type)
|
|
||||||
|
|
||||||
|
|
||||||
async def send_admin_notification(
|
|
||||||
message: str,
|
|
||||||
message_type: str | None = None,
|
|
||||||
) -> None:
|
|
||||||
return await send_notification(
|
|
||||||
settings.lnbits_telegram_notifications_chat_id,
|
|
||||||
settings.lnbits_nostr_notifications_identifiers,
|
|
||||||
settings.lnbits_email_notifications_to_emails,
|
|
||||||
message,
|
|
||||||
message_type,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def send_user_notification(
|
|
||||||
user_notifications: UserNotifications,
|
|
||||||
message: str,
|
|
||||||
message_type: str | None = None,
|
|
||||||
) -> None:
|
|
||||||
|
|
||||||
email_address = (
|
|
||||||
[user_notifications.email_address] if user_notifications.email_address else []
|
|
||||||
)
|
|
||||||
nostr_identifiers = (
|
|
||||||
[user_notifications.nostr_identifier]
|
|
||||||
if user_notifications.nostr_identifier
|
|
||||||
else []
|
|
||||||
)
|
|
||||||
return await send_notification(
|
|
||||||
user_notifications.telegram_chat_id,
|
|
||||||
nostr_identifiers,
|
|
||||||
email_address,
|
|
||||||
message,
|
|
||||||
message_type,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def send_notification(
|
async def send_notification(
|
||||||
telegram_chat_id: str | None,
|
|
||||||
nostr_identifiers: list[str] | None,
|
|
||||||
email_addresses: list[str] | None,
|
|
||||||
message: str,
|
message: str,
|
||||||
message_type: str | None = None,
|
message_type: Optional[str] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
try:
|
try:
|
||||||
if telegram_chat_id and settings.is_telegram_notifications_configured():
|
if settings.lnbits_telegram_notifications_enabled:
|
||||||
await send_telegram_notification(telegram_chat_id, message)
|
await send_telegram_notification(message)
|
||||||
logger.debug(f"Sent telegram notification: {message_type}")
|
logger.debug(f"Sent telegram notification: {message_type}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error sending telegram notification {message_type}: {e}")
|
logger.error(f"Error sending telegram notification {message_type}: {e}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if nostr_identifiers and settings.is_nostr_notifications_configured():
|
if settings.lnbits_nostr_notifications_enabled:
|
||||||
await send_nostr_notifications(nostr_identifiers, message)
|
await send_nostr_notification(message)
|
||||||
logger.debug(f"Sent nostr notification: {message_type}")
|
logger.debug(f"Sent nostr notification: {message_type}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error sending nostr notification {message_type}: {e}")
|
logger.error(f"Error sending nostr notification {message_type}: {e}")
|
||||||
try:
|
try:
|
||||||
if email_addresses and settings.lnbits_email_notifications_enabled:
|
if settings.lnbits_email_notifications_enabled:
|
||||||
await send_email_notification(email_addresses, message)
|
await send_email_notification(message)
|
||||||
logger.debug(f"Sent email notification: {message_type}")
|
logger.debug(f"Sent email notification: {message_type}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error sending email notification {message_type}: {e}")
|
logger.error(f"Error sending email notification {message_type}: {e}")
|
||||||
|
|
||||||
|
|
||||||
async def send_nostr_notifications(identifiers: list[str], message: str) -> list[str]:
|
async def send_nostr_notification(message: str) -> dict:
|
||||||
success_sent: list[str] = []
|
for i in settings.lnbits_nostr_notifications_identifiers:
|
||||||
for identifier in identifiers:
|
|
||||||
try:
|
try:
|
||||||
await send_nostr_notification(identifier, message)
|
identifier = await fetch_nip5_details(i)
|
||||||
success_sent.append(identifier)
|
user_pubkey = identifier[0]
|
||||||
except Exception as e:
|
relays = identifier[1]
|
||||||
logger.warning(f"Error notifying identifier {identifier}: {e}")
|
|
||||||
return success_sent
|
|
||||||
|
|
||||||
|
|
||||||
async def send_nostr_notification(identifier: str, message: str):
|
|
||||||
nip5 = await fetch_nip5_details(identifier)
|
|
||||||
user_pubkey = nip5[0]
|
|
||||||
relays = nip5[1]
|
|
||||||
server_private_key = normalize_private_key(
|
server_private_key = normalize_private_key(
|
||||||
settings.lnbits_nostr_notifications_private_key
|
settings.lnbits_nostr_notifications_private_key
|
||||||
)
|
)
|
||||||
@@ -156,12 +88,16 @@ async def send_nostr_notification(identifier: str, message: str):
|
|||||||
message,
|
message,
|
||||||
relays,
|
relays,
|
||||||
)
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Error notifying identifier {i}: {e}")
|
||||||
|
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
async def send_telegram_notification(chat_id: str, message: str) -> dict:
|
async def send_telegram_notification(message: str) -> dict:
|
||||||
return await send_telegram_message(
|
return await send_telegram_message(
|
||||||
settings.lnbits_telegram_notifications_access_token,
|
settings.lnbits_telegram_notifications_access_token,
|
||||||
chat_id,
|
settings.lnbits_telegram_notifications_chat_id,
|
||||||
message,
|
message,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -175,56 +111,76 @@ async def send_telegram_message(token: str, chat_id: str, message: str) -> dict:
|
|||||||
return response.json()
|
return response.json()
|
||||||
|
|
||||||
|
|
||||||
async def send_email_notification(
|
async def send_email_notification(message: str) -> dict:
|
||||||
to_emails: list[str], 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(
|
await send_email(
|
||||||
settings.lnbits_email_notifications_server,
|
settings.lnbits_email_notifications_server,
|
||||||
settings.lnbits_email_notifications_port,
|
settings.lnbits_email_notifications_port,
|
||||||
settings.lnbits_email_notifications_username,
|
|
||||||
settings.lnbits_email_notifications_password,
|
settings.lnbits_email_notifications_password,
|
||||||
settings.lnbits_email_notifications_email,
|
settings.lnbits_email_notifications_email,
|
||||||
to_emails,
|
settings.lnbits_email_notifications_to_emails,
|
||||||
subject,
|
"LNbits Notification",
|
||||||
message,
|
message,
|
||||||
)
|
)
|
||||||
return {"status": "ok"}
|
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(
|
async def send_email(
|
||||||
server: str,
|
server: str,
|
||||||
port: int,
|
port: int,
|
||||||
username: str,
|
|
||||||
password: str,
|
password: str,
|
||||||
from_email: str,
|
from_email: str,
|
||||||
to_emails: list[str],
|
to_emails: list,
|
||||||
subject: str,
|
subject: str,
|
||||||
message: 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 = MIMEMultipart()
|
||||||
msg["From"] = from_email
|
msg["From"] = from_email
|
||||||
msg["To"] = ", ".join(to_emails)
|
msg["To"] = ", ".join(to_emails)
|
||||||
msg["Subject"] = subject
|
msg["Subject"] = subject
|
||||||
msg.attach(MIMEText(message, "plain"))
|
msg.attach(MIMEText(message, "plain"))
|
||||||
username = username if len(username) > 0 else from_email
|
try:
|
||||||
with smtplib.SMTP(server, port) as smtp_server:
|
with smtplib.SMTP(server, port) as smtp_server:
|
||||||
smtp_server.starttls()
|
smtp_server.starttls()
|
||||||
smtp_server.login(username, password)
|
smtp_server.login(from_email, password)
|
||||||
smtp_server.sendmail(from_email, to_emails, msg.as_string())
|
smtp_server.sendmail(from_email, to_emails, msg.as_string())
|
||||||
return True
|
logger.debug(f"Emails sent successfully to: {', '.join(to_emails)}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Failed to send email: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def is_message_type_enabled(message_type: NotificationType) -> bool:
|
||||||
|
if message_type == NotificationType.balance_update:
|
||||||
|
return settings.lnbits_notification_credit_debit
|
||||||
|
if message_type == NotificationType.settings_update:
|
||||||
|
return settings.lnbits_notification_settings_update
|
||||||
|
if message_type == NotificationType.watchdog_check:
|
||||||
|
return settings.lnbits_notification_watchdog
|
||||||
|
if message_type == NotificationType.balance_delta:
|
||||||
|
return settings.notification_balance_delta_changed
|
||||||
|
if message_type == NotificationType.server_start_stop:
|
||||||
|
return settings.lnbits_notification_server_start_stop
|
||||||
|
if message_type == NotificationType.server_status:
|
||||||
|
return settings.lnbits_notification_server_status_hours > 0
|
||||||
|
if message_type == NotificationType.incoming_payment:
|
||||||
|
return settings.lnbits_notification_incoming_payment_amount_sats > 0
|
||||||
|
if message_type == NotificationType.outgoing_payment:
|
||||||
|
return settings.lnbits_notification_outgoing_payment_amount_sats > 0
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _notification_message_to_text(
|
||||||
|
notification_message: NotificationMessage,
|
||||||
|
) -> Tuple[str, str]:
|
||||||
|
message_type = notification_message.message_type.value
|
||||||
|
meesage_value = NOTIFICATION_TEMPLATES.get(message_type, message_type)
|
||||||
|
try:
|
||||||
|
text = meesage_value.format(**notification_message.values)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Error formatting notification message: {e}")
|
||||||
|
text = meesage_value
|
||||||
|
text = f"""[{settings.lnbits_site_title}]\n{text}"""
|
||||||
|
return message_type, text
|
||||||
|
|
||||||
|
|
||||||
async def dispatch_webhook(payment: Payment):
|
async def dispatch_webhook(payment: Payment):
|
||||||
@@ -234,23 +190,24 @@ async def dispatch_webhook(payment: Payment):
|
|||||||
logger.debug("sending webhook", payment.webhook)
|
logger.debug("sending webhook", payment.webhook)
|
||||||
|
|
||||||
if not payment.webhook:
|
if not payment.webhook:
|
||||||
return await mark_webhook_sent(payment.payment_hash, "-1")
|
return await mark_webhook_sent(payment.payment_hash, -1)
|
||||||
|
|
||||||
headers = {"User-Agent": settings.user_agent}
|
headers = {"User-Agent": settings.user_agent}
|
||||||
async with httpx.AsyncClient(headers=headers) as client:
|
async with httpx.AsyncClient(headers=headers) as client:
|
||||||
|
data = payment.dict()
|
||||||
try:
|
try:
|
||||||
check_callback_url(payment.webhook)
|
check_callback_url(payment.webhook)
|
||||||
r = await client.post(payment.webhook, json=payment.json(), timeout=40)
|
r = await client.post(payment.webhook, json=data, timeout=40)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
await mark_webhook_sent(payment.payment_hash, str(r.status_code))
|
await mark_webhook_sent(payment.payment_hash, r.status_code)
|
||||||
except httpx.HTTPStatusError as exc:
|
except httpx.HTTPStatusError as exc:
|
||||||
await mark_webhook_sent(payment.payment_hash, str(exc.response.status_code))
|
await mark_webhook_sent(payment.payment_hash, exc.response.status_code)
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"webhook returned a bad status_code: {exc.response.status_code} "
|
f"webhook returned a bad status_code: {exc.response.status_code} "
|
||||||
f"while requesting {exc.request.url!r}."
|
f"while requesting {exc.request.url!r}."
|
||||||
)
|
)
|
||||||
except httpx.RequestError:
|
except httpx.RequestError:
|
||||||
await mark_webhook_sent(payment.payment_hash, "-1")
|
await mark_webhook_sent(payment.payment_hash, -1)
|
||||||
logger.warning(f"Could not send webhook to {payment.webhook}")
|
logger.warning(f"Could not send webhook to {payment.webhook}")
|
||||||
|
|
||||||
|
|
||||||
@@ -258,26 +215,23 @@ async def send_payment_notification(wallet: Wallet, payment: Payment):
|
|||||||
try:
|
try:
|
||||||
await send_ws_payment_notification(wallet, payment)
|
await send_ws_payment_notification(wallet, payment)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error sending websocket payment notification {e!s}")
|
logger.error("Error sending websocket payment notification", e)
|
||||||
try:
|
try:
|
||||||
await send_chat_payment_notification(wallet, payment)
|
send_chat_payment_notification(wallet, payment)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error sending chat payment notification {e!s}")
|
logger.error("Error sending chat payment notification", e)
|
||||||
try:
|
try:
|
||||||
await send_payment_push_notification(wallet, payment)
|
await send_payment_push_notification(wallet, payment)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error sending push payment notification {e!s}")
|
logger.error("Error sending push payment notification", e)
|
||||||
|
|
||||||
try:
|
|
||||||
if payment.webhook and not payment.webhook_status:
|
if payment.webhook and not payment.webhook_status:
|
||||||
await dispatch_webhook(payment)
|
await dispatch_webhook(payment)
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error dispatching webhook: {e!s}")
|
|
||||||
|
|
||||||
|
|
||||||
async def send_ws_payment_notification(wallet: Wallet, payment: Payment):
|
async def send_ws_payment_notification(wallet: Wallet, payment: Payment):
|
||||||
# TODO: websocket message should be a clean payment model
|
# TODO: websocket message should be a clean payment model
|
||||||
# await websocket_manager.send(wallet.inkey, payment.json())
|
# await websocket_manager.send_data(payment.json(), wallet.inkey)
|
||||||
# TODO: figure out why we send the balance with the payment here.
|
# TODO: figure out why we send the balance with the payment here.
|
||||||
# cleaner would be to have a separate message for the balance
|
# cleaner would be to have a separate message for the balance
|
||||||
# and send it with the id of the wallet so wallets can subscribe to it
|
# and send it with the id of the wallet so wallets can subscribe to it
|
||||||
@@ -288,15 +242,15 @@ async def send_ws_payment_notification(wallet: Wallet, payment: Payment):
|
|||||||
"payment": json.loads(payment.json()),
|
"payment": json.loads(payment.json()),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
await websocket_manager.send(wallet.inkey, payment_notification)
|
await websocket_manager.send_data(payment_notification, wallet.inkey)
|
||||||
await websocket_manager.send(wallet.adminkey, payment_notification)
|
await websocket_manager.send_data(payment_notification, wallet.adminkey)
|
||||||
await websocket_manager.send(
|
|
||||||
payment.payment_hash,
|
await websocket_manager.send_data(
|
||||||
json.dumps({"pending": payment.pending, "status": payment.status}),
|
json.dumps({"pending": payment.pending}), payment.payment_hash
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def send_chat_payment_notification(wallet: Wallet, payment: Payment):
|
def send_chat_payment_notification(wallet: Wallet, payment: Payment):
|
||||||
amount_sats = abs(payment.sat)
|
amount_sats = abs(payment.sat)
|
||||||
values: dict = {
|
values: dict = {
|
||||||
"wallet_id": wallet.id,
|
"wallet_id": wallet.id,
|
||||||
@@ -311,23 +265,10 @@ async def send_chat_payment_notification(wallet: Wallet, payment: Payment):
|
|||||||
|
|
||||||
if payment.is_out:
|
if payment.is_out:
|
||||||
if amount_sats >= settings.lnbits_notification_outgoing_payment_amount_sats:
|
if amount_sats >= settings.lnbits_notification_outgoing_payment_amount_sats:
|
||||||
enqueue_admin_notification(NotificationType.outgoing_payment, values)
|
enqueue_notification(NotificationType.outgoing_payment, values)
|
||||||
elif amount_sats >= settings.lnbits_notification_incoming_payment_amount_sats:
|
else:
|
||||||
enqueue_admin_notification(NotificationType.incoming_payment, values)
|
if amount_sats >= settings.lnbits_notification_incoming_payment_amount_sats:
|
||||||
|
enqueue_notification(NotificationType.incoming_payment, values)
|
||||||
user = await get_user(wallet.user)
|
|
||||||
user_notifications = user.extra.notifications if user else None
|
|
||||||
if user_notifications and wallet.id not in user_notifications.excluded_wallets:
|
|
||||||
out_limit = user_notifications.outgoing_payments_sats
|
|
||||||
in_limit = user_notifications.incoming_payments_sats
|
|
||||||
if payment.is_out and (amount_sats >= out_limit):
|
|
||||||
enqueue_user_notification(
|
|
||||||
NotificationType.outgoing_payment, values, user_notifications
|
|
||||||
)
|
|
||||||
elif amount_sats >= in_limit:
|
|
||||||
enqueue_user_notification(
|
|
||||||
NotificationType.incoming_payment, values, user_notifications
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def send_payment_push_notification(wallet: Wallet, payment: Payment):
|
async def send_payment_push_notification(wallet: Wallet, payment: Payment):
|
||||||
@@ -371,38 +312,3 @@ async def send_push_notification(subscription, title, body, url=""):
|
|||||||
f"failed sending push notification: "
|
f"failed sending push notification: "
|
||||||
f"{e.response.text if e.response else e}"
|
f"{e.response.text if e.response else e}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _is_message_type_enabled(message_type: NotificationType) -> bool:
|
|
||||||
if message_type == NotificationType.balance_update:
|
|
||||||
return settings.lnbits_notification_credit_debit
|
|
||||||
if message_type == NotificationType.settings_update:
|
|
||||||
return settings.lnbits_notification_settings_update
|
|
||||||
if message_type == NotificationType.watchdog_check:
|
|
||||||
return settings.lnbits_notification_watchdog
|
|
||||||
if message_type == NotificationType.balance_delta:
|
|
||||||
return settings.notification_balance_delta_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
|
|
||||||
|
|||||||
+103
-414
@@ -1,30 +1,25 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import time
|
import time
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
from bolt11 import Bolt11, MilliSatoshi, Tags
|
from bolt11 import Bolt11, MilliSatoshi, Tags
|
||||||
from bolt11 import decode as bolt11_decode
|
from bolt11 import decode as bolt11_decode
|
||||||
from bolt11 import encode as bolt11_encode
|
from bolt11 import encode as bolt11_encode
|
||||||
from lnurl import LnurlErrorResponse, LnurlSuccessResponse
|
|
||||||
from lnurl import execute_withdraw as lnurl_withdraw
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from lnbits.core.crud.payments import get_daily_stats
|
from lnbits.core.crud.payments import get_daily_stats
|
||||||
from lnbits.core.db import db
|
from lnbits.core.db import db
|
||||||
from lnbits.core.models import PaymentDailyStats, PaymentFilters
|
from lnbits.core.models import PaymentDailyStats, PaymentFilters
|
||||||
from lnbits.core.models.payments import CreateInvoice
|
|
||||||
from lnbits.db import Connection, Filters
|
from lnbits.db import Connection, Filters
|
||||||
from lnbits.decorators import check_user_extension_access
|
from lnbits.decorators import check_user_extension_access
|
||||||
from lnbits.exceptions import InvoiceError, PaymentError, UnsupportedError
|
from lnbits.exceptions import InvoiceError, PaymentError
|
||||||
from lnbits.fiat import get_fiat_provider
|
|
||||||
from lnbits.helpers import check_callback_url
|
|
||||||
from lnbits.settings import settings
|
from lnbits.settings import settings
|
||||||
from lnbits.tasks import create_task, internal_invoice_queue_put
|
from lnbits.tasks import create_task
|
||||||
from lnbits.utils.crypto import fake_privkey, random_secret_and_hash, verify_preimage
|
from lnbits.utils.crypto import fake_privkey, random_secret_and_hash
|
||||||
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis, satoshis_amount_as_fiat
|
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis, satoshis_amount_as_fiat
|
||||||
from lnbits.wallets import fake_wallet, get_funding_source
|
from lnbits.wallets import fake_wallet, get_funding_source
|
||||||
from lnbits.wallets.base import (
|
from lnbits.wallets.base import (
|
||||||
InvoiceResponse,
|
|
||||||
PaymentPendingStatus,
|
PaymentPendingStatus,
|
||||||
PaymentResponse,
|
PaymentResponse,
|
||||||
PaymentStatus,
|
PaymentStatus,
|
||||||
@@ -49,25 +44,21 @@ from ..models import (
|
|||||||
)
|
)
|
||||||
from .notifications import send_payment_notification
|
from .notifications import send_payment_notification
|
||||||
|
|
||||||
payment_lock = asyncio.Lock()
|
|
||||||
wallets_payments_lock: dict[str, asyncio.Lock] = {}
|
|
||||||
|
|
||||||
|
|
||||||
async def pay_invoice(
|
async def pay_invoice(
|
||||||
*,
|
*,
|
||||||
wallet_id: str,
|
wallet_id: str,
|
||||||
payment_request: str,
|
payment_request: str,
|
||||||
max_sat: int | None = None,
|
max_sat: Optional[int] = None,
|
||||||
extra: dict | None = None,
|
extra: Optional[dict] = None,
|
||||||
description: str = "",
|
description: str = "",
|
||||||
tag: str = "",
|
tag: str = "",
|
||||||
conn: Connection | None = None,
|
conn: Optional[Connection] = None,
|
||||||
) -> Payment:
|
) -> Payment:
|
||||||
if settings.lnbits_only_allow_incoming_payments:
|
if settings.lnbits_only_allow_incoming_payments:
|
||||||
raise PaymentError("Only incoming payments allowed.", status="failed")
|
raise PaymentError("Only incoming payments allowed.", status="failed")
|
||||||
invoice = _validate_payment_request(payment_request, max_sat)
|
invoice = _validate_payment_request(payment_request, max_sat)
|
||||||
if not invoice.amount_msat:
|
assert invoice.amount_msat
|
||||||
raise ValueError("Missig invoice amount.")
|
|
||||||
|
|
||||||
async with db.reuse_conn(conn) if conn else db.connect() as new_conn:
|
async with db.reuse_conn(conn) if conn else db.connect() as new_conn:
|
||||||
amount_msat = invoice.amount_msat
|
amount_msat = invoice.amount_msat
|
||||||
@@ -88,142 +79,10 @@ async def pay_invoice(
|
|||||||
extra=extra,
|
extra=extra,
|
||||||
)
|
)
|
||||||
|
|
||||||
payment = await _pay_invoice(wallet.id, create_payment_model, conn)
|
payment = await _pay_invoice(wallet, create_payment_model, conn)
|
||||||
|
|
||||||
async with db.reuse_conn(conn) if conn else db.connect() as new_conn:
|
async with db.reuse_conn(conn) if conn else db.connect() as new_conn:
|
||||||
await _credit_service_fee_wallet(wallet, payment, new_conn)
|
await _credit_service_fee_wallet(payment, 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: Connection | None = 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)
|
|
||||||
if not fiat_provider:
|
|
||||||
raise InvoiceError("No fiat provider found.", status="failed")
|
|
||||||
|
|
||||||
fiat_invoice = await fiat_provider.create_invoice(
|
|
||||||
amount=invoice_data.amount,
|
|
||||||
payment_hash=internal_payment.payment_hash,
|
|
||||||
currency=invoice_data.unit,
|
|
||||||
memo=invoice_data.memo,
|
|
||||||
extra=invoice_data.extra or {},
|
|
||||||
)
|
|
||||||
|
|
||||||
if fiat_invoice.failed:
|
|
||||||
logger.warning(fiat_invoice.error_message)
|
|
||||||
internal_payment.status = PaymentState.FAILED
|
|
||||||
await update_payment(internal_payment, conn=conn)
|
|
||||||
raise ValueError(
|
|
||||||
f"Cannot create payment request for '{fiat_provider_name}'.",
|
|
||||||
)
|
|
||||||
|
|
||||||
internal_payment.fee = -abs(
|
|
||||||
service_fee_fiat(internal_payment.msat, fiat_provider_name)
|
|
||||||
)
|
|
||||||
|
|
||||||
internal_payment.fiat_provider = fiat_provider_name
|
|
||||||
internal_payment.extra["fiat_checking_id"] = fiat_invoice.checking_id
|
|
||||||
# todo: move to payent
|
|
||||||
internal_payment.extra["fiat_payment_request"] = fiat_invoice.payment_request
|
|
||||||
new_checking_id = (
|
|
||||||
f"fiat_{fiat_provider_name}_"
|
|
||||||
f"{fiat_invoice.checking_id or internal_payment.checking_id}"
|
|
||||||
)
|
|
||||||
await update_payment(internal_payment, new_checking_id, conn=conn)
|
|
||||||
internal_payment.checking_id = new_checking_id
|
|
||||||
|
|
||||||
return internal_payment
|
|
||||||
|
|
||||||
|
|
||||||
async def create_wallet_invoice(wallet_id: str, data: CreateInvoice) -> Payment:
|
|
||||||
description_hash = None
|
|
||||||
unhashed_description = None
|
|
||||||
memo = data.memo or settings.lnbits_site_title
|
|
||||||
if data.description_hash or data.unhashed_description:
|
|
||||||
if data.description_hash:
|
|
||||||
try:
|
|
||||||
description_hash = bytes.fromhex(data.description_hash)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise ValueError(
|
|
||||||
"'description_hash' must be a valid hex string"
|
|
||||||
) from exc
|
|
||||||
if data.unhashed_description:
|
|
||||||
try:
|
|
||||||
unhashed_description = bytes.fromhex(data.unhashed_description)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise ValueError(
|
|
||||||
"'unhashed_description' must be a valid hex string",
|
|
||||||
) from exc
|
|
||||||
# do not save memo if description_hash or unhashed_description is set
|
|
||||||
memo = ""
|
|
||||||
|
|
||||||
payment = await create_invoice(
|
|
||||||
wallet_id=wallet_id,
|
|
||||||
amount=data.amount,
|
|
||||||
memo=memo,
|
|
||||||
currency=data.unit,
|
|
||||||
description_hash=description_hash,
|
|
||||||
unhashed_description=unhashed_description,
|
|
||||||
expiry=data.expiry,
|
|
||||||
extra=data.extra,
|
|
||||||
webhook=data.webhook,
|
|
||||||
internal=data.internal,
|
|
||||||
payment_hash=data.payment_hash,
|
|
||||||
)
|
|
||||||
|
|
||||||
if data.lnurl_withdraw:
|
|
||||||
try:
|
|
||||||
check_callback_url(data.lnurl_withdraw.callback)
|
|
||||||
res = await lnurl_withdraw(
|
|
||||||
data.lnurl_withdraw,
|
|
||||||
payment.bolt11,
|
|
||||||
user_agent=settings.user_agent,
|
|
||||||
timeout=10,
|
|
||||||
)
|
|
||||||
if isinstance(res, LnurlErrorResponse):
|
|
||||||
payment.extra["lnurl_response"] = res.reason
|
|
||||||
payment.status = "failed"
|
|
||||||
elif isinstance(res, LnurlSuccessResponse):
|
|
||||||
payment.extra["lnurl_response"] = True
|
|
||||||
payment.status = "success"
|
|
||||||
except Exception as exc:
|
|
||||||
payment.extra["lnurl_response"] = str(exc)
|
|
||||||
payment.status = "failed"
|
|
||||||
# updating to payment here would run into a race condition
|
|
||||||
# with the payment listeners and they will overwrite each other
|
|
||||||
|
|
||||||
return payment
|
return payment
|
||||||
|
|
||||||
@@ -232,16 +91,15 @@ async def create_invoice(
|
|||||||
*,
|
*,
|
||||||
wallet_id: str,
|
wallet_id: str,
|
||||||
amount: float,
|
amount: float,
|
||||||
currency: str | None = "sat",
|
currency: Optional[str] = "sat",
|
||||||
memo: str,
|
memo: str,
|
||||||
description_hash: bytes | None = None,
|
description_hash: Optional[bytes] = None,
|
||||||
unhashed_description: bytes | None = None,
|
unhashed_description: Optional[bytes] = None,
|
||||||
expiry: int | None = None,
|
expiry: Optional[int] = None,
|
||||||
extra: dict | None = None,
|
extra: Optional[dict] = None,
|
||||||
webhook: str | None = None,
|
webhook: Optional[str] = None,
|
||||||
internal: bool | None = False,
|
internal: Optional[bool] = False,
|
||||||
payment_hash: str | None = None,
|
conn: Optional[Connection] = None,
|
||||||
conn: Connection | None = None,
|
|
||||||
) -> Payment:
|
) -> Payment:
|
||||||
if not amount > 0:
|
if not amount > 0:
|
||||||
raise InvoiceError("Amountless invoices not supported.", status="failed")
|
raise InvoiceError("Amountless invoices not supported.", status="failed")
|
||||||
@@ -250,7 +108,7 @@ async def create_invoice(
|
|||||||
if not user_wallet:
|
if not user_wallet:
|
||||||
raise InvoiceError(f"Could not fetch wallet '{wallet_id}'.", status="failed")
|
raise InvoiceError(f"Could not fetch wallet '{wallet_id}'.", status="failed")
|
||||||
|
|
||||||
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
|
# use the fake wallet if the invoice is for internal use only
|
||||||
funding_source = fake_wallet if internal else get_funding_source()
|
funding_source = fake_wallet if internal else get_funding_source()
|
||||||
@@ -274,54 +132,38 @@ async def create_invoice(
|
|||||||
status="failed",
|
status="failed",
|
||||||
)
|
)
|
||||||
|
|
||||||
if payment_hash:
|
(
|
||||||
try:
|
ok,
|
||||||
invoice_response = await funding_source.create_hold_invoice(
|
checking_id,
|
||||||
amount=amount_sat,
|
payment_request,
|
||||||
memo=invoice_memo,
|
error_message,
|
||||||
payment_hash=payment_hash,
|
) = await funding_source.create_invoice(
|
||||||
description_hash=description_hash,
|
|
||||||
)
|
|
||||||
extra["hold_invoice"] = True
|
|
||||||
except UnsupportedError as exc:
|
|
||||||
raise InvoiceError(
|
|
||||||
"Hold invoices are not supported by the funding source.",
|
|
||||||
status="failed",
|
|
||||||
) from exc
|
|
||||||
else:
|
|
||||||
invoice_response = await funding_source.create_invoice(
|
|
||||||
amount=amount_sat,
|
amount=amount_sat,
|
||||||
memo=invoice_memo,
|
memo=invoice_memo,
|
||||||
description_hash=description_hash,
|
description_hash=description_hash,
|
||||||
unhashed_description=unhashed_description,
|
unhashed_description=unhashed_description,
|
||||||
expiry=expiry or settings.lightning_invoice_expiry,
|
expiry=expiry or settings.lightning_invoice_expiry,
|
||||||
)
|
)
|
||||||
if (
|
if not ok or not payment_request or not checking_id:
|
||||||
not invoice_response.ok
|
|
||||||
or not invoice_response.payment_request
|
|
||||||
or not invoice_response.checking_id
|
|
||||||
):
|
|
||||||
raise InvoiceError(
|
raise InvoiceError(
|
||||||
message=invoice_response.error_message or "unexpected backend error.",
|
error_message or "unexpected backend error.", status="pending"
|
||||||
status="pending",
|
|
||||||
)
|
)
|
||||||
invoice = bolt11_decode(invoice_response.payment_request)
|
|
||||||
|
invoice = bolt11_decode(payment_request)
|
||||||
|
|
||||||
create_payment_model = CreatePayment(
|
create_payment_model = CreatePayment(
|
||||||
wallet_id=wallet_id,
|
wallet_id=wallet_id,
|
||||||
bolt11=invoice_response.payment_request,
|
bolt11=payment_request,
|
||||||
payment_hash=invoice.payment_hash,
|
payment_hash=invoice.payment_hash,
|
||||||
preimage=invoice_response.preimage,
|
|
||||||
amount_msat=amount_sat * 1000,
|
amount_msat=amount_sat * 1000,
|
||||||
expiry=invoice.expiry_date,
|
expiry=invoice.expiry_date,
|
||||||
memo=memo,
|
memo=memo,
|
||||||
extra=extra,
|
extra=extra,
|
||||||
webhook=webhook,
|
webhook=webhook,
|
||||||
fee=invoice_response.fee_msat or 0,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
payment = await create_payment(
|
payment = await create_payment(
|
||||||
checking_id=invoice_response.checking_id,
|
checking_id=checking_id,
|
||||||
data=create_payment_model,
|
data=create_payment_model,
|
||||||
conn=conn,
|
conn=conn,
|
||||||
)
|
)
|
||||||
@@ -339,45 +181,17 @@ async def update_pending_payments(wallet_id: str):
|
|||||||
await update_pending_payment(payment)
|
await update_pending_payment(payment)
|
||||||
|
|
||||||
|
|
||||||
async def update_pending_payment(payment: Payment) -> Payment:
|
async def update_pending_payment(payment: Payment) -> bool:
|
||||||
status = await payment.check_status()
|
status = await payment.check_status()
|
||||||
if status.failed:
|
if status.failed:
|
||||||
payment.status = PaymentState.FAILED
|
payment.status = PaymentState.FAILED
|
||||||
await update_payment(payment)
|
await update_payment(payment)
|
||||||
elif status.success:
|
return True
|
||||||
payment = await update_payment_success_status(payment, status)
|
if status.success:
|
||||||
return payment
|
payment.status = PaymentState.SUCCESS
|
||||||
|
await update_payment(payment)
|
||||||
|
return True
|
||||||
async def check_pending_payments():
|
return False
|
||||||
"""
|
|
||||||
check_pending_payments is called during startup to check for pending payments with
|
|
||||||
the backend and also to delete expired invoices. Incoming payments will be
|
|
||||||
checked only once, outgoing pending payments will be checked regularly.
|
|
||||||
"""
|
|
||||||
funding_source = get_funding_source()
|
|
||||||
if funding_source.__class__.__name__ == "VoidWallet":
|
|
||||||
logger.warning("Task: skipping pending check for VoidWallet")
|
|
||||||
return
|
|
||||||
start_time = time.time()
|
|
||||||
pending_payments = await get_payments(
|
|
||||||
since=(int(time.time()) - 60 * 60 * 24 * 15), # 15 days ago
|
|
||||||
complete=False,
|
|
||||||
pending=True,
|
|
||||||
exclude_uncheckable=True,
|
|
||||||
)
|
|
||||||
count = len(pending_payments)
|
|
||||||
if count > 0:
|
|
||||||
logger.info(f"Task: checking {count} pending payments of last 15 days...")
|
|
||||||
for i, payment in enumerate(pending_payments):
|
|
||||||
payment = await update_pending_payment(payment)
|
|
||||||
prefix = f"payment ({i+1} / {count})"
|
|
||||||
logger.debug(f"{prefix} {payment.status} {payment.checking_id}")
|
|
||||||
await asyncio.sleep(0.01) # to avoid complete blocking
|
|
||||||
logger.info(
|
|
||||||
f"Task: pending check finished for {count} payments"
|
|
||||||
f" (took {time.time() - start_time:0.3f} s)"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def fee_reserve_total(amount_msat: int, internal: bool = False) -> int:
|
def fee_reserve_total(amount_msat: int, internal: bool = False) -> int:
|
||||||
@@ -385,7 +199,6 @@ def fee_reserve_total(amount_msat: int, internal: bool = False) -> int:
|
|||||||
|
|
||||||
|
|
||||||
def fee_reserve(amount_msat: int, internal: bool = False) -> int:
|
def fee_reserve(amount_msat: int, internal: bool = False) -> int:
|
||||||
amount_msat = abs(amount_msat)
|
|
||||||
return settings.fee_reserve(amount_msat, internal)
|
return settings.fee_reserve(amount_msat, internal)
|
||||||
|
|
||||||
|
|
||||||
@@ -405,30 +218,10 @@ def service_fee(amount_msat: int, internal: bool = False) -> int:
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def service_fee_fiat(amount_msat: int, fiat_provider_name: str) -> int:
|
|
||||||
"""
|
|
||||||
Calculate the service fee for a fiat provider based on the amount in msat.
|
|
||||||
Return the fee in msat.
|
|
||||||
"""
|
|
||||||
limits = settings.get_fiat_provider_limits(fiat_provider_name)
|
|
||||||
if not limits:
|
|
||||||
return 0
|
|
||||||
amount_msat = abs(amount_msat)
|
|
||||||
fee_max = limits.service_max_fee_sats * 1000
|
|
||||||
if not limits.service_fee_wallet_id:
|
|
||||||
return 0
|
|
||||||
|
|
||||||
fee_percentage = int(amount_msat / 100 * limits.service_fee_percent)
|
|
||||||
if fee_max > 0 and fee_percentage > fee_max:
|
|
||||||
return fee_max
|
|
||||||
else:
|
|
||||||
return fee_percentage
|
|
||||||
|
|
||||||
|
|
||||||
async def update_wallet_balance(
|
async def update_wallet_balance(
|
||||||
wallet: Wallet,
|
wallet: Wallet,
|
||||||
amount: int,
|
amount: int,
|
||||||
conn: Connection | None = None,
|
conn: Optional[Connection] = None,
|
||||||
):
|
):
|
||||||
if amount == 0:
|
if amount == 0:
|
||||||
raise ValueError("Amount cannot be 0.")
|
raise ValueError("Amount cannot be 0.")
|
||||||
@@ -483,18 +276,21 @@ async def update_wallet_balance(
|
|||||||
)
|
)
|
||||||
payment.status = PaymentState.SUCCESS
|
payment.status = PaymentState.SUCCESS
|
||||||
await update_payment(payment, conn=conn)
|
await update_payment(payment, conn=conn)
|
||||||
await internal_invoice_queue_put(payment.checking_id)
|
# notify receiver asynchronously
|
||||||
|
from lnbits.tasks import internal_invoice_queue
|
||||||
|
|
||||||
|
await internal_invoice_queue.put(payment.checking_id)
|
||||||
|
|
||||||
|
|
||||||
async def check_wallet_limits(
|
async def check_wallet_limits(
|
||||||
wallet_id: str, amount_msat: int, conn: Connection | None = None
|
wallet_id: str, amount_msat: int, conn: Optional[Connection] = None
|
||||||
):
|
):
|
||||||
await check_time_limit_between_transactions(wallet_id, conn)
|
await check_time_limit_between_transactions(wallet_id, conn)
|
||||||
await check_wallet_daily_withdraw_limit(wallet_id, amount_msat, conn)
|
await check_wallet_daily_withdraw_limit(wallet_id, amount_msat, conn)
|
||||||
|
|
||||||
|
|
||||||
async def check_time_limit_between_transactions(
|
async def check_time_limit_between_transactions(
|
||||||
wallet_id: str, conn: Connection | None = None
|
wallet_id: str, conn: Optional[Connection] = None
|
||||||
):
|
):
|
||||||
limit = settings.lnbits_wallet_limit_secs_between_trans
|
limit = settings.lnbits_wallet_limit_secs_between_trans
|
||||||
if not limit or limit <= 0:
|
if not limit or limit <= 0:
|
||||||
@@ -514,7 +310,7 @@ async def check_time_limit_between_transactions(
|
|||||||
|
|
||||||
|
|
||||||
async def check_wallet_daily_withdraw_limit(
|
async def check_wallet_daily_withdraw_limit(
|
||||||
wallet_id: str, amount_msat: int, conn: Connection | None = None
|
wallet_id: str, amount_msat: int, conn: Optional[Connection] = None
|
||||||
):
|
):
|
||||||
limit = settings.lnbits_wallet_limit_daily_max_withdraw
|
limit = settings.lnbits_wallet_limit_daily_max_withdraw
|
||||||
if not limit:
|
if not limit:
|
||||||
@@ -547,8 +343,8 @@ async def check_wallet_daily_withdraw_limit(
|
|||||||
async def calculate_fiat_amounts(
|
async def calculate_fiat_amounts(
|
||||||
amount: float,
|
amount: float,
|
||||||
wallet: Wallet,
|
wallet: Wallet,
|
||||||
currency: str | None = None,
|
currency: Optional[str] = None,
|
||||||
extra: dict | None = None,
|
extra: Optional[dict] = None,
|
||||||
) -> tuple[int, dict]:
|
) -> tuple[int, dict]:
|
||||||
wallet_currency = wallet.currency or settings.lnbits_default_accounting_currency
|
wallet_currency = wallet.currency or settings.lnbits_default_accounting_currency
|
||||||
fiat_amounts: dict = extra or {}
|
fiat_amounts: dict = extra or {}
|
||||||
@@ -583,9 +379,9 @@ async def calculate_fiat_amounts(
|
|||||||
|
|
||||||
|
|
||||||
async def check_transaction_status(
|
async def check_transaction_status(
|
||||||
wallet_id: str, payment_hash: str, conn: Connection | None = None
|
wallet_id: str, payment_hash: str, conn: Optional[Connection] = None
|
||||||
) -> PaymentStatus:
|
) -> PaymentStatus:
|
||||||
payment: Payment | None = await get_wallet_payment(
|
payment: Optional[Payment] = await get_wallet_payment(
|
||||||
wallet_id, payment_hash, conn=conn
|
wallet_id, payment_hash, conn=conn
|
||||||
)
|
)
|
||||||
if not payment:
|
if not payment:
|
||||||
@@ -599,9 +395,8 @@ async def check_transaction_status(
|
|||||||
|
|
||||||
async def get_payments_daily_stats(
|
async def get_payments_daily_stats(
|
||||||
filters: Filters[PaymentFilters],
|
filters: Filters[PaymentFilters],
|
||||||
user_id: str | None = None,
|
|
||||||
) -> list[PaymentDailyStats]:
|
) -> list[PaymentDailyStats]:
|
||||||
data_in, data_out = await get_daily_stats(filters, user_id=user_id)
|
data_in, data_out = await get_daily_stats(filters)
|
||||||
balance_total: float = 0
|
balance_total: float = 0
|
||||||
|
|
||||||
_none = PaymentDailyStats(date=datetime.now(timezone.utc))
|
_none = PaymentDailyStats(date=datetime.now(timezone.utc))
|
||||||
@@ -614,16 +409,13 @@ async def get_payments_daily_stats(
|
|||||||
|
|
||||||
data: list[PaymentDailyStats] = []
|
data: list[PaymentDailyStats] = []
|
||||||
|
|
||||||
def _tz(dt: datetime) -> datetime:
|
start_date = min(data_in[0].date, data_out[0].date)
|
||||||
return dt.replace(tzinfo=timezone.utc)
|
end_date = max(data_in[-1].date, data_out[-1].date)
|
||||||
|
|
||||||
start_date = min(_tz(data_in[0].date), _tz(data_out[0].date))
|
|
||||||
end_date = max(_tz(data_in[-1].date), _tz(data_out[-1].date))
|
|
||||||
delta = timedelta(days=1)
|
delta = timedelta(days=1)
|
||||||
while start_date <= end_date:
|
while start_date <= end_date:
|
||||||
|
|
||||||
data_in_point = next((x for x in data_in if _tz(x.date) == start_date), _none)
|
data_in_point = next((x for x in data_in if x.date == start_date), _none)
|
||||||
data_out_point = next((x for x in data_out if _tz(x.date) == start_date), _none)
|
data_out_point = next((x for x in data_out if x.date == start_date), _none)
|
||||||
|
|
||||||
balance_total += data_in_point.balance + data_out_point.balance
|
balance_total += data_in_point.balance + data_out_point.balance
|
||||||
data.append(
|
data.append(
|
||||||
@@ -645,23 +437,7 @@ async def get_payments_daily_stats(
|
|||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
async def _pay_invoice(
|
async def _pay_invoice(wallet, create_payment_model, conn):
|
||||||
wallet_id: str,
|
|
||||||
create_payment_model: CreatePayment,
|
|
||||||
conn: Connection | None = 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)
|
payment = await _pay_internal_invoice(wallet, create_payment_model, conn)
|
||||||
if not payment:
|
if not payment:
|
||||||
payment = await _pay_external_invoice(wallet, create_payment_model, conn)
|
payment = await _pay_external_invoice(wallet, create_payment_model, conn)
|
||||||
@@ -671,8 +447,8 @@ async def _pay_invoice(
|
|||||||
async def _pay_internal_invoice(
|
async def _pay_internal_invoice(
|
||||||
wallet: Wallet,
|
wallet: Wallet,
|
||||||
create_payment_model: CreatePayment,
|
create_payment_model: CreatePayment,
|
||||||
conn: Connection | None = None,
|
conn: Optional[Connection] = None,
|
||||||
) -> Payment | None:
|
) -> Optional[Payment]:
|
||||||
"""
|
"""
|
||||||
Pay an internal payment.
|
Pay an internal payment.
|
||||||
returns None if the payment is not internal.
|
returns None if the payment is not internal.
|
||||||
@@ -700,15 +476,12 @@ async def _pay_internal_invoice(
|
|||||||
):
|
):
|
||||||
raise PaymentError("Invalid invoice. Bolt11 changed.", status="failed")
|
raise PaymentError("Invalid invoice. Bolt11 changed.", status="failed")
|
||||||
|
|
||||||
fee_reserve_total_msat = fee_reserve_total(amount_msat, internal=True)
|
fee_reserve_total_msat = fee_reserve_total(abs(amount_msat), internal=True)
|
||||||
create_payment_model.fee = abs(fee_reserve_total_msat)
|
create_payment_model.fee = abs(fee_reserve_total_msat)
|
||||||
|
|
||||||
if wallet.balance_msat < abs(amount_msat) + fee_reserve_total_msat:
|
if wallet.balance_msat < abs(amount_msat) + fee_reserve_total_msat:
|
||||||
raise PaymentError("Insufficient balance.", status="failed")
|
raise PaymentError("Insufficient balance.", status="failed")
|
||||||
|
|
||||||
# release the preimage
|
|
||||||
create_payment_model.preimage = internal_invoice.preimage
|
|
||||||
|
|
||||||
internal_id = f"internal_{create_payment_model.payment_hash}"
|
internal_id = f"internal_{create_payment_model.payment_hash}"
|
||||||
logger.debug(f"creating temporary internal payment with id {internal_id}")
|
logger.debug(f"creating temporary internal payment with id {internal_id}")
|
||||||
payment = await create_payment(
|
payment = await create_payment(
|
||||||
@@ -739,15 +512,13 @@ async def _pay_internal_invoice(
|
|||||||
async def _pay_external_invoice(
|
async def _pay_external_invoice(
|
||||||
wallet: Wallet,
|
wallet: Wallet,
|
||||||
create_payment_model: CreatePayment,
|
create_payment_model: CreatePayment,
|
||||||
conn: Connection | None = None,
|
conn: Optional[Connection] = None,
|
||||||
) -> Payment:
|
) -> Payment:
|
||||||
checking_id = create_payment_model.payment_hash
|
checking_id = create_payment_model.payment_hash
|
||||||
amount_msat = create_payment_model.amount_msat
|
amount_msat = create_payment_model.amount_msat
|
||||||
|
|
||||||
fee_reserve_total_msat = fee_reserve_total(amount_msat, internal=False)
|
fee_reserve_total_msat = fee_reserve_total(amount_msat, internal=False)
|
||||||
|
|
||||||
if wallet.balance_msat < abs(amount_msat):
|
|
||||||
raise PaymentError("Insufficient balance.", status="failed")
|
|
||||||
if wallet.balance_msat < abs(amount_msat) + fee_reserve_total_msat:
|
if wallet.balance_msat < abs(amount_msat) + fee_reserve_total_msat:
|
||||||
raise PaymentError(
|
raise PaymentError(
|
||||||
f"You must reserve at least ({round(fee_reserve_total_msat/1000)}"
|
f"You must reserve at least ({round(fee_reserve_total_msat/1000)}"
|
||||||
@@ -767,55 +538,57 @@ async def _pay_external_invoice(
|
|||||||
)
|
)
|
||||||
|
|
||||||
fee_reserve_msat = fee_reserve(amount_msat, internal=False)
|
fee_reserve_msat = fee_reserve(amount_msat, internal=False)
|
||||||
|
service_fee_msat = service_fee(amount_msat, internal=False)
|
||||||
|
|
||||||
task = create_task(
|
task = create_task(
|
||||||
_fundingsource_pay_invoice(checking_id, payment.bolt11, fee_reserve_msat)
|
_fundingsource_pay_invoice(checking_id, payment.bolt11, fee_reserve_msat)
|
||||||
)
|
)
|
||||||
|
|
||||||
# make sure a hold invoice or deferred payment is not blocking the server
|
# make sure a hold invoice or deferred payment is not blocking the server
|
||||||
wait_time = max(1, settings.lnbits_funding_source_pay_invoice_wait_seconds)
|
|
||||||
try:
|
try:
|
||||||
payment_response = await asyncio.wait_for(task, timeout=wait_time)
|
wait_time = max(1, settings.lnbits_funding_source_pay_invoice_wait_seconds)
|
||||||
|
payment_response = await asyncio.wait_for(task, wait_time)
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
# return pending payment on timeout
|
# return pending payment on timeout
|
||||||
logger.debug(
|
logger.debug(f"payment timeout, {checking_id} is still pending")
|
||||||
f"payment timeout after {wait_time}s, {checking_id} is still pending"
|
|
||||||
)
|
|
||||||
return payment
|
return payment
|
||||||
|
|
||||||
# payment failed
|
if payment_response.checking_id and payment_response.checking_id != checking_id:
|
||||||
if (
|
logger.warning(
|
||||||
payment_response.checking_id is None
|
f"backend sent unexpected checking_id (expected: {checking_id} got:"
|
||||||
or payment_response.ok is False
|
f" {payment_response.checking_id})"
|
||||||
or payment_response.checking_id != checking_id
|
|
||||||
):
|
|
||||||
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
|
|
||||||
)
|
)
|
||||||
|
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)
|
await send_payment_notification(wallet, payment)
|
||||||
logger.success(f"payment successful {payment_response.checking_id}")
|
logger.success(f"payment successful {payment_response.checking_id}")
|
||||||
|
elif payment_response.checking_id is None and payment_response.ok is False:
|
||||||
payment.checking_id = payment_response.checking_id
|
# payment failed
|
||||||
return payment
|
logger.debug(f"payment failed {checking_id}, {payment_response.error_message}")
|
||||||
|
payment.status = PaymentState.FAILED
|
||||||
|
|
||||||
async def update_payment_success_status(
|
|
||||||
payment: Payment,
|
|
||||||
status: PaymentStatus,
|
|
||||||
conn: Connection | None = None,
|
|
||||||
) -> Payment:
|
|
||||||
if status.success:
|
|
||||||
service_fee_msat = service_fee(payment.amount, internal=False)
|
|
||||||
payment.status = PaymentState.SUCCESS
|
|
||||||
payment.fee = -(abs(status.fee_msat or 0) + abs(service_fee_msat))
|
|
||||||
payment.preimage = payment.preimage or status.preimage
|
|
||||||
await update_payment(payment, conn=conn)
|
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
|
return payment
|
||||||
|
|
||||||
|
|
||||||
@@ -832,7 +605,7 @@ async def _fundingsource_pay_invoice(
|
|||||||
|
|
||||||
|
|
||||||
async def _verify_external_payment(
|
async def _verify_external_payment(
|
||||||
payment: Payment, conn: Connection | None = None
|
payment: Payment, conn: Optional[Connection] = None
|
||||||
) -> Payment:
|
) -> Payment:
|
||||||
# fail on pending payments
|
# fail on pending payments
|
||||||
if payment.pending:
|
if payment.pending:
|
||||||
@@ -849,7 +622,8 @@ async def _verify_external_payment(
|
|||||||
|
|
||||||
if status.success:
|
if status.success:
|
||||||
# payment was successful on the fundingsource
|
# payment was successful on the fundingsource
|
||||||
await update_payment_success_status(payment, status, conn=conn)
|
payment.status = PaymentState.SUCCESS
|
||||||
|
await update_payment(payment, conn=conn)
|
||||||
raise PaymentError(
|
raise PaymentError(
|
||||||
"Failed payment was already paid on the fundingsource.",
|
"Failed payment was already paid on the fundingsource.",
|
||||||
status="success",
|
status="success",
|
||||||
@@ -863,7 +637,7 @@ async def _check_wallet_for_payment(
|
|||||||
wallet_id: str,
|
wallet_id: str,
|
||||||
tag: str,
|
tag: str,
|
||||||
amount_msat: int,
|
amount_msat: int,
|
||||||
conn: Connection | None = None,
|
conn: Optional[Connection] = None,
|
||||||
):
|
):
|
||||||
wallet = await get_wallet(wallet_id, conn=conn)
|
wallet = await get_wallet(wallet_id, conn=conn)
|
||||||
if not wallet:
|
if not wallet:
|
||||||
@@ -879,7 +653,7 @@ async def _check_wallet_for_payment(
|
|||||||
|
|
||||||
|
|
||||||
def _validate_payment_request(
|
def _validate_payment_request(
|
||||||
payment_request: str, max_sat: int | None = None
|
payment_request: str, max_sat: Optional[int] = None
|
||||||
) -> Bolt11:
|
) -> Bolt11:
|
||||||
try:
|
try:
|
||||||
invoice = bolt11_decode(payment_request)
|
invoice = bolt11_decode(payment_request)
|
||||||
@@ -902,22 +676,18 @@ def _validate_payment_request(
|
|||||||
|
|
||||||
|
|
||||||
async def _credit_service_fee_wallet(
|
async def _credit_service_fee_wallet(
|
||||||
wallet: Wallet, payment: Payment, conn: Connection | None = None
|
payment: Payment, conn: Optional[Connection] = None
|
||||||
):
|
):
|
||||||
service_fee_msat = service_fee(payment.amount, internal=payment.is_internal)
|
service_fee_msat = service_fee(payment.amount, internal=payment.is_internal)
|
||||||
if not settings.lnbits_service_fee_wallet or not service_fee_msat:
|
if not settings.lnbits_service_fee_wallet or not service_fee_msat:
|
||||||
return
|
return
|
||||||
|
|
||||||
memo = f"""
|
|
||||||
Service fee for payment of {abs(payment.sat)} sats.
|
|
||||||
Wallet: '{wallet.name}' ({wallet.id})."""
|
|
||||||
|
|
||||||
create_payment_model = CreatePayment(
|
create_payment_model = CreatePayment(
|
||||||
wallet_id=settings.lnbits_service_fee_wallet,
|
wallet_id=settings.lnbits_service_fee_wallet,
|
||||||
bolt11=payment.bolt11,
|
bolt11=payment.bolt11,
|
||||||
payment_hash=payment.payment_hash,
|
payment_hash=payment.payment_hash,
|
||||||
amount_msat=abs(service_fee_msat),
|
amount_msat=abs(service_fee_msat),
|
||||||
memo=memo,
|
memo="Service fee",
|
||||||
)
|
)
|
||||||
await create_payment(
|
await create_payment(
|
||||||
checking_id=f"service_fee_{payment.payment_hash}",
|
checking_id=f"service_fee_{payment.payment_hash}",
|
||||||
@@ -925,84 +695,3 @@ async def _credit_service_fee_wallet(
|
|||||||
status=PaymentState.SUCCESS,
|
status=PaymentState.SUCCESS,
|
||||||
conn=conn,
|
conn=conn,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _check_fiat_invoice_limits(
|
|
||||||
amount_sat: int, fiat_provider_name: str, conn: Connection | None = None
|
|
||||||
):
|
|
||||||
limits = settings.get_fiat_provider_limits(fiat_provider_name)
|
|
||||||
if not limits:
|
|
||||||
raise ValueError(
|
|
||||||
f"Fiat provider '{fiat_provider_name}' does not have limits configured.",
|
|
||||||
)
|
|
||||||
|
|
||||||
min_amount_sat = limits.service_min_amount_sats
|
|
||||||
if min_amount_sat and (amount_sat < min_amount_sat):
|
|
||||||
raise ValueError(
|
|
||||||
f"Minimum amount is {min_amount_sat} " f"sats for '{fiat_provider_name}'.",
|
|
||||||
)
|
|
||||||
max_amount_sats = limits.service_max_amount_sats
|
|
||||||
if max_amount_sats and (amount_sat > max_amount_sats):
|
|
||||||
raise ValueError(
|
|
||||||
f"Maximum amount is {max_amount_sats} " f"sats for '{fiat_provider_name}'.",
|
|
||||||
)
|
|
||||||
|
|
||||||
if limits.service_max_fee_sats > 0 or limits.service_fee_percent > 0:
|
|
||||||
if not limits.service_fee_wallet_id:
|
|
||||||
raise ValueError(
|
|
||||||
f"Fiat provider '{fiat_provider_name}' service fee wallet missing.",
|
|
||||||
)
|
|
||||||
fees_wallet = await get_wallet(limits.service_fee_wallet_id, conn=conn)
|
|
||||||
if not fees_wallet:
|
|
||||||
raise ValueError(
|
|
||||||
f"Fiat provider '{fiat_provider_name}' service fee wallet not found.",
|
|
||||||
)
|
|
||||||
|
|
||||||
if limits.service_faucet_wallet_id:
|
|
||||||
faucet_wallet = await get_wallet(limits.service_faucet_wallet_id, conn=conn)
|
|
||||||
if not faucet_wallet:
|
|
||||||
raise ValueError(
|
|
||||||
f"Fiat provider '{fiat_provider_name}' faucet wallet not found.",
|
|
||||||
)
|
|
||||||
if faucet_wallet.balance < amount_sat:
|
|
||||||
raise ValueError(
|
|
||||||
f"The amount exceeds the '{fiat_provider_name}'"
|
|
||||||
"faucet wallet balance.",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def settle_hold_invoice(payment: Payment, preimage: str) -> InvoiceResponse:
|
|
||||||
if verify_preimage(preimage, payment.payment_hash) is False:
|
|
||||||
raise InvoiceError("Invalid preimage.", status="failed")
|
|
||||||
|
|
||||||
funding_source = get_funding_source()
|
|
||||||
response = await funding_source.settle_hold_invoice(preimage=preimage)
|
|
||||||
|
|
||||||
if not response.ok:
|
|
||||||
raise InvoiceError(
|
|
||||||
response.error_message or "Unexpected backend error.", status="failed"
|
|
||||||
)
|
|
||||||
|
|
||||||
payment.preimage = preimage
|
|
||||||
payment.extra["hold_invoice_settled"] = True
|
|
||||||
await update_payment(payment)
|
|
||||||
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
async def cancel_hold_invoice(payment: Payment) -> InvoiceResponse:
|
|
||||||
funding_source = get_funding_source()
|
|
||||||
response = await funding_source.cancel_hold_invoice(
|
|
||||||
payment_hash=payment.payment_hash
|
|
||||||
)
|
|
||||||
|
|
||||||
if not response.ok:
|
|
||||||
raise InvoiceError(
|
|
||||||
response.error_message or "Unexpected backend error.", status="failed"
|
|
||||||
)
|
|
||||||
|
|
||||||
payment.status = PaymentState.FAILED
|
|
||||||
payment.extra["hold_invoice_cancelled"] = True
|
|
||||||
await update_payment(payment)
|
|
||||||
|
|
||||||
return response
|
|
||||||
|
|||||||
@@ -19,8 +19,7 @@ async def check_webpush_settings():
|
|||||||
vapid = Vapid()
|
vapid = Vapid()
|
||||||
vapid.generate_keys()
|
vapid.generate_keys()
|
||||||
privkey = vapid.private_pem()
|
privkey = vapid.private_pem()
|
||||||
if not vapid.public_key:
|
assert vapid.public_key, "VAPID public key does not exist"
|
||||||
raise ValueError("VAPID public key does not exist")
|
|
||||||
pubkey = b64urlencode(
|
pubkey = b64urlencode(
|
||||||
vapid.public_key.public_bytes(
|
vapid.public_key.public_bytes(
|
||||||
serialization.Encoding.X962,
|
serialization.Encoding.X962,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
@@ -7,6 +8,7 @@ from lnbits.core.models.extensions import UserExtension
|
|||||||
from lnbits.settings import (
|
from lnbits.settings import (
|
||||||
EditableSettings,
|
EditableSettings,
|
||||||
SuperSettings,
|
SuperSettings,
|
||||||
|
send_admin_user_to_saas,
|
||||||
settings,
|
settings,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -36,7 +38,7 @@ from .settings import update_cached_settings
|
|||||||
|
|
||||||
|
|
||||||
async def create_user_account(
|
async def create_user_account(
|
||||||
account: Account | None = None, wallet_name: str | None = None
|
account: Optional[Account] = None, wallet_name: Optional[str] = None
|
||||||
) -> User:
|
) -> User:
|
||||||
if not settings.new_accounts_allowed:
|
if not settings.new_accounts_allowed:
|
||||||
raise ValueError("Account creation is disabled.")
|
raise ValueError("Account creation is disabled.")
|
||||||
@@ -45,9 +47,7 @@ async def create_user_account(
|
|||||||
|
|
||||||
|
|
||||||
async def create_user_account_no_ckeck(
|
async def create_user_account_no_ckeck(
|
||||||
account: Account | None = None,
|
account: Optional[Account] = None, wallet_name: Optional[str] = None
|
||||||
wallet_name: str | None = None,
|
|
||||||
default_exts: list[str] | None = None,
|
|
||||||
) -> User:
|
) -> User:
|
||||||
|
|
||||||
if account:
|
if account:
|
||||||
@@ -70,17 +70,12 @@ async def create_user_account_no_ckeck(
|
|||||||
wallet_name=wallet_name or settings.lnbits_default_wallet_name,
|
wallet_name=wallet_name or settings.lnbits_default_wallet_name,
|
||||||
)
|
)
|
||||||
|
|
||||||
user_extensions = (default_exts or []) + settings.lnbits_user_default_extensions
|
for ext_id in settings.lnbits_user_default_extensions:
|
||||||
for ext_id in user_extensions:
|
|
||||||
try:
|
|
||||||
user_ext = UserExtension(user=account.id, extension=ext_id, active=True)
|
user_ext = UserExtension(user=account.id, extension=ext_id, active=True)
|
||||||
await create_user_extension(user_ext)
|
await update_user_extension(user_ext)
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error enabeling default extension {ext_id}: {e}")
|
|
||||||
|
|
||||||
user = await get_user_from_account(account)
|
user = await get_user_from_account(account)
|
||||||
if not user:
|
assert user, "Cannot find user for account."
|
||||||
raise ValueError("Cannot find user for account.")
|
|
||||||
|
|
||||||
return user
|
return user
|
||||||
|
|
||||||
@@ -159,17 +154,25 @@ async def check_admin_settings():
|
|||||||
with open(Path(settings.lnbits_data_folder) / ".super_user", "w") as file:
|
with open(Path(settings.lnbits_data_folder) / ".super_user", "w") as file:
|
||||||
file.write(settings.super_user)
|
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)
|
account = await get_account(settings.super_user)
|
||||||
if account and account.extra and account.extra.provider == "env":
|
if account and account.extra and account.extra.provider == "env":
|
||||||
settings.first_install = True
|
settings.first_install = True
|
||||||
|
|
||||||
logger.success(
|
logger.success(
|
||||||
"✔️ Admin UI is enabled. run `uv run lnbits-cli superuser` "
|
"✔️ Admin UI is enabled. run `poetry run lnbits-cli superuser` "
|
||||||
"to get the superuser."
|
"to get the superuser."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def init_admin_settings(super_user: str | None = None) -> SuperSettings:
|
async def init_admin_settings(super_user: Optional[str] = None) -> SuperSettings:
|
||||||
account = None
|
account = None
|
||||||
if super_user:
|
if super_user:
|
||||||
account = await get_account(super_user)
|
account = await get_account(super_user)
|
||||||
|
|||||||
@@ -1,65 +1,27 @@
|
|||||||
from asyncio import Queue
|
from fastapi import WebSocket
|
||||||
from dataclasses import dataclass
|
|
||||||
|
|
||||||
from fastapi import WebSocket, WebSocketDisconnect
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from lnbits.settings import settings
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class WebsocketConnection:
|
|
||||||
item_id: str
|
|
||||||
websocket: WebSocket
|
|
||||||
receive_queue: Queue[str]
|
|
||||||
|
|
||||||
|
|
||||||
class WebsocketConnectionManager:
|
class WebsocketConnectionManager:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.active_connections: list[WebsocketConnection] = []
|
self.active_connections: list[WebSocket] = []
|
||||||
|
|
||||||
async def connect(self, item_id: str, websocket: WebSocket) -> WebsocketConnection:
|
async def connect(self, websocket: WebSocket, item_id: str):
|
||||||
logger.debug(f"Websocket connected to {item_id}")
|
logger.debug(f"Websocket connected to {item_id}")
|
||||||
await websocket.accept()
|
await websocket.accept()
|
||||||
conn = WebsocketConnection(
|
self.active_connections.append(websocket)
|
||||||
item_id=item_id,
|
|
||||||
websocket=websocket,
|
|
||||||
receive_queue=Queue(),
|
|
||||||
)
|
|
||||||
self.active_connections.append(conn)
|
|
||||||
return conn
|
|
||||||
|
|
||||||
async def listen(self, conn: WebsocketConnection) -> None:
|
def disconnect(self, websocket: WebSocket):
|
||||||
while settings.lnbits_running:
|
self.active_connections.remove(websocket)
|
||||||
try:
|
|
||||||
data = await conn.websocket.receive_text()
|
|
||||||
logger.debug(f"WS received data from {conn.item_id}: {data}")
|
|
||||||
conn.receive_queue.put_nowait(data)
|
|
||||||
except WebSocketDisconnect:
|
|
||||||
for _conn in self.active_connections:
|
|
||||||
if _conn.websocket == conn.websocket:
|
|
||||||
self.active_connections.remove(_conn)
|
|
||||||
logger.debug(f"WS disconnected from {conn.item_id}")
|
|
||||||
break # out of the listen and the fastapi route
|
|
||||||
|
|
||||||
def get_connections(self, item_id: str) -> list[WebsocketConnection]:
|
async def send_data(self, message: str, item_id: str):
|
||||||
conns = []
|
for connection in self.active_connections:
|
||||||
for conn in self.active_connections:
|
if connection.path_params["item_id"] == item_id:
|
||||||
if conn.item_id == item_id:
|
await connection.send_text(message)
|
||||||
conns.append(conn)
|
|
||||||
return conns
|
|
||||||
|
|
||||||
def has_connection(self, item_id: str) -> bool:
|
|
||||||
return len(self.get_connections(item_id)) > 0
|
|
||||||
|
|
||||||
async def send(self, item_id: str, data: str) -> None:
|
|
||||||
for conn in self.get_connections(item_id):
|
|
||||||
await conn.websocket.send_text(data)
|
|
||||||
|
|
||||||
|
|
||||||
websocket_manager = WebsocketConnectionManager()
|
websocket_manager = WebsocketConnectionManager()
|
||||||
|
|
||||||
|
|
||||||
# deprecated import and use `websocket_manager.send()` instead
|
async def websocket_updater(item_id: str, data: str):
|
||||||
async def websocket_updater(item_id: str, data: str) -> None:
|
return await websocket_manager.send_data(data, item_id)
|
||||||
return await websocket_manager.send(item_id, data)
|
|
||||||
|
|||||||
+14
-14
@@ -1,6 +1,6 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import traceback
|
import traceback
|
||||||
from collections.abc import Callable, Coroutine
|
from typing import Callable, Coroutine
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
@@ -12,7 +12,7 @@ from lnbits.core.crud.audit import delete_expired_audit_entries
|
|||||||
from lnbits.core.crud.payments import get_payments_status_count
|
from lnbits.core.crud.payments import get_payments_status_count
|
||||||
from lnbits.core.crud.users import get_accounts
|
from lnbits.core.crud.users import get_accounts
|
||||||
from lnbits.core.crud.wallets import get_wallets_count
|
from lnbits.core.crud.wallets import get_wallets_count
|
||||||
from lnbits.core.models.audit import AuditEntry
|
from lnbits.core.models import AuditEntry
|
||||||
from lnbits.core.models.extensions import InstallableExtension
|
from lnbits.core.models.extensions import InstallableExtension
|
||||||
from lnbits.core.models.notifications import NotificationType
|
from lnbits.core.models.notifications import NotificationType
|
||||||
from lnbits.core.services.funding_source import (
|
from lnbits.core.services.funding_source import (
|
||||||
@@ -21,7 +21,7 @@ from lnbits.core.services.funding_source import (
|
|||||||
get_balance_delta,
|
get_balance_delta,
|
||||||
)
|
)
|
||||||
from lnbits.core.services.notifications import (
|
from lnbits.core.services.notifications import (
|
||||||
enqueue_admin_notification,
|
enqueue_notification,
|
||||||
process_next_notification,
|
process_next_notification,
|
||||||
send_payment_notification,
|
send_payment_notification,
|
||||||
)
|
)
|
||||||
@@ -30,10 +30,10 @@ from lnbits.settings import settings
|
|||||||
from lnbits.tasks import create_unique_task
|
from lnbits.tasks import create_unique_task
|
||||||
from lnbits.utils.exchange_rates import btc_rates
|
from lnbits.utils.exchange_rates import btc_rates
|
||||||
|
|
||||||
audit_queue: asyncio.Queue[AuditEntry] = asyncio.Queue()
|
audit_queue: asyncio.Queue = asyncio.Queue()
|
||||||
|
|
||||||
|
|
||||||
async def run_by_the_minute_tasks() -> None:
|
async def run_by_the_minute_tasks():
|
||||||
minute_counter = 0
|
minute_counter = 0
|
||||||
while settings.lnbits_running:
|
while settings.lnbits_running:
|
||||||
status_minutes = settings.lnbits_notification_server_status_hours * 60
|
status_minutes = settings.lnbits_notification_server_status_hours * 60
|
||||||
@@ -68,7 +68,7 @@ async def run_by_the_minute_tasks() -> None:
|
|||||||
await asyncio.sleep(60)
|
await asyncio.sleep(60)
|
||||||
|
|
||||||
|
|
||||||
async def _notify_server_status() -> None:
|
async def _notify_server_status():
|
||||||
accounts = await get_accounts(filters=Filters(limit=0))
|
accounts = await get_accounts(filters=Filters(limit=0))
|
||||||
wallets_count = await get_wallets_count()
|
wallets_count = await get_wallets_count()
|
||||||
payments = await get_payments_status_count()
|
payments = await get_payments_status_count()
|
||||||
@@ -86,10 +86,10 @@ async def _notify_server_status() -> None:
|
|||||||
"lnbits_balance_sats": status.lnbits_balance_sats,
|
"lnbits_balance_sats": status.lnbits_balance_sats,
|
||||||
"node_balance_sats": status.node_balance_sats,
|
"node_balance_sats": status.node_balance_sats,
|
||||||
}
|
}
|
||||||
enqueue_admin_notification(NotificationType.server_status, values)
|
enqueue_notification(NotificationType.server_status, values)
|
||||||
|
|
||||||
|
|
||||||
async def wait_for_paid_invoices(invoice_paid_queue: asyncio.Queue) -> None:
|
async def wait_for_paid_invoices(invoice_paid_queue: asyncio.Queue):
|
||||||
"""
|
"""
|
||||||
This worker dispatches events to all extensions and dispatches webhooks.
|
This worker dispatches events to all extensions and dispatches webhooks.
|
||||||
"""
|
"""
|
||||||
@@ -102,13 +102,13 @@ async def wait_for_paid_invoices(invoice_paid_queue: asyncio.Queue) -> None:
|
|||||||
await send_payment_notification(wallet, payment)
|
await send_payment_notification(wallet, payment)
|
||||||
|
|
||||||
|
|
||||||
async def wait_for_audit_data() -> None:
|
async def wait_for_audit_data():
|
||||||
"""
|
"""
|
||||||
Waits for audit entries to be pushed to the queue.
|
Waits for audit entries to be pushed to the queue.
|
||||||
Then it inserts the entries into the DB.
|
Then it inserts the entries into the DB.
|
||||||
"""
|
"""
|
||||||
while settings.lnbits_running:
|
while settings.lnbits_running:
|
||||||
data = await audit_queue.get()
|
data: AuditEntry = await audit_queue.get()
|
||||||
try:
|
try:
|
||||||
await create_audit_entry(data)
|
await create_audit_entry(data)
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
@@ -116,17 +116,17 @@ async def wait_for_audit_data() -> None:
|
|||||||
await asyncio.sleep(3)
|
await asyncio.sleep(3)
|
||||||
|
|
||||||
|
|
||||||
async def wait_notification_messages() -> None:
|
async def wait_notification_messages():
|
||||||
|
|
||||||
while settings.lnbits_running:
|
while settings.lnbits_running:
|
||||||
try:
|
try:
|
||||||
await process_next_notification()
|
await process_next_notification()
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
logger.warning("Payment notification error", ex)
|
logger.log("error", ex)
|
||||||
await asyncio.sleep(3)
|
await asyncio.sleep(3)
|
||||||
|
|
||||||
|
|
||||||
async def purge_audit_data() -> None:
|
async def purge_audit_data():
|
||||||
"""
|
"""
|
||||||
Remove audit entries which have passed their retention period.
|
Remove audit entries which have passed their retention period.
|
||||||
"""
|
"""
|
||||||
@@ -140,7 +140,7 @@ async def purge_audit_data() -> None:
|
|||||||
await asyncio.sleep(60 * 60)
|
await asyncio.sleep(60 * 60)
|
||||||
|
|
||||||
|
|
||||||
async def collect_exchange_rates_data() -> None:
|
async def collect_exchange_rates_data():
|
||||||
"""
|
"""
|
||||||
Collect exchange rates data. Used for monitoring only.
|
Collect exchange rates data. Used for monitoring only.
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -84,27 +84,6 @@
|
|||||||
/>
|
/>
|
||||||
</q-item-section>
|
</q-item-section>
|
||||||
</q-item>
|
</q-item>
|
||||||
<q-item tag="label" v-ripple>
|
|
||||||
<q-item-section>
|
|
||||||
<q-item-label>
|
|
||||||
<span v-text="$t('misc_disable_extensions_builder')"></span>
|
|
||||||
</q-item-label>
|
|
||||||
<q-item-label caption>
|
|
||||||
<span
|
|
||||||
v-text="$t('misc_disable_extensions_builder_label')"
|
|
||||||
></span>
|
|
||||||
</q-item-label>
|
|
||||||
</q-item-section>
|
|
||||||
<q-item-section avatar>
|
|
||||||
<q-toggle
|
|
||||||
size="md"
|
|
||||||
v-model="formData.lnbits_extensions_builder_activate_non_admins"
|
|
||||||
checked-icon="check"
|
|
||||||
color="green"
|
|
||||||
unchecked-icon="clear"
|
|
||||||
/>
|
|
||||||
</q-item-section>
|
|
||||||
</q-item>
|
|
||||||
<q-item tag="label" v-ripple>
|
<q-item tag="label" v-ripple>
|
||||||
<q-item-section>
|
<q-item-section>
|
||||||
<q-item-label>
|
<q-item-label>
|
||||||
@@ -126,17 +105,6 @@
|
|||||||
</q-item>
|
</q-item>
|
||||||
<br />
|
<br />
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12 col-md-6">
|
|
||||||
<p>
|
|
||||||
<span v-text="$t('extension_builder_manifest_url')"></span>
|
|
||||||
</p>
|
|
||||||
<q-input
|
|
||||||
filled
|
|
||||||
v-model="formData.lnbits_extensions_builder_manifest_url"
|
|
||||||
:label="$t('extension_builder_manifest_url')"
|
|
||||||
:hint="$t('extension_builder_manifest_url_hint')"
|
|
||||||
></q-input>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -150,6 +150,7 @@
|
|||||||
<div class="col-sm-12">
|
<div class="col-sm-12">
|
||||||
<q-separator></q-separator>
|
<q-separator></q-separator>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<strong v-text="$t('notifications_email_config')"></strong>
|
<strong v-text="$t('notifications_email_config')"></strong>
|
||||||
<q-item tag="label" v-ripple>
|
<q-item tag="label" v-ripple>
|
||||||
@@ -193,24 +194,6 @@
|
|||||||
/>
|
/>
|
||||||
</q-item-section>
|
</q-item-section>
|
||||||
</q-item>
|
</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 tag="label" v-ripple>
|
||||||
<q-item-section>
|
<q-item-section>
|
||||||
<q-item-label
|
<q-item-label
|
||||||
@@ -229,17 +212,7 @@
|
|||||||
/>
|
/>
|
||||||
</q-item-section>
|
</q-item-section>
|
||||||
</q-item>
|
</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="col">
|
|
||||||
<q-item tag="label" v-ripple>
|
<q-item tag="label" v-ripple>
|
||||||
<q-item-section>
|
<q-item-section>
|
||||||
<q-item-label
|
<q-item-label
|
||||||
@@ -276,6 +249,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</q-item-section>
|
</q-item-section>
|
||||||
</q-item>
|
</q-item>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col">
|
||||||
<q-item tag="label" v-ripple>
|
<q-item tag="label" v-ripple>
|
||||||
<q-item-section>
|
<q-item-section>
|
||||||
<q-item-label
|
<q-item-label
|
||||||
|
|||||||
@@ -132,8 +132,8 @@
|
|||||||
>
|
>
|
||||||
<strong class="q-my-none q-mb-sm">Keycloak Auth</strong>
|
<strong class="q-my-none q-mb-sm">Keycloak Auth</strong>
|
||||||
|
|
||||||
<div class="row q-col-gutter-sm q-col-gutter-y-md">
|
<div class="row">
|
||||||
<div class="col-md-4 col-sm-12">
|
<div class="col-md-4 col-sm-12 q-pr-sm">
|
||||||
<q-input
|
<q-input
|
||||||
filled
|
filled
|
||||||
v-model="formData.keycloak_discovery_url"
|
v-model="formData.keycloak_discovery_url"
|
||||||
@@ -141,7 +141,7 @@
|
|||||||
>
|
>
|
||||||
</q-input>
|
</q-input>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4 col-sm-12">
|
<div class="col-md-4 col-sm-12 q-pr-sm">
|
||||||
<q-input
|
<q-input
|
||||||
filled
|
filled
|
||||||
v-model="formData.keycloak_client_id"
|
v-model="formData.keycloak_client_id"
|
||||||
@@ -159,22 +159,6 @@
|
|||||||
>
|
>
|
||||||
</q-input>
|
</q-input>
|
||||||
</div>
|
</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')"
|
|
||||||
>
|
|
||||||
</q-input>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
<q-separator></q-separator>
|
<q-separator></q-separator>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
filled
|
filled
|
||||||
type="text"
|
type="text"
|
||||||
v-model="formData.lnbits_site_title"
|
v-model="formData.lnbits_site_title"
|
||||||
:label="$t('ui_site_title') + $t('ui_changing_remove_lnbits_elements')"
|
:label="$t('ui_site_title')"
|
||||||
></q-input>
|
></q-input>
|
||||||
<br />
|
<br />
|
||||||
</div>
|
</div>
|
||||||
@@ -53,6 +53,17 @@
|
|||||||
:label="$t('lnbits_wallet')"
|
:label="$t('lnbits_wallet')"
|
||||||
></q-input>
|
></q-input>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="col-12 col-md-4">
|
||||||
|
<p><span v-text="$t('denomination')"></span></p>
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
type="text"
|
||||||
|
v-model="formData.lnbits_denomination"
|
||||||
|
label="sats"
|
||||||
|
:hint="$t('denomination_hint')"
|
||||||
|
:rules="[(val) => !val || val.length == 3 || val == 'sats' || $t('denomination_error')]"
|
||||||
|
></q-input>
|
||||||
|
</div>
|
||||||
<div class="col-12 col-md-4">
|
<div class="col-12 col-md-4">
|
||||||
<p><span v-text="$t('ui_qr_code_logo')"></span></p>
|
<p><span v-text="$t('ui_qr_code_logo')"></span></p>
|
||||||
<q-input
|
<q-input
|
||||||
@@ -145,9 +156,7 @@
|
|||||||
type="text"
|
type="text"
|
||||||
:label="$t('ad_slots_label')"
|
:label="$t('ad_slots_label')"
|
||||||
:hint="$t('ad_slots_hint')"
|
:hint="$t('ad_slots_hint')"
|
||||||
><q-tooltip>
|
>
|
||||||
format {url};{img-light};{img-dark},{url};{img-light};{img-dark}"
|
|
||||||
</q-tooltip>
|
|
||||||
</q-input>
|
</q-input>
|
||||||
<q-toggle
|
<q-toggle
|
||||||
v-model="formData.lnbits_ad_space_enabled"
|
v-model="formData.lnbits_ad_space_enabled"
|
||||||
@@ -156,9 +165,9 @@
|
|||||||
<br />
|
<br />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row q-col-gutter-md q-my-md">
|
<p><span v-text="$t('ui_default_theme')"></span></p>
|
||||||
<div class="col-12" v-text="$t('ui_default_theme')"></div>
|
<div class="row q-col-gutter-md">
|
||||||
<div class="col-12 col-sm-6 col-lg-3">
|
<div class="col">
|
||||||
<q-select
|
<q-select
|
||||||
v-model="formData.lnbits_default_border"
|
v-model="formData.lnbits_default_border"
|
||||||
:options="globalBorderOptions"
|
:options="globalBorderOptions"
|
||||||
@@ -167,7 +176,7 @@
|
|||||||
>
|
>
|
||||||
</q-select>
|
</q-select>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12 col-sm-6 col-lg-3">
|
<div class="col">
|
||||||
<q-select
|
<q-select
|
||||||
v-model="formData.lnbits_default_theme"
|
v-model="formData.lnbits_default_theme"
|
||||||
:options="lnbits_theme_options"
|
:options="lnbits_theme_options"
|
||||||
@@ -176,7 +185,7 @@
|
|||||||
>
|
>
|
||||||
</q-select>
|
</q-select>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12 col-sm-6 col-lg-3">
|
<div class="col">
|
||||||
<q-select
|
<q-select
|
||||||
v-model="formData.lnbits_default_reaction"
|
v-model="formData.lnbits_default_reaction"
|
||||||
:options="reactionOptions"
|
:options="reactionOptions"
|
||||||
@@ -185,7 +194,7 @@
|
|||||||
>
|
>
|
||||||
</q-select>
|
</q-select>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12 col-sm-6 col-lg-3">
|
<div class="col">
|
||||||
<q-input
|
<q-input
|
||||||
type="text"
|
type="text"
|
||||||
v-model="formData.lnbits_default_bgimage"
|
v-model="formData.lnbits_default_bgimage"
|
||||||
@@ -195,7 +204,7 @@
|
|||||||
>
|
>
|
||||||
</q-input>
|
</q-input>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12 col-sm-6 col-lg-3">
|
<div class="col">
|
||||||
<q-toggle
|
<q-toggle
|
||||||
type="bool"
|
type="bool"
|
||||||
v-model="formData.lnbits_default_gradient"
|
v-model="formData.lnbits_default_gradient"
|
||||||
|
|||||||
@@ -115,13 +115,6 @@ import window_vars with context %} {% block scripts %} {{ window_vars(user) }}
|
|||||||
><q-tooltip v-if="!$q.screen.gt.sm"
|
><q-tooltip v-if="!$q.screen.gt.sm"
|
||||||
><span v-text="$t('exchanges')"></span></q-tooltip
|
><span v-text="$t('exchanges')"></span></q-tooltip
|
||||||
></q-tab>
|
></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
|
<q-tab
|
||||||
name="users"
|
name="users"
|
||||||
icon="group"
|
icon="group"
|
||||||
@@ -156,14 +149,6 @@ import window_vars with context %} {% block scripts %} {{ window_vars(user) }}
|
|||||||
><q-tooltip v-if="!$q.screen.gt.sm"
|
><q-tooltip v-if="!$q.screen.gt.sm"
|
||||||
><span v-text="$t('audit')"></span></q-tooltip
|
><span v-text="$t('audit')"></span></q-tooltip
|
||||||
></q-tab>
|
></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
|
<q-tab
|
||||||
style="word-break: break-all"
|
style="word-break: break-all"
|
||||||
name="site_customisation"
|
name="site_customisation"
|
||||||
@@ -178,27 +163,22 @@ import window_vars with context %} {% block scripts %} {{ window_vars(user) }}
|
|||||||
|
|
||||||
<template v-slot:after>
|
<template v-slot:after>
|
||||||
<q-form name="settings_form" id="settings_form">
|
<q-form name="settings_form" id="settings_form">
|
||||||
<q-scroll-area style="height: 100vh">
|
|
||||||
<q-tab-panels
|
<q-tab-panels
|
||||||
v-model="tab"
|
v-model="tab"
|
||||||
animated
|
animated
|
||||||
swipeable
|
swipeable
|
||||||
vertical
|
vertical
|
||||||
scroll
|
|
||||||
transition-prev="jump-up"
|
transition-prev="jump-up"
|
||||||
transition-next="jump-up"
|
transition-next="jump-up"
|
||||||
>
|
>
|
||||||
{% include "admin/_tab_funding.html" %} {% include
|
{% include "admin/_tab_funding.html" %} {% include
|
||||||
"admin/_tab_users.html" %} {% include "admin/_tab_server.html"
|
"admin/_tab_users.html" %} {% include "admin/_tab_server.html" %}
|
||||||
%} {% include "admin/_tab_exchange_providers.html" %}{% include
|
{% include "admin/_tab_exchange_providers.html" %} {% include
|
||||||
"admin/_tab_fiat_providers.html" %} {% include
|
|
||||||
"admin/_tab_extensions.html" %} {% include
|
"admin/_tab_extensions.html" %} {% include
|
||||||
"admin/_tab_notifications.html" %} {% include
|
"admin/_tab_notifications.html" %} {% include
|
||||||
"admin/_tab_security.html" %} {% include "admin/_tab_theme.html"
|
"admin/_tab_security.html" %} {% include "admin/_tab_theme.html"
|
||||||
%}{% include "admin/_tab_audit.html"%}{% include
|
%}{% include "admin/_tab_audit.html"%}
|
||||||
"admin/_tab_library.html"%}
|
|
||||||
</q-tab-panels>
|
</q-tab-panels>
|
||||||
</q-scroll-area>
|
|
||||||
</q-form>
|
</q-form>
|
||||||
</template>
|
</template>
|
||||||
</q-splitter>
|
</q-splitter>
|
||||||
|
|||||||
@@ -48,16 +48,6 @@
|
|||||||
class="cursor-pointer q-ml-sm"
|
class="cursor-pointer q-ml-sm"
|
||||||
@click="copyText(wallet.adminkey)"
|
@click="copyText(wallet.adminkey)"
|
||||||
></q-icon>
|
></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"
|
|
||||||
:show-buttons="false"
|
|
||||||
></lnbits-qrcode>
|
|
||||||
</div>
|
|
||||||
</q-popup-proxy>
|
|
||||||
</q-icon>
|
|
||||||
</div>
|
</div>
|
||||||
</q-item-section>
|
</q-item-section>
|
||||||
</q-item>
|
</q-item>
|
||||||
@@ -80,16 +70,6 @@
|
|||||||
class="cursor-pointer q-ml-sm"
|
class="cursor-pointer q-ml-sm"
|
||||||
@click="copyText(wallet.inkey)"
|
@click="copyText(wallet.inkey)"
|
||||||
></q-icon>
|
></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"
|
|
||||||
:show-buttons="false"
|
|
||||||
></lnbits-qrcode>
|
|
||||||
</div>
|
|
||||||
</q-popup-proxy>
|
|
||||||
</q-icon>
|
|
||||||
</div>
|
</div>
|
||||||
</q-item-section>
|
</q-item-section>
|
||||||
</q-item>
|
</q-item>
|
||||||
@@ -105,12 +85,7 @@
|
|||||||
<q-card-section>
|
<q-card-section>
|
||||||
<code><span class="text-light-green">GET</span> /api/v1/wallet</code>
|
<code><span class="text-light-green">GET</span> /api/v1/wallet</code>
|
||||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||||
<code
|
<code>{"X-Api-Key": "<i v-text="wallet.inkey"></i>"}</code><br />
|
||||||
>{"X-Api-Key": "<i
|
|
||||||
v-text="inkeyHidden ? '****************' : wallet.inkey"
|
|
||||||
></i
|
|
||||||
>"}</code
|
|
||||||
><br />
|
|
||||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||||
Returns 200 OK (application/json)
|
Returns 200 OK (application/json)
|
||||||
</h5>
|
</h5>
|
||||||
@@ -121,8 +96,7 @@
|
|||||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||||
<code
|
<code
|
||||||
>curl <span v-text="baseUrl"></span>api/v1/wallet -H "X-Api-Key:
|
>curl <span v-text="baseUrl"></span>api/v1/wallet -H "X-Api-Key:
|
||||||
<i v-text="inkeyHidden ? '****************' : wallet.inkey"></i
|
<i v-text="wallet.inkey"></i>"</code
|
||||||
>"</code
|
|
||||||
>
|
>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-card>
|
</q-card>
|
||||||
@@ -138,12 +112,7 @@
|
|||||||
<q-card-section>
|
<q-card-section>
|
||||||
<code><span class="text-light-green">POST</span> /api/v1/payments</code>
|
<code><span class="text-light-green">POST</span> /api/v1/payments</code>
|
||||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||||
<code
|
<code>{"X-Api-Key": "<i v-text="wallet.inkey"></i>"}</code><br />
|
||||||
>{"X-Api-Key": "<i
|
|
||||||
v-text="inkeyHidden ? '****************' : wallet.inkey"
|
|
||||||
></i
|
|
||||||
>"}</code
|
|
||||||
><br />
|
|
||||||
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||||
<code
|
<code
|
||||||
>{"out": false, "amount": <int>, "memo": <string>,
|
>{"out": false, "amount": <int>, "memo": <string>,
|
||||||
@@ -161,9 +130,8 @@
|
|||||||
<code
|
<code
|
||||||
>curl -X POST <span v-text="baseUrl"></span>api/v1/payments -d
|
>curl -X POST <span v-text="baseUrl"></span>api/v1/payments -d
|
||||||
'{"out": false, "amount": <int>, "memo": <string>}' -H
|
'{"out": false, "amount": <int>, "memo": <string>}' -H
|
||||||
"X-Api-Key:
|
"X-Api-Key: <i v-text="wallet.inkey"></i>" -H "Content-type:
|
||||||
<i v-text="inkeyHidden ? '****************' : wallet.inkey"></i>" -H
|
application/json"</code
|
||||||
"Content-type: application/json"</code
|
|
||||||
>
|
>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-card>
|
</q-card>
|
||||||
@@ -250,12 +218,7 @@
|
|||||||
/api/v1/payments/<payment_hash></code
|
/api/v1/payments/<payment_hash></code
|
||||||
>
|
>
|
||||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||||
<code
|
<code>{"X-Api-Key": "{{ wallet.inkey }}"}</code>
|
||||||
>{"X-Api-Key": "<i
|
|
||||||
v-text="inkeyHidden ? '****************' : wallet.inkey"
|
|
||||||
></i
|
|
||||||
>"}</code
|
|
||||||
>
|
|
||||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||||
Returns 200 OK (application/json)
|
Returns 200 OK (application/json)
|
||||||
</h5>
|
</h5>
|
||||||
@@ -264,33 +227,11 @@
|
|||||||
<code
|
<code
|
||||||
>curl -X GET
|
>curl -X GET
|
||||||
<span v-text="baseUrl"></span>api/v1/payments/<payment_hash> -H
|
<span v-text="baseUrl"></span>api/v1/payments/<payment_hash> -H
|
||||||
"X-Api-Key:
|
"X-Api-Key: <i v-text="wallet.inkey"></i>" -H "Content-type:
|
||||||
<i v-text="inkeyHidden ? '****************' : wallet.inkey"></i>" -H
|
application/json"</code
|
||||||
"Content-type: application/json"</code
|
|
||||||
>
|
>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-card>
|
</q-card>
|
||||||
<q-card>
|
|
||||||
<q-card-section>
|
|
||||||
<code
|
|
||||||
><span class="text-pink">WS</span>
|
|
||||||
/api/v1/ws/<invoice_key></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": <int>, "payment": <object>}</code>
|
|
||||||
</q-card-section>
|
|
||||||
</q-card>
|
|
||||||
</q-expansion-item>
|
</q-expansion-item>
|
||||||
<q-separator></q-separator>
|
<q-separator></q-separator>
|
||||||
<q-card-section>
|
<q-card-section>
|
||||||
|
|||||||
@@ -7,78 +7,45 @@
|
|||||||
<div class="row q-col-gutter-md q-mb-md">
|
<div class="row q-col-gutter-md q-mb-md">
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<q-card>
|
<q-card>
|
||||||
<div class="q-pa-sm">
|
<div>
|
||||||
<div class="row items-center justify-between q-gutter-xs">
|
<div class="q-gutter-y-md">
|
||||||
<div class="col">
|
<q-tabs v-model="tab" align="left">
|
||||||
<q-btn @click="updateAccount" unelevated color="primary">
|
<q-tab
|
||||||
<span v-text="$t('update_account')"></span>
|
name="user"
|
||||||
</q-btn>
|
:label="$t('account_settings')"
|
||||||
</div>
|
@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>
|
||||||
</div>
|
</div>
|
||||||
</q-card>
|
</q-card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row q-col-gutter-md q-mb-md">
|
<div class="row q-col-gutter-md">
|
||||||
<div class="col-12">
|
<div v-if="user" class="col-md-12 col-lg-6 q-gutter-y-md">
|
||||||
<q-card>
|
<q-card>
|
||||||
<q-splitter>
|
<q-card-section>
|
||||||
<template v-slot:before>
|
<div class="row">
|
||||||
<q-tabs v-model="tab" vertical active-color="primary">
|
<div class="col">
|
||||||
<q-tab
|
<q-tab-panels v-model="tab">
|
||||||
name="user"
|
|
||||||
icon="person"
|
|
||||||
:label="$q.screen.gt.sm ? $t('account_settings') : ''"
|
|
||||||
@update="val => tab = val.name"
|
|
||||||
>
|
|
||||||
<q-tooltip v-if="!$q.screen.gt.sm"
|
|
||||||
><span v-text="$t('account_settings')"></span
|
|
||||||
></q-tooltip>
|
|
||||||
</q-tab>
|
|
||||||
|
|
||||||
<q-tab
|
|
||||||
name="notifications"
|
|
||||||
icon="notifications"
|
|
||||||
:label="$q.screen.gt.sm ? $t('notifications') : ''"
|
|
||||||
@update="val => tab = val.name"
|
|
||||||
>
|
|
||||||
<q-tooltip v-if="!$q.screen.gt.sm"
|
|
||||||
><span v-text="$t('notifications')"></span
|
|
||||||
></q-tooltip>
|
|
||||||
</q-tab>
|
|
||||||
<q-tab
|
|
||||||
name="theme"
|
|
||||||
icon="palette"
|
|
||||||
:label="$q.screen.gt.sm ? $t('look_and_feel') : ''"
|
|
||||||
@update="val => tab = val.name"
|
|
||||||
>
|
|
||||||
<q-tooltip v-if="!$q.screen.gt.sm"
|
|
||||||
><span v-text="$t('look_and_feel')"></span
|
|
||||||
></q-tooltip>
|
|
||||||
</q-tab>
|
|
||||||
<q-tab
|
|
||||||
name="api_acls"
|
|
||||||
icon="lock"
|
|
||||||
:label="$q.screen.gt.sm ? $t('access_control_list') : ''"
|
|
||||||
@update="val => tab = val.name"
|
|
||||||
>
|
|
||||||
<q-tooltip v-if="!$q.screen.gt.sm"
|
|
||||||
><span v-text="$t('access_control_list')"></span
|
|
||||||
></q-tooltip>
|
|
||||||
</q-tab>
|
|
||||||
</q-tabs>
|
|
||||||
</template>
|
|
||||||
<template v-slot:after>
|
|
||||||
<q-scroll-area style="height: 80vh">
|
|
||||||
<q-tab-panels v-if="user" v-model="tab">
|
|
||||||
<q-tab-panel name="user">
|
<q-tab-panel name="user">
|
||||||
<div v-if="credentialsData.show">
|
<div v-if="credentialsData.show">
|
||||||
<q-card-section>
|
<q-card-section>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<h4 class="q-my-none">
|
<h4 class="q-my-none">
|
||||||
<span v-text="$t('password')"></span>
|
<span v-text="$t('password_config')"></span>
|
||||||
</h4>
|
</h4>
|
||||||
</div>
|
</div>
|
||||||
<div class="col">
|
<div class="col">
|
||||||
@@ -135,7 +102,7 @@
|
|||||||
unelevated
|
unelevated
|
||||||
color="primary"
|
color="primary"
|
||||||
class="float-right"
|
class="float-right"
|
||||||
:label="$t('update_password')"
|
:label="$t('change_password')"
|
||||||
>
|
>
|
||||||
</q-btn>
|
</q-btn>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
@@ -143,7 +110,7 @@
|
|||||||
<q-card-section>
|
<q-card-section>
|
||||||
<div class="col q-mb-sm">
|
<div class="col q-mb-sm">
|
||||||
<h4 class="q-my-none">
|
<h4 class="q-my-none">
|
||||||
Nostr <span v-text="$t('pubkey')"></span>
|
<span v-text="$t('pubkey')"></span>
|
||||||
</h4>
|
</h4>
|
||||||
</div>
|
</div>
|
||||||
<q-input
|
<q-input
|
||||||
@@ -305,30 +272,22 @@
|
|||||||
class="q-mb-md"
|
class="q-mb-md"
|
||||||
>
|
>
|
||||||
</q-input>
|
</q-input>
|
||||||
<q-input
|
|
||||||
v-model="user.external_id"
|
|
||||||
:label="$t('external_id')"
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
readonly
|
|
||||||
class="q-mb-md"
|
|
||||||
>
|
|
||||||
</q-input>
|
|
||||||
|
|
||||||
<q-input
|
<q-input
|
||||||
v-model="user.extra.picture"
|
v-model="user.extra.picture"
|
||||||
:label="$t('picture')"
|
:label="$t('picture')"
|
||||||
filled
|
filled
|
||||||
dense
|
|
||||||
class="q-mb-md"
|
class="q-mb-md"
|
||||||
>
|
>
|
||||||
</q-input>
|
</q-input>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
<q-separator></q-separator>
|
<q-separator></q-separator>
|
||||||
<q-card-section>
|
<q-card-section>
|
||||||
|
<q-btn @click="updateAccount" unelevated color="primary">
|
||||||
|
<span v-text="$t('update_account')"></span>
|
||||||
|
</q-btn>
|
||||||
<q-btn
|
<q-btn
|
||||||
@click="showUpdateCredentials()"
|
@click="showUpdateCredentials()"
|
||||||
:label="$t('change_password')"
|
:label="$t('update_credentials')"
|
||||||
filled
|
filled
|
||||||
color="primary"
|
color="primary"
|
||||||
class="float-right"
|
class="float-right"
|
||||||
@@ -376,111 +335,95 @@
|
|||||||
</q-btn-dropdown>
|
</q-btn-dropdown>
|
||||||
</div>
|
</div>
|
||||||
</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="row q-mb-md">
|
||||||
<div class="col-4">
|
<div class="col-4">
|
||||||
<span v-text="$t('color_scheme')"></span>
|
<span v-text="$t('color_scheme')"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-8">
|
<div class="col-8">
|
||||||
<q-btn
|
<q-btn
|
||||||
v-if="allowedThemes.includes('classic')"
|
v-if="g.allowedThemes.includes('classic')"
|
||||||
dense
|
dense
|
||||||
flat
|
flat
|
||||||
@click="changeTheme('classic')"
|
@click="themeChoiceFunc('classic')"
|
||||||
icon="circle"
|
icon="circle"
|
||||||
color="deep-purple"
|
color="deep-purple"
|
||||||
size="md"
|
size="md"
|
||||||
><q-tooltip>classic</q-tooltip>
|
><q-tooltip>classic</q-tooltip>
|
||||||
</q-btn>
|
</q-btn>
|
||||||
<q-btn
|
<q-btn
|
||||||
v-if="allowedThemes.includes('bitcoin')"
|
v-if="g.allowedThemes.includes('bitcoin')"
|
||||||
dense
|
dense
|
||||||
flat
|
flat
|
||||||
@click="changeTheme('bitcoin')"
|
@click="themeChoiceFunc('bitcoin')"
|
||||||
icon="circle"
|
icon="circle"
|
||||||
color="deep-orange"
|
color="orange"
|
||||||
size="md"
|
size="md"
|
||||||
><q-tooltip>bitcoin</q-tooltip>
|
><q-tooltip>bitcoin</q-tooltip>
|
||||||
</q-btn>
|
</q-btn>
|
||||||
<q-btn
|
<q-btn
|
||||||
v-if="allowedThemes.includes('mint')"
|
v-if="g.allowedThemes.includes('mint')"
|
||||||
dense
|
dense
|
||||||
flat
|
flat
|
||||||
@click="changeTheme('mint')"
|
@click="themeChoiceFunc('mint')"
|
||||||
icon="circle"
|
icon="circle"
|
||||||
color="green"
|
color="green"
|
||||||
size="md"
|
size="md"
|
||||||
><q-tooltip>mint</q-tooltip> </q-btn
|
><q-tooltip>mint</q-tooltip> </q-btn
|
||||||
><q-btn
|
><q-btn
|
||||||
v-if="allowedThemes.includes('autumn')"
|
v-if="g.allowedThemes.includes('autumn')"
|
||||||
dense
|
dense
|
||||||
flat
|
flat
|
||||||
@click="changeTheme('autumn')"
|
@click="themeChoiceFunc('autumn')"
|
||||||
icon="circle"
|
icon="circle"
|
||||||
color="brown"
|
color="brown"
|
||||||
size="md"
|
size="md"
|
||||||
><q-tooltip>autumn</q-tooltip>
|
><q-tooltip>autumn</q-tooltip>
|
||||||
</q-btn>
|
</q-btn>
|
||||||
<q-btn
|
<q-btn
|
||||||
v-if="allowedThemes.includes('monochrome')"
|
v-if="g.allowedThemes.includes('monochrome')"
|
||||||
dense
|
dense
|
||||||
flat
|
flat
|
||||||
@click="changeTheme('monochrome')"
|
@click="themeChoiceFunc('monochrome')"
|
||||||
icon="circle"
|
icon="circle"
|
||||||
color="grey"
|
color="grey"
|
||||||
size="md"
|
size="md"
|
||||||
><q-tooltip>monochrome</q-tooltip>
|
><q-tooltip>monochrome</q-tooltip>
|
||||||
</q-btn>
|
</q-btn>
|
||||||
<q-btn
|
<q-btn
|
||||||
v-if="allowedThemes.includes('salvador')"
|
v-if="g.allowedThemes.includes('salvador')"
|
||||||
dense
|
dense
|
||||||
flat
|
flat
|
||||||
@click="changeTheme('salvador')"
|
@click="themeChoiceFunc('salvador')"
|
||||||
icon="circle"
|
icon="circle"
|
||||||
color="blue-10"
|
color="blue-10"
|
||||||
size="md"
|
size="md"
|
||||||
><q-tooltip>elSalvador</q-tooltip>
|
><q-tooltip>elSalvador</q-tooltip>
|
||||||
</q-btn>
|
</q-btn>
|
||||||
<q-btn
|
<q-btn
|
||||||
v-if="allowedThemes.includes('freedom')"
|
v-if="g.allowedThemes.includes('freedom')"
|
||||||
dense
|
dense
|
||||||
flat
|
flat
|
||||||
@click="changeTheme('freedom')"
|
@click="themeChoiceFunc('freedom')"
|
||||||
icon="circle"
|
icon="circle"
|
||||||
color="pink-13"
|
color="pink-13"
|
||||||
size="md"
|
size="md"
|
||||||
><q-tooltip>freedom</q-tooltip>
|
><q-tooltip>freedom</q-tooltip>
|
||||||
</q-btn>
|
</q-btn>
|
||||||
<q-btn
|
<q-btn
|
||||||
v-if="allowedThemes.includes('cyber')"
|
v-if="g.allowedThemes.includes('cyber')"
|
||||||
dense
|
dense
|
||||||
flat
|
flat
|
||||||
@click="changeTheme('cyber')"
|
@click="themeChoiceFunc('cyber')"
|
||||||
icon="circle"
|
icon="circle"
|
||||||
color="light-green-9"
|
color="light-green-9"
|
||||||
size="md"
|
size="md"
|
||||||
><q-tooltip>cyber</q-tooltip>
|
><q-tooltip>cyber</q-tooltip>
|
||||||
</q-btn>
|
</q-btn>
|
||||||
<q-btn
|
<q-btn
|
||||||
v-if="allowedThemes.includes('flamingo')"
|
v-if="g.allowedThemes.includes('flamingo')"
|
||||||
dense
|
dense
|
||||||
flat
|
flat
|
||||||
@click="changeTheme('flamingo')"
|
@click="themeChoiceFunc('flamingo')"
|
||||||
icon="circle"
|
icon="circle"
|
||||||
color="pink-3"
|
color="pink-3"
|
||||||
size="md"
|
size="md"
|
||||||
@@ -494,9 +437,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="col-8">
|
<div class="col-8">
|
||||||
<q-input
|
<q-input
|
||||||
v-model="bgimageChoice"
|
v-model="bgimageSelection"
|
||||||
:label="$t('background_image')"
|
:label="$t('background_image')"
|
||||||
@update:model-value="applyBackgroundImage"
|
@update:model-value="bgimageChoiceFunc"
|
||||||
>
|
>
|
||||||
<q-tooltip
|
<q-tooltip
|
||||||
><span v-text="$t('background_image')"></span
|
><span v-text="$t('background_image')"></span
|
||||||
@@ -509,18 +452,19 @@
|
|||||||
<span v-text="$t('gradient_background')"></span>
|
<span v-text="$t('gradient_background')"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-8">
|
<div class="col-8">
|
||||||
<q-toggle
|
<q-btn
|
||||||
dense
|
dense
|
||||||
flat
|
flat
|
||||||
round
|
round
|
||||||
|
@click="toggleGradient"
|
||||||
icon="gradient"
|
icon="gradient"
|
||||||
|
size="sm"
|
||||||
v-model="gradientChoice"
|
v-model="gradientChoice"
|
||||||
@update:model-value="applyGradient"
|
|
||||||
>
|
>
|
||||||
<q-tooltip
|
<q-tooltip
|
||||||
><span v-text="$t('toggle_gradient')"></span
|
><span v-text="$t('toggle_gradient')"></span
|
||||||
></q-tooltip>
|
></q-tooltip>
|
||||||
</q-toggle>
|
</q-btn>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -529,11 +473,10 @@
|
|||||||
<span v-text="$t('toggle_darkmode')"></span>
|
<span v-text="$t('toggle_darkmode')"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-8">
|
<div class="col-8">
|
||||||
<q-toggle
|
<q-btn
|
||||||
dense
|
dense
|
||||||
flat
|
flat
|
||||||
round
|
round
|
||||||
v-model="darkChoice"
|
|
||||||
@click="toggleDarkMode"
|
@click="toggleDarkMode"
|
||||||
:icon="($q.dark.isActive) ? 'brightness_3' : 'wb_sunny'"
|
:icon="($q.dark.isActive) ? 'brightness_3' : 'wb_sunny'"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -541,7 +484,7 @@
|
|||||||
<q-tooltip
|
<q-tooltip
|
||||||
><span v-text="$t('toggle_darkmode')"></span
|
><span v-text="$t('toggle_darkmode')"></span
|
||||||
></q-tooltip>
|
></q-tooltip>
|
||||||
</q-toggle>
|
</q-btn>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row q-mb-md">
|
<div class="row q-mb-md">
|
||||||
@@ -550,7 +493,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="col-8">
|
<div class="col-8">
|
||||||
<q-select
|
<q-select
|
||||||
v-model="borderChoice"
|
v-model="borderSelection"
|
||||||
:options="borderOptions"
|
:options="borderOptions"
|
||||||
label="Borders"
|
label="Borders"
|
||||||
@update:model-value="applyBorder"
|
@update:model-value="applyBorder"
|
||||||
@@ -578,7 +521,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="col-8">
|
<div class="col-8">
|
||||||
<q-select
|
<q-select
|
||||||
v-model="reactionChoice"
|
v-model="reactionSelection"
|
||||||
:options="reactionOptions"
|
:options="reactionOptions"
|
||||||
label="Reactions"
|
label="Reactions"
|
||||||
@update:model-value="reactionChoiceFunc"
|
@update:model-value="reactionChoiceFunc"
|
||||||
@@ -590,101 +533,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</q-tab-panel>
|
</q-tab-panel>
|
||||||
<q-tab-panel name="notifications">
|
|
||||||
<q-card-section>
|
|
||||||
<div class="row q-mb-md">
|
|
||||||
<div class="col-4">
|
|
||||||
<span
|
|
||||||
v-text="$t('notifications_nostr_identifier')"
|
|
||||||
></span>
|
|
||||||
{%if not nostr_configured%}
|
|
||||||
<br />
|
|
||||||
<q-badge v-text="$t('not_connected')"></q-badge>
|
|
||||||
{%endif%}
|
|
||||||
</div>
|
|
||||||
<div class="col-8">
|
|
||||||
<q-input
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
v-model="user.extra.notifications.nostr_identifier"
|
|
||||||
:hint="$t('notifications_nostr_identifier_desc')"
|
|
||||||
>
|
|
||||||
</q-input>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row q-mb-md">
|
|
||||||
<div class="col-4">
|
|
||||||
<span v-text="$t('notifications_chat_id')"></span>
|
|
||||||
{%if not telegram_configured%}
|
|
||||||
<br />
|
|
||||||
<q-badge v-text="$t('not_connected')"></q-badge>
|
|
||||||
{%endif%}
|
|
||||||
</div>
|
|
||||||
<div class="col-8">
|
|
||||||
<q-input
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
v-model="user.extra.notifications.telegram_chat_id"
|
|
||||||
:hint="$t('notifications_chat_id_desc')"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<q-separator class="q-mb-md"></q-separator>
|
|
||||||
<div class="row q-mb-md">
|
|
||||||
<div class="col-4">
|
|
||||||
<span v-text="$t('notification_outgoing_payment')"></span>
|
|
||||||
</div>
|
|
||||||
<div class="col-8">
|
|
||||||
<q-input
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
step="1"
|
|
||||||
v-model="user.extra.notifications.outgoing_payments_sats"
|
|
||||||
:hint="$t('notification_outgoing_payment_desc')"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="row q-mb-md">
|
|
||||||
<div class="col-4">
|
|
||||||
<span v-text="$t('notification_incoming_payment')"></span>
|
|
||||||
</div>
|
|
||||||
<div class="col-8">
|
|
||||||
<q-input
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
step="1"
|
|
||||||
v-model="user.extra.notifications.incoming_payments_sats"
|
|
||||||
:hint="$t('notification_incoming_payment_desc')"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="row q-mb-md">
|
|
||||||
<div class="col-4">
|
|
||||||
<span v-text="$t('exclude_wallets')"></span>
|
|
||||||
</div>
|
|
||||||
<div class="col-8">
|
|
||||||
<q-select
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
emit-value
|
|
||||||
map-options
|
|
||||||
multiple
|
|
||||||
v-model="user.extra.notifications.excluded_wallets"
|
|
||||||
:options="g.user.walletOptions"
|
|
||||||
:label="$t('exclude_wallets')"
|
|
||||||
:hint="$t('notifications_excluded_wallets_desc')"
|
|
||||||
class="q-mt-sm"
|
|
||||||
>
|
|
||||||
</q-select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</q-card-section>
|
|
||||||
</q-tab-panel>
|
|
||||||
<q-tab-panel name="api_acls">
|
<q-tab-panel name="api_acls">
|
||||||
<div class="row q-mb-md">
|
<div class="row q-mb-md">
|
||||||
<q-badge v-if="user.admin">
|
<q-badge v-if="user.admin">
|
||||||
@@ -910,9 +758,16 @@
|
|||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-tab-panel>
|
</q-tab-panel>
|
||||||
</q-tab-panels>
|
</q-tab-panels>
|
||||||
</q-scroll-area>
|
</div>
|
||||||
</template>
|
</div>
|
||||||
</q-splitter>
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</div>
|
||||||
|
<div v-else class="col-12 col-md-6 q-gutter-y-md">
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<h4 class="q-my-none"><span v-text="$t('account')"></span></h4>
|
||||||
|
</q-card-section>
|
||||||
</q-card>
|
</q-card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -24,7 +24,6 @@
|
|||||||
v-text="$t('only_admins_can_install')"
|
v-text="$t('only_admins_can_install')"
|
||||||
></i>
|
></i>
|
||||||
<q-space></q-space>
|
<q-space></q-space>
|
||||||
|
|
||||||
<q-input
|
<q-input
|
||||||
:label="$t('search_extensions')"
|
:label="$t('search_extensions')"
|
||||||
:dense="dense"
|
:dense="dense"
|
||||||
@@ -54,18 +53,6 @@
|
|||||||
v-text="$t('new_version') + ` (${updatableExtensions?.length})`"
|
v-text="$t('new_version') + ` (${updatableExtensions?.length})`"
|
||||||
></span>
|
></span>
|
||||||
</q-badge>
|
</q-badge>
|
||||||
{% if extension_builder_enabled %}
|
|
||||||
<q-btn flat no-caps icon="architecture" to="/extensions/builder"
|
|
||||||
><span v-text="$t('create_extension')"></span
|
|
||||||
></q-btn>
|
|
||||||
{% else %}
|
|
||||||
<q-btn disabled flat no-caps icon="architecture"
|
|
||||||
><span v-text="$t('create_extension')"></span>
|
|
||||||
<q-tooltip
|
|
||||||
v-text="$t('only_admins_can_create_extensions')"
|
|
||||||
></q-tooltip>
|
|
||||||
</q-btn>
|
|
||||||
{% endif %}
|
|
||||||
<q-btn
|
<q-btn
|
||||||
v-if="g.user.admin"
|
v-if="g.user.admin"
|
||||||
flat
|
flat
|
||||||
@@ -137,40 +124,8 @@
|
|||||||
@click="showExtensionDetails(extension.id, extension.details_link)"
|
@click="showExtensionDetails(extension.id, extension.details_link)"
|
||||||
v-text="extension.name"
|
v-text="extension.name"
|
||||||
></div>
|
></div>
|
||||||
<div style="justify-content: space-between; display: flex">
|
<div>
|
||||||
<lnbits-extension-rating :rating="0" />
|
<lnbits-extension-rating :rating="0" />
|
||||||
<q-btn-group size="xs" style="margin: 5px 0">
|
|
||||||
<q-btn
|
|
||||||
v-if="extension.hasFreeRelease"
|
|
||||||
color="green"
|
|
||||||
size="xs"
|
|
||||||
:label="$t('free')"
|
|
||||||
>
|
|
||||||
<q-tooltip>
|
|
||||||
<span v-text="$t('extension_has_free_release')"></span>
|
|
||||||
</q-tooltip>
|
|
||||||
</q-btn>
|
|
||||||
<q-btn
|
|
||||||
v-if="extension.hasPaidRelease || extension.paidFeatures"
|
|
||||||
color="primary"
|
|
||||||
size="xs"
|
|
||||||
:label="$t('paid')"
|
|
||||||
>
|
|
||||||
<q-tooltip>
|
|
||||||
<span
|
|
||||||
v-if="extension.hasPaidRelease"
|
|
||||||
v-text="$t('extension_has_paid_release')"
|
|
||||||
></span>
|
|
||||||
<br
|
|
||||||
v-if="extension.hasPaidRelease && extension.paidFeatures"
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
v-if="extension.paidFeatures"
|
|
||||||
v-text="extension.paidFeatures"
|
|
||||||
></span>
|
|
||||||
</q-tooltip>
|
|
||||||
</q-btn>
|
|
||||||
</q-btn-group>
|
|
||||||
</div>
|
</div>
|
||||||
<div style="justify-content: space-between; display: flex">
|
<div style="justify-content: space-between; display: flex">
|
||||||
<q-toggle
|
<q-toggle
|
||||||
@@ -373,10 +328,13 @@
|
|||||||
<q-card v-if="selectedRelease" class="q-pa-lg lnbits__dialog-card">
|
<q-card v-if="selectedRelease" class="q-pa-lg lnbits__dialog-card">
|
||||||
<q-card-section>
|
<q-card-section>
|
||||||
<div v-if="selectedRelease.paymentRequest">
|
<div v-if="selectedRelease.paymentRequest">
|
||||||
|
<a :href="'lightning:' + selectedRelease.paymentRequest">
|
||||||
|
<q-responsive :ratio="1" class="q-mx-xl">
|
||||||
<lnbits-qrcode
|
<lnbits-qrcode
|
||||||
:value="'lightning:' + selectedRelease.paymentRequest.toUpperCase()"
|
:value="'lightning:' + selectedRelease.paymentRequest.toUpperCase()"
|
||||||
:href="'lightning:' + selectedRelease.paymentRequest"
|
|
||||||
></lnbits-qrcode>
|
></lnbits-qrcode>
|
||||||
|
</q-responsive>
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div v-else>
|
<div v-else>
|
||||||
<q-spinner-bars color="primary" size="2.55em"></q-spinner-bars>
|
<q-spinner-bars color="primary" size="2.55em"></q-spinner-bars>
|
||||||
@@ -744,10 +702,13 @@
|
|||||||
<q-card-section v-if="selectedExtension.payToEnable.showQRCode">
|
<q-card-section v-if="selectedExtension.payToEnable.showQRCode">
|
||||||
<div class="row q-mt-lg">
|
<div class="row q-mt-lg">
|
||||||
<div v-if="selectedExtension.payToEnable.paymentRequest" class="col">
|
<div v-if="selectedExtension.payToEnable.paymentRequest" class="col">
|
||||||
|
<a
|
||||||
|
:href="'lightning:' + selectedExtension.payToEnable.paymentRequest"
|
||||||
|
>
|
||||||
<lnbits-qrcode
|
<lnbits-qrcode
|
||||||
:value="'lightning:' + selectedExtension.payToEnable.paymentRequest.toUpperCase()"
|
:value="'lightning:' + selectedExtension.payToEnable.paymentRequest.toUpperCase()"
|
||||||
:href="'lightning:' + selectedExtension.payToEnable.paymentRequest"
|
|
||||||
></lnbits-qrcode>
|
></lnbits-qrcode>
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="col">
|
<div v-else class="col">
|
||||||
<q-spinner-bars color="primary" size="2.55em"></q-spinner-bars>
|
<q-spinner-bars color="primary" size="2.55em"></q-spinner-bars>
|
||||||
@@ -948,6 +909,7 @@
|
|||||||
:href="selectedExtensionDetails.repo"
|
:href="selectedExtensionDetails.repo"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
|
class="q-pr-xs"
|
||||||
><q-tooltip>repository</q-tooltip></q-btn
|
><q-tooltip>repository</q-tooltip></q-btn
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,830 +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">
|
|
||||||
<div class="col-12">
|
|
||||||
<q-stepper
|
|
||||||
v-model="step"
|
|
||||||
ref="stepper"
|
|
||||||
color="primary"
|
|
||||||
animated
|
|
||||||
header-nav
|
|
||||||
class="q-pt-sm"
|
|
||||||
@update:model-value="onStepChange"
|
|
||||||
>
|
|
||||||
<q-step
|
|
||||||
:name="1"
|
|
||||||
title="Describe"
|
|
||||||
icon="info"
|
|
||||||
:done="step > 1"
|
|
||||||
style="min-height: 100px"
|
|
||||||
>
|
|
||||||
<div class="row q-col-gutter-md">
|
|
||||||
<div class="col-12">
|
|
||||||
<span class="text-h6">
|
|
||||||
Tell us something about your extension:
|
|
||||||
</span>
|
|
||||||
<ul>
|
|
||||||
<li>This is the first step, you can return and change it.</li>
|
|
||||||
<li>
|
|
||||||
The <code>`name`</code> and
|
|
||||||
<code>`sort description`</code> fields are what the users will
|
|
||||||
see when browsing the list of extensions.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
The <code>`id`</code> field is used internally and in the URL of
|
|
||||||
your extension.
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- todo: add icon -->
|
|
||||||
|
|
||||||
<div class="col-12">
|
|
||||||
<div>
|
|
||||||
<q-btn
|
|
||||||
color="primary"
|
|
||||||
label="Upload Existing config"
|
|
||||||
@click="$refs.extensionDataInput.click()"
|
|
||||||
class="q-mb-md"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
ref="extensionDataInput"
|
|
||||||
accept="application/json"
|
|
||||||
style="display: none"
|
|
||||||
@change="onJsonDataInput"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<q-separator class="q-mt-sm"></q-separator>
|
|
||||||
<div class="row q-col-gutter-md q-mt-md">
|
|
||||||
<div class="col-md-4 col-sm-12">
|
|
||||||
<q-input
|
|
||||||
filled
|
|
||||||
v-model="extensionData.name"
|
|
||||||
label="Extension Name"
|
|
||||||
hint="The name of your extension"
|
|
||||||
>
|
|
||||||
</q-input>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-4 col-sm-12">
|
|
||||||
<q-input
|
|
||||||
filled
|
|
||||||
v-model="extensionData.id"
|
|
||||||
label="Extension Id"
|
|
||||||
hint="Lowercase letters, numbers, and underscores only (snake_case). This will be used in the URL."
|
|
||||||
>
|
|
||||||
</q-input>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-4 col-sm-12">
|
|
||||||
<q-input
|
|
||||||
filled
|
|
||||||
v-model="extensionData.short_description"
|
|
||||||
label="Short Description"
|
|
||||||
hint="A short description that is shown in the extension list."
|
|
||||||
>
|
|
||||||
</q-input>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="row q-mt-lg">
|
|
||||||
<div class="col-12">
|
|
||||||
<q-input
|
|
||||||
filled
|
|
||||||
v-model="extensionData.description"
|
|
||||||
label="Description"
|
|
||||||
hint="A detailed description of your extension."
|
|
||||||
type="textarea"
|
|
||||||
rows="3"
|
|
||||||
maxlength="1000"
|
|
||||||
>
|
|
||||||
</q-input>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</q-step>
|
|
||||||
|
|
||||||
<q-step
|
|
||||||
:name="2"
|
|
||||||
title="Settings"
|
|
||||||
icon="settings"
|
|
||||||
:done="step > 2"
|
|
||||||
style="min-height: 100px"
|
|
||||||
>
|
|
||||||
<div class="row q-col-gutter-md q-mt-md">
|
|
||||||
<div class="col-md-8 col-sm-12">
|
|
||||||
<iframe
|
|
||||||
ref="iframeStep2"
|
|
||||||
class="full-width"
|
|
||||||
height="400px"
|
|
||||||
sandbox="allow-scripts allow-same-origin"
|
|
||||||
></iframe>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-4 col-sm-12">
|
|
||||||
<q-btn
|
|
||||||
@click="previewExtension('settings')"
|
|
||||||
color="primary"
|
|
||||||
outline
|
|
||||||
label="Refresh Preview"
|
|
||||||
class="full-width q-mb-md"
|
|
||||||
></q-btn>
|
|
||||||
<q-toggle
|
|
||||||
v-model="extensionData.settings_data.enabled"
|
|
||||||
label="Generate Settings Fields"
|
|
||||||
size="md"
|
|
||||||
color="green"
|
|
||||||
/>
|
|
||||||
<br />
|
|
||||||
|
|
||||||
<ul>
|
|
||||||
<li>Define what settings your extension will have.</li>
|
|
||||||
<li>
|
|
||||||
You can choose if each user has its own settings or if the
|
|
||||||
settings are global (set by the admin).
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<q-separator
|
|
||||||
v-if="extensionData.settings_data.enabled"
|
|
||||||
class="q-mt-sm"
|
|
||||||
></q-separator>
|
|
||||||
<div v-if="extensionData.settings_data.enabled" class="row q-mt-lg">
|
|
||||||
<div class="col-md-2 col-sm-12">
|
|
||||||
<q-select
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
emit-value
|
|
||||||
map-options
|
|
||||||
v-model="extensionData.settings_data.type"
|
|
||||||
:options="settingsTypes"
|
|
||||||
></q-select>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-10 col-sm-12 q-pt-sm">
|
|
||||||
<q-badge
|
|
||||||
v-if="extensionData.settings_data.type === 'user'"
|
|
||||||
outline
|
|
||||||
class="text-caption q-ml-md"
|
|
||||||
>Each user can set its own settings for this extension.</q-badge
|
|
||||||
>
|
|
||||||
<q-badge v-else outline class="text-caption q-ml-md"
|
|
||||||
>Settings are set by the admin and apply to all users of the
|
|
||||||
extension</q-badge
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-if="extensionData.settings_data.enabled" class="row q-mt-lg">
|
|
||||||
<div class="col-12">
|
|
||||||
<lnbits-data-fields
|
|
||||||
:fields="extensionData.settings_data.fields"
|
|
||||||
:hide-advanced="true"
|
|
||||||
></lnbits-data-fields>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</q-step>
|
|
||||||
|
|
||||||
<q-step
|
|
||||||
:name="3"
|
|
||||||
:done="step > 3"
|
|
||||||
title="Owner Data"
|
|
||||||
icon="list"
|
|
||||||
style="min-height: 100px"
|
|
||||||
>
|
|
||||||
<div class="row q-col-gutter-md q-mt-md">
|
|
||||||
<div class="col-md-8 col-sm-12">
|
|
||||||
<iframe
|
|
||||||
ref="iframeStep3"
|
|
||||||
class="full-width"
|
|
||||||
height="400px"
|
|
||||||
sandbox="allow-scripts allow-same-origin"
|
|
||||||
></iframe>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-4 col-sm-12">
|
|
||||||
<q-btn
|
|
||||||
@click="previewExtension('owner_data')"
|
|
||||||
color="primary"
|
|
||||||
outline
|
|
||||||
label="Refresh Preview"
|
|
||||||
class="full-width q-mb-md"
|
|
||||||
></q-btn>
|
|
||||||
<q-input
|
|
||||||
v-model="extensionData.owner_data.name"
|
|
||||||
filled
|
|
||||||
label="Owner Table Name"
|
|
||||||
hint="CamelCase name for the owner data table (e.g. Campaign, PoS, etc.)"
|
|
||||||
class="q-mb-xl"
|
|
||||||
>
|
|
||||||
</q-input>
|
|
||||||
|
|
||||||
<ul>
|
|
||||||
<li>
|
|
||||||
The owner of the extension manages this data. It can add, remove
|
|
||||||
and update instances of it.
|
|
||||||
</li>
|
|
||||||
|
|
||||||
<li>
|
|
||||||
Some fileds are present by default, like
|
|
||||||
<code>created_at</code>, <code>updated_at</code> and
|
|
||||||
<code>extra</code>.
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row q-mt-lg">
|
|
||||||
<div class="col-12">
|
|
||||||
<lnbits-data-fields
|
|
||||||
:fields="extensionData.owner_data.fields"
|
|
||||||
></lnbits-data-fields>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</q-step>
|
|
||||||
|
|
||||||
<q-step
|
|
||||||
:name="4"
|
|
||||||
:done="step > 4"
|
|
||||||
title="Client Data"
|
|
||||||
icon="blur_linear"
|
|
||||||
style="min-height: 100px"
|
|
||||||
>
|
|
||||||
<div class="row q-col-gutter-md q-mt-md">
|
|
||||||
<div class="col-md-8 col-sm-12">
|
|
||||||
<iframe
|
|
||||||
ref="iframeStep4"
|
|
||||||
class="full-width"
|
|
||||||
height="400px"
|
|
||||||
sandbox="allow-scripts allow-same-origin"
|
|
||||||
></iframe>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-4 col-sm-12">
|
|
||||||
<q-btn
|
|
||||||
@click="previewExtension('client_data')"
|
|
||||||
color="primary"
|
|
||||||
outline
|
|
||||||
label="Refresh Preview"
|
|
||||||
class="full-width q-mb-md"
|
|
||||||
></q-btn>
|
|
||||||
<!-- <q-toggle
|
|
||||||
v-model="extensionData.client_data.enabled"
|
|
||||||
label="Generate Client Table"
|
|
||||||
disable
|
|
||||||
size="md"
|
|
||||||
color="green"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<br /> -->
|
|
||||||
<q-input
|
|
||||||
v-if="extensionData.client_data.enabled"
|
|
||||||
v-model="extensionData.client_data.name"
|
|
||||||
filled
|
|
||||||
label="Client Table Name"
|
|
||||||
hint="CamelCase name for the client data table (e.g. Donation, Payment, etc.)"
|
|
||||||
class="q-mb-xl"
|
|
||||||
>
|
|
||||||
</q-input>
|
|
||||||
<ul>
|
|
||||||
<li>
|
|
||||||
This data is created by users of the extension. Usually when
|
|
||||||
they submit a form or make a payment.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
The owner of the extension can view this data, but should not
|
|
||||||
modify it.
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<q-separator
|
|
||||||
v-if="extensionData.client_data.enabled"
|
|
||||||
class="q-mt-sm"
|
|
||||||
></q-separator>
|
|
||||||
<div v-if="extensionData.client_data.enabled" class="row q-mt-lg">
|
|
||||||
<div class="col-12">
|
|
||||||
<lnbits-data-fields
|
|
||||||
:fields="extensionData.client_data.fields"
|
|
||||||
></lnbits-data-fields>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</q-step>
|
|
||||||
|
|
||||||
<q-step
|
|
||||||
:name="5"
|
|
||||||
:done="step > 5"
|
|
||||||
title="Public Pages"
|
|
||||||
icon="link"
|
|
||||||
style="min-height: 100px"
|
|
||||||
>
|
|
||||||
<div class="row q-col-gutter-md q-mt-md">
|
|
||||||
<div class="col-md-8 col-sm-12">
|
|
||||||
<iframe
|
|
||||||
ref="iframeStep5"
|
|
||||||
class="full-width"
|
|
||||||
height="400px"
|
|
||||||
sandbox="allow-scripts allow-same-origin"
|
|
||||||
></iframe>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-md-4 col-sm-12">
|
|
||||||
<q-btn
|
|
||||||
@click="previewExtension('public_page')"
|
|
||||||
color="primary"
|
|
||||||
outline
|
|
||||||
label="Refresh Preview"
|
|
||||||
class="full-width q-mb-md"
|
|
||||||
></q-btn>
|
|
||||||
<q-toggle
|
|
||||||
v-model="extensionData.public_page.has_public_page"
|
|
||||||
label="Generate Public Page"
|
|
||||||
size="md"
|
|
||||||
color="green"
|
|
||||||
/>
|
|
||||||
<br />
|
|
||||||
|
|
||||||
<ul>
|
|
||||||
<li>
|
|
||||||
Most extensions have a public page that can be shared (this page
|
|
||||||
will still be accessible even if you have restricted access to
|
|
||||||
your LNbits install).
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-if="extensionData.public_page.has_public_page">
|
|
||||||
<div class="row q-col-gutter-md q-mt-md">
|
|
||||||
<div class="col-12 col-md-6">
|
|
||||||
<q-item tag="label" v-ripple>
|
|
||||||
<q-item-section>
|
|
||||||
<q-item-label>Public page title</q-item-label>
|
|
||||||
<q-item-label caption
|
|
||||||
>Select the field from the
|
|
||||||
<code v-text="extensionData.owner_data.name"></code>
|
|
||||||
(Owner Data) that will be used as a title for the public
|
|
||||||
page.</q-item-label
|
|
||||||
>
|
|
||||||
</q-item-section>
|
|
||||||
<q-item-section>
|
|
||||||
<q-select
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
emit-value
|
|
||||||
map-options
|
|
||||||
v-model="extensionData.public_page.owner_data_fields.name"
|
|
||||||
:options="[''].concat(extensionData.owner_data.fields.map(f => f.name))"
|
|
||||||
></q-select>
|
|
||||||
</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>Public page description</q-item-label>
|
|
||||||
<q-item-label caption
|
|
||||||
>Select the field from the
|
|
||||||
<code v-text="extensionData.owner_data.name"></code>
|
|
||||||
(Owner Data) that will be used as a description for the
|
|
||||||
public page.</q-item-label
|
|
||||||
>
|
|
||||||
</q-item-section>
|
|
||||||
<q-item-section>
|
|
||||||
<q-select
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
emit-value
|
|
||||||
map-options
|
|
||||||
v-model="extensionData.public_page.owner_data_fields.description"
|
|
||||||
:options="[''].concat(extensionData.owner_data.fields.map(f => f.name))"
|
|
||||||
></q-select>
|
|
||||||
</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>Public page inputs</q-item-label>
|
|
||||||
<q-item-label caption>
|
|
||||||
<ul class="q-pa-none q-ma-none">
|
|
||||||
<li>
|
|
||||||
Select the fields from the
|
|
||||||
<code v-text="extensionData.client_data.name"></code
|
|
||||||
> (Client Data) that will be shown as inputs in
|
|
||||||
the public page form.
|
|
||||||
</li>
|
|
||||||
<li>You can select multiple fields.</li>
|
|
||||||
<li>
|
|
||||||
A corresponding input field will be created for each
|
|
||||||
selected field.
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</q-item-label>
|
|
||||||
</q-item-section>
|
|
||||||
<q-item-section>
|
|
||||||
<q-select
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
emit-value
|
|
||||||
map-options
|
|
||||||
multiple
|
|
||||||
use-chips
|
|
||||||
v-model="extensionData.public_page.client_data_fields.public_inputs"
|
|
||||||
:options="extensionData.client_data.fields.map(f => f.name)"
|
|
||||||
></q-select>
|
|
||||||
</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>Generate Action Button</q-item-label>
|
|
||||||
<q-item-label caption>
|
|
||||||
<ul class="q-pa-none q-ma-none">
|
|
||||||
<li>
|
|
||||||
If enabled, the public page will have a button to
|
|
||||||
perform an action (e.g. generate a payment request).
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
The action will use the selected input fields from
|
|
||||||
<code v-text="extensionData.client_data.name"></code
|
|
||||||
> (Client Data) as parameters.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
A corresponding REST API endpoint will be created.
|
|
||||||
</li>
|
|
||||||
</ul></q-item-label
|
|
||||||
>
|
|
||||||
</q-item-section>
|
|
||||||
<q-item-section avatar>
|
|
||||||
<q-toggle
|
|
||||||
v-model="extensionData.public_page.action_fields.generate_action"
|
|
||||||
size="md"
|
|
||||||
color="green"
|
|
||||||
/>
|
|
||||||
</q-item-section>
|
|
||||||
</q-item>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<q-separator
|
|
||||||
v-if="extensionData.public_page.action_fields.generate_action"
|
|
||||||
class="q-mt-sm"
|
|
||||||
></q-separator>
|
|
||||||
|
|
||||||
<div v-if="extensionData.public_page.action_fields.generate_action">
|
|
||||||
<div class="row q-col-gutter-md q-mt-md">
|
|
||||||
<div class="col-12 col-md-6">
|
|
||||||
<q-item tag="label" v-ripple>
|
|
||||||
<q-item-section>
|
|
||||||
<q-item-label>Generate Payment Logic</q-item-label>
|
|
||||||
<q-item-label caption>
|
|
||||||
<ul class="q-pa-none q-ma-none">
|
|
||||||
<li>
|
|
||||||
If enabled, the endpoint will create an invoice from
|
|
||||||
the submitted data and the UI will show the QR code
|
|
||||||
with the invoice.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
A listener will be created to check for the pay event.
|
|
||||||
</li>
|
|
||||||
<li>You must map the fieds.</li>
|
|
||||||
</ul></q-item-label
|
|
||||||
>
|
|
||||||
</q-item-section>
|
|
||||||
<q-item-section avatar>
|
|
||||||
<q-toggle
|
|
||||||
v-model="extensionData.public_page.action_fields.generate_payment_logic"
|
|
||||||
size="md"
|
|
||||||
color="green"
|
|
||||||
/>
|
|
||||||
</q-item-section>
|
|
||||||
</q-item>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-12 col-md-6"></div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-if="extensionData.public_page.action_fields.generate_action && extensionData.public_page.action_fields.generate_payment_logic"
|
|
||||||
class="row q-col-gutter-md q-mt-md"
|
|
||||||
>
|
|
||||||
<div class="col-12 col-md-6">
|
|
||||||
<q-item tag="label" v-ripple>
|
|
||||||
<q-item-section>
|
|
||||||
<q-item-label>Wallet</q-item-label>
|
|
||||||
<q-item-label caption>
|
|
||||||
<ul class="q-pa-none q-ma-none">
|
|
||||||
<li>
|
|
||||||
Select the field from the
|
|
||||||
<code v-text="extensionData.owner_data.name"></code
|
|
||||||
> (Owner Data) that represents the wallet which
|
|
||||||
will generate the invoice and receive the payments.
|
|
||||||
</li>
|
|
||||||
|
|
||||||
<li>
|
|
||||||
Only fields with the type <code>Wallet</code> will be
|
|
||||||
shown.
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</q-item-label>
|
|
||||||
</q-item-section>
|
|
||||||
<q-item-section>
|
|
||||||
<q-select
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
emit-value
|
|
||||||
map-options
|
|
||||||
v-model="extensionData.public_page.action_fields.wallet_id"
|
|
||||||
:options="[''].concat(extensionData.owner_data.fields.filter(f => f.type === 'wallet').map(f => f.name))"
|
|
||||||
></q-select>
|
|
||||||
</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>Currency</q-item-label>
|
|
||||||
<q-item-label caption>
|
|
||||||
<ul class="q-pa-none q-ma-none">
|
|
||||||
<li>
|
|
||||||
Select the field from the
|
|
||||||
<code v-text="extensionData.owner_data.name"></code
|
|
||||||
> (Owner Data) that represents the currency
|
|
||||||
which will be used to for the amount.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Only fields with the type <code>Currency</code> will
|
|
||||||
be shown.
|
|
||||||
</li>
|
|
||||||
<li>Empty if you want to use sats.</li>
|
|
||||||
</ul>
|
|
||||||
</q-item-label>
|
|
||||||
</q-item-section>
|
|
||||||
<q-item-section>
|
|
||||||
<q-select
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
emit-value
|
|
||||||
map-options
|
|
||||||
v-model="extensionData.public_page.action_fields.currency"
|
|
||||||
:options="[''].concat(extensionData.owner_data.fields.filter(f => f.type === 'currency').map(f => f.name))"
|
|
||||||
></q-select>
|
|
||||||
</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>Amount</q-item-label>
|
|
||||||
<q-item-label caption>
|
|
||||||
<ul class="q-pa-none q-ma-none">
|
|
||||||
<li>
|
|
||||||
Select the field from the
|
|
||||||
<code v-text="extensionData.owner_data.name"></code
|
|
||||||
> (Owner Data) or
|
|
||||||
<code v-text="extensionData.client_data.name"></code
|
|
||||||
> (Client Data) that represents the amount (in
|
|
||||||
the selected currency).
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Only fields with the type <code>Integer</code> and
|
|
||||||
<code>Float</code> will be shown.
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</q-item-label>
|
|
||||||
</q-item-section>
|
|
||||||
<q-item-section>
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-6">
|
|
||||||
<q-select
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
emit-value
|
|
||||||
map-options
|
|
||||||
v-model="extensionData.public_page.action_fields.amount_source"
|
|
||||||
:options="amountSource"
|
|
||||||
class="q-mr-sm"
|
|
||||||
></q-select>
|
|
||||||
</div>
|
|
||||||
<div class="col-6">
|
|
||||||
<q-select
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
emit-value
|
|
||||||
map-options
|
|
||||||
v-model="extensionData.public_page.action_fields.amount"
|
|
||||||
:options="paymentActionAmountFields"
|
|
||||||
></q-select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</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>Paid Flag</q-item-label>
|
|
||||||
<q-item-label caption>
|
|
||||||
<ul class="q-pa-none q-ma-none">
|
|
||||||
<li>
|
|
||||||
Select the field from the
|
|
||||||
<code v-text="extensionData.client_data.name"></code
|
|
||||||
> (Client Data) that will be set to true when
|
|
||||||
the invoice is paid.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Only fields with the type <code>Boolean</code> will be
|
|
||||||
shown.
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</q-item-label>
|
|
||||||
</q-item-section>
|
|
||||||
<q-item-section>
|
|
||||||
<q-select
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
emit-value
|
|
||||||
map-options
|
|
||||||
v-model="extensionData.public_page.action_fields.paid_flag"
|
|
||||||
:options="[''].concat(extensionData.client_data.fields.filter(f => f.type === 'bool').map(f => f.name))"
|
|
||||||
></q-select>
|
|
||||||
</q-item-section>
|
|
||||||
</q-item>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</q-step>
|
|
||||||
|
|
||||||
<q-step
|
|
||||||
:name="6"
|
|
||||||
:done="step > 6"
|
|
||||||
title="Publish"
|
|
||||||
icon="publish"
|
|
||||||
style="min-height: 100px"
|
|
||||||
>
|
|
||||||
<div v-if="g.user.admin" class="row">
|
|
||||||
<div class="col-md-4 col-sm-12 col-xs-12">
|
|
||||||
<q-select
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
emit-value
|
|
||||||
map-options
|
|
||||||
v-model="extensionData.stub_version"
|
|
||||||
hint="The version of the extension stub. Make sure it is compatible with your LNbits install."
|
|
||||||
:options="extensionStubVersions.map(f => f.version)"
|
|
||||||
></q-select>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-4 col-sm-12 col-xs-12">
|
|
||||||
<q-btn
|
|
||||||
@click="cleanCacheData()"
|
|
||||||
color="grey"
|
|
||||||
outline
|
|
||||||
label="Clean Cache"
|
|
||||||
class="q-ml-md"
|
|
||||||
/>
|
|
||||||
<q-icon
|
|
||||||
name="info"
|
|
||||||
size="md"
|
|
||||||
color="primary"
|
|
||||||
class="cursor-pointer q-ml-xs q-mb-xs"
|
|
||||||
>
|
|
||||||
<q-tooltip>
|
|
||||||
<ul class="q-pl-sm">
|
|
||||||
<li>
|
|
||||||
The extension builder uses caching to speed up the build
|
|
||||||
process.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
This action clears old data and redownloads the Extension
|
|
||||||
Builder Stub release.
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</q-tooltip>
|
|
||||||
</q-icon>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-if="g.user.admin" class="row q-mt-md">
|
|
||||||
<div class="col-md-4 col-sm-12 col-xs-12">
|
|
||||||
<div class="row">
|
|
||||||
<q-btn
|
|
||||||
@click="buildExtensionAndDeploy()"
|
|
||||||
color="primary"
|
|
||||||
label="Build and Deploy (Admin Only)"
|
|
||||||
class="col"
|
|
||||||
/>
|
|
||||||
<q-icon
|
|
||||||
name="info"
|
|
||||||
size="md"
|
|
||||||
color="primary"
|
|
||||||
class="cursor-pointer q-ml-sm self-center"
|
|
||||||
>
|
|
||||||
<q-tooltip>
|
|
||||||
<ul class="q-pl-sm">
|
|
||||||
<li>
|
|
||||||
Installs the extension directly to this LNbits instance.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
The extension will be enabled by default, and available to
|
|
||||||
all users.
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</q-tooltip>
|
|
||||||
</q-icon>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row q-mt-md">
|
|
||||||
<div class="col-md-4 col-sm-12 col-xs-12">
|
|
||||||
<div class="row">
|
|
||||||
<q-btn
|
|
||||||
@click="buildExtension()"
|
|
||||||
outline
|
|
||||||
color="gray"
|
|
||||||
label="Download Extension Zip"
|
|
||||||
icon="download"
|
|
||||||
class="col"
|
|
||||||
/>
|
|
||||||
<q-icon
|
|
||||||
name="info"
|
|
||||||
size="md"
|
|
||||||
color="primary"
|
|
||||||
class="cursor-pointer q-ml-sm self-center"
|
|
||||||
>
|
|
||||||
<q-tooltip>
|
|
||||||
Builds the extension and downloads a zip file with the code.
|
|
||||||
You can then install it manually in your LNbits instance.
|
|
||||||
</q-tooltip>
|
|
||||||
</q-icon>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</q-step>
|
|
||||||
|
|
||||||
<template v-slot:navigation>
|
|
||||||
<q-separator></q-separator>
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-6 col-sm-12 q-pl-md q-pt-md">
|
|
||||||
<q-btn
|
|
||||||
v-if="step == 1"
|
|
||||||
label="Clear All Data"
|
|
||||||
color="negative"
|
|
||||||
@click="clearAllData"
|
|
||||||
></q-btn>
|
|
||||||
<q-btn
|
|
||||||
v-else
|
|
||||||
flat
|
|
||||||
color="grey-8"
|
|
||||||
class="q-mr-sm"
|
|
||||||
@click="previousStep()"
|
|
||||||
label="Back"
|
|
||||||
icon="chevron_left"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6 col-sm-12 q-pr-md q-pb-md">
|
|
||||||
<q-stepper-navigation class="float-right">
|
|
||||||
<q-btn
|
|
||||||
v-if="step < 6"
|
|
||||||
@click="nextStep()"
|
|
||||||
color="primary"
|
|
||||||
label="Next"
|
|
||||||
></q-btn>
|
|
||||||
<template v-else>
|
|
||||||
<q-btn
|
|
||||||
@click="exportJsonData()"
|
|
||||||
color="primary"
|
|
||||||
label="Export JSON Data"
|
|
||||||
></q-btn>
|
|
||||||
<q-icon
|
|
||||||
name="info"
|
|
||||||
size="md"
|
|
||||||
color="primary"
|
|
||||||
class="cursor-pointer q-ml-sm self-center"
|
|
||||||
>
|
|
||||||
<q-tooltip>
|
|
||||||
<ul class="q-pl-sm">
|
|
||||||
<li>
|
|
||||||
Exports the config JSON so it can be later imported or
|
|
||||||
shared.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
This JSON is also added to the zip in a file called
|
|
||||||
`builder.json`.
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</q-tooltip>
|
|
||||||
</q-icon>
|
|
||||||
</template>
|
|
||||||
</q-stepper-navigation>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</q-stepper>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="row q-col-gutter-md"></div>
|
|
||||||
|
|
||||||
{% endblock %}
|
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
:type="loginData.isPwdRepeat ? 'password' : 'text'"
|
:type="loginData.isPwdRepeat ? 'password' : 'text'"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
:label="$t('password_repeat')"
|
:label="$t('password_repeat')"
|
||||||
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password'), (val) => val === loginData.password || $t('invalid_password_repeat')]"
|
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password'), (val) => val === loginData.password || 'Passwords_dont_match']"
|
||||||
><template v-slot:append>
|
><template v-slot:append>
|
||||||
<q-icon
|
<q-icon
|
||||||
:name="loginData.isPwdRepeat ? 'visibility_off' : 'visibility'"
|
:name="loginData.isPwdRepeat ? 'visibility_off' : 'visibility'"
|
||||||
|
|||||||
@@ -59,6 +59,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
<script>
|
||||||
|
const ALLOWED_REGISTER = {{ LNBITS_NEW_ACCOUNTS_ALLOWED | tojson }};
|
||||||
|
</script>
|
||||||
<script src="{{ static_url_for('static', 'js/index.js') }}"></script>
|
<script src="{{ static_url_for('static', 'js/index.js') }}"></script>
|
||||||
{% endblock %} {% block page_container %}
|
{% endblock %} {% block page_container %}
|
||||||
<q-page-container>
|
<q-page-container>
|
||||||
@@ -84,7 +87,6 @@
|
|||||||
>
|
>
|
||||||
{{SITE_TITLE}}
|
{{SITE_TITLE}}
|
||||||
</h5>
|
</h5>
|
||||||
<template v-if="$q.screen.gt.sm">
|
|
||||||
<h6
|
<h6
|
||||||
class="q-my-sm"
|
class="q-my-sm"
|
||||||
v-if="'{{LNBITS_SHOW_HOME_PAGE_ELEMENTS}}' == 'True'"
|
v-if="'{{LNBITS_SHOW_HOME_PAGE_ELEMENTS}}' == 'True'"
|
||||||
@@ -97,7 +99,6 @@
|
|||||||
>
|
>
|
||||||
{{SITE_DESCRIPTION}}
|
{{SITE_DESCRIPTION}}
|
||||||
</p>
|
</p>
|
||||||
</template>
|
|
||||||
<!-- <div
|
<!-- <div
|
||||||
class="gt-sm"
|
class="gt-sm"
|
||||||
v-html="formatDescription"
|
v-html="formatDescription"
|
||||||
@@ -148,7 +149,7 @@
|
|||||||
<username-password
|
<username-password
|
||||||
v-if="authMethod != 'user-id-only'"
|
v-if="authMethod != 'user-id-only'"
|
||||||
:allowed_new_users="allowedRegister"
|
:allowed_new_users="allowedRegister"
|
||||||
:auth-methods="LNBITS_AUTH_METHODS"
|
:auth-methods="{{ LNBITS_AUTH_METHODS }}"
|
||||||
:auth-action="authAction"
|
:auth-action="authAction"
|
||||||
v-model:user-name="username"
|
v-model:user-name="username"
|
||||||
v-model:password_1="password"
|
v-model:password_1="password"
|
||||||
@@ -166,12 +167,11 @@
|
|||||||
v-if="authAction === 'login' && allowedRegister"
|
v-if="authAction === 'login' && allowedRegister"
|
||||||
class="q-mb-none"
|
class="q-mb-none"
|
||||||
>
|
>
|
||||||
Not registered?
|
Not registered? Create an
|
||||||
<a
|
<span
|
||||||
href="#"
|
|
||||||
class="text-secondary cursor-pointer"
|
class="text-secondary cursor-pointer"
|
||||||
@click.prevent="showRegister('username-password')"
|
@click="showRegister('username-password')"
|
||||||
>Create an Account</a
|
>Account</span
|
||||||
>
|
>
|
||||||
</p>
|
</p>
|
||||||
<p
|
<p
|
||||||
@@ -180,15 +180,14 @@
|
|||||||
>
|
>
|
||||||
<span v-text="$t('new_user_not_allowed')"></span>
|
<span v-text="$t('new_user_not_allowed')"></span>
|
||||||
</p>
|
</p>
|
||||||
<p v-else-if="authAction === 'register'" class="q-mb-none">
|
|
||||||
<span v-text="$t('existing_account_question')"></span>
|
|
||||||
|
|
||||||
<a
|
<p v-else-if="authAction === 'register'" class="q-mb-none">
|
||||||
href="#"
|
Aready have an account?
|
||||||
class="text-secondary cursor-pointer q-ml-sm"
|
<span
|
||||||
@click.prevent="showLogin('username-password')"
|
class="text-secondary cursor-pointer"
|
||||||
v-text="$t('login')"
|
@click="showLogin('username-password')"
|
||||||
></a>
|
>Login</span
|
||||||
|
>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</username-password>
|
</username-password>
|
||||||
@@ -224,7 +223,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="'{{ LNBITS_DENOMINATION }}' == 'sats' && '{{ SITE_TITLE }}' == 'LNbits' && '{{ LNBITS_SHOW_HOME_PAGE_ELEMENTS }}' == 'True'"
|
v-if="'{{ LNBITS_DENOMINATION }}' == 'sats'"
|
||||||
class="full-width q-mb-lg q-mt-sm"
|
class="full-width q-mb-lg q-mt-sm"
|
||||||
>
|
>
|
||||||
<div class="flex flex-center q-gutter-md q-py-md">
|
<div class="flex flex-center q-gutter-md q-py-md">
|
||||||
@@ -250,7 +249,7 @@
|
|||||||
></q-btn>
|
></q-btn>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% if AD_SPACE_ENABLED and AD_SPACE %}
|
{% if AD_SPACE %}
|
||||||
<div class="q-pt-md full-width">
|
<div class="q-pt-md full-width">
|
||||||
<div class="row justify-center q-mb-xl">
|
<div class="row justify-center q-mb-xl">
|
||||||
<div class="full-width text-center">
|
<div class="full-width text-center">
|
||||||
@@ -280,10 +279,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div
|
<div v-if="'{{ LNBITS_DENOMINATION }}' == 'sats'" class="full-width">
|
||||||
v-if="'{{ LNBITS_DENOMINATION }}' == 'sats' && '{{ SITE_TITLE }}' == 'LNbits' && '{{ LNBITS_SHOW_HOME_PAGE_ELEMENTS }}' == 'True'"
|
|
||||||
class="full-width"
|
|
||||||
>
|
|
||||||
<div class="wrapper">
|
<div class="wrapper">
|
||||||
<div class="marquee">
|
<div class="marquee">
|
||||||
<div class="marquee__group">
|
<div class="marquee__group">
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
{% block scripts %} {{ window_vars(user, wallet) }}{% endblock %} {% block page
|
{% block scripts %} {{ window_vars(user, wallet) }}{% endblock %} {% block page
|
||||||
%}
|
%}
|
||||||
<div class="row q-col-gutter-md">
|
<div class="row q-col-gutter-md">
|
||||||
{% if HIDE_API and AD_SPACE_ENABLED and AD_SPACE %}
|
{% if HIDE_API and AD_SPACE %}
|
||||||
<div class="col-12 col-md-8 q-gutter-y-md">
|
<div class="col-12 col-md-8 q-gutter-y-md">
|
||||||
{% elif HIDE_API %}
|
{% elif HIDE_API %}
|
||||||
<div class="col-12 q-gutter-y-md">
|
<div class="col-12 q-gutter-y-md">
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
<q-card-section style="height: 130px">
|
<q-card-section style="height: 130px">
|
||||||
<div class="row q-gutter-md">
|
<div class="row q-gutter-md">
|
||||||
<div
|
<div
|
||||||
v-if="isSatsDenomination"
|
v-if="'{{ LNBITS_DENOMINATION }}' == 'sats'"
|
||||||
class="col-1"
|
class="col-1"
|
||||||
style="max-width: 30px"
|
style="max-width: 30px"
|
||||||
>
|
>
|
||||||
@@ -59,11 +59,9 @@
|
|||||||
<div class="col-7">
|
<div class="col-7">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-auto">
|
<div class="col-auto">
|
||||||
<div class="text-h3 q-my-none full-width">
|
<div class="text-h3 q-my-none text-no-wrap">
|
||||||
<strong
|
<strong
|
||||||
v-text="walletFormatBalance(this.g.wallet.sat)"
|
v-text="walletFormatBalance(this.g.wallet.sat)"
|
||||||
class="text-no-wrap"
|
|
||||||
:style="{fontSize: 'clamp(0.75rem, 10vw, 3rem)', display: 'inline-block', maxWidth: '100%'}"
|
|
||||||
></strong>
|
></strong>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -168,12 +166,7 @@
|
|||||||
@click="showReceiveDialog"
|
@click="showReceiveDialog"
|
||||||
:label="$t('create_invoice')"
|
:label="$t('create_invoice')"
|
||||||
></q-btn>
|
></q-btn>
|
||||||
<q-btn
|
<q-btn unelevated color="secondary" icon="qr_code_scanner">
|
||||||
unelevated
|
|
||||||
color="secondary"
|
|
||||||
icon="qr_code_scanner"
|
|
||||||
@click="showCamera"
|
|
||||||
>
|
|
||||||
<q-tooltip
|
<q-tooltip
|
||||||
><span v-text="$t('camera_tooltip')"></span
|
><span v-text="$t('camera_tooltip')"></span
|
||||||
></q-tooltip>
|
></q-tooltip>
|
||||||
@@ -209,6 +202,11 @@
|
|||||||
></payment-list>
|
></payment-list>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-card>
|
</q-card>
|
||||||
|
<div id="hiddenQrCodeContainer" style="display: none">
|
||||||
|
<lnbits-qrcode
|
||||||
|
:value="'lightning:' + this.receive.paymentReq"
|
||||||
|
></lnbits-qrcode>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% if HIDE_API %}
|
{% if HIDE_API %}
|
||||||
<div class="col-12 col-md-4 q-gutter-y-md">
|
<div class="col-12 col-md-4 q-gutter-y-md">
|
||||||
@@ -237,23 +235,6 @@
|
|||||||
{{ SITE_TITLE }} Wallet:
|
{{ SITE_TITLE }} Wallet:
|
||||||
<strong><em v-text="g.wallet.name"></em></strong>
|
<strong><em v-text="g.wallet.name"></em></strong>
|
||||||
</div>
|
</div>
|
||||||
<q-space></q-space>
|
|
||||||
<div class="float-right">
|
|
||||||
<q-btn
|
|
||||||
@click="updateWallet({ pinned: !g.wallet.extra.pinned })"
|
|
||||||
round
|
|
||||||
class="float-right"
|
|
||||||
:color="g.wallet.extra.pinned ? 'primary' : 'grey-5'"
|
|
||||||
text-color="black"
|
|
||||||
size="sm"
|
|
||||||
icon="push_pin"
|
|
||||||
style="transform: rotate(30deg)"
|
|
||||||
>
|
|
||||||
<q-tooltip
|
|
||||||
><span v-text="$t('pin_wallet')"></span
|
|
||||||
></q-tooltip>
|
|
||||||
</q-btn>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
<q-card-section class="q-pa-none">
|
<q-card-section class="q-pa-none">
|
||||||
@@ -267,101 +248,21 @@
|
|||||||
:label="$t('drain_funds')"
|
:label="$t('drain_funds')"
|
||||||
>
|
>
|
||||||
<q-card>
|
<q-card>
|
||||||
<q-card-section>
|
<q-card-section class="text-center">
|
||||||
|
<a href="lightning:{{wallet.lnurlwithdraw_full}}">
|
||||||
<lnbits-qrcode
|
<lnbits-qrcode
|
||||||
value="lightning:{{wallet.lnurlwithdraw_full}}"
|
:value="lightning:{{wallet.lnurlwithdraw_full}}"
|
||||||
href="lightning:{{wallet.lnurlwithdraw_full}}"
|
|
||||||
></lnbits-qrcode>
|
></lnbits-qrcode>
|
||||||
<p
|
</a>
|
||||||
class="text-center"
|
<p v-text="$t('drain_funds_desc')"></p>
|
||||||
v-text="$t('drain_funds_desc')"
|
|
||||||
></p>
|
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-card>
|
</q-card>
|
||||||
</q-expansion-item>
|
</q-expansion-item>
|
||||||
<q-separator></q-separator>
|
<q-separator></q-separator>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<q-expansion-item
|
<q-expansion-item
|
||||||
group="extras"
|
v-if="'{{ LNBITS_DENOMINATION }}' == 'sats'"
|
||||||
icon="qr_code"
|
|
||||||
v-if="stored_paylinks.length > 0"
|
|
||||||
:label="$t('stored_paylinks')"
|
|
||||||
>
|
|
||||||
<q-card>
|
|
||||||
<q-card-section>
|
|
||||||
<div class="row flex" v-for="paylink in stored_paylinks">
|
|
||||||
<q-btn
|
|
||||||
dense
|
|
||||||
flat
|
|
||||||
color="primary"
|
|
||||||
icon="send"
|
|
||||||
size="xs"
|
|
||||||
@click="sendToPaylink(paylink.lnurl)"
|
|
||||||
>
|
|
||||||
<q-tooltip>
|
|
||||||
<span v-text="`send to: ${paylink.lnurl}`"></span>
|
|
||||||
</q-tooltip>
|
|
||||||
</q-btn>
|
|
||||||
<q-btn
|
|
||||||
dense
|
|
||||||
flat
|
|
||||||
color="secondary"
|
|
||||||
icon="content_copy"
|
|
||||||
size="xs"
|
|
||||||
@click="copyText(paylink.lnurl)"
|
|
||||||
>
|
|
||||||
<q-tooltip>
|
|
||||||
<span v-text="`copy: ${paylink.lnurl}`"></span>
|
|
||||||
</q-tooltip>
|
|
||||||
</q-btn>
|
|
||||||
<span
|
|
||||||
v-text="paylink.label"
|
|
||||||
class="q-mr-xs q-ml-xs"
|
|
||||||
></span>
|
|
||||||
<q-btn dense flat color="primary" icon="edit" size="xs">
|
|
||||||
<q-popup-edit
|
|
||||||
@update:model-value="editPaylink()"
|
|
||||||
v-model="paylink.label"
|
|
||||||
v-slot="scope"
|
|
||||||
>
|
|
||||||
<q-input
|
|
||||||
dark
|
|
||||||
color="white"
|
|
||||||
v-model="scope.value"
|
|
||||||
dense
|
|
||||||
autofocus
|
|
||||||
counter
|
|
||||||
@keyup.enter="scope.set"
|
|
||||||
>
|
|
||||||
<template v-slot:append>
|
|
||||||
<q-icon name="edit" />
|
|
||||||
</template>
|
|
||||||
</q-input>
|
|
||||||
</q-popup-edit>
|
|
||||||
<q-tooltip>
|
|
||||||
<span v-text="$t('edit')"></span>
|
|
||||||
</q-tooltip>
|
|
||||||
</q-btn>
|
|
||||||
<span style="flex-grow: 1"></span>
|
|
||||||
<q-btn
|
|
||||||
dense
|
|
||||||
flat
|
|
||||||
color="red"
|
|
||||||
icon="delete"
|
|
||||||
size="xs"
|
|
||||||
@click="deletePaylink(paylink.lnurl)"
|
|
||||||
>
|
|
||||||
<q-tooltip>
|
|
||||||
<span v-text="$t('delete')"></span>
|
|
||||||
</q-tooltip>
|
|
||||||
</q-btn>
|
|
||||||
<span v-text="dateFromNow(paylink.last_used)"></span>
|
|
||||||
</div>
|
|
||||||
</q-card-section>
|
|
||||||
</q-card>
|
|
||||||
</q-expansion-item>
|
|
||||||
<q-separator></q-separator>
|
|
||||||
<q-expansion-item
|
|
||||||
group="extras"
|
group="extras"
|
||||||
icon="phone_android"
|
icon="phone_android"
|
||||||
:label="$t('access_wallet_on_mobile')"
|
:label="$t('access_wallet_on_mobile')"
|
||||||
@@ -403,15 +304,21 @@
|
|||||||
:label="$t('export_to_phone')"
|
:label="$t('export_to_phone')"
|
||||||
>
|
>
|
||||||
<q-card>
|
<q-card>
|
||||||
<q-card-section>
|
<q-card-section class="text-center">
|
||||||
<p
|
<p v-text="$t('export_to_phone_desc')"></p>
|
||||||
class="text-center"
|
|
||||||
v-text="$t('export_to_phone_desc')"
|
|
||||||
></p>
|
|
||||||
<lnbits-qrcode
|
<lnbits-qrcode
|
||||||
:value="`${baseUrl}wallet?usr=${g.user.id}&wal=${g.wallet.id}`"
|
:value="`${baseUrl}wallet?usr=${g.user.id}&wal=${g.wallet.id}`"
|
||||||
></lnbits-qrcode>
|
></lnbits-qrcode>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
|
<span v-text="exportWalletQR"></span>
|
||||||
|
<q-card-actions class="flex-center q-pb-md">
|
||||||
|
<q-btn
|
||||||
|
outline
|
||||||
|
color="grey"
|
||||||
|
:label="$t('copy_wallet_url')"
|
||||||
|
@click="copyText(`${baseUrl}wallet?usr=${g.user.id}&wal=${g.wallet.id}`)"
|
||||||
|
></q-btn>
|
||||||
|
</q-card-actions>
|
||||||
</q-card>
|
</q-card>
|
||||||
</q-expansion-item>
|
</q-expansion-item>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
@@ -554,7 +461,7 @@
|
|||||||
</q-list>
|
</q-list>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-card>
|
</q-card>
|
||||||
{% endif %} {% if AD_SPACE_ENABLED and AD_SPACE %}
|
{% endif %} {% if AD_SPACE %}
|
||||||
<q-card>
|
<q-card>
|
||||||
<q-card-section>
|
<q-card-section>
|
||||||
<h6 class="text-subtitle1 q-mt-none q-mb-sm">
|
<h6 class="text-subtitle1 q-mt-none q-mb-sm">
|
||||||
@@ -688,8 +595,6 @@
|
|||||||
:readonly="receive.lnurl && receive.lnurl.fixed"
|
:readonly="receive.lnurl && receive.lnurl.fixed"
|
||||||
></q-input>
|
></q-input>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="row">
|
|
||||||
<div class="col-10">
|
|
||||||
<q-select
|
<q-select
|
||||||
filled
|
filled
|
||||||
dense
|
dense
|
||||||
@@ -698,20 +603,6 @@
|
|||||||
:label="$t('unit')"
|
:label="$t('unit')"
|
||||||
:options="receive.units"
|
:options="receive.units"
|
||||||
></q-select>
|
></q-select>
|
||||||
</div>
|
|
||||||
<div class="col-2">
|
|
||||||
<q-btn
|
|
||||||
v-if="g.fiatTracking"
|
|
||||||
@click="swapBalancePriority"
|
|
||||||
class="float-right"
|
|
||||||
color="primary"
|
|
||||||
flat
|
|
||||||
dense
|
|
||||||
icon="swap_vert"
|
|
||||||
></q-btn>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<q-input
|
<q-input
|
||||||
ref="setAmount"
|
ref="setAmount"
|
||||||
filled
|
filled
|
||||||
@@ -725,97 +616,13 @@
|
|||||||
:readonly="receive.lnurl && receive.lnurl.fixed"
|
:readonly="receive.lnurl && receive.lnurl.fixed"
|
||||||
></q-input>
|
></q-input>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<q-input
|
|
||||||
v-if="has_holdinvoice"
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
v-model="receive.data.payment_hash"
|
|
||||||
:label="$t('hold_invoice_payment_hash')"
|
|
||||||
></q-input>
|
|
||||||
<q-input
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
type="textarea"
|
|
||||||
rows="2"
|
|
||||||
v-model="receive.data.memo"
|
|
||||||
:label="$t('memo')"
|
|
||||||
>
|
|
||||||
<template v-if="receive.data.internalMemo === null" v-slot:append>
|
|
||||||
<q-icon
|
|
||||||
name="add_comment"
|
|
||||||
@click.stop.prevent="receive.data.internalMemo = ''"
|
|
||||||
class="cursor-pointer"
|
|
||||||
></q-icon>
|
|
||||||
<q-tooltip>
|
|
||||||
<span v-text="$t('internal_memo')"></span>
|
|
||||||
</q-tooltip>
|
|
||||||
</template>
|
|
||||||
</q-input>
|
|
||||||
<q-input
|
|
||||||
v-if="receive.data.internalMemo !== null"
|
|
||||||
autogrow
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
v-model="receive.data.internalMemo"
|
|
||||||
class="q-mb-lg"
|
|
||||||
:label="$t('internal_memo')"
|
|
||||||
:hint="$t('internal_memo_hint_receive')"
|
|
||||||
:rules="[ val => !val || val.length <= 512 || 'Please use maximum 512 characters' ]"
|
|
||||||
><template v-slot:append>
|
|
||||||
<q-icon
|
|
||||||
name="cancel"
|
|
||||||
@click.stop.prevent="receive.data.internalMemo = null"
|
|
||||||
class="cursor-pointer"
|
|
||||||
/> </template
|
|
||||||
></q-input>
|
|
||||||
<div v-if="g.user.fiat_providers?.length" class="q-mt-md">
|
|
||||||
<q-list bordered dense class="rounded-borders">
|
|
||||||
<q-item-label dense header>
|
|
||||||
<span v-text="$t('select_payment_provider')"></span>
|
|
||||||
</q-item-label>
|
|
||||||
<q-separator></q-separator>
|
|
||||||
<q-item
|
|
||||||
:active="!receive.fiatProvider"
|
|
||||||
@click="receive.fiatProvider = ''"
|
|
||||||
active-class="bg-teal-1 text-grey-8 text-weight-bold"
|
|
||||||
clickable
|
|
||||||
v-ripple
|
|
||||||
>
|
|
||||||
<q-item-section avatar>
|
|
||||||
<q-avatar square>
|
|
||||||
<img
|
|
||||||
:src="'{{ static_url_for('static', 'images/logos/lnbits.png') }}'"
|
|
||||||
/>
|
|
||||||
</q-avatar>
|
|
||||||
</q-item-section>
|
|
||||||
<q-item-section>
|
|
||||||
<span
|
|
||||||
v-text="$t('pay_with', {provider: 'Lightning Network'})"
|
|
||||||
></span>
|
|
||||||
</q-item-section>
|
|
||||||
</q-item>
|
|
||||||
<q-separator></q-separator>
|
|
||||||
<q-item
|
|
||||||
:active="receive.fiatProvider === 'stripe'"
|
|
||||||
@click="receive.fiatProvider = 'stripe'"
|
|
||||||
active-class="bg-teal-1 text-grey-8 text-weight-bold"
|
|
||||||
clickable
|
|
||||||
v-ripple
|
|
||||||
>
|
|
||||||
<q-item-section avatar>
|
|
||||||
<q-avatar>
|
|
||||||
<img
|
|
||||||
:src="'{{ static_url_for('static', 'images/stripe_logo.ico') }}'"
|
|
||||||
/>
|
|
||||||
</q-avatar>
|
|
||||||
</q-item-section>
|
|
||||||
<q-item-section>
|
|
||||||
<span v-text="$t('pay_with', {provider: 'Stripe'})"></span>
|
|
||||||
</q-item-section>
|
|
||||||
</q-item>
|
|
||||||
</q-list>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="receive.data.memo"
|
||||||
|
:label="$t('memo')"
|
||||||
|
></q-input>
|
||||||
<div v-if="receive.status == 'pending'" class="row q-mt-lg">
|
<div v-if="receive.status == 'pending'" class="row q-mt-lg">
|
||||||
<q-btn
|
<q-btn
|
||||||
unelevated
|
unelevated
|
||||||
@@ -825,7 +632,7 @@
|
|||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
v-if="receive.lnurl"
|
v-if="receive.lnurl"
|
||||||
v-text="`${$t('withdraw_from')} ${receive.lnurl.domain}`"
|
v-text="$t('withdraw_from') + receive.lnurl.domain"
|
||||||
></span>
|
></span>
|
||||||
<span v-else v-text="$t('create_invoice')"></span>
|
<span v-else v-text="$t('create_invoice')"></span>
|
||||||
</q-btn>
|
</q-btn>
|
||||||
@@ -848,19 +655,11 @@
|
|||||||
v-else-if="receive.paymentReq && receive.lnurl == null"
|
v-else-if="receive.paymentReq && receive.lnurl == null"
|
||||||
class="q-pa-lg q-pt-xl lnbits__dialog-card"
|
class="q-pa-lg q-pt-xl lnbits__dialog-card"
|
||||||
>
|
>
|
||||||
<lnbits-qrcode
|
<div class="text-center q-mb-lg">
|
||||||
v-if="receive.fiatPaymentReq"
|
<a :href="'lightning:' + receive.paymentReq">
|
||||||
:show-buttons="false"
|
<div v-html="invoiceQrCode"></div>
|
||||||
:href="receive.fiatPaymentReq"
|
</a>
|
||||||
:value="receive.fiatPaymentReq"
|
</div>
|
||||||
>
|
|
||||||
</lnbits-qrcode>
|
|
||||||
<lnbits-qrcode
|
|
||||||
v-else
|
|
||||||
:href="'lightning:' + receive.paymentReq"
|
|
||||||
:value="'lightning:' + receive.paymentReq"
|
|
||||||
>
|
|
||||||
</lnbits-qrcode>
|
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<h3 class="q-my-md">
|
<h3 class="q-my-md">
|
||||||
<span v-text="formattedAmount"></span>
|
<span v-text="formattedAmount"></span>
|
||||||
@@ -868,7 +667,6 @@
|
|||||||
<h5 v-if="receive.unit != 'sat'" class="q-mt-none q-mb-sm">
|
<h5 v-if="receive.unit != 'sat'" class="q-mt-none q-mb-sm">
|
||||||
<span v-text="formattedSatAmount"></span>
|
<span v-text="formattedSatAmount"></span>
|
||||||
</h5>
|
</h5>
|
||||||
<div v-if="!receive.fiatPaymentReq">
|
|
||||||
<q-chip v-if="hasNfc" outline square color="positive">
|
<q-chip v-if="hasNfc" outline square color="positive">
|
||||||
<q-avatar
|
<q-avatar
|
||||||
icon="nfc"
|
icon="nfc"
|
||||||
@@ -883,8 +681,13 @@
|
|||||||
v-text="$t('nfc_not_supported')"
|
v-text="$t('nfc_not_supported')"
|
||||||
></span>
|
></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="row q-mt-lg">
|
<div class="row q-mt-lg">
|
||||||
|
<q-btn
|
||||||
|
outline
|
||||||
|
color="grey"
|
||||||
|
@click="copyText(receive.paymentReq)"
|
||||||
|
:label="$t('copy_invoice')"
|
||||||
|
></q-btn>
|
||||||
<q-btn
|
<q-btn
|
||||||
v-close-popup
|
v-close-popup
|
||||||
flat
|
flat
|
||||||
@@ -923,13 +726,6 @@
|
|||||||
</div>
|
</div>
|
||||||
<div v-if="g.fiatTracking">
|
<div v-if="g.fiatTracking">
|
||||||
<div v-if="isFiatPriority">
|
<div v-if="isFiatPriority">
|
||||||
<h5 class="q-my-none text-bold">
|
|
||||||
<span
|
|
||||||
v-text="walletFormatBalance(parse.invoice.sat)"
|
|
||||||
></span>
|
|
||||||
</h5>
|
|
||||||
</div>
|
|
||||||
<div v-else style="opacity: 0.75">
|
|
||||||
<div class="text-h5 text-italic">
|
<div class="text-h5 text-italic">
|
||||||
<span
|
<span
|
||||||
v-text="parse.invoice.fiatAmount"
|
v-text="parse.invoice.fiatAmount"
|
||||||
@@ -937,25 +733,17 @@
|
|||||||
></span>
|
></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-else style="opacity: 0.75">
|
||||||
|
<h5 class="q-my-none text-bold">
|
||||||
|
<span
|
||||||
|
v-text="walletFormatBalance(parse.invoice.sat)"
|
||||||
|
></span>
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<q-separator></q-separator>
|
<q-separator></q-separator>
|
||||||
<h6 class="text-center" v-text="parse.invoice.description"></h6>
|
<h6 class="text-center" v-text="parse.invoice.description"></h6>
|
||||||
<q-input
|
|
||||||
autogrow
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
v-model="parse.data.internalMemo"
|
|
||||||
:label="$t('internal_memo')"
|
|
||||||
:hint="$t('internal_memo_hint_pay')"
|
|
||||||
class="q-mb-lg"
|
|
||||||
:rules="[ val => !val || val.length <= 512 || 'Please use maximum 512 characters' ]"
|
|
||||||
><template v-if="parse.data.internalMemo" v-slot:append>
|
|
||||||
<q-icon
|
|
||||||
name="cancel"
|
|
||||||
@click.stop.prevent="parse.data.internalMemo = null"
|
|
||||||
class="cursor-pointer" /></template
|
|
||||||
></q-input>
|
|
||||||
<q-list separator bordered dense class="q-mb-md">
|
<q-list separator bordered dense class="q-mb-md">
|
||||||
<q-expansion-item expand-separator icon="info" label="Details">
|
<q-expansion-item expand-separator icon="info" label="Details">
|
||||||
<q-list separator>
|
<q-list separator>
|
||||||
@@ -1149,7 +937,7 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col q-mb-lg">
|
<div class="col">
|
||||||
<q-select
|
<q-select
|
||||||
filled
|
filled
|
||||||
dense
|
dense
|
||||||
@@ -1166,8 +954,8 @@
|
|||||||
dense
|
dense
|
||||||
v-model.number="parse.data.amount"
|
v-model.number="parse.data.amount"
|
||||||
:label="$t('amount') + ' (' + parse.data.unit + ') *'"
|
:label="$t('amount') + ' (' + parse.data.unit + ') *'"
|
||||||
:mask="parse.data.unit == 'sat' ? '#' : ''"
|
:mask="parse.data.unit != 'sat' ? '#.##' : '#'"
|
||||||
:step="parse.data.unit == 'sat' ? '1': '0.01'"
|
:step="parse.data.unit != 'sat' ? '0.01' : '1'"
|
||||||
fill-mask="0"
|
fill-mask="0"
|
||||||
reverse-fill-mask
|
reverse-fill-mask
|
||||||
:min="parse.lnurlpay.minSendable / 1000"
|
:min="parse.lnurlpay.minSendable / 1000"
|
||||||
@@ -1183,39 +971,9 @@
|
|||||||
filled
|
filled
|
||||||
dense
|
dense
|
||||||
v-model="parse.data.comment"
|
v-model="parse.data.comment"
|
||||||
:type="parse.lnurlpay.commentAllowed > 512 ? 'textarea' : 'text'"
|
:type="parse.lnurlpay.commentAllowed > 64 ? 'textarea' : 'text'"
|
||||||
label="Comment (optional)"
|
label="Comment (optional)"
|
||||||
:maxlength="parse.lnurlpay.commentAllowed"
|
:maxlength="parse.lnurlpay.commentAllowed"
|
||||||
><template
|
|
||||||
v-if="parse.data.internalMemo === null"
|
|
||||||
v-slot:append
|
|
||||||
>
|
|
||||||
<q-icon
|
|
||||||
name="add_comment"
|
|
||||||
@click.stop.prevent="parse.data.internalMemo = ''"
|
|
||||||
class="cursor-pointer"
|
|
||||||
></q-icon>
|
|
||||||
<q-tooltip>
|
|
||||||
<span v-text="$t('internal_memo')"></span>
|
|
||||||
</q-tooltip> </template
|
|
||||||
></q-input>
|
|
||||||
<br />
|
|
||||||
<q-input
|
|
||||||
v-if="parse.data.internalMemo !== null"
|
|
||||||
autogrow
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
v-model="parse.data.internalMemo"
|
|
||||||
:label="$t('internal_memo')"
|
|
||||||
:hint="$t('internal_memo_hint_pay')"
|
|
||||||
class=""
|
|
||||||
:rules="[ val => !val || val.length <= 512 || 'Please use maximum 512 characters' ]"
|
|
||||||
><template v-slot:append>
|
|
||||||
<q-icon
|
|
||||||
name="cancel"
|
|
||||||
@click.stop.prevent="parse.data.internalMemo = null"
|
|
||||||
class="cursor-pointer"
|
|
||||||
/> </template
|
|
||||||
></q-input>
|
></q-input>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 %}
|
|
||||||
@@ -232,57 +232,15 @@
|
|||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</q-input>
|
</q-input>
|
||||||
<q-btn
|
<q-select
|
||||||
v-else-if="['status'].includes(col.name)"
|
v-else-if="['status'].includes(col.name)"
|
||||||
flat
|
v-model="searchData[col.name]"
|
||||||
dense
|
:options="searchOptions[col.name]"
|
||||||
:label="$q.screen.gt.md ? 'Status' : null"
|
@update:model-value="searchPaymentsBy()"
|
||||||
icon="filter_alt"
|
:label="col.label"
|
||||||
color="grey"
|
clearable
|
||||||
class="text-capitalize"
|
style="width: 100px"
|
||||||
>
|
></q-select>
|
||||||
<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
|
<q-select
|
||||||
v-else-if="['tag'].includes(col.name)"
|
v-else-if="['tag'].includes(col.name)"
|
||||||
v-model="searchData[col.name]"
|
v-model="searchData[col.name]"
|
||||||
|
|||||||
@@ -61,7 +61,7 @@
|
|||||||
:label="$t('set_password')"
|
:label="$t('set_password')"
|
||||||
v-model="activeUser.setPassword"
|
v-model="activeUser.setPassword"
|
||||||
>
|
>
|
||||||
<q-tooltip v-text="$t('set_password_tooltip')"></q-tooltip>
|
<q-tooltip>Toggle Admin</q-tooltip>
|
||||||
</q-toggle>
|
</q-toggle>
|
||||||
|
|
||||||
<q-input
|
<q-input
|
||||||
@@ -105,12 +105,11 @@
|
|||||||
|
|
||||||
<q-input
|
<q-input
|
||||||
v-model="activeUser.data.pubkey"
|
v-model="activeUser.data.pubkey"
|
||||||
:label="'Nostr '+ $t('pubkey')"
|
:label="$t('pubkey')"
|
||||||
filled
|
filled
|
||||||
dense
|
dense
|
||||||
class="q-mb-md"
|
class="q-mb-md"
|
||||||
>
|
>
|
||||||
<q-tooltip v-text="$t('nostr_pubkey_tooltip')"></q-tooltip>
|
|
||||||
</q-input>
|
</q-input>
|
||||||
<q-input
|
<q-input
|
||||||
v-model="activeUser.data.email"
|
v-model="activeUser.data.email"
|
||||||
@@ -147,14 +146,6 @@
|
|||||||
class="q-mb-md"
|
class="q-mb-md"
|
||||||
>
|
>
|
||||||
</q-input>
|
</q-input>
|
||||||
<q-input
|
|
||||||
v-model="activeUser.data.external_id"
|
|
||||||
:label="$t('external_id')"
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
class="q-mb-md"
|
|
||||||
>
|
|
||||||
</q-input>
|
|
||||||
<q-input
|
<q-input
|
||||||
v-model="activeUser.data.extra.picture"
|
v-model="activeUser.data.extra.picture"
|
||||||
:label="$t('picture')"
|
:label="$t('picture')"
|
||||||
|
|||||||
@@ -15,37 +15,20 @@
|
|||||||
</q-card>
|
</q-card>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="activeWallet.show">
|
<div v-else-if="activeWallet.show">
|
||||||
<div class="row q-col-gutter-md q-mb-md">
|
<div class="row q-mb-lg">
|
||||||
<div class="col-12">
|
<div class="col">
|
||||||
<q-card>
|
|
||||||
<div class="q-pa-sm">
|
|
||||||
<div class="row">
|
|
||||||
<div class="q-pa-xs">
|
|
||||||
<q-btn
|
<q-btn
|
||||||
icon="arrow_back_ios"
|
icon="arrow_back_ios"
|
||||||
@click="backToUsersPage()"
|
@click="backToUsersPage()"
|
||||||
:label="$t('back')"
|
:label="$t('back')"
|
||||||
></q-btn>
|
></q-btn>
|
||||||
</div>
|
|
||||||
<div class="q-pa-xs">
|
|
||||||
<q-btn
|
<q-btn
|
||||||
@click="createWalletDialog.show = true"
|
@click="createWalletDialog.show = true"
|
||||||
:label="$t('create_new_wallet')"
|
:label="$t('create_new_wallet')"
|
||||||
color="primary"
|
color="primary"
|
||||||
|
class="q-ml-md"
|
||||||
></q-btn>
|
></q-btn>
|
||||||
</div>
|
</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>
|
|
||||||
</div>
|
</div>
|
||||||
<q-card class="q-pa-md">
|
<q-card class="q-pa-md">
|
||||||
<h2 class="text-h6 q-mb-md">Wallets</h2>
|
<h2 class="text-h6 q-mb-md">Wallets</h2>
|
||||||
|
|||||||
@@ -127,17 +127,7 @@
|
|||||||
:label="props.row.wallet_count"
|
:label="props.row.wallet_count"
|
||||||
@click="fetchWallets(props.row.id)"
|
@click="fetchWallets(props.row.id)"
|
||||||
>
|
>
|
||||||
</q-btn>
|
<q-tooltip>Show Wallets</q-tooltip>
|
||||||
<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-btn>
|
</q-btn>
|
||||||
</q-td>
|
</q-td>
|
||||||
|
|
||||||
|
|||||||
+12
-128
@@ -1,38 +1,31 @@
|
|||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
from pathlib import Path
|
from shutil import make_archive
|
||||||
from shutil import make_archive, move
|
|
||||||
from subprocess import Popen
|
from subprocess import Popen
|
||||||
from tempfile import NamedTemporaryFile
|
from typing import Optional
|
||||||
from typing import IO
|
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
import filetype
|
from fastapi import APIRouter, Depends
|
||||||
from fastapi import APIRouter, Depends, File, Header, HTTPException, UploadFile
|
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
|
|
||||||
from lnbits.core.models import User
|
from lnbits.core.models import User
|
||||||
from lnbits.core.models.misc import Image, SimpleStatus
|
|
||||||
from lnbits.core.models.notifications import NotificationType
|
from lnbits.core.models.notifications import NotificationType
|
||||||
from lnbits.core.services import (
|
from lnbits.core.services import (
|
||||||
enqueue_admin_notification,
|
enqueue_notification,
|
||||||
get_balance_delta,
|
get_balance_delta,
|
||||||
update_cached_settings,
|
update_cached_settings,
|
||||||
)
|
)
|
||||||
from lnbits.core.services.notifications import send_email_notification
|
|
||||||
from lnbits.core.services.settings import dict_to_settings
|
from lnbits.core.services.settings import dict_to_settings
|
||||||
from lnbits.decorators import check_admin, check_super_user
|
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.server import server_restart
|
||||||
from lnbits.settings import AdminSettings, Settings, UpdateSettings, settings
|
from lnbits.settings import AdminSettings, Settings, UpdateSettings, settings
|
||||||
from lnbits.tasks import invoice_listeners
|
from lnbits.tasks import invoice_listeners
|
||||||
|
|
||||||
from .. import core_app_extra
|
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")
|
admin_router = APIRouter(tags=["Admin UI"], prefix="/admin")
|
||||||
file_upload = File(...)
|
|
||||||
|
|
||||||
|
|
||||||
@admin_router.get(
|
@admin_router.get(
|
||||||
@@ -57,24 +50,10 @@ async def api_monitor():
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@admin_router.get(
|
@admin_router.get("/api/v1/settings", response_model=Optional[AdminSettings])
|
||||||
"/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(
|
|
||||||
settings.lnbits_email_notifications_to_emails,
|
|
||||||
"This is a LNbits test email.",
|
|
||||||
"LNbits Test Email",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@admin_router.get("/api/v1/settings")
|
|
||||||
async def api_get_settings(
|
async def api_get_settings(
|
||||||
user: User = Depends(check_admin),
|
user: User = Depends(check_admin),
|
||||||
) -> AdminSettings | None:
|
) -> Optional[AdminSettings]:
|
||||||
admin_settings = await get_admin_settings(user.super_user)
|
admin_settings = await get_admin_settings(user.super_user)
|
||||||
return admin_settings
|
return admin_settings
|
||||||
|
|
||||||
@@ -84,13 +63,10 @@ async def api_get_settings(
|
|||||||
status_code=HTTPStatus.OK,
|
status_code=HTTPStatus.OK,
|
||||||
)
|
)
|
||||||
async def api_update_settings(data: UpdateSettings, user: User = Depends(check_admin)):
|
async def api_update_settings(data: UpdateSettings, user: User = Depends(check_admin)):
|
||||||
enqueue_admin_notification(
|
enqueue_notification(NotificationType.settings_update, {"username": user.username})
|
||||||
NotificationType.settings_update, {"username": user.username}
|
|
||||||
)
|
|
||||||
await update_admin_settings(data)
|
await update_admin_settings(data)
|
||||||
admin_settings = await get_admin_settings(user.super_user)
|
admin_settings = await get_admin_settings(user.super_user)
|
||||||
if not admin_settings:
|
assert admin_settings, "Updated admin settings not found."
|
||||||
raise ValueError("Updated admin settings not found.")
|
|
||||||
update_cached_settings(admin_settings.dict())
|
update_cached_settings(admin_settings.dict())
|
||||||
core_app_extra.register_new_ratelimiter()
|
core_app_extra.register_new_ratelimiter()
|
||||||
return {"status": "Success"}
|
return {"status": "Success"}
|
||||||
@@ -117,10 +93,8 @@ async def api_reset_settings(field_name: str):
|
|||||||
|
|
||||||
@admin_router.delete("/api/v1/settings", status_code=HTTPStatus.OK)
|
@admin_router.delete("/api/v1/settings", status_code=HTTPStatus.OK)
|
||||||
async def api_delete_settings(user: User = Depends(check_super_user)) -> None:
|
async def api_delete_settings(user: User = Depends(check_super_user)) -> None:
|
||||||
enqueue_admin_notification(
|
enqueue_notification(NotificationType.settings_update, {"username": user.username})
|
||||||
NotificationType.settings_update, {"username": user.username}
|
await delete_admin_settings()
|
||||||
)
|
|
||||||
await reset_core_settings()
|
|
||||||
server_restart.set()
|
server_restart.set()
|
||||||
|
|
||||||
|
|
||||||
@@ -147,7 +121,7 @@ async def api_download_backup() -> FileResponse:
|
|||||||
pg_backup_filename = f"{settings.lnbits_data_folder}/lnbits-database.dmp"
|
pg_backup_filename = f"{settings.lnbits_data_folder}/lnbits-database.dmp"
|
||||||
is_pg = db_url and db_url.startswith("postgres://")
|
is_pg = db_url and db_url.startswith("postgres://")
|
||||||
|
|
||||||
if is_pg and db_url:
|
if is_pg:
|
||||||
p = urlparse(db_url)
|
p = urlparse(db_url)
|
||||||
command = (
|
command = (
|
||||||
f"pg_dump --host={p.hostname} "
|
f"pg_dump --host={p.hostname} "
|
||||||
@@ -172,93 +146,3 @@ async def api_download_backup() -> FileResponse:
|
|||||||
return FileResponse(
|
return FileResponse(
|
||||||
path=f"{last_filename}.zip", filename=filename, media_type="application/zip"
|
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")
|
|
||||||
|
|||||||
+150
-3
@@ -1,20 +1,36 @@
|
|||||||
|
import hashlib
|
||||||
|
import json
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from time import time
|
from time import time
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
from urllib.parse import ParseResult, parse_qs, urlencode, urlparse, urlunparse
|
||||||
|
|
||||||
|
import httpx
|
||||||
import pyqrcode
|
import pyqrcode
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import (
|
||||||
|
APIRouter,
|
||||||
|
Depends,
|
||||||
|
)
|
||||||
|
from fastapi.exceptions import HTTPException
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
|
|
||||||
from lnbits.core.models import (
|
from lnbits.core.models import (
|
||||||
BaseWallet,
|
BaseWallet,
|
||||||
ConversionData,
|
ConversionData,
|
||||||
|
CreateLnurlAuth,
|
||||||
CreateWallet,
|
CreateWallet,
|
||||||
User,
|
User,
|
||||||
Wallet,
|
Wallet,
|
||||||
)
|
)
|
||||||
from lnbits.decorators import check_user_exists
|
from lnbits.decorators import (
|
||||||
|
WalletTypeInfo,
|
||||||
|
check_user_exists,
|
||||||
|
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.settings import settings
|
||||||
from lnbits.utils.exchange_rates import (
|
from lnbits.utils.exchange_rates import (
|
||||||
allowed_currencies,
|
allowed_currencies,
|
||||||
@@ -25,7 +41,7 @@ from lnbits.utils.exchange_rates import (
|
|||||||
from lnbits.wallets import get_funding_source
|
from lnbits.wallets import get_funding_source
|
||||||
from lnbits.wallets.base import StatusResponse
|
from lnbits.wallets.base import StatusResponse
|
||||||
|
|
||||||
from ..services import create_user_account
|
from ..services import create_user_account, perform_lnurlauth
|
||||||
|
|
||||||
api_router = APIRouter(tags=["Core"])
|
api_router = APIRouter(tags=["Core"])
|
||||||
|
|
||||||
@@ -76,6 +92,137 @@ async def api_create_account(data: CreateWallet) -> Wallet:
|
|||||||
return user.wallets[0]
|
return user.wallets[0]
|
||||||
|
|
||||||
|
|
||||||
|
@api_router.get("/api/v1/lnurlscan/{code}")
|
||||||
|
async def api_lnurlscan(
|
||||||
|
code: str, wallet: WalletTypeInfo = Depends(require_invoice_key)
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
url = str(lnurl_decode(code))
|
||||||
|
domain = urlparse(url).netloc
|
||||||
|
except Exception as exc:
|
||||||
|
# parse internet identifier (user@domain.com)
|
||||||
|
name_domain = code.split("@")
|
||||||
|
if len(name_domain) == 2 and len(name_domain[1].split(".")) >= 2:
|
||||||
|
name, domain = name_domain
|
||||||
|
url = (
|
||||||
|
("http://" if domain.endswith(".onion") else "https://")
|
||||||
|
+ domain
|
||||||
|
+ "/.well-known/lnurlp/"
|
||||||
|
+ name
|
||||||
|
)
|
||||||
|
# will proceed with these values
|
||||||
|
else:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.BAD_REQUEST, detail="invalid lnurl"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
# params is what will be returned to the client
|
||||||
|
params: dict = {"domain": domain}
|
||||||
|
|
||||||
|
if "tag=login" in url:
|
||||||
|
params.update(kind="auth")
|
||||||
|
params.update(callback=url) # with k1 already in it
|
||||||
|
|
||||||
|
lnurlauth_key = wallet.wallet.lnurlauth_key(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)
|
||||||
|
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"},
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = json.loads(r.text)
|
||||||
|
except json.decoder.JSONDecodeError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.SERVICE_UNAVAILABLE,
|
||||||
|
detail={
|
||||||
|
"domain": domain,
|
||||||
|
"message": f"got invalid response '{r.text[:200]}'",
|
||||||
|
},
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
try:
|
||||||
|
tag: str = data.get("tag")
|
||||||
|
params.update(**data)
|
||||||
|
if tag == "channelRequest":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
|
detail={
|
||||||
|
"domain": domain,
|
||||||
|
"kind": "channel",
|
||||||
|
"message": "unsupported",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
elif tag == "withdrawRequest":
|
||||||
|
params.update(kind="withdraw")
|
||||||
|
params.update(fixed=data["minWithdrawable"] == data["maxWithdrawable"])
|
||||||
|
|
||||||
|
# callback with k1 already in it
|
||||||
|
parsed_callback: ParseResult = urlparse(data["callback"])
|
||||||
|
qs: dict = parse_qs(parsed_callback.query)
|
||||||
|
qs["k1"] = data["k1"]
|
||||||
|
|
||||||
|
# balanceCheck/balanceNotify
|
||||||
|
if "balanceCheck" in data:
|
||||||
|
params.update(balanceCheck=data["balanceCheck"])
|
||||||
|
|
||||||
|
# format callback url and send to client
|
||||||
|
parsed_callback = parsed_callback._replace(
|
||||||
|
query=urlencode(qs, doseq=True)
|
||||||
|
)
|
||||||
|
params.update(callback=urlunparse(parsed_callback))
|
||||||
|
elif tag == "payRequest":
|
||||||
|
params.update(kind="pay")
|
||||||
|
params.update(fixed=data["minSendable"] == data["maxSendable"])
|
||||||
|
|
||||||
|
params.update(
|
||||||
|
description_hash=hashlib.sha256(
|
||||||
|
data["metadata"].encode()
|
||||||
|
).hexdigest()
|
||||||
|
)
|
||||||
|
metadata = json.loads(data["metadata"])
|
||||||
|
for [k, v] in metadata:
|
||||||
|
if k == "text/plain":
|
||||||
|
params.update(description=v)
|
||||||
|
if k in ("image/jpeg;base64", "image/png;base64"):
|
||||||
|
data_uri = f"data:{k},{v}"
|
||||||
|
params.update(image=data_uri)
|
||||||
|
if k in ("text/email", "text/identifier"):
|
||||||
|
params.update(targetUser=v)
|
||||||
|
params.update(commentAllowed=data.get("commentAllowed", 0))
|
||||||
|
|
||||||
|
except KeyError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.SERVICE_UNAVAILABLE,
|
||||||
|
detail={
|
||||||
|
"domain": domain,
|
||||||
|
"message": f"lnurl JSON response invalid: {exc}",
|
||||||
|
},
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
return params
|
||||||
|
|
||||||
|
|
||||||
|
@api_router.post("/api/v1/lnurlauth")
|
||||||
|
async def api_perform_lnurlauth(
|
||||||
|
data: CreateLnurlAuth, wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||||
|
):
|
||||||
|
err = await perform_lnurlauth(data.callback, wallet=wallet)
|
||||||
|
if err:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.SERVICE_UNAVAILABLE, detail=err.reason
|
||||||
|
)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
@api_router.get(
|
@api_router.get(
|
||||||
"/api/v1/rate/history",
|
"/api/v1/rate/history",
|
||||||
dependencies=[Depends(check_user_exists)],
|
dependencies=[Depends(check_user_exists)],
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import base64
|
import base64
|
||||||
import importlib
|
import importlib
|
||||||
import json
|
import json
|
||||||
from collections.abc import Callable
|
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
from time import time
|
from time import time
|
||||||
|
from typing import Callable, Optional
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
@@ -74,7 +74,7 @@ async def get_auth_user(user: User = Depends(check_user_exists)) -> User:
|
|||||||
async def login(data: LoginUsernamePassword) -> JSONResponse:
|
async def login(data: LoginUsernamePassword) -> JSONResponse:
|
||||||
if not settings.is_auth_method_allowed(AuthMethods.username_and_password):
|
if not settings.is_auth_method_allowed(AuthMethods.username_and_password):
|
||||||
raise HTTPException(
|
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)
|
account = await get_account_by_username_or_email(data.username)
|
||||||
if not account or not account.verify_password(data.password):
|
if not account or not account.verify_password(data.password):
|
||||||
@@ -85,7 +85,9 @@ async def login(data: LoginUsernamePassword) -> JSONResponse:
|
|||||||
@auth_router.post("/nostr", description="Login via Nostr")
|
@auth_router.post("/nostr", description="Login via Nostr")
|
||||||
async def nostr_login(request: Request) -> JSONResponse:
|
async def nostr_login(request: Request) -> JSONResponse:
|
||||||
if not settings.is_auth_method_allowed(AuthMethods.nostr_auth_nip98):
|
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)
|
event = _nostr_nip98_event(request)
|
||||||
account = await get_account_by_pubkey(event["pubkey"])
|
account = await get_account_by_pubkey(event["pubkey"])
|
||||||
if not account:
|
if not account:
|
||||||
@@ -102,7 +104,7 @@ async def nostr_login(request: Request) -> JSONResponse:
|
|||||||
async def login_usr(data: LoginUsr) -> JSONResponse:
|
async def login_usr(data: LoginUsr) -> JSONResponse:
|
||||||
if not settings.is_auth_method_allowed(AuthMethods.user_id_only):
|
if not settings.is_auth_method_allowed(AuthMethods.user_id_only):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
HTTPStatus.FORBIDDEN,
|
HTTPStatus.UNAUTHORIZED,
|
||||||
"Login by 'User ID' not allowed.",
|
"Login by 'User ID' not allowed.",
|
||||||
)
|
)
|
||||||
account = await get_account(data.usr)
|
account = await get_account(data.usr)
|
||||||
@@ -110,7 +112,7 @@ async def login_usr(data: LoginUsr) -> JSONResponse:
|
|||||||
raise HTTPException(HTTPStatus.UNAUTHORIZED, "User ID does not exist.")
|
raise HTTPException(HTTPStatus.UNAUTHORIZED, "User ID does not exist.")
|
||||||
if account.is_admin:
|
if account.is_admin:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
HTTPStatus.FORBIDDEN, "Admin users cannot login with user id only."
|
HTTPStatus.UNAUTHORIZED, "Admin users cannot login with user id only."
|
||||||
)
|
)
|
||||||
return _auth_success_response(account.username, account.id, account.email)
|
return _auth_success_response(account.username, account.id, account.email)
|
||||||
|
|
||||||
@@ -191,14 +193,12 @@ async def api_create_user_api_token(
|
|||||||
data: ApiTokenRequest,
|
data: ApiTokenRequest,
|
||||||
user: User = Depends(check_user_exists),
|
user: User = Depends(check_user_exists),
|
||||||
) -> ApiTokenResponse:
|
) -> ApiTokenResponse:
|
||||||
if not data.expiration_time_minutes > 0:
|
assert data.expiration_time_minutes > 0, "Expiration time must be in the future."
|
||||||
raise ValueError("Expiration time must be in the future.")
|
|
||||||
account = await get_account(user.id)
|
account = await get_account(user.id)
|
||||||
if not account or not account.verify_password(data.password):
|
if not account or not account.verify_password(data.password):
|
||||||
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid credentials.")
|
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid credentials.")
|
||||||
|
|
||||||
if not account.username:
|
assert account.username, "Username must be configured."
|
||||||
raise ValueError("Username must be configured.")
|
|
||||||
|
|
||||||
acls = await get_user_access_control_lists(user.id)
|
acls = await get_user_access_control_lists(user.id)
|
||||||
acl = acls.get_acl_by_id(data.acl_id)
|
acl = acls.get_acl_by_id(data.acl_id)
|
||||||
@@ -225,8 +225,7 @@ async def api_delete_user_api_token(
|
|||||||
if not account or not account.verify_password(data.password):
|
if not account or not account.verify_password(data.password):
|
||||||
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid credentials.")
|
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid credentials.")
|
||||||
|
|
||||||
if not account.username:
|
assert account.username, "Username must be configured."
|
||||||
raise ValueError("Username must be configured.")
|
|
||||||
|
|
||||||
acls = await get_user_access_control_lists(user.id)
|
acls = await get_user_access_control_lists(user.id)
|
||||||
acl = acls.get_acl_by_id(data.acl_id)
|
acl = acls.get_acl_by_id(data.acl_id)
|
||||||
@@ -238,12 +237,12 @@ async def api_delete_user_api_token(
|
|||||||
|
|
||||||
@auth_router.get("/{provider}", description="SSO Provider")
|
@auth_router.get("/{provider}", description="SSO Provider")
|
||||||
async def login_with_sso_provider(
|
async def login_with_sso_provider(
|
||||||
request: Request, provider: str, user_id: str | None = None
|
request: Request, provider: str, user_id: Optional[str] = None
|
||||||
):
|
):
|
||||||
provider_sso = _new_sso(provider)
|
provider_sso = _new_sso(provider)
|
||||||
if not provider_sso:
|
if not provider_sso:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
HTTPStatus.FORBIDDEN,
|
HTTPStatus.UNAUTHORIZED,
|
||||||
f"Login by '{provider}' not allowed.",
|
f"Login by '{provider}' not allowed.",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -258,7 +257,7 @@ async def handle_oauth_token(request: Request, provider: str) -> RedirectRespons
|
|||||||
provider_sso = _new_sso(provider)
|
provider_sso = _new_sso(provider)
|
||||||
if not provider_sso:
|
if not provider_sso:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
HTTPStatus.FORBIDDEN,
|
HTTPStatus.UNAUTHORIZED,
|
||||||
f"Login by '{provider}' not allowed.",
|
f"Login by '{provider}' not allowed.",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -286,7 +285,7 @@ async def logout() -> JSONResponse:
|
|||||||
async def register(data: RegisterUser) -> JSONResponse:
|
async def register(data: RegisterUser) -> JSONResponse:
|
||||||
if not settings.is_auth_method_allowed(AuthMethods.username_and_password):
|
if not settings.is_auth_method_allowed(AuthMethods.username_and_password):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
HTTPStatus.FORBIDDEN,
|
HTTPStatus.UNAUTHORIZED,
|
||||||
"Register by 'Username and Password' not allowed.",
|
"Register by 'Username and Password' not allowed.",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -319,9 +318,9 @@ async def update_pubkey(
|
|||||||
data: UpdateUserPubkey,
|
data: UpdateUserPubkey,
|
||||||
user: User = Depends(check_user_exists),
|
user: User = Depends(check_user_exists),
|
||||||
payload: AccessTokenPayload = Depends(access_token_payload),
|
payload: AccessTokenPayload = Depends(access_token_payload),
|
||||||
) -> User | None:
|
) -> Optional[User]:
|
||||||
if data.user_id != user.id:
|
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)
|
_validate_auth_timeout(payload.auth_time)
|
||||||
if (
|
if (
|
||||||
@@ -329,7 +328,7 @@ async def update_pubkey(
|
|||||||
and data.pubkey != user.pubkey
|
and data.pubkey != user.pubkey
|
||||||
and await get_account_by_pubkey(data.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)
|
account = await get_account(user.id)
|
||||||
if not account:
|
if not account:
|
||||||
@@ -345,10 +344,9 @@ async def update_password(
|
|||||||
data: UpdateUserPassword,
|
data: UpdateUserPassword,
|
||||||
user: User = Depends(check_user_exists),
|
user: User = Depends(check_user_exists),
|
||||||
payload: AccessTokenPayload = Depends(access_token_payload),
|
payload: AccessTokenPayload = Depends(access_token_payload),
|
||||||
) -> User | None:
|
) -> Optional[User]:
|
||||||
_validate_auth_timeout(payload.auth_time)
|
_validate_auth_timeout(payload.auth_time)
|
||||||
if data.user_id != user.id:
|
assert data.user_id == user.id, "Invalid user ID."
|
||||||
raise ValueError("Invalid user ID.")
|
|
||||||
if (
|
if (
|
||||||
data.username
|
data.username
|
||||||
and user.username != data.username
|
and user.username != data.username
|
||||||
@@ -357,15 +355,12 @@ async def update_password(
|
|||||||
raise HTTPException(HTTPStatus.BAD_REQUEST, "Username already exists.")
|
raise HTTPException(HTTPStatus.BAD_REQUEST, "Username already exists.")
|
||||||
|
|
||||||
account = await get_account(user.id)
|
account = await get_account(user.id)
|
||||||
if not account:
|
assert account, "Account not found."
|
||||||
raise ValueError("Account not found.")
|
|
||||||
|
|
||||||
# old accounts do not have a password
|
# old accounts do not have a password
|
||||||
if account.password_hash:
|
if account.password_hash:
|
||||||
if not data.password_old:
|
assert data.password_old, "Missing old password."
|
||||||
raise ValueError("Missing old password.")
|
assert account.verify_password(data.password_old), "Invalid old password."
|
||||||
if not account.verify_password(data.password_old):
|
|
||||||
raise ValueError("Invalid old password.")
|
|
||||||
|
|
||||||
account.username = data.username
|
account.username = data.username
|
||||||
account.hash_password(data.password)
|
account.hash_password(data.password)
|
||||||
@@ -380,13 +375,11 @@ async def update_password(
|
|||||||
async def reset_password(data: ResetUserPassword) -> JSONResponse:
|
async def reset_password(data: ResetUserPassword) -> JSONResponse:
|
||||||
if not settings.is_auth_method_allowed(AuthMethods.username_and_password):
|
if not settings.is_auth_method_allowed(AuthMethods.username_and_password):
|
||||||
raise HTTPException(
|
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:
|
assert data.password == data.password_repeat, "Passwords do not match."
|
||||||
raise ValueError("Passwords do not match.")
|
assert data.reset_key[:10].startswith("reset_key_"), "This is not a reset key."
|
||||||
if not data.reset_key[:10].startswith("reset_key_"):
|
|
||||||
raise ValueError("This is not a reset key.")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
reset_key = base64.b64decode(data.reset_key[10:]).decode()
|
reset_key = base64.b64decode(data.reset_key[10:]).decode()
|
||||||
@@ -394,16 +387,12 @@ async def reset_password(data: ResetUserPassword) -> JSONResponse:
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise ValueError("Invalid reset key.") from exc
|
raise ValueError("Invalid reset key.") from exc
|
||||||
|
|
||||||
if not reset_data_json:
|
assert reset_data_json, "Cannot process reset key."
|
||||||
raise ValueError("Cannot process reset key.")
|
|
||||||
|
|
||||||
action, user_id, request_time = json.loads(reset_data_json)
|
action, user_id, request_time = json.loads(reset_data_json)
|
||||||
if not action:
|
assert action, "Missing action."
|
||||||
raise ValueError("Missing action.")
|
assert user_id, "Missing user ID."
|
||||||
if not user_id:
|
assert request_time, "Missing reset time."
|
||||||
raise ValueError("Missing user ID.")
|
|
||||||
if not request_time:
|
|
||||||
raise ValueError("Missing reset time.")
|
|
||||||
|
|
||||||
_validate_auth_timeout(request_time)
|
_validate_auth_timeout(request_time)
|
||||||
|
|
||||||
@@ -419,18 +408,28 @@ async def reset_password(data: ResetUserPassword) -> JSONResponse:
|
|||||||
@auth_router.put("/update")
|
@auth_router.put("/update")
|
||||||
async def update(
|
async def update(
|
||||||
data: UpdateUser, user: User = Depends(check_user_exists)
|
data: UpdateUser, user: User = Depends(check_user_exists)
|
||||||
) -> User | None:
|
) -> Optional[User]:
|
||||||
if data.user_id != user.id:
|
if data.user_id != user.id:
|
||||||
raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid user ID.")
|
raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid user ID.")
|
||||||
if data.username and not is_valid_username(data.username):
|
if data.username and not is_valid_username(data.username):
|
||||||
raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid username.")
|
raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid username.")
|
||||||
|
if data.email != user.email:
|
||||||
|
raise HTTPException(
|
||||||
|
HTTPStatus.BAD_REQUEST,
|
||||||
|
"Email mismatch.",
|
||||||
|
)
|
||||||
if (
|
if (
|
||||||
data.username
|
data.username
|
||||||
and user.username != data.username
|
and user.username != data.username
|
||||||
and await get_account_by_username(data.username)
|
and await get_account_by_username(data.username)
|
||||||
):
|
):
|
||||||
raise HTTPException(HTTPStatus.BAD_REQUEST, "Username already exists.")
|
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)
|
account = await get_account(user.id)
|
||||||
if not account:
|
if not account:
|
||||||
@@ -438,6 +437,8 @@ async def update(
|
|||||||
|
|
||||||
if data.username:
|
if data.username:
|
||||||
account.username = data.username
|
account.username = data.username
|
||||||
|
if data.email:
|
||||||
|
account.email = data.email
|
||||||
if data.extra:
|
if data.extra:
|
||||||
account.extra = data.extra
|
account.extra = data.extra
|
||||||
|
|
||||||
@@ -448,7 +449,7 @@ async def update(
|
|||||||
@auth_router.put("/first_install")
|
@auth_router.put("/first_install")
|
||||||
async def first_install(data: UpdateSuperuserPassword) -> JSONResponse:
|
async def first_install(data: UpdateSuperuserPassword) -> JSONResponse:
|
||||||
if not settings.first_install:
|
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)
|
account = await get_account(settings.super_user)
|
||||||
if not account:
|
if not account:
|
||||||
raise HTTPException(HTTPStatus.INTERNAL_SERVER_ERROR, "Superuser not found.")
|
raise HTTPException(HTTPStatus.INTERNAL_SERVER_ERROR, "Superuser not found.")
|
||||||
@@ -461,7 +462,7 @@ async def first_install(data: UpdateSuperuserPassword) -> JSONResponse:
|
|||||||
return _auth_success_response(account.username, account.id, account.email)
|
return _auth_success_response(account.username, account.id, account.email)
|
||||||
|
|
||||||
|
|
||||||
async def _handle_sso_login(userinfo: OpenID, verified_user_id: str | None = None):
|
async def _handle_sso_login(userinfo: OpenID, verified_user_id: Optional[str] = None):
|
||||||
email = userinfo.email
|
email = userinfo.email
|
||||||
if not email or not is_valid_email_address(email):
|
if not email or not is_valid_email_address(email):
|
||||||
raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid email.")
|
raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid email.")
|
||||||
@@ -471,10 +472,10 @@ async def _handle_sso_login(userinfo: OpenID, verified_user_id: str | None = Non
|
|||||||
|
|
||||||
if verified_user_id:
|
if verified_user_id:
|
||||||
if account:
|
if account:
|
||||||
raise HTTPException(HTTPStatus.FORBIDDEN, "Email already used.")
|
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Email already used.")
|
||||||
account = await get_account(verified_user_id)
|
account = await get_account(verified_user_id)
|
||||||
if not account:
|
if not account:
|
||||||
raise HTTPException(HTTPStatus.FORBIDDEN, "Cannot verify user email.")
|
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Cannot verify user email.")
|
||||||
redirect_path = "/account"
|
redirect_path = "/account"
|
||||||
|
|
||||||
if account:
|
if account:
|
||||||
@@ -490,20 +491,17 @@ async def _handle_sso_login(userinfo: OpenID, verified_user_id: str | None = Non
|
|||||||
|
|
||||||
|
|
||||||
def _auth_success_response(
|
def _auth_success_response(
|
||||||
username: str | None = None,
|
username: Optional[str] = None,
|
||||||
user_id: str | None = None,
|
user_id: Optional[str] = None,
|
||||||
email: str | None = None,
|
email: Optional[str] = None,
|
||||||
) -> JSONResponse:
|
) -> JSONResponse:
|
||||||
payload = AccessTokenPayload(
|
payload = AccessTokenPayload(
|
||||||
sub=username or "", usr=user_id, email=email, auth_time=int(time())
|
sub=username or "", usr=user_id, email=email, auth_time=int(time())
|
||||||
)
|
)
|
||||||
access_token = create_access_token(data=payload.dict())
|
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 = JSONResponse({"access_token": access_token, "token_type": "bearer"})
|
||||||
response.set_cookie(
|
response.set_cookie("cookie_access_token", access_token, httponly=True)
|
||||||
"cookie_access_token", access_token, httponly=True, max_age=max_age
|
response.set_cookie("is_lnbits_user_authorized", "true")
|
||||||
)
|
|
||||||
response.set_cookie("is_lnbits_user_authorized", "true", max_age=max_age)
|
|
||||||
response.delete_cookie("is_access_token_expired")
|
response.delete_cookie("is_access_token_expired")
|
||||||
|
|
||||||
return response
|
return response
|
||||||
@@ -523,17 +521,14 @@ def _auth_api_token_response(
|
|||||||
def _auth_redirect_response(path: str, email: str) -> RedirectResponse:
|
def _auth_redirect_response(path: str, email: str) -> RedirectResponse:
|
||||||
payload = AccessTokenPayload(sub="" or "", email=email, auth_time=int(time()))
|
payload = AccessTokenPayload(sub="" or "", email=email, auth_time=int(time()))
|
||||||
access_token = create_access_token(data=payload.dict())
|
access_token = create_access_token(data=payload.dict())
|
||||||
max_age = settings.auth_token_expire_minutes * 60
|
|
||||||
response = RedirectResponse(path)
|
response = RedirectResponse(path)
|
||||||
response.set_cookie(
|
response.set_cookie("cookie_access_token", access_token, httponly=True)
|
||||||
"cookie_access_token", access_token, httponly=True, max_age=max_age
|
response.set_cookie("is_lnbits_user_authorized", "true")
|
||||||
)
|
|
||||||
response.set_cookie("is_lnbits_user_authorized", "true", max_age=max_age)
|
|
||||||
response.delete_cookie("is_access_token_expired")
|
response.delete_cookie("is_access_token_expired")
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
def _new_sso(provider: str) -> SSOBase | None:
|
def _new_sso(provider: str) -> Optional[SSOBase]:
|
||||||
try:
|
try:
|
||||||
if not settings.is_auth_method_allowed(AuthMethods(f"{provider}-auth")):
|
if not settings.is_auth_method_allowed(AuthMethods(f"{provider}-auth")):
|
||||||
return None
|
return None
|
||||||
@@ -570,8 +565,8 @@ def _find_auth_provider_class(provider: str) -> Callable:
|
|||||||
provider_class = getattr(provider_module, f"{provider.title()}SSO")
|
provider_class = getattr(provider_module, f"{provider.title()}SSO")
|
||||||
if provider_class:
|
if provider_class:
|
||||||
return provider_class
|
return provider_class
|
||||||
except Exception as exc:
|
except Exception:
|
||||||
logger.debug(exc)
|
pass
|
||||||
|
|
||||||
raise ValueError(f"No SSO provider found for '{provider}'.")
|
raise ValueError(f"No SSO provider found for '{provider}'.")
|
||||||
|
|
||||||
@@ -579,53 +574,41 @@ def _find_auth_provider_class(provider: str) -> Callable:
|
|||||||
def _nostr_nip98_event(request: Request) -> dict:
|
def _nostr_nip98_event(request: Request) -> dict:
|
||||||
auth_header = request.headers.get("Authorization")
|
auth_header = request.headers.get("Authorization")
|
||||||
if not auth_header:
|
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()
|
scheme, token = auth_header.split()
|
||||||
if scheme.lower() != "nostr":
|
if scheme.lower() != "nostr":
|
||||||
raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid Authorization scheme.")
|
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid Authorization scheme.")
|
||||||
event = None
|
event = None
|
||||||
try:
|
try:
|
||||||
event_json = base64.b64decode(token.encode("ascii"))
|
event_json = base64.b64decode(token.encode("ascii"))
|
||||||
event = json.loads(event_json)
|
event = json.loads(event_json)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning(exc)
|
logger.warning(exc)
|
||||||
if not event:
|
assert event, "Nostr login event cannot be parsed."
|
||||||
raise ValueError("Nostr login event cannot be parsed.")
|
|
||||||
|
|
||||||
if not verify_event(event):
|
if not verify_event(event):
|
||||||
raise HTTPException(HTTPStatus.BAD_REQUEST, "Nostr login event is not valid.")
|
raise HTTPException(HTTPStatus.BAD_REQUEST, "Nostr login event is not valid.")
|
||||||
if not event["kind"] == 27_235:
|
assert event["kind"] == 27_235, "Invalid event kind."
|
||||||
raise ValueError("Invalid event kind.")
|
|
||||||
|
|
||||||
auth_threshold = settings.auth_credetials_update_threshold
|
auth_threshold = settings.auth_credetials_update_threshold
|
||||||
if not (abs(time() - event["created_at"]) < auth_threshold):
|
assert (
|
||||||
raise ValueError(
|
abs(time() - event["created_at"]) < auth_threshold
|
||||||
f"More than {auth_threshold} seconds have passed "
|
), f"More than {auth_threshold} seconds have passed since the event was signed."
|
||||||
"since the event was signed."
|
|
||||||
)
|
|
||||||
|
|
||||||
_check_nostr_event_tags(event)
|
method: Optional[str] = next((v for k, v in event["tags"] if k == "method"), None)
|
||||||
|
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)
|
||||||
|
|
||||||
|
assert url, "Tag 'u' for URL is missing."
|
||||||
|
accepted_urls = [f"{u}/nostr" for u in settings.nostr_absolute_request_urls]
|
||||||
|
assert url in accepted_urls, f"Invalid value for tag 'u': '{url}'."
|
||||||
|
|
||||||
return event
|
return event
|
||||||
|
|
||||||
|
|
||||||
def _check_nostr_event_tags(event: dict):
|
def _validate_auth_timeout(auth_time: Optional[int] = 0):
|
||||||
method: str | None = 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'.")
|
|
||||||
|
|
||||||
url = next((v for k, v in event["tags"] if k == "u"), None)
|
|
||||||
|
|
||||||
if not url:
|
|
||||||
raise ValueError("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}'.")
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_auth_timeout(auth_time: int | None = 0):
|
|
||||||
if abs(time() - (auth_time or 0)) > settings.auth_credetials_update_threshold:
|
if abs(time() - (auth_time or 0)) > settings.auth_credetials_update_threshold:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
HTTPStatus.BAD_REQUEST,
|
HTTPStatus.BAD_REQUEST,
|
||||||
|
|||||||
@@ -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}'.",
|
|
||||||
)
|
|
||||||
@@ -3,7 +3,11 @@ import traceback
|
|||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
|
|
||||||
from bolt11 import decode as bolt11_decode
|
from bolt11 import decode as bolt11_decode
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import (
|
||||||
|
APIRouter,
|
||||||
|
Depends,
|
||||||
|
HTTPException,
|
||||||
|
)
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from lnbits.core.crud.extensions import get_user_extensions
|
from lnbits.core.crud.extensions import get_user_extensions
|
||||||
@@ -61,10 +65,9 @@ async def api_install_extension(data: CreateExtension):
|
|||||||
data.ext_id, data.source_repo, data.archive, data.version
|
data.ext_id, data.source_repo, data.archive, data.version
|
||||||
)
|
)
|
||||||
if not release:
|
if not release:
|
||||||
raise HTTPException(HTTPStatus.NOT_FOUND, "Release not found")
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.NOT_FOUND, detail="Release not found"
|
||||||
if not release.is_version_compatible:
|
)
|
||||||
raise HTTPException(HTTPStatus.BAD_REQUEST, "Incompatible extension version.")
|
|
||||||
|
|
||||||
release.payment_hash = data.payment_hash
|
release.payment_hash = data.payment_hash
|
||||||
ext_meta = ExtensionMeta(installed_release=release)
|
ext_meta = ExtensionMeta(installed_release=release)
|
||||||
@@ -167,13 +170,11 @@ async def api_enable_extension(
|
|||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
HTTPStatus.NOT_FOUND, f"Extension '{ext_id}' doesn't exist."
|
HTTPStatus.NOT_FOUND, f"Extension '{ext_id}' doesn't exist."
|
||||||
)
|
)
|
||||||
|
try:
|
||||||
logger.info(f"Enabling extension: {ext_id}.")
|
logger.info(f"Enabling extension: {ext_id}.")
|
||||||
ext = await get_installed_extension(ext_id)
|
ext = await get_installed_extension(ext_id)
|
||||||
if not ext:
|
assert ext, f"Extension '{ext_id}' is not installed."
|
||||||
raise ValueError(f"Extension '{ext_id}' is not installed.")
|
assert ext.active, f"Extension '{ext_id}' is not activated."
|
||||||
if not ext.active:
|
|
||||||
raise ValueError(f"Extension '{ext_id}' is not activated.")
|
|
||||||
|
|
||||||
user_ext = await get_user_extension(user.id, ext_id)
|
user_ext = await get_user_extension(user.id, ext_id)
|
||||||
if not user_ext:
|
if not user_ext:
|
||||||
@@ -193,10 +194,13 @@ async def api_enable_extension(
|
|||||||
if user_ext.is_paid:
|
if user_ext.is_paid:
|
||||||
user_ext.active = True
|
user_ext.active = True
|
||||||
await update_user_extension(user_ext)
|
await update_user_extension(user_ext)
|
||||||
return SimpleStatus(success=True, message=f"Paid extension '{ext_id}' enabled.")
|
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:
|
assert (
|
||||||
raise ValueError(f"Extension '{ext_id}' is missing payment wallet.")
|
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(
|
payment_status = await check_transaction_status(
|
||||||
wallet_id=ext.meta.pay_to_enable.wallet,
|
wallet_id=ext.meta.pay_to_enable.wallet,
|
||||||
@@ -214,6 +218,17 @@ async def api_enable_extension(
|
|||||||
await update_user_extension(user_ext)
|
await update_user_extension(user_ext)
|
||||||
return SimpleStatus(success=True, message=f"Paid extension '{ext_id}' enabled.")
|
return SimpleStatus(success=True, message=f"Paid extension '{ext_id}' enabled.")
|
||||||
|
|
||||||
|
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(
|
||||||
|
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||||
|
detail=(f"Failed to enable '{ext_id}' "),
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
@extension_router.put("/{ext_id}/disable")
|
@extension_router.put("/{ext_id}/disable")
|
||||||
async def api_disable_extension(
|
async def api_disable_extension(
|
||||||
@@ -228,7 +243,7 @@ async def api_disable_extension(
|
|||||||
return SimpleStatus(
|
return SimpleStatus(
|
||||||
success=True, message=f"Extension '{ext_id}' already disabled."
|
success=True, message=f"Extension '{ext_id}' already disabled."
|
||||||
)
|
)
|
||||||
logger.info(f"Disabling extension: {ext_id}.")
|
logger.info(f"Disabeling extension: {ext_id}.")
|
||||||
user_ext.active = False
|
user_ext.active = False
|
||||||
await update_user_extension(user_ext)
|
await update_user_extension(user_ext)
|
||||||
return SimpleStatus(success=True, message=f"Extension '{ext_id}' disabled.")
|
return SimpleStatus(success=True, message=f"Extension '{ext_id}' disabled.")
|
||||||
@@ -240,8 +255,7 @@ async def api_activate_extension(ext_id: str) -> SimpleStatus:
|
|||||||
logger.info(f"Activating extension: '{ext_id}'.")
|
logger.info(f"Activating extension: '{ext_id}'.")
|
||||||
|
|
||||||
ext = await get_valid_extension(ext_id)
|
ext = await get_valid_extension(ext_id)
|
||||||
if not ext:
|
assert ext, f"Extension '{ext_id}' doesn't exist."
|
||||||
raise ValueError(f"Extension '{ext_id}' doesn't exist.")
|
|
||||||
|
|
||||||
await activate_extension(ext)
|
await activate_extension(ext)
|
||||||
return SimpleStatus(success=True, message=f"Extension '{ext_id}' activated.")
|
return SimpleStatus(success=True, message=f"Extension '{ext_id}' activated.")
|
||||||
@@ -260,8 +274,7 @@ async def api_deactivate_extension(ext_id: str) -> SimpleStatus:
|
|||||||
logger.info(f"Deactivating extension: '{ext_id}'.")
|
logger.info(f"Deactivating extension: '{ext_id}'.")
|
||||||
|
|
||||||
ext = await get_valid_extension(ext_id)
|
ext = await get_valid_extension(ext_id)
|
||||||
if not ext:
|
assert ext, f"Extension '{ext_id}' doesn't exist."
|
||||||
raise ValueError(f"Extension '{ext_id}' doesn't exist.")
|
|
||||||
|
|
||||||
await deactivate_extension(ext_id)
|
await deactivate_extension(ext_id)
|
||||||
return SimpleStatus(success=True, message=f"Extension '{ext_id}' deactivated.")
|
return SimpleStatus(success=True, message=f"Extension '{ext_id}' deactivated.")
|
||||||
@@ -341,36 +354,41 @@ async def get_extension_releases(ext_id: str) -> list[ExtensionRelease]:
|
|||||||
async def get_pay_to_install_invoice(
|
async def get_pay_to_install_invoice(
|
||||||
ext_id: str, data: CreateExtension
|
ext_id: str, data: CreateExtension
|
||||||
) -> ReleasePaymentInfo:
|
) -> ReleasePaymentInfo:
|
||||||
if ext_id != data.ext_id:
|
try:
|
||||||
raise ValueError(
|
assert (
|
||||||
f"Wrong extension id. Expected {ext_id}, but got {data.ext_id}"
|
ext_id == data.ext_id
|
||||||
)
|
), f"Wrong extension id. Expected {ext_id}, but got {data.ext_id}"
|
||||||
if not data.cost_sats:
|
assert data.cost_sats, "A non-zero amount must be specified."
|
||||||
raise ValueError("A non-zero amount must be specified.")
|
|
||||||
release = await InstallableExtension.get_extension_release(
|
release = await InstallableExtension.get_extension_release(
|
||||||
data.ext_id, data.source_repo, data.archive, data.version
|
data.ext_id, data.source_repo, data.archive, data.version
|
||||||
)
|
)
|
||||||
if not release:
|
assert release, "Release not found."
|
||||||
raise ValueError("Release not found.")
|
assert release.pay_link, "Pay link not found for release."
|
||||||
if not release.pay_link:
|
|
||||||
raise ValueError("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):
|
assert payment_info and payment_info.payment_request, "Cannot request invoice."
|
||||||
raise ValueError("Cannot request invoice.")
|
|
||||||
invoice = bolt11_decode(payment_info.payment_request)
|
invoice = bolt11_decode(payment_info.payment_request)
|
||||||
|
|
||||||
if invoice.amount_msat is None:
|
assert invoice.amount_msat is not None, "Invoic amount is missing."
|
||||||
raise ValueError("Invoic amount is missing.")
|
|
||||||
invoice_amount = int(invoice.amount_msat / 1000)
|
invoice_amount = int(invoice.amount_msat / 1000)
|
||||||
if invoice_amount != data.cost_sats:
|
assert (
|
||||||
raise ValueError(f"Wrong invoice amount: {invoice_amount}.")
|
invoice_amount == data.cost_sats
|
||||||
if payment_info.payment_hash != invoice.payment_hash:
|
), f"Wrong invoice amount: {invoice_amount}."
|
||||||
raise ValueError("Wrong invoice payment hash.")
|
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")
|
@extension_router.put("/{ext_id}/invoice/enable")
|
||||||
async def get_pay_to_enable_invoice(
|
async def get_pay_to_enable_invoice(
|
||||||
|
|||||||
@@ -1,164 +0,0 @@
|
|||||||
import os
|
|
||||||
import shutil
|
|
||||||
from hashlib import sha256
|
|
||||||
from http import HTTPStatus
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
|
||||||
from fastapi.responses import FileResponse
|
|
||||||
|
|
||||||
from lnbits.core.models import (
|
|
||||||
SimpleStatus,
|
|
||||||
User,
|
|
||||||
)
|
|
||||||
from lnbits.core.models.extensions import (
|
|
||||||
Extension,
|
|
||||||
ExtensionMeta,
|
|
||||||
InstallableExtension,
|
|
||||||
UserExtension,
|
|
||||||
)
|
|
||||||
from lnbits.core.models.extensions_builder import ExtensionData
|
|
||||||
from lnbits.core.services.extensions import (
|
|
||||||
activate_extension,
|
|
||||||
install_extension,
|
|
||||||
)
|
|
||||||
from lnbits.core.services.extensions_builder import (
|
|
||||||
build_extension_from_data,
|
|
||||||
clean_extension_builder_data,
|
|
||||||
zip_directory,
|
|
||||||
)
|
|
||||||
from lnbits.decorators import (
|
|
||||||
check_admin,
|
|
||||||
check_user_exists,
|
|
||||||
)
|
|
||||||
from lnbits.settings import settings
|
|
||||||
|
|
||||||
from ..crud import (
|
|
||||||
create_user_extension,
|
|
||||||
get_user_extension,
|
|
||||||
update_user_extension,
|
|
||||||
)
|
|
||||||
|
|
||||||
extension_builder_router = APIRouter(
|
|
||||||
tags=["Extension Managment"],
|
|
||||||
prefix="/api/v1/extension/builder",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@extension_builder_router.post(
|
|
||||||
"/zip",
|
|
||||||
summary="Build and download extension zip.",
|
|
||||||
description="""
|
|
||||||
This endpoint generates a zip file for the extension based on the provided data.
|
|
||||||
""",
|
|
||||||
)
|
|
||||||
async def api_build_extension(
|
|
||||||
data: ExtensionData,
|
|
||||||
user: User = Depends(check_user_exists),
|
|
||||||
) -> FileResponse:
|
|
||||||
if not settings.lnbits_extensions_builder_activate_non_admins and not user.admin:
|
|
||||||
raise HTTPException(
|
|
||||||
HTTPStatus.FORBIDDEN,
|
|
||||||
"Extension Builder is disabled for non admin users.",
|
|
||||||
)
|
|
||||||
stub_ext_id = "extension_builder_stub" # todo: do not hardcode, fetch from manifest
|
|
||||||
release, build_dir = await build_extension_from_data(data, stub_ext_id)
|
|
||||||
|
|
||||||
ext_info = InstallableExtension(
|
|
||||||
id=data.id,
|
|
||||||
name=data.name,
|
|
||||||
version="0.1.0",
|
|
||||||
short_description=data.short_description,
|
|
||||||
meta=ExtensionMeta(installed_release=release),
|
|
||||||
)
|
|
||||||
ext_zip_file = ext_info.zip_path
|
|
||||||
if ext_zip_file.is_file():
|
|
||||||
os.remove(ext_zip_file)
|
|
||||||
|
|
||||||
zip_directory(build_dir, ext_zip_file)
|
|
||||||
shutil.rmtree(build_dir, True)
|
|
||||||
|
|
||||||
return FileResponse(
|
|
||||||
ext_zip_file, filename=f"{data.id}.zip", media_type="application/zip"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@extension_builder_router.post(
|
|
||||||
"/deploy",
|
|
||||||
summary="Build extension based on provided config.",
|
|
||||||
description="""
|
|
||||||
This endpoint generates a zip file for the extension based on the provided data.
|
|
||||||
If `deploy` is set to true, the extension will be installed and activated.
|
|
||||||
""",
|
|
||||||
)
|
|
||||||
async def api_deploy_extension(
|
|
||||||
data: ExtensionData,
|
|
||||||
user: User = Depends(check_admin),
|
|
||||||
) -> SimpleStatus:
|
|
||||||
working_dir_name = "deploy_" + sha256(user.id.encode("utf-8")).hexdigest()
|
|
||||||
stub_ext_id = "extension_builder_stub"
|
|
||||||
release, build_dir = await build_extension_from_data(
|
|
||||||
data, stub_ext_id, working_dir_name
|
|
||||||
)
|
|
||||||
|
|
||||||
ext_info = InstallableExtension(
|
|
||||||
id=data.id,
|
|
||||||
name=data.name,
|
|
||||||
version="0.1.0",
|
|
||||||
short_description=data.short_description,
|
|
||||||
meta=ExtensionMeta(installed_release=release),
|
|
||||||
icon=release.icon,
|
|
||||||
)
|
|
||||||
ext_zip_file = ext_info.zip_path
|
|
||||||
if ext_zip_file.is_file():
|
|
||||||
os.remove(ext_zip_file)
|
|
||||||
|
|
||||||
zip_directory(build_dir.parent, ext_zip_file)
|
|
||||||
|
|
||||||
await install_extension(ext_info, skip_download=True)
|
|
||||||
|
|
||||||
await activate_extension(Extension.from_installable_ext(ext_info))
|
|
||||||
|
|
||||||
user_ext = await get_user_extension(user.id, data.id)
|
|
||||||
if not user_ext:
|
|
||||||
user_ext = UserExtension(user=user.id, extension=data.id, active=True)
|
|
||||||
await create_user_extension(user_ext)
|
|
||||||
elif not user_ext.active:
|
|
||||||
user_ext.active = True
|
|
||||||
await update_user_extension(user_ext)
|
|
||||||
|
|
||||||
return SimpleStatus(success=True, message=f"Extension '{data.id}' deployed.")
|
|
||||||
|
|
||||||
|
|
||||||
@extension_builder_router.post(
|
|
||||||
"/preview",
|
|
||||||
summary="Build and preview the extension ui.",
|
|
||||||
)
|
|
||||||
async def api_preview_extension(
|
|
||||||
data: ExtensionData,
|
|
||||||
user: User = Depends(check_user_exists),
|
|
||||||
) -> SimpleStatus:
|
|
||||||
if not settings.lnbits_extensions_builder_activate_non_admins and not user.admin:
|
|
||||||
raise HTTPException(
|
|
||||||
HTTPStatus.FORBIDDEN,
|
|
||||||
"Extension Builder is disabled for non admin users.",
|
|
||||||
)
|
|
||||||
stub_ext_id = "extension_builder_stub"
|
|
||||||
working_dir_name = "preview_" + sha256(user.id.encode("utf-8")).hexdigest()
|
|
||||||
await build_extension_from_data(data, stub_ext_id, working_dir_name)
|
|
||||||
|
|
||||||
return SimpleStatus(success=True, message=f"Extension '{data.id}' preview ready.")
|
|
||||||
|
|
||||||
|
|
||||||
@extension_builder_router.delete(
|
|
||||||
"",
|
|
||||||
summary="Clean extension builder data.",
|
|
||||||
description="""
|
|
||||||
This endpoint cleans the extension builder data.
|
|
||||||
""",
|
|
||||||
dependencies=[Depends(check_admin)],
|
|
||||||
)
|
|
||||||
async def api_delete_extension_builder_data() -> SimpleStatus:
|
|
||||||
|
|
||||||
clean_extension_builder_data()
|
|
||||||
|
|
||||||
return SimpleStatus(success=True, message="Extension Builder data cleaned.")
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
from http import HTTPStatus
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
|
||||||
|
|
||||||
from lnbits.core.models.misc import SimpleStatus
|
|
||||||
from lnbits.core.services.fiat_providers import test_connection
|
|
||||||
from lnbits.decorators import check_admin
|
|
||||||
from lnbits.fiat import StripeWallet, get_fiat_provider
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
@fiat_router.post(
|
|
||||||
"/{provider}/connection_token",
|
|
||||||
status_code=HTTPStatus.OK,
|
|
||||||
dependencies=[Depends(check_admin)],
|
|
||||||
)
|
|
||||||
async def connection_token(provider: str):
|
|
||||||
provider_wallet = await get_fiat_provider(provider)
|
|
||||||
if provider == "stripe":
|
|
||||||
if not isinstance(provider_wallet, StripeWallet):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=500, detail="Stripe wallet/provider not configured"
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
tok = await provider_wallet.create_terminal_connection_token()
|
|
||||||
secret = tok.get("secret")
|
|
||||||
if not secret:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=502, detail="Stripe returned no connection token"
|
|
||||||
)
|
|
||||||
return {"secret": secret}
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=500, detail="Failed to create connection token"
|
|
||||||
) from e
|
|
||||||
+12
-119
@@ -1,7 +1,6 @@
|
|||||||
from hashlib import sha256
|
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Annotated
|
from typing import Annotated, List, Optional, Union
|
||||||
from urllib.parse import urlencode, urlparse
|
from urllib.parse import urlencode, urlparse
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@@ -9,7 +8,7 @@ from fastapi import Cookie, Depends, Query, Request
|
|||||||
from fastapi.exceptions import HTTPException
|
from fastapi.exceptions import HTTPException
|
||||||
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
|
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
|
||||||
from fastapi.routing import APIRouter
|
from fastapi.routing import APIRouter
|
||||||
from lnurl import url_decode
|
from lnurl import decode as lnurl_decode
|
||||||
from pydantic.types import UUID4
|
from pydantic.types import UUID4
|
||||||
|
|
||||||
from lnbits.core.helpers import to_valid_user_id
|
from lnbits.core.helpers import to_valid_user_id
|
||||||
@@ -38,7 +37,7 @@ generic_router = APIRouter(
|
|||||||
|
|
||||||
@generic_router.get("/favicon.ico", response_class=FileResponse)
|
@generic_router.get("/favicon.ico", response_class=FileResponse)
|
||||||
async def favicon():
|
async def favicon():
|
||||||
return RedirectResponse(settings.lnbits_qr_logo)
|
return FileResponse(Path("lnbits", "static", "favicon.ico"))
|
||||||
|
|
||||||
|
|
||||||
@generic_router.get("/", response_class=HTMLResponse)
|
@generic_router.get("/", response_class=HTMLResponse)
|
||||||
@@ -72,7 +71,7 @@ async def robots():
|
|||||||
|
|
||||||
@generic_router.get("/extensions", name="extensions", response_class=HTMLResponse)
|
@generic_router.get("/extensions", name="extensions", response_class=HTMLResponse)
|
||||||
async def extensions(request: Request, user: User = Depends(check_user_exists)):
|
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]
|
installed_exts_ids = [e.id for e in installed_exts]
|
||||||
|
|
||||||
installable_exts = await InstallableExtension.get_installable_extensions()
|
installable_exts = await InstallableExtension.get_installable_extensions()
|
||||||
@@ -126,9 +125,6 @@ async def extensions(request: Request, user: User = Depends(check_user_exists)):
|
|||||||
if ext.meta and ext.meta.latest_release
|
if ext.meta and ext.meta.latest_release
|
||||||
else None
|
else None
|
||||||
),
|
),
|
||||||
"hasPaidRelease": ext.meta.has_paid_release if ext.meta else False,
|
|
||||||
"hasFreeRelease": ext.meta.has_free_release if ext.meta else False,
|
|
||||||
"paidFeatures": ext.meta.paid_features if ext.meta else False,
|
|
||||||
"installedRelease": (
|
"installedRelease": (
|
||||||
dict(ext.meta.installed_release)
|
dict(ext.meta.installed_release)
|
||||||
if ext.meta and ext.meta.installed_release
|
if ext.meta and ext.meta.installed_release
|
||||||
@@ -154,95 +150,11 @@ async def extensions(request: Request, user: User = Depends(check_user_exists)):
|
|||||||
{
|
{
|
||||||
"user": user.json(),
|
"user": user.json(),
|
||||||
"extension_data": extension_data,
|
"extension_data": extension_data,
|
||||||
"extension_builder_enabled": user.admin
|
|
||||||
or settings.lnbits_extensions_builder_activate_non_admins,
|
|
||||||
"ajax": _is_ajax_request(request),
|
"ajax": _is_ajax_request(request),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@generic_router.get(
|
|
||||||
"/extensions/builder", name="extensions builder", response_class=HTMLResponse
|
|
||||||
)
|
|
||||||
async def extensions_builder(request: Request, user: User = Depends(check_user_exists)):
|
|
||||||
if not settings.lnbits_extensions_builder_activate_non_admins and not user.admin:
|
|
||||||
raise HTTPException(
|
|
||||||
HTTPStatus.FORBIDDEN,
|
|
||||||
"Extension Builder is disabled for non admin users.",
|
|
||||||
)
|
|
||||||
return template_renderer().TemplateResponse(
|
|
||||||
request,
|
|
||||||
"core/extensions_builder.html",
|
|
||||||
{
|
|
||||||
"user": user.json(),
|
|
||||||
"ajax": _is_ajax_request(request),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@generic_router.get(
|
|
||||||
"/extensions/builder/preview/{ext_id}",
|
|
||||||
name="extensions builder",
|
|
||||||
response_class=HTMLResponse,
|
|
||||||
)
|
|
||||||
async def extensions_builder_preview(
|
|
||||||
request: Request,
|
|
||||||
ext_id: str,
|
|
||||||
page_name: str | None = None,
|
|
||||||
user: User = Depends(check_user_exists),
|
|
||||||
):
|
|
||||||
if not settings.lnbits_extensions_builder_activate_non_admins and not user.admin:
|
|
||||||
raise HTTPException(
|
|
||||||
HTTPStatus.FORBIDDEN,
|
|
||||||
"Extension Builder is disabled for non admin users.",
|
|
||||||
)
|
|
||||||
working_dir_name = "preview_" + sha256(user.id.encode("utf-8")).hexdigest()
|
|
||||||
html_file_name = "index.html"
|
|
||||||
if page_name == "public_page":
|
|
||||||
html_file_name = "public_page.html"
|
|
||||||
|
|
||||||
html_file_path = Path(
|
|
||||||
"extension_builder_stub",
|
|
||||||
ext_id,
|
|
||||||
working_dir_name,
|
|
||||||
ext_id,
|
|
||||||
"templates",
|
|
||||||
ext_id,
|
|
||||||
html_file_name,
|
|
||||||
)
|
|
||||||
|
|
||||||
html_file_full_path = Path(
|
|
||||||
settings.extension_builder_working_dir_path, html_file_path
|
|
||||||
)
|
|
||||||
|
|
||||||
if not html_file_full_path.is_file():
|
|
||||||
return template_renderer().TemplateResponse(
|
|
||||||
request,
|
|
||||||
"error.html",
|
|
||||||
{
|
|
||||||
"err": f"Extension {ext_id} not found",
|
|
||||||
"message": "Please 'Refresh Preview' first.",
|
|
||||||
},
|
|
||||||
status_code=HTTPStatus.NOT_FOUND,
|
|
||||||
)
|
|
||||||
|
|
||||||
response = template_renderer().TemplateResponse(
|
|
||||||
request,
|
|
||||||
html_file_path.as_posix(),
|
|
||||||
{
|
|
||||||
"user": user.json(),
|
|
||||||
"ajax": _is_ajax_request(request),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
response.headers["Content-Security-Policy"] = (
|
|
||||||
"default-src 'self'; "
|
|
||||||
"style-src 'self' 'unsafe-inline'; "
|
|
||||||
"script-src 'self' 'unsafe-inline' 'unsafe-eval'"
|
|
||||||
)
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
@generic_router.get(
|
@generic_router.get(
|
||||||
"/wallet",
|
"/wallet",
|
||||||
response_class=HTMLResponse,
|
response_class=HTMLResponse,
|
||||||
@@ -250,9 +162,9 @@ async def extensions_builder_preview(
|
|||||||
)
|
)
|
||||||
async def wallet(
|
async def wallet(
|
||||||
request: Request,
|
request: Request,
|
||||||
lnbits_last_active_wallet: Annotated[str | None, Cookie()] = None,
|
lnbits_last_active_wallet: Annotated[Union[str, None], Cookie()] = None,
|
||||||
user: User = Depends(check_user_exists),
|
user: User = Depends(check_user_exists),
|
||||||
wal: UUID4 | None = Query(None),
|
wal: Optional[UUID4] = Query(None),
|
||||||
):
|
):
|
||||||
if wal:
|
if wal:
|
||||||
wallet = await get_wallet(wal.hex)
|
wallet = await get_wallet(wal.hex)
|
||||||
@@ -295,32 +207,9 @@ async def account(
|
|||||||
request: Request,
|
request: Request,
|
||||||
user: User = Depends(check_user_exists),
|
user: User = Depends(check_user_exists),
|
||||||
):
|
):
|
||||||
nostr_configured = settings.is_nostr_notifications_configured()
|
|
||||||
telegram_configured = settings.is_telegram_notifications_configured()
|
|
||||||
return template_renderer().TemplateResponse(
|
return template_renderer().TemplateResponse(
|
||||||
request,
|
request,
|
||||||
"core/account.html",
|
"core/account.html",
|
||||||
{
|
|
||||||
"user": user.json(),
|
|
||||||
"nostr_configured": nostr_configured,
|
|
||||||
"telegram_configured": telegram_configured,
|
|
||||||
"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(),
|
"user": user.json(),
|
||||||
"ajax": _is_ajax_request(request),
|
"ajax": _is_ajax_request(request),
|
||||||
@@ -434,6 +323,7 @@ async def node(request: Request, user: User = Depends(check_admin)):
|
|||||||
"node/index.html",
|
"node/index.html",
|
||||||
{
|
{
|
||||||
"user": user.json(),
|
"user": user.json(),
|
||||||
|
"settings": settings.dict(),
|
||||||
"balance": balance,
|
"balance": balance,
|
||||||
"wallets": user.wallets[0].json(),
|
"wallets": user.wallets[0].json(),
|
||||||
"ajax": _is_ajax_request(request),
|
"ajax": _is_ajax_request(request),
|
||||||
@@ -453,6 +343,7 @@ async def node_public(request: Request):
|
|||||||
request,
|
request,
|
||||||
"node/public.html",
|
"node/public.html",
|
||||||
{
|
{
|
||||||
|
"settings": settings.dict(),
|
||||||
"balance": balance,
|
"balance": balance,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -471,6 +362,7 @@ async def admin_index(request: Request, user: User = Depends(check_admin)):
|
|||||||
"admin/index.html",
|
"admin/index.html",
|
||||||
{
|
{
|
||||||
"user": user.json(),
|
"user": user.json(),
|
||||||
|
"settings": settings.dict(),
|
||||||
"balance": balance,
|
"balance": balance,
|
||||||
"currencies": list(currencies.keys()),
|
"currencies": list(currencies.keys()),
|
||||||
"ajax": _is_ajax_request(request),
|
"ajax": _is_ajax_request(request),
|
||||||
@@ -488,6 +380,7 @@ async def users_index(request: Request, user: User = Depends(check_admin)):
|
|||||||
{
|
{
|
||||||
"request": request,
|
"request": request,
|
||||||
"user": user.json(),
|
"user": user.json(),
|
||||||
|
"settings": settings.dict(),
|
||||||
"currencies": list(currencies.keys()),
|
"currencies": list(currencies.keys()),
|
||||||
"ajax": _is_ajax_request(request),
|
"ajax": _is_ajax_request(request),
|
||||||
},
|
},
|
||||||
@@ -510,7 +403,7 @@ async def audit_index(request: Request, user: User = Depends(check_admin)):
|
|||||||
|
|
||||||
|
|
||||||
@generic_router.get("/payments", response_class=HTMLResponse)
|
@generic_router.get("/payments", response_class=HTMLResponse)
|
||||||
async def payments_index(request: Request, user: User = Depends(check_user_exists)):
|
async def payments_index(request: Request, user: User = Depends(check_admin)):
|
||||||
return template_renderer().TemplateResponse(
|
return template_renderer().TemplateResponse(
|
||||||
"payments/index.html",
|
"payments/index.html",
|
||||||
{
|
{
|
||||||
@@ -545,7 +438,7 @@ async def lnurlwallet(request: Request, lightning: str = ""):
|
|||||||
if not settings.lnbits_allow_new_accounts:
|
if not settings.lnbits_allow_new_accounts:
|
||||||
return {"status": "ERROR", "reason": "New accounts are not allowed."}
|
return {"status": "ERROR", "reason": "New accounts are not allowed."}
|
||||||
|
|
||||||
lnurl = url_decode(lightning)
|
lnurl = lnurl_decode(lightning)
|
||||||
|
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
check_callback_url(lnurl)
|
check_callback_url(lnurl)
|
||||||
|
|||||||
@@ -1,136 +0,0 @@
|
|||||||
from http import HTTPStatus
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from fastapi import (
|
|
||||||
APIRouter,
|
|
||||||
Depends,
|
|
||||||
HTTPException,
|
|
||||||
)
|
|
||||||
from lnurl import LnurlResponseException
|
|
||||||
from lnurl import execute_login as lnurlauth
|
|
||||||
from lnurl import handle as lnurl_handle
|
|
||||||
from lnurl.models import (
|
|
||||||
LnurlAuthResponse,
|
|
||||||
LnurlErrorResponse,
|
|
||||||
LnurlPayResponse,
|
|
||||||
LnurlResponseModel,
|
|
||||||
LnurlWithdrawResponse,
|
|
||||||
)
|
|
||||||
from loguru import logger
|
|
||||||
|
|
||||||
from lnbits.core.models import Payment
|
|
||||||
from lnbits.core.models.lnurl import CreateLnurlPayment, LnurlScan
|
|
||||||
from lnbits.decorators import (
|
|
||||||
WalletTypeInfo,
|
|
||||||
require_admin_key,
|
|
||||||
require_invoice_key,
|
|
||||||
)
|
|
||||||
from lnbits.helpers import check_callback_url
|
|
||||||
from lnbits.settings import settings
|
|
||||||
|
|
||||||
from ..services import fetch_lnurl_pay_request, pay_invoice
|
|
||||||
|
|
||||||
lnurl_router = APIRouter(tags=["LNURL"])
|
|
||||||
|
|
||||||
|
|
||||||
async def _handle(lnurl: str) -> LnurlResponseModel:
|
|
||||||
try:
|
|
||||||
res = await lnurl_handle(lnurl, user_agent=settings.user_agent, timeout=5)
|
|
||||||
if isinstance(res, LnurlErrorResponse):
|
|
||||||
raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=res.reason)
|
|
||||||
except LnurlResponseException as exc:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=HTTPStatus.BAD_REQUEST, detail=str(exc)
|
|
||||||
) from exc
|
|
||||||
return res
|
|
||||||
|
|
||||||
|
|
||||||
@lnurl_router.get(
|
|
||||||
"/api/v1/lnurlscan/{code}",
|
|
||||||
dependencies=[Depends(require_invoice_key)],
|
|
||||||
deprecated=True,
|
|
||||||
response_model=LnurlPayResponse
|
|
||||||
| LnurlWithdrawResponse
|
|
||||||
| LnurlAuthResponse
|
|
||||||
| LnurlErrorResponse,
|
|
||||||
)
|
|
||||||
async def api_lnurlscan(code: str) -> LnurlResponseModel:
|
|
||||||
res = await _handle(code)
|
|
||||||
if isinstance(res, LnurlPayResponse | LnurlWithdrawResponse | LnurlAuthResponse):
|
|
||||||
check_callback_url(res.callback)
|
|
||||||
return res
|
|
||||||
|
|
||||||
|
|
||||||
@lnurl_router.post(
|
|
||||||
"/api/v1/lnurlscan",
|
|
||||||
dependencies=[Depends(require_invoice_key)],
|
|
||||||
response_model=LnurlPayResponse
|
|
||||||
| LnurlWithdrawResponse
|
|
||||||
| LnurlAuthResponse
|
|
||||||
| LnurlErrorResponse,
|
|
||||||
)
|
|
||||||
async def api_lnurlscan_post(scan: LnurlScan) -> LnurlResponseModel:
|
|
||||||
return await _handle(scan.lnurl)
|
|
||||||
|
|
||||||
|
|
||||||
@lnurl_router.post("/api/v1/lnurlauth")
|
|
||||||
async def api_perform_lnurlauth(
|
|
||||||
data: LnurlAuthResponse, key_type: WalletTypeInfo = Depends(require_admin_key)
|
|
||||||
) -> LnurlResponseModel:
|
|
||||||
check_callback_url(data.callback)
|
|
||||||
try:
|
|
||||||
res = await lnurlauth(
|
|
||||||
res=data,
|
|
||||||
seed=key_type.wallet.adminkey,
|
|
||||||
user_agent=settings.user_agent,
|
|
||||||
timeout=5,
|
|
||||||
)
|
|
||||||
return res
|
|
||||||
except LnurlResponseException as exc:
|
|
||||||
logger.warning(exc)
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=HTTPStatus.BAD_REQUEST, detail=str(exc)
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
|
|
||||||
@lnurl_router.post("/api/v1/payments/lnurl")
|
|
||||||
async def api_payments_pay_lnurl(
|
|
||||||
data: CreateLnurlPayment, wallet: WalletTypeInfo = Depends(require_admin_key)
|
|
||||||
) -> Payment:
|
|
||||||
"""
|
|
||||||
Pay an LNURL payment request.
|
|
||||||
Either provice `res` (LnurlPayResponse) or `lnurl` (str) in the `data` object.
|
|
||||||
"""
|
|
||||||
if not data.res and not data.lnurl:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=HTTPStatus.BAD_REQUEST,
|
|
||||||
detail="Missing LNURL or LnurlPayResponse data.",
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
res, res2 = await fetch_lnurl_pay_request(data=data, wallet=wallet.wallet)
|
|
||||||
except LnurlResponseException as exc:
|
|
||||||
logger.warning(exc)
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=HTTPStatus.BAD_REQUEST, detail=str(exc)
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
extra: dict[str, Any] = {}
|
|
||||||
if res2.disposable is False:
|
|
||||||
extra["stored"] = True
|
|
||||||
if res2.successAction:
|
|
||||||
extra["success_action"] = res2.successAction.json()
|
|
||||||
if data.comment:
|
|
||||||
extra["comment"] = data.comment
|
|
||||||
if data.unit and data.unit != "sat":
|
|
||||||
extra["fiat_currency"] = data.unit
|
|
||||||
extra["fiat_amount"] = data.amount / 1000
|
|
||||||
|
|
||||||
payment = await pay_invoice(
|
|
||||||
wallet_id=wallet.wallet.id,
|
|
||||||
payment_request=str(res2.pr),
|
|
||||||
description=res.metadata.text,
|
|
||||||
extra=extra,
|
|
||||||
)
|
|
||||||
|
|
||||||
return payment
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import APIRouter, Body, Depends, HTTPException
|
from fastapi import APIRouter, Body, Depends, HTTPException
|
||||||
@@ -6,9 +7,8 @@ from pydantic import BaseModel
|
|||||||
from starlette.status import HTTP_503_SERVICE_UNAVAILABLE
|
from starlette.status import HTTP_503_SERVICE_UNAVAILABLE
|
||||||
|
|
||||||
from lnbits.decorators import check_admin, check_super_user, parse_filters
|
from lnbits.decorators import check_admin, check_super_user, parse_filters
|
||||||
|
from lnbits.nodes import get_node_class
|
||||||
from lnbits.settings import settings
|
from lnbits.settings import settings
|
||||||
from lnbits.wallets import get_funding_source
|
|
||||||
from lnbits.wallets.base import Feature
|
|
||||||
|
|
||||||
from ...db import Filters, Page
|
from ...db import Filters, Page
|
||||||
from ...nodes.base import (
|
from ...nodes.base import (
|
||||||
@@ -26,13 +26,9 @@ from ...nodes.base import (
|
|||||||
from ...utils.cache import cache
|
from ...utils.cache import cache
|
||||||
|
|
||||||
|
|
||||||
def require_node() -> Node:
|
def require_node():
|
||||||
funding_source = get_funding_source()
|
node_class = get_node_class()
|
||||||
if (
|
if not node_class:
|
||||||
not funding_source.features
|
|
||||||
or Feature.nodemanager not in funding_source.features
|
|
||||||
or not funding_source.__node_cls__
|
|
||||||
):
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.NOT_IMPLEMENTED,
|
status_code=HTTPStatus.NOT_IMPLEMENTED,
|
||||||
detail="Active backend does not implement Node API",
|
detail="Active backend does not implement Node API",
|
||||||
@@ -42,7 +38,7 @@ def require_node() -> Node:
|
|||||||
status_code=HTTPStatus.SERVICE_UNAVAILABLE,
|
status_code=HTTPStatus.SERVICE_UNAVAILABLE,
|
||||||
detail="Not enabled",
|
detail="Not enabled",
|
||||||
)
|
)
|
||||||
return funding_source.__node_cls__(funding_source)
|
return node_class
|
||||||
|
|
||||||
|
|
||||||
def check_public():
|
def check_public():
|
||||||
@@ -88,14 +84,14 @@ async def api_get_public_info(node: Node = Depends(require_node)) -> PublicNodeI
|
|||||||
@node_router.get("/info")
|
@node_router.get("/info")
|
||||||
async def api_get_info(
|
async def api_get_info(
|
||||||
node: Node = Depends(require_node),
|
node: Node = Depends(require_node),
|
||||||
) -> NodeInfoResponse | None:
|
) -> Optional[NodeInfoResponse]:
|
||||||
return await node.get_info()
|
return await node.get_info()
|
||||||
|
|
||||||
|
|
||||||
@node_router.get("/channels")
|
@node_router.get("/channels")
|
||||||
async def api_get_channels(
|
async def api_get_channels(
|
||||||
node: Node = Depends(require_node),
|
node: Node = Depends(require_node),
|
||||||
) -> list[NodeChannel] | None:
|
) -> Optional[List[NodeChannel]]:
|
||||||
return await node.get_channels()
|
return await node.get_channels()
|
||||||
|
|
||||||
|
|
||||||
@@ -103,7 +99,7 @@ async def api_get_channels(
|
|||||||
async def api_get_channel(
|
async def api_get_channel(
|
||||||
channel_id: str,
|
channel_id: str,
|
||||||
node: Node = Depends(require_node),
|
node: Node = Depends(require_node),
|
||||||
) -> NodeChannel | None:
|
) -> Optional[NodeChannel]:
|
||||||
return await node.get_channel(channel_id)
|
return await node.get_channel(channel_id)
|
||||||
|
|
||||||
|
|
||||||
@@ -112,20 +108,20 @@ async def api_create_channel(
|
|||||||
node: Node = Depends(require_node),
|
node: Node = Depends(require_node),
|
||||||
peer_id: str = Body(),
|
peer_id: str = Body(),
|
||||||
funding_amount: int = Body(),
|
funding_amount: int = Body(),
|
||||||
push_amount: int | None = Body(None),
|
push_amount: Optional[int] = Body(None),
|
||||||
fee_rate: int | None = Body(None),
|
fee_rate: Optional[int] = Body(None),
|
||||||
):
|
):
|
||||||
return await node.open_channel(peer_id, funding_amount, push_amount, fee_rate)
|
return await node.open_channel(peer_id, funding_amount, push_amount, fee_rate)
|
||||||
|
|
||||||
|
|
||||||
@super_node_router.delete("/channels")
|
@super_node_router.delete("/channels")
|
||||||
async def api_delete_channel(
|
async def api_delete_channel(
|
||||||
short_id: str | None,
|
short_id: Optional[str],
|
||||||
funding_txid: str | None,
|
funding_txid: Optional[str],
|
||||||
output_index: int | None,
|
output_index: Optional[int],
|
||||||
force: bool = False,
|
force: bool = False,
|
||||||
node: Node = Depends(require_node),
|
node: Node = Depends(require_node),
|
||||||
) -> list[NodeChannel] | None:
|
) -> Optional[List[NodeChannel]]:
|
||||||
return await node.close_channel(
|
return await node.close_channel(
|
||||||
short_id,
|
short_id,
|
||||||
(
|
(
|
||||||
@@ -151,7 +147,7 @@ async def api_set_channel_fees(
|
|||||||
async def api_get_payments(
|
async def api_get_payments(
|
||||||
node: Node = Depends(require_node),
|
node: Node = Depends(require_node),
|
||||||
filters: Filters = Depends(parse_filters(NodePaymentsFilters)),
|
filters: Filters = Depends(parse_filters(NodePaymentsFilters)),
|
||||||
) -> Page[NodePayment] | None:
|
) -> Optional[Page[NodePayment]]:
|
||||||
if not settings.lnbits_node_ui_transactions:
|
if not settings.lnbits_node_ui_transactions:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
HTTP_503_SERVICE_UNAVAILABLE,
|
HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
@@ -164,7 +160,7 @@ async def api_get_payments(
|
|||||||
async def api_get_invoices(
|
async def api_get_invoices(
|
||||||
node: Node = Depends(require_node),
|
node: Node = Depends(require_node),
|
||||||
filters: Filters = Depends(parse_filters(NodeInvoiceFilters)),
|
filters: Filters = Depends(parse_filters(NodeInvoiceFilters)),
|
||||||
) -> Page[NodeInvoice] | None:
|
) -> Optional[Page[NodeInvoice]]:
|
||||||
if not settings.lnbits_node_ui_transactions:
|
if not settings.lnbits_node_ui_transactions:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
HTTP_503_SERVICE_UNAVAILABLE,
|
HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
@@ -174,7 +170,7 @@ async def api_get_invoices(
|
|||||||
|
|
||||||
|
|
||||||
@node_router.get("/peers")
|
@node_router.get("/peers")
|
||||||
async def api_get_peers(node: Node = Depends(require_node)) -> list[NodePeerInfo]:
|
async def api_get_peers(node: Node = Depends(require_node)) -> List[NodePeerInfo]:
|
||||||
return await node.get_peers()
|
return await node.get_peers()
|
||||||
|
|
||||||
|
|
||||||
@@ -191,35 +187,32 @@ async def api_disconnect_peer(peer_id: str, node: Node = Depends(require_node)):
|
|||||||
|
|
||||||
|
|
||||||
class NodeRank(BaseModel):
|
class NodeRank(BaseModel):
|
||||||
capacity: int | None
|
capacity: Optional[int]
|
||||||
channelcount: int | None
|
channelcount: Optional[int]
|
||||||
age: int | None
|
age: Optional[int]
|
||||||
growth: int | None
|
growth: Optional[int]
|
||||||
availability: int | None
|
availability: Optional[int]
|
||||||
|
|
||||||
|
|
||||||
# Same for public and private api
|
# Same for public and private api
|
||||||
@node_router.get(
|
@node_router.get(
|
||||||
"/rank",
|
"/rank",
|
||||||
description="Retrieve node ranks from https://1ml.com",
|
description="Retrieve node ranks from https://1ml.com",
|
||||||
response_model=NodeRank | None,
|
response_model=Optional[NodeRank],
|
||||||
)
|
)
|
||||||
@public_node_router.get(
|
@public_node_router.get(
|
||||||
"/rank",
|
"/rank",
|
||||||
description="Retrieve node ranks from https://1ml.com",
|
description="Retrieve node ranks from https://1ml.com",
|
||||||
response_model=NodeRank | None,
|
response_model=Optional[NodeRank],
|
||||||
)
|
)
|
||||||
async def api_get_1ml_stats(node: Node = Depends(require_node)) -> NodeRank | None:
|
async def api_get_1ml_stats(node: Node = Depends(require_node)) -> Optional[NodeRank]:
|
||||||
node_id = await node.get_id()
|
node_id = await node.get_id()
|
||||||
headers = {"User-Agent": settings.user_agent}
|
headers = {"User-Agent": settings.user_agent}
|
||||||
async with httpx.AsyncClient(headers=headers) as client:
|
async with httpx.AsyncClient(headers=headers) as client:
|
||||||
r = await client.get(url=f"https://1ml.com/node/{node_id}/json", timeout=15)
|
r = await client.get(url=f"https://1ml.com/node/{node_id}/json", timeout=15)
|
||||||
try:
|
try:
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
data = r.json()
|
return r.json()["noderank"]
|
||||||
if "noderank" not in data:
|
|
||||||
return None
|
|
||||||
return data["noderank"]
|
|
||||||
except httpx.HTTPStatusError as exc:
|
except httpx.HTTPStatusError as exc:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.NOT_FOUND, detail="Node not found on 1ml.com"
|
status_code=HTTPStatus.NOT_FOUND, detail="Node not found on 1ml.com"
|
||||||
|
|||||||
+242
-110
@@ -1,6 +1,11 @@
|
|||||||
from hashlib import sha256
|
import json
|
||||||
|
import ssl
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
|
from math import ceil
|
||||||
|
from typing import List, Optional
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
import httpx
|
||||||
from fastapi import (
|
from fastapi import (
|
||||||
APIRouter,
|
APIRouter,
|
||||||
Depends,
|
Depends,
|
||||||
@@ -9,7 +14,7 @@ from fastapi import (
|
|||||||
Query,
|
Query,
|
||||||
)
|
)
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from lnurl import url_decode
|
from loguru import logger
|
||||||
|
|
||||||
from lnbits import bolt11
|
from lnbits import bolt11
|
||||||
from lnbits.core.crud.payments import (
|
from lnbits.core.crud.payments import (
|
||||||
@@ -17,11 +22,11 @@ from lnbits.core.crud.payments import (
|
|||||||
get_wallets_stats,
|
get_wallets_stats,
|
||||||
)
|
)
|
||||||
from lnbits.core.models import (
|
from lnbits.core.models import (
|
||||||
CancelInvoice,
|
|
||||||
CreateInvoice,
|
CreateInvoice,
|
||||||
CreateLnurlWithdraw,
|
CreateLnurl,
|
||||||
DecodePayment,
|
DecodePayment,
|
||||||
KeyType,
|
KeyType,
|
||||||
|
PayLnurlWData,
|
||||||
Payment,
|
Payment,
|
||||||
PaymentCountField,
|
PaymentCountField,
|
||||||
PaymentCountStat,
|
PaymentCountStat,
|
||||||
@@ -29,23 +34,30 @@ from lnbits.core.models import (
|
|||||||
PaymentFilters,
|
PaymentFilters,
|
||||||
PaymentHistoryPoint,
|
PaymentHistoryPoint,
|
||||||
PaymentWalletStats,
|
PaymentWalletStats,
|
||||||
SettleInvoice,
|
Wallet,
|
||||||
SimpleStatus,
|
|
||||||
)
|
)
|
||||||
from lnbits.core.models.users import User
|
from lnbits.core.models.users import User
|
||||||
|
from lnbits.core.services.payments import (
|
||||||
|
get_payments_daily_stats,
|
||||||
|
update_pending_payment,
|
||||||
|
)
|
||||||
from lnbits.db import Filters, Page
|
from lnbits.db import Filters, Page
|
||||||
from lnbits.decorators import (
|
from lnbits.decorators import (
|
||||||
WalletTypeInfo,
|
WalletTypeInfo,
|
||||||
|
check_admin,
|
||||||
check_user_exists,
|
check_user_exists,
|
||||||
parse_filters,
|
parse_filters,
|
||||||
require_admin_key,
|
require_admin_key,
|
||||||
require_invoice_key,
|
require_invoice_key,
|
||||||
)
|
)
|
||||||
from lnbits.helpers import (
|
from lnbits.helpers import (
|
||||||
|
check_callback_url,
|
||||||
filter_dict_keys,
|
filter_dict_keys,
|
||||||
generate_filter_params_openapi,
|
generate_filter_params_openapi,
|
||||||
)
|
)
|
||||||
from lnbits.wallets.base import InvoiceResponse
|
from lnbits.lnurl import decode as lnurl_decode
|
||||||
|
from lnbits.settings import settings
|
||||||
|
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis
|
||||||
|
|
||||||
from ..crud import (
|
from ..crud import (
|
||||||
DateTrunc,
|
DateTrunc,
|
||||||
@@ -56,14 +68,9 @@ from ..crud import (
|
|||||||
get_wallet_for_key,
|
get_wallet_for_key,
|
||||||
)
|
)
|
||||||
from ..services import (
|
from ..services import (
|
||||||
cancel_hold_invoice,
|
create_invoice,
|
||||||
create_payment_request,
|
|
||||||
fee_reserve_total,
|
fee_reserve_total,
|
||||||
get_payments_daily_stats,
|
|
||||||
pay_invoice,
|
pay_invoice,
|
||||||
perform_withdraw,
|
|
||||||
settle_hold_invoice,
|
|
||||||
update_pending_payment,
|
|
||||||
update_pending_payments,
|
update_pending_payments,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -75,7 +82,7 @@ payment_router = APIRouter(prefix="/api/v1/payments", tags=["Payments"])
|
|||||||
name="Payment List",
|
name="Payment List",
|
||||||
summary="get list of payments",
|
summary="get list of payments",
|
||||||
response_description="list of payments",
|
response_description="list of payments",
|
||||||
response_model=list[Payment],
|
response_model=List[Payment],
|
||||||
openapi_extra=generate_filter_params_openapi(PaymentFilters),
|
openapi_extra=generate_filter_params_openapi(PaymentFilters),
|
||||||
)
|
)
|
||||||
async def api_payments(
|
async def api_payments(
|
||||||
@@ -94,7 +101,7 @@ async def api_payments(
|
|||||||
@payment_router.get(
|
@payment_router.get(
|
||||||
"/history",
|
"/history",
|
||||||
name="Get payments history",
|
name="Get payments history",
|
||||||
response_model=list[PaymentHistoryPoint],
|
response_model=List[PaymentHistoryPoint],
|
||||||
openapi_extra=generate_filter_params_openapi(PaymentFilters),
|
openapi_extra=generate_filter_params_openapi(PaymentFilters),
|
||||||
)
|
)
|
||||||
async def api_payments_history(
|
async def api_payments_history(
|
||||||
@@ -109,61 +116,59 @@ async def api_payments_history(
|
|||||||
@payment_router.get(
|
@payment_router.get(
|
||||||
"/stats/count",
|
"/stats/count",
|
||||||
name="Get payments history for all users",
|
name="Get payments history for all users",
|
||||||
response_model=list[PaymentCountStat],
|
dependencies=[Depends(check_admin)],
|
||||||
|
response_model=List[PaymentCountStat],
|
||||||
openapi_extra=generate_filter_params_openapi(PaymentFilters),
|
openapi_extra=generate_filter_params_openapi(PaymentFilters),
|
||||||
)
|
)
|
||||||
async def api_payments_counting_stats(
|
async def api_payments_counting_stats(
|
||||||
count_by: PaymentCountField = Query("tag"),
|
count_by: PaymentCountField = Query("tag"),
|
||||||
filters: Filters[PaymentFilters] = Depends(parse_filters(PaymentFilters)),
|
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)
|
return await get_payment_count_stats(count_by, filters)
|
||||||
|
|
||||||
|
|
||||||
@payment_router.get(
|
@payment_router.get(
|
||||||
"/stats/wallets",
|
"/stats/wallets",
|
||||||
name="Get payments history for all users",
|
name="Get payments history for all users",
|
||||||
response_model=list[PaymentWalletStats],
|
dependencies=[Depends(check_admin)],
|
||||||
|
response_model=List[PaymentWalletStats],
|
||||||
openapi_extra=generate_filter_params_openapi(PaymentFilters),
|
openapi_extra=generate_filter_params_openapi(PaymentFilters),
|
||||||
)
|
)
|
||||||
async def api_payments_wallets_stats(
|
async def api_payments_wallets_stats(
|
||||||
filters: Filters[PaymentFilters] = Depends(parse_filters(PaymentFilters)),
|
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)
|
return await get_wallets_stats(filters)
|
||||||
|
|
||||||
|
|
||||||
@payment_router.get(
|
@payment_router.get(
|
||||||
"/stats/daily",
|
"/stats/daily",
|
||||||
name="Get payments history per day",
|
name="Get payments history per day",
|
||||||
response_model=list[PaymentDailyStats],
|
response_model=List[PaymentDailyStats],
|
||||||
openapi_extra=generate_filter_params_openapi(PaymentFilters),
|
openapi_extra=generate_filter_params_openapi(PaymentFilters),
|
||||||
)
|
)
|
||||||
async def api_payments_daily_stats(
|
async def api_payments_daily_stats(
|
||||||
user: User = Depends(check_user_exists),
|
user: User = Depends(check_user_exists),
|
||||||
filters: Filters[PaymentFilters] = Depends(parse_filters(PaymentFilters)),
|
filters: Filters[PaymentFilters] = Depends(parse_filters(PaymentFilters)),
|
||||||
):
|
):
|
||||||
if user.admin:
|
|
||||||
# admin user can see payments from all wallets
|
if not user.admin:
|
||||||
for_user_id = None
|
exc = HTTPException(
|
||||||
else:
|
status_code=HTTPStatus.UNAUTHORIZED,
|
||||||
# regular user can only see payments from their wallets
|
detail="Missing wallet id.",
|
||||||
for_user_id = user.id
|
)
|
||||||
return await get_payments_daily_stats(filters, user_id=for_user_id)
|
wallet_filter = next(
|
||||||
|
(f for f in filters.filters if f.field == "wallet_id"), None
|
||||||
|
)
|
||||||
|
if not wallet_filter:
|
||||||
|
raise exc
|
||||||
|
wallet_id = list((wallet_filter.values or {}).values())
|
||||||
|
if len(wallet_id) == 0:
|
||||||
|
raise exc
|
||||||
|
if not user.get_wallet(wallet_id[0]):
|
||||||
|
raise exc
|
||||||
|
return await get_payments_daily_stats(filters)
|
||||||
|
|
||||||
|
|
||||||
@payment_router.get(
|
@payment_router.get(
|
||||||
@@ -189,6 +194,69 @@ async def api_payments_paginated(
|
|||||||
return page
|
return page
|
||||||
|
|
||||||
|
|
||||||
|
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 = ""
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
@payment_router.get(
|
@payment_router.get(
|
||||||
"/all/paginated",
|
"/all/paginated",
|
||||||
name="Payment List",
|
name="Payment List",
|
||||||
@@ -196,21 +264,13 @@ async def api_payments_paginated(
|
|||||||
response_description="list of payments",
|
response_description="list of payments",
|
||||||
response_model=Page[Payment],
|
response_model=Page[Payment],
|
||||||
openapi_extra=generate_filter_params_openapi(PaymentFilters),
|
openapi_extra=generate_filter_params_openapi(PaymentFilters),
|
||||||
|
dependencies=[Depends(check_admin)],
|
||||||
)
|
)
|
||||||
async def api_all_payments_paginated(
|
async def api_all_payments_paginated(
|
||||||
filters: Filters = Depends(parse_filters(PaymentFilters)),
|
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
|
|
||||||
|
|
||||||
return await get_payments_paginated(
|
return await get_payments_paginated(
|
||||||
filters=filters,
|
filters=filters,
|
||||||
user_id=for_user_id,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -236,7 +296,6 @@ async def api_payments_create(
|
|||||||
invoice_data: CreateInvoice,
|
invoice_data: CreateInvoice,
|
||||||
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
||||||
) -> Payment:
|
) -> Payment:
|
||||||
wallet_id = wallet.wallet.id
|
|
||||||
if invoice_data.out is True and wallet.key_type == KeyType.admin:
|
if invoice_data.out is True and wallet.key_type == KeyType.admin:
|
||||||
if not invoice_data.bolt11:
|
if not invoice_data.bolt11:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -244,21 +303,21 @@ async def api_payments_create(
|
|||||||
detail="Missing BOLT11 invoice",
|
detail="Missing BOLT11 invoice",
|
||||||
)
|
)
|
||||||
payment = await pay_invoice(
|
payment = await pay_invoice(
|
||||||
wallet_id=wallet_id,
|
wallet_id=wallet.wallet.id,
|
||||||
payment_request=invoice_data.bolt11,
|
payment_request=invoice_data.bolt11,
|
||||||
extra=invoice_data.extra,
|
extra=invoice_data.extra,
|
||||||
)
|
)
|
||||||
return payment
|
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(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.FORBIDDEN,
|
status_code=HTTPStatus.UNAUTHORIZED,
|
||||||
detail="Invoice (or Admin) key required.",
|
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.get("/fee-reserve")
|
@payment_router.get("/fee-reserve")
|
||||||
async def api_payments_fee_reserve(invoice: str = Query("invoice")) -> JSONResponse:
|
async def api_payments_fee_reserve(invoice: str = Query("invoice")) -> JSONResponse:
|
||||||
@@ -275,9 +334,82 @@ async def api_payments_fee_reserve(invoice: str = Query("invoice")) -> JSONRespo
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@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
|
||||||
|
check_callback_url(data.callback)
|
||||||
|
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.HTTPError, ssl.SSLError) as exc:
|
||||||
|
logger.warning(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(
|
||||||
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
|
detail=(
|
||||||
|
f"{domain} returned an invalid invoice. Expected"
|
||||||
|
f" {amount_msat} msat, got {invoice.amount_msat}."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
extra = {}
|
||||||
|
if params.get("successAction"):
|
||||||
|
extra["success_action"] = params["successAction"]
|
||||||
|
if data.comment:
|
||||||
|
extra["comment"] = data.comment
|
||||||
|
if data.unit and data.unit != "sat":
|
||||||
|
extra["fiat_currency"] = data.unit
|
||||||
|
extra["fiat_amount"] = data.amount / 1000
|
||||||
|
assert data.description is not None, "description is required"
|
||||||
|
|
||||||
|
payment = await pay_invoice(
|
||||||
|
wallet_id=wallet.wallet.id,
|
||||||
|
payment_request=params["pr"],
|
||||||
|
description=data.description,
|
||||||
|
extra=extra,
|
||||||
|
)
|
||||||
|
return payment
|
||||||
|
|
||||||
|
|
||||||
# TODO: refactor this route into a public and admin one
|
# TODO: refactor this route into a public and admin one
|
||||||
@payment_router.get("/{payment_hash}")
|
@payment_router.get("/{payment_hash}")
|
||||||
async def api_payment(payment_hash, x_api_key: str | None = Header(None)):
|
async def api_payment(payment_hash, x_api_key: Optional[str] = Header(None)):
|
||||||
# We use X_Api_Key here because we want this call to work with and without keys
|
# We use X_Api_Key here because we want this call to work with and without keys
|
||||||
# If a valid key is given, we also return the field "details", otherwise not
|
# If a valid key is given, we also return the field "details", otherwise not
|
||||||
wallet = await get_wallet_for_key(x_api_key) if isinstance(x_api_key, str) else None
|
wallet = await get_wallet_for_key(x_api_key) if isinstance(x_api_key, str) else None
|
||||||
@@ -295,11 +427,6 @@ async def api_payment(payment_hash, x_api_key: str | None = Header(None)):
|
|||||||
return {"paid": True, "preimage": payment.preimage, "details": payment}
|
return {"paid": True, "preimage": payment.preimage, "details": payment}
|
||||||
return {"paid": True, "preimage": payment.preimage}
|
return {"paid": True, "preimage": payment.preimage}
|
||||||
|
|
||||||
if payment.failed:
|
|
||||||
if wallet and wallet.id == payment.wallet_id:
|
|
||||||
return {"paid": False, "status": "failed", "details": payment}
|
|
||||||
return {"paid": False, "status": "failed"}
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
status = await payment.check_status()
|
status = await payment.check_status()
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -322,7 +449,7 @@ async def api_payments_decode(data: DecodePayment) -> JSONResponse:
|
|||||||
payment_str = data.data
|
payment_str = data.data
|
||||||
try:
|
try:
|
||||||
if payment_str[:5] == "LNURL":
|
if payment_str[:5] == "LNURL":
|
||||||
url = str(url_decode(payment_str))
|
url = str(lnurl_decode(payment_str))
|
||||||
return JSONResponse({"domain": url})
|
return JSONResponse({"domain": url})
|
||||||
else:
|
else:
|
||||||
invoice = bolt11.decode(payment_str)
|
invoice = bolt11.decode(payment_str)
|
||||||
@@ -335,52 +462,57 @@ async def api_payments_decode(data: DecodePayment) -> JSONResponse:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@payment_router.post("/settle")
|
@payment_router.post("/{payment_request}/pay-with-nfc", status_code=HTTPStatus.OK)
|
||||||
async def api_payments_settle(
|
|
||||||
data: SettleInvoice, key_type: WalletTypeInfo = Depends(require_admin_key)
|
|
||||||
) -> InvoiceResponse:
|
|
||||||
payment_hash = sha256(bytes.fromhex(data.preimage)).hexdigest()
|
|
||||||
payment = await get_standalone_payment(
|
|
||||||
payment_hash, incoming=True, wallet_id=key_type.wallet.id
|
|
||||||
)
|
|
||||||
if not payment:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=HTTPStatus.NOT_FOUND,
|
|
||||||
detail="Payment does not exist or does not belong to this wallet.",
|
|
||||||
)
|
|
||||||
return await settle_hold_invoice(payment, data.preimage)
|
|
||||||
|
|
||||||
|
|
||||||
@payment_router.post("/cancel")
|
|
||||||
async def api_payments_cancel(
|
|
||||||
data: CancelInvoice, key_type: WalletTypeInfo = Depends(require_admin_key)
|
|
||||||
) -> InvoiceResponse:
|
|
||||||
payment = await get_standalone_payment(
|
|
||||||
data.payment_hash, incoming=True, wallet_id=key_type.wallet.id
|
|
||||||
)
|
|
||||||
if not payment:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=HTTPStatus.NOT_FOUND,
|
|
||||||
detail="Payment does not exist or does not belong to this wallet.",
|
|
||||||
)
|
|
||||||
return await cancel_hold_invoice(payment)
|
|
||||||
|
|
||||||
|
|
||||||
@payment_router.post("/{payment_request}/pay-with-nfc")
|
|
||||||
async def api_payment_pay_with_nfc(
|
async def api_payment_pay_with_nfc(
|
||||||
payment_request: str,
|
payment_request: str,
|
||||||
lnurl_data: CreateLnurlWithdraw,
|
lnurl_data: PayLnurlWData,
|
||||||
) -> SimpleStatus:
|
) -> JSONResponse:
|
||||||
if not lnurl_data.lnurl_w.lud17:
|
lnurl = lnurl_data.lnurl_w.lower()
|
||||||
raise HTTPException(
|
|
||||||
status_code=HTTPStatus.BAD_REQUEST,
|
|
||||||
detail="LNURL-withdraw lud17 not provided.",
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
await perform_withdraw(lnurl_data.lnurl_w.lud17, payment_request)
|
|
||||||
except Exception as exc:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=HTTPStatus.BAD_REQUEST, detail=str(exc)
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
return SimpleStatus(success=True, message="Payment sent with NFC.")
|
# Follow LUD-17 -> https://github.com/lnurl/luds/blob/luds/17.md
|
||||||
|
url = lnurl.replace("lnurlw://", "https://")
|
||||||
|
|
||||||
|
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(
|
||||||
|
{"success": False, "detail": "Error loading LNURL request"}
|
||||||
|
)
|
||||||
|
|
||||||
|
lnurl_res = lnurl_req.json()
|
||||||
|
|
||||||
|
if lnurl_res.get("status") == "ERROR":
|
||||||
|
return JSONResponse({"success": False, "detail": lnurl_res["reason"]})
|
||||||
|
|
||||||
|
if lnurl_res.get("tag") != "withdrawRequest":
|
||||||
|
return JSONResponse(
|
||||||
|
{"success": False, "detail": "Invalid LNURL-withdraw"}
|
||||||
|
)
|
||||||
|
|
||||||
|
callback_url = lnurl_res["callback"]
|
||||||
|
k1 = lnurl_res["k1"]
|
||||||
|
|
||||||
|
callback_req = await client.get(
|
||||||
|
callback_url,
|
||||||
|
params={"k1": k1, "pr": payment_request},
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
if callback_req.is_error:
|
||||||
|
return JSONResponse(
|
||||||
|
{"success": False, "detail": "Error loading callback request"}
|
||||||
|
)
|
||||||
|
|
||||||
|
callback_res = callback_req.json()
|
||||||
|
|
||||||
|
if callback_res.get("status") == "ERROR":
|
||||||
|
return JSONResponse(
|
||||||
|
{"success": False, "detail": callback_res["reason"]}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return JSONResponse({"success": True, "detail": callback_res})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return JSONResponse({"success": False, "detail": f"Unexpected error: {e}"})
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import base64
|
|||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
|
from typing import List, Optional
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
import shortuuid
|
import shortuuid
|
||||||
@@ -34,7 +35,7 @@ from lnbits.core.models.notifications import NotificationType
|
|||||||
from lnbits.core.models.users import Account
|
from lnbits.core.models.users import Account
|
||||||
from lnbits.core.services import (
|
from lnbits.core.services import (
|
||||||
create_user_account_no_ckeck,
|
create_user_account_no_ckeck,
|
||||||
enqueue_admin_notification,
|
enqueue_notification,
|
||||||
update_user_account,
|
update_user_account,
|
||||||
update_user_extensions,
|
update_user_extensions,
|
||||||
update_wallet_balance,
|
update_wallet_balance,
|
||||||
@@ -47,7 +48,6 @@ from lnbits.helpers import (
|
|||||||
)
|
)
|
||||||
from lnbits.settings import EditableSettings, settings
|
from lnbits.settings import EditableSettings, settings
|
||||||
from lnbits.utils.exchange_rates import allowed_currencies
|
from lnbits.utils.exchange_rates import allowed_currencies
|
||||||
from lnbits.utils.nostr import normalize_public_key
|
|
||||||
|
|
||||||
users_router = APIRouter(
|
users_router = APIRouter(
|
||||||
prefix="/users/api/v1", dependencies=[Depends(check_admin)], tags=["Users"]
|
prefix="/users/api/v1", dependencies=[Depends(check_admin)], tags=["Users"]
|
||||||
@@ -95,51 +95,35 @@ async def api_create_user(data: CreateUser) -> CreateUser:
|
|||||||
data.extra = data.extra or UserExtra()
|
data.extra = data.extra or UserExtra()
|
||||||
data.extra.provider = data.extra.provider or "lnbits"
|
data.extra.provider = data.extra.provider or "lnbits"
|
||||||
|
|
||||||
if data.pubkey:
|
|
||||||
data.pubkey = normalize_public_key(data.pubkey)
|
|
||||||
|
|
||||||
account = Account(
|
account = Account(
|
||||||
id=uuid4().hex,
|
id=uuid4().hex,
|
||||||
username=data.username,
|
username=data.username,
|
||||||
email=data.email,
|
email=data.email,
|
||||||
pubkey=data.pubkey,
|
pubkey=data.pubkey,
|
||||||
external_id=data.external_id,
|
|
||||||
extra=data.extra,
|
extra=data.extra,
|
||||||
)
|
)
|
||||||
account.validate_fields()
|
account.validate_fields()
|
||||||
account.hash_password(data.password)
|
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
|
data.id = user.id
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
@users_router.put("/user/{user_id}", name="Update user")
|
@users_router.put("/user/{user_id}", name="Update user")
|
||||||
async def api_update_user(
|
async def api_update_user(user_id: str, data: CreateUser) -> CreateUser:
|
||||||
user_id: str, data: CreateUser, user: User = Depends(check_admin)
|
|
||||||
) -> CreateUser:
|
|
||||||
if user_id != data.id:
|
if user_id != data.id:
|
||||||
raise HTTPException(HTTPStatus.BAD_REQUEST, "User Id missmatch.")
|
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:
|
if data.password or data.password_repeat:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
HTTPStatus.BAD_REQUEST, "Use 'reset password' functionality."
|
HTTPStatus.BAD_REQUEST, "Use 'reset password' functionality."
|
||||||
)
|
)
|
||||||
|
|
||||||
if data.pubkey:
|
|
||||||
data.pubkey = normalize_public_key(data.pubkey)
|
|
||||||
|
|
||||||
account = Account(
|
account = Account(
|
||||||
id=user_id,
|
id=user_id,
|
||||||
username=data.username,
|
username=data.username,
|
||||||
email=data.email,
|
email=data.email,
|
||||||
pubkey=data.pubkey,
|
pubkey=data.pubkey,
|
||||||
external_id=data.external_id,
|
|
||||||
extra=data.extra or UserExtra(),
|
extra=data.extra or UserExtra(),
|
||||||
)
|
)
|
||||||
await update_user_account(account)
|
await update_user_account(account)
|
||||||
@@ -156,7 +140,7 @@ async def api_update_user(
|
|||||||
async def api_users_delete_user(
|
async def api_users_delete_user(
|
||||||
user_id: str, user: User = Depends(check_admin)
|
user_id: str, user: User = Depends(check_admin)
|
||||||
) -> SimpleStatus:
|
) -> SimpleStatus:
|
||||||
wallets = await get_wallets(user_id, deleted=False)
|
wallets = await get_wallets(user_id)
|
||||||
if len(wallets) > 0:
|
if len(wallets) > 0:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.BAD_REQUEST,
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
@@ -193,8 +177,7 @@ async def api_users_reset_password(user_id: str) -> str:
|
|||||||
reset_data = ["reset", user_id, int(time.time())]
|
reset_data = ["reset", user_id, int(time.time())]
|
||||||
reset_data_json = json.dumps(reset_data, separators=(",", ":"), ensure_ascii=False)
|
reset_data_json = json.dumps(reset_data, separators=(",", ":"), ensure_ascii=False)
|
||||||
reset_key = encrypt_internal_message(reset_data_json)
|
reset_key = encrypt_internal_message(reset_data_json)
|
||||||
if not reset_key:
|
assert reset_key, "Cannot generate reset key."
|
||||||
raise ValueError("Cannot generate reset key.")
|
|
||||||
reset_key_b64 = base64.b64encode(reset_key.encode()).decode()
|
reset_key_b64 = base64.b64encode(reset_key.encode()).decode()
|
||||||
return f"reset_key_{reset_key_b64}"
|
return f"reset_key_{reset_key_b64}"
|
||||||
|
|
||||||
@@ -223,13 +206,13 @@ async def api_users_toggle_admin(user_id: str) -> SimpleStatus:
|
|||||||
|
|
||||||
|
|
||||||
@users_router.get("/user/{user_id}/wallet", name="Get wallets for user")
|
@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)
|
return await get_wallets(user_id)
|
||||||
|
|
||||||
|
|
||||||
@users_router.post("/user/{user_id}/wallet", name="Create a new wallet for user")
|
@users_router.post("/user/{user_id}/wallet", name="Create a new wallet for user")
|
||||||
async def api_users_create_user_wallet(
|
async def api_users_create_user_wallet(
|
||||||
user_id: str, name: str | None = Body(None), currency: str | None = Body(None)
|
user_id: str, name: Optional[str] = Body(None), currency: Optional[str] = Body(None)
|
||||||
):
|
):
|
||||||
if currency and currency not in allowed_currencies():
|
if currency and currency not in allowed_currencies():
|
||||||
raise ValueError(f"Currency '{currency}' not allowed.")
|
raise ValueError(f"Currency '{currency}' not allowed.")
|
||||||
@@ -266,50 +249,19 @@ async def api_users_undelete_user_wallet(user_id: str, wallet: str) -> SimpleSta
|
|||||||
return SimpleStatus(success=True, message="Wallet is already active.")
|
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(
|
@users_router.delete(
|
||||||
"/user/{user_id}/wallet/{wallet}",
|
"/user/{user_id}/wallet/{wallet}",
|
||||||
name="Delete wallet by id",
|
name="Delete wallet by id",
|
||||||
summary="First time it is called it does a soft delete (only sets a flag)."
|
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",
|
"The second time it is called will delete the entry from the DB",
|
||||||
)
|
)
|
||||||
async def api_users_delete_user_wallet(
|
async def api_users_delete_user_wallet(user_id: str, wallet: str) -> SimpleStatus:
|
||||||
user_id: str, wallet: str, user: User = Depends(check_admin)
|
|
||||||
) -> SimpleStatus:
|
|
||||||
wal = await get_wallet(wallet)
|
wal = await get_wallet(wallet)
|
||||||
if not wal:
|
if not wal:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.NOT_FOUND,
|
status_code=HTTPStatus.NOT_FOUND,
|
||||||
detail="Wallet does not exist.",
|
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:
|
if wal.deleted:
|
||||||
await force_delete_wallet(wallet)
|
await force_delete_wallet(wallet)
|
||||||
await delete_wallet(user_id=user_id, wallet_id=wallet)
|
await delete_wallet(user_id=user_id, wallet_id=wallet)
|
||||||
@@ -327,7 +279,7 @@ async def api_update_balance(data: UpdateBalance) -> SimpleStatus:
|
|||||||
if not wallet:
|
if not wallet:
|
||||||
raise HTTPException(HTTPStatus.NOT_FOUND, "Wallet not found.")
|
raise HTTPException(HTTPStatus.NOT_FOUND, "Wallet not found.")
|
||||||
await update_wallet_balance(wallet=wallet, amount=int(data.amount))
|
await update_wallet_balance(wallet=wallet, amount=int(data.amount))
|
||||||
enqueue_admin_notification(
|
enqueue_notification(
|
||||||
NotificationType.balance_update,
|
NotificationType.balance_update,
|
||||||
{
|
{
|
||||||
"amount": int(data.amount),
|
"amount": int(data.amount),
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
|
from typing import Optional
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from fastapi import (
|
from fastapi import (
|
||||||
@@ -8,18 +9,13 @@ from fastapi import (
|
|||||||
HTTPException,
|
HTTPException,
|
||||||
)
|
)
|
||||||
|
|
||||||
from lnbits.core.crud.wallets import get_wallets_paginated
|
from lnbits.core.models import CreateWallet, KeyType, User, Wallet
|
||||||
from lnbits.core.models import CreateWallet, KeyType, User, Wallet, WalletTypeInfo
|
|
||||||
from lnbits.core.models.lnurl import StoredPayLink, StoredPayLinks
|
|
||||||
from lnbits.core.models.wallets import WalletsFilters
|
|
||||||
from lnbits.db import Filters, Page
|
|
||||||
from lnbits.decorators import (
|
from lnbits.decorators import (
|
||||||
|
WalletTypeInfo,
|
||||||
check_user_exists,
|
check_user_exists,
|
||||||
parse_filters,
|
|
||||||
require_admin_key,
|
require_admin_key,
|
||||||
require_invoice_key,
|
require_invoice_key,
|
||||||
)
|
)
|
||||||
from lnbits.helpers import generate_filter_params_openapi
|
|
||||||
|
|
||||||
from ..crud import (
|
from ..crud import (
|
||||||
create_wallet,
|
create_wallet,
|
||||||
@@ -42,26 +38,6 @@ async def api_wallet(key_info: WalletTypeInfo = Depends(require_invoice_key)):
|
|||||||
return res
|
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}")
|
@wallet_router.put("/{new_name}")
|
||||||
async def api_update_wallet_name(
|
async def api_update_wallet_name(
|
||||||
new_name: str, key_info: WalletTypeInfo = Depends(require_admin_key)
|
new_name: str, key_info: WalletTypeInfo = Depends(require_admin_key)
|
||||||
@@ -92,28 +68,12 @@ async def api_reset_wallet_keys(
|
|||||||
return wallet
|
return wallet
|
||||||
|
|
||||||
|
|
||||||
@wallet_router.put("/stored_paylinks/{wallet_id}")
|
|
||||||
async def api_put_stored_paylinks(
|
|
||||||
wallet_id: str,
|
|
||||||
data: StoredPayLinks,
|
|
||||||
key_info: WalletTypeInfo = Depends(require_admin_key),
|
|
||||||
) -> list[StoredPayLink]:
|
|
||||||
if key_info.wallet.id != wallet_id:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=HTTPStatus.FORBIDDEN, detail="You cannot modify this wallet"
|
|
||||||
)
|
|
||||||
key_info.wallet.stored_paylinks.links = data.links
|
|
||||||
wallet = await update_wallet(key_info.wallet)
|
|
||||||
return wallet.stored_paylinks.links
|
|
||||||
|
|
||||||
|
|
||||||
@wallet_router.patch("")
|
@wallet_router.patch("")
|
||||||
async def api_update_wallet(
|
async def api_update_wallet(
|
||||||
name: str | None = Body(None),
|
name: Optional[str] = Body(None),
|
||||||
icon: str | None = Body(None),
|
icon: Optional[str] = Body(None),
|
||||||
color: str | None = Body(None),
|
color: Optional[str] = Body(None),
|
||||||
currency: str | None = Body(None),
|
currency: Optional[str] = Body(None),
|
||||||
pinned: bool | None = Body(None),
|
|
||||||
key_info: WalletTypeInfo = Depends(require_admin_key),
|
key_info: WalletTypeInfo = Depends(require_admin_key),
|
||||||
) -> Wallet:
|
) -> Wallet:
|
||||||
wallet = await get_wallet(key_info.wallet.id)
|
wallet = await get_wallet(key_info.wallet.id)
|
||||||
@@ -122,7 +82,6 @@ async def api_update_wallet(
|
|||||||
wallet.name = name or wallet.name
|
wallet.name = name or wallet.name
|
||||||
wallet.extra.icon = icon or wallet.extra.icon
|
wallet.extra.icon = icon or wallet.extra.icon
|
||||||
wallet.extra.color = color or wallet.extra.color
|
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
|
wallet.currency = currency if currency is not None else wallet.currency
|
||||||
await update_wallet(wallet)
|
await update_wallet(wallet)
|
||||||
return wallet
|
return wallet
|
||||||
|
|||||||
@@ -1,20 +1,33 @@
|
|||||||
from fastapi import APIRouter, WebSocket
|
from fastapi import (
|
||||||
|
APIRouter,
|
||||||
|
WebSocket,
|
||||||
|
WebSocketDisconnect,
|
||||||
|
)
|
||||||
|
|
||||||
from ..services import websocket_manager
|
from lnbits.settings import settings
|
||||||
|
|
||||||
|
from ..services import (
|
||||||
|
websocket_manager,
|
||||||
|
websocket_updater,
|
||||||
|
)
|
||||||
|
|
||||||
websocket_router = APIRouter(prefix="/api/v1/ws", tags=["Websocket"])
|
websocket_router = APIRouter(prefix="/api/v1/ws", tags=["Websocket"])
|
||||||
|
|
||||||
|
|
||||||
@websocket_router.websocket("/{item_id}")
|
@websocket_router.websocket("/{item_id}")
|
||||||
async def websocket_connect(websocket: WebSocket, item_id: str) -> None:
|
async def websocket_connect(websocket: WebSocket, item_id: str):
|
||||||
conn = await websocket_manager.connect(item_id, websocket)
|
await websocket_manager.connect(websocket, item_id)
|
||||||
await websocket_manager.listen(conn)
|
try:
|
||||||
|
while settings.lnbits_running:
|
||||||
|
await websocket.receive_text()
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
websocket_manager.disconnect(websocket)
|
||||||
|
|
||||||
|
|
||||||
@websocket_router.post("/{item_id}")
|
@websocket_router.post("/{item_id}")
|
||||||
async def websocket_update_post(item_id: str, data: str):
|
async def websocket_update_post(item_id: str, data: str):
|
||||||
try:
|
try:
|
||||||
await websocket_manager.send(item_id, data)
|
await websocket_updater(item_id, data)
|
||||||
return {"sent": True, "data": data}
|
return {"sent": True, "data": data}
|
||||||
except Exception:
|
except Exception:
|
||||||
return {"sent": False, "data": data}
|
return {"sent": False, "data": data}
|
||||||
@@ -23,7 +36,7 @@ async def websocket_update_post(item_id: str, data: str):
|
|||||||
@websocket_router.get("/{item_id}/{data}")
|
@websocket_router.get("/{item_id}/{data}")
|
||||||
async def websocket_update_get(item_id: str, data: str):
|
async def websocket_update_get(item_id: str, data: str):
|
||||||
try:
|
try:
|
||||||
await websocket_manager.send(item_id, data)
|
await websocket_updater(item_id, data)
|
||||||
return {"sent": True, "data": data}
|
return {"sent": True, "data": data}
|
||||||
except Exception:
|
except Exception:
|
||||||
return {"sent": False, "data": data}
|
return {"sent": False, "data": data}
|
||||||
|
|||||||
+16
-33
@@ -252,7 +252,7 @@ class Connection(Compat):
|
|||||||
{clause}
|
{clause}
|
||||||
{group_by_string}
|
{group_by_string}
|
||||||
) as count
|
) as count
|
||||||
""", # noqa: S608
|
""",
|
||||||
parsed_values,
|
parsed_values,
|
||||||
)
|
)
|
||||||
row = result.mappings().first()
|
row = result.mappings().first()
|
||||||
@@ -281,19 +281,15 @@ class Database(Compat):
|
|||||||
self.schema = self.name
|
self.schema = self.name
|
||||||
self.type = DB_TYPE
|
self.type = DB_TYPE
|
||||||
|
|
||||||
if self.type == POSTGRES and settings.lnbits_database_url:
|
if DB_TYPE == SQLITE:
|
||||||
database_uri = settings.lnbits_database_url.replace(
|
|
||||||
"postgres://", "postgresql+asyncpg://"
|
|
||||||
)
|
|
||||||
elif self.type == COCKROACH and settings.lnbits_database_url:
|
|
||||||
database_uri = settings.lnbits_database_url.replace(
|
|
||||||
"cockroachdb://", "cockroachdb+asyncpg://"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
self.path = os.path.join(
|
self.path = os.path.join(
|
||||||
settings.lnbits_data_folder, f"{self.name}.sqlite3"
|
settings.lnbits_data_folder, f"{self.name}.sqlite3"
|
||||||
)
|
)
|
||||||
database_uri = f"sqlite+aiosqlite:///{self.path}"
|
database_uri = f"sqlite+aiosqlite:///{self.path}"
|
||||||
|
else:
|
||||||
|
database_uri = settings.lnbits_database_url.replace(
|
||||||
|
"postgres://", "postgresql+asyncpg://"
|
||||||
|
)
|
||||||
|
|
||||||
if self.name.startswith("ext_"):
|
if self.name.startswith("ext_"):
|
||||||
self.schema = self.name[4:]
|
self.schema = self.name[4:]
|
||||||
@@ -558,13 +554,9 @@ class Filters(BaseModel, Generic[TFilterModel]):
|
|||||||
for page_filter in self.filters:
|
for page_filter in self.filters:
|
||||||
where_stmts.append(page_filter.statement)
|
where_stmts.append(page_filter.statement)
|
||||||
if self.search and self.model and self.model.__search_fields__:
|
if self.search and self.model and self.model.__search_fields__:
|
||||||
# Use `COALESCE` to handle `NULL` values and `||`
|
where_stmts.append(
|
||||||
# for cross-database compatible string concatenation
|
f"lower(concat({', '.join(self.model.__search_fields__)})) LIKE :search"
|
||||||
_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 where_stmts:
|
if where_stmts:
|
||||||
return "WHERE " + " AND ".join(where_stmts)
|
return "WHERE " + " AND ".join(where_stmts)
|
||||||
@@ -584,7 +576,7 @@ class Filters(BaseModel, Generic[TFilterModel]):
|
|||||||
for key, value in page_filter.values.items():
|
for key, value in page_filter.values.items():
|
||||||
values[key] = value
|
values[key] = value
|
||||||
if self.search and self.model:
|
if self.search and self.model:
|
||||||
values["search"] = f"%{self.search.lower()}%"
|
values["search"] = f"%{self.search}%"
|
||||||
return values
|
return values
|
||||||
|
|
||||||
|
|
||||||
@@ -601,7 +593,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)
|
# add quotes to keys to avoid SQL conflicts (e.g. `user` is a reserved keyword)
|
||||||
fields = ", ".join([f'"{key}"' for key in keys])
|
fields = ", ".join([f'"{key}"' for key in keys])
|
||||||
values = ", ".join(placeholders)
|
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(
|
def update_query(
|
||||||
@@ -619,7 +611,7 @@ def update_query(
|
|||||||
# add quotes to keys to avoid SQL conflicts (e.g. `user` is a reserved keyword)
|
# add quotes to keys to avoid SQL conflicts (e.g. `user` is a reserved keyword)
|
||||||
fields.append(f'"{field}" = {placeholder}')
|
fields.append(f'"{field}" = {placeholder}')
|
||||||
query = ", ".join(fields)
|
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:
|
def model_to_dict(model: BaseModel) -> dict:
|
||||||
@@ -652,9 +644,9 @@ def model_to_dict(model: BaseModel) -> dict:
|
|||||||
def dict_to_submodel(model: type[TModel], value: dict | str) -> TModel | None:
|
def dict_to_submodel(model: type[TModel], value: dict | str) -> TModel | None:
|
||||||
"""convert a dictionary or JSON string to a Pydantic model"""
|
"""convert a dictionary or JSON string to a Pydantic model"""
|
||||||
if isinstance(value, str):
|
if isinstance(value, str):
|
||||||
if value == "null" or value == "":
|
if value == "null":
|
||||||
return None
|
return None
|
||||||
_subdict = _safe_load_json(value)
|
_subdict = json.loads(value)
|
||||||
elif isinstance(value, dict):
|
elif isinstance(value, dict):
|
||||||
_subdict = value
|
_subdict = value
|
||||||
|
|
||||||
@@ -662,7 +654,7 @@ def dict_to_submodel(model: type[TModel], value: dict | str) -> TModel | None:
|
|||||||
return dict_to_model(_subdict, model)
|
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
|
Convert a dictionary with JSON-encoded nested models to a Pydantic model
|
||||||
:param _dict: Dictionary from database
|
:param _dict: Dictionary from database
|
||||||
@@ -678,7 +670,7 @@ def dict_to_model(_row: dict, model: type[TModel]) -> TModel: # noqa: C901
|
|||||||
type_ = model.__fields__[key].type_
|
type_ = model.__fields__[key].type_
|
||||||
outertype_ = model.__fields__[key].outer_type_
|
outertype_ = model.__fields__[key].outer_type_
|
||||||
if get_origin(outertype_) is list:
|
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[key] = [
|
||||||
dict_to_submodel(type_, v) if issubclass(type_, BaseModel) else v
|
dict_to_submodel(type_, v) if issubclass(type_, BaseModel) else v
|
||||||
for v in _items
|
for v in _items
|
||||||
@@ -699,7 +691,7 @@ def dict_to_model(_row: dict, model: type[TModel]) -> TModel: # noqa: C901
|
|||||||
# TODO: remove this when all sub models are migrated to Pydantic
|
# TODO: remove this when all sub models are migrated to Pydantic
|
||||||
# NOTE: this is for type dict on BaseModel, (used in Payment class)
|
# NOTE: this is for type dict on BaseModel, (used in Payment class)
|
||||||
if type_ is dict and value:
|
if type_ is dict and value:
|
||||||
_dict[key] = _safe_load_json(value)
|
_dict[key] = json.loads(value)
|
||||||
continue
|
continue
|
||||||
_dict[key] = value
|
_dict[key] = value
|
||||||
continue
|
continue
|
||||||
@@ -707,12 +699,3 @@ def dict_to_model(_row: dict, model: type[TModel]) -> TModel: # noqa: C901
|
|||||||
if isinstance(_model, BaseModel):
|
if isinstance(_model, BaseModel):
|
||||||
_model.__init__(**_dict) # type: ignore
|
_model.__init__(**_dict) # type: ignore
|
||||||
return _model
|
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 {}
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user