Compare commits
229
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2320018302 | ||
|
|
0dc0725a9b | ||
|
|
8c77f75cf1 | ||
|
|
21505471d5 | ||
|
|
baa9a35773 | ||
|
|
7f114ddcc0 | ||
|
|
92aad20dd7 | ||
|
|
0f4ae5da86 | ||
|
|
7a796c6510 | ||
|
|
0910687328 | ||
|
|
33e2fc2ea8 | ||
|
|
0c6e8394c8 | ||
|
|
d55e2a0e1f | ||
|
|
148ba9d275 | ||
|
|
d2ca774f6f | ||
|
|
233398b512 | ||
|
|
152c1dbb74 | ||
|
|
44985cb0d1 | ||
|
|
b1a7692ce4 | ||
|
|
31bff37b1e | ||
|
|
3f7a1798d5 | ||
|
|
98d4dbab8b | ||
|
|
7c7a04da9d | ||
|
|
3ccefb70fa | ||
|
|
5346ac47b1 | ||
|
|
9f4ed53888 | ||
|
|
faac56c14c | ||
|
|
3e0d0d9896 | ||
|
|
b9a004a5e4 | ||
|
|
da3cb548bd | ||
|
|
4144359617 | ||
|
|
07268b87d2 | ||
|
|
c356874bcb | ||
|
|
f9b5c7db7a | ||
|
|
20f9ddb57c | ||
|
|
ee57ba4c1a | ||
|
|
ff7f5c1ca5 | ||
|
|
1463d75ee2 | ||
|
|
f1fc4710ee | ||
|
|
00eaec8290 | ||
|
|
28c4235c94 | ||
|
|
b7d178c08e | ||
|
|
9d0ec97d39 | ||
|
|
b6f533be24 | ||
|
|
5e36bb4fcd | ||
|
|
f9b0604711 | ||
|
|
39c33699af | ||
|
|
c89721223f | ||
|
|
1e6e97c12d | ||
|
|
783efb19db | ||
|
|
9ffc63b5dc | ||
|
|
0ec8139e5c | ||
|
|
a8c3181852 | ||
|
|
e038ceb9be | ||
|
|
b4eccb9e5d | ||
|
|
691b7ad055 | ||
|
|
c84d1b66c6 | ||
|
|
cf7726ddfd | ||
|
|
bd50a3c546 | ||
|
|
c9270bbf7f | ||
|
|
6474aeb982 | ||
|
|
37ba437ad1 | ||
|
|
35af181e24 | ||
|
|
f8c58aef0e | ||
|
|
8755984bd8 | ||
|
|
0d5661cda7 | ||
|
|
c2a3fbc6c0 | ||
|
|
6bac34fc6b | ||
|
|
b54eedee84 | ||
|
|
bd07f7a5ef | ||
|
|
a40306f5cd | ||
|
|
2fecec2623 | ||
|
|
4ad89396cc | ||
|
|
82307c4839 | ||
|
|
8506199c2f | ||
|
|
d142d76148 | ||
|
|
98e9a61b98 | ||
|
|
877af0c21e | ||
|
|
dd27b190f3 | ||
|
|
2856803ca7 | ||
|
|
ca8264b1f5 | ||
|
|
39ca9da870 | ||
|
|
2b603bdc48 | ||
|
|
26780df065 | ||
|
|
248fcc06ab | ||
|
|
f794373d30 | ||
|
|
87f25ea715 | ||
|
|
b9de754598 | ||
|
|
785fb7af8e | ||
|
|
55c5ab3a6d | ||
|
|
bd07a319ab | ||
|
|
6b732a2a6a | ||
|
|
25c8dd18e0 | ||
|
|
2b1b5cadaa | ||
|
|
6b41820422 | ||
|
|
7116353431 | ||
|
|
de6827af58 | ||
|
|
40c065708a | ||
|
|
4cf9fae3e3 | ||
|
|
bf06def9b7 | ||
|
|
182894fd93 | ||
|
|
d0cf374cda | ||
|
|
1e0dab32c3 | ||
|
|
34c9e218bb | ||
|
|
9cc6a96433 | ||
|
|
e5ff928c3c | ||
|
|
f31ffba06c | ||
|
|
1163e44265 | ||
|
|
266b16834b | ||
|
|
ad606b2517 | ||
|
|
1ec28079d9 | ||
|
|
aeaddc1dc6 | ||
|
|
a9374d509c | ||
|
|
3e72788fc8 | ||
|
|
f48d24aca8 | ||
|
|
015262c9b3 | ||
|
|
e5e481f836 | ||
|
|
6c32ebf7e6 | ||
|
|
7c88702754 | ||
|
|
210b8a7c18 | ||
|
|
4f9a5090c2 | ||
|
|
c05122e5fb | ||
|
|
c054e47913 | ||
|
|
92d3269a85 | ||
|
|
183b84c167 | ||
|
|
60f50a71a2 | ||
|
|
c3252ce4dc | ||
|
|
c312d70e63 | ||
|
|
36fc911b88 | ||
|
|
672a5b3a4d | ||
|
|
c0b33560bb | ||
|
|
15b6b1d512 | ||
|
|
67c92a79cf | ||
|
|
0d4751d6e0 | ||
|
|
213bde61a8 | ||
|
|
59553a6e1e | ||
|
|
918add5e6e | ||
|
|
2375f7bcf8 | ||
|
|
5021570f68 | ||
|
|
0d91d5b5be | ||
|
|
49f02a5b77 | ||
|
|
95b9dabb60 | ||
|
|
bc0087ec36 | ||
|
|
8272147012 | ||
|
|
244de2ae86 | ||
|
|
ef371e303c | ||
|
|
5ba06d42d0 | ||
|
|
15984fa49b | ||
|
|
67dc096909 | ||
|
|
2daddf8b74 | ||
|
|
ae6cf47244 | ||
|
|
1488f580ff | ||
|
|
609c949394 | ||
|
|
c24d2f24cf | ||
|
|
0ba81ec719 | ||
|
|
5e03707be5 | ||
|
|
50f1111356 | ||
|
|
112a0f9698 | ||
|
|
75016d1a27 | ||
|
|
5b9f244a9f | ||
|
|
0d2ff2e663 | ||
|
|
bdfefc2506 | ||
|
|
2a9f471783 | ||
|
|
793c7c45ed | ||
|
|
ff535ae065 | ||
|
|
01fdc1defb | ||
|
|
dce2bfb440 | ||
|
|
a36ab2d408 | ||
|
|
98f732fb6f | ||
|
|
65864cb216 | ||
|
|
114e7b8c3c | ||
|
|
fbfab880f3 | ||
|
|
133afff8ee | ||
|
|
851c952c6d | ||
|
|
8b29148085 | ||
|
|
ad48092549 | ||
|
|
ee449ca612 | ||
|
|
73f45ae859 | ||
|
|
094a9fc1a1 | ||
|
|
5e9919c5c5 | ||
|
|
f91c933919 | ||
|
|
6e9f451419 | ||
|
|
256a8098c6 | ||
|
|
1a02095758 | ||
|
|
ec5b7493b3 | ||
|
|
cdc1a6dc8f | ||
|
|
7d573ef859 | ||
|
|
4bd2a4fba9 | ||
|
|
209c251fb6 | ||
|
|
69a7ef74fd | ||
|
|
35e2c4b0a7 | ||
|
|
2c6dce808e | ||
|
|
fa89313e6f | ||
|
|
76ecf113c3 | ||
|
|
e0749e186e | ||
|
|
0529ee2835 | ||
|
|
09b91f9f79 | ||
|
|
90ab642dcd | ||
|
|
88cf1ac853 | ||
|
|
9aa2194d94 | ||
|
|
ba0f7a01ef | ||
|
|
5765ea0276 | ||
|
|
88b01e6079 | ||
|
|
b0d0901969 | ||
|
|
6563610b6c | ||
|
|
c4aa0b598c | ||
|
|
aab76e691d | ||
|
|
3ed30be93e | ||
|
|
37f99e7f96 | ||
|
|
a16078a6ba | ||
|
|
c4c03d96a3 | ||
|
|
ab3248da15 | ||
|
|
b4b37cd733 | ||
|
|
26eb7a449c | ||
|
|
695e9b6471 | ||
|
|
5c9511ccfe | ||
|
|
c7b7832a88 | ||
|
|
ff24847980 | ||
|
|
659ec31edd | ||
|
|
edb7d66efc | ||
|
|
efcc5e2148 | ||
|
|
55889abd88 | ||
|
|
063aa4fd06 | ||
|
|
7c0a955b30 | ||
|
|
b1a09af82c | ||
|
|
6d2f39d390 | ||
|
|
18496f0dd9 | ||
|
|
acf6c732ad | ||
|
|
b39bd257e0 |
+112
-61
@@ -1,11 +1,95 @@
|
||||
#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 ##########
|
||||
######################################
|
||||
# Secret Key: will default to the hash of the super user.
|
||||
# !!!!! It is strongly recommended that you set your own strong random value !!!!
|
||||
AUTH_SECRET_KEY=""
|
||||
|
||||
AUTH_TOKEN_EXPIRE_MINUTES=525600
|
||||
# Possible authorization methods: user-id-only, username-password, nostr-auth-nip98, google-auth, github-auth, keycloak-auth
|
||||
AUTH_ALLOWED_METHODS="user-id-only, username-password"
|
||||
@@ -16,14 +100,6 @@ AUTH_ALLOWED_METHODS="user-id-only, username-password"
|
||||
########### 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
|
||||
LNBITS_SITE_TITLE="LNbits"
|
||||
LNBITS_SITE_TAGLINE="Open Source Lightning Payments Platform"
|
||||
@@ -32,22 +108,14 @@ 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_CUSTOM_LOGO="https://lnbits.com/assets/images/logo/logo.svg"
|
||||
|
||||
HOST=127.0.0.1
|
||||
PORT=5000
|
||||
|
||||
######################################
|
||||
########## 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, StrikeWallet"
|
||||
|
||||
LNBITS_BACKEND_WALLET_CLASS=VoidWallet
|
||||
# VoidWallet is just a fallback that works without any actual Lightning capabilities,
|
||||
# just so you can see the UI before dealing with this file.
|
||||
|
||||
# How many times to retry connectiong to the Funding Source before defaulting to the VoidWallet
|
||||
# FUNDING_SOURCE_MAX_RETRIES=4
|
||||
|
||||
# Invoice expiry for LND, CLN, Eclair, LNbits funding sources
|
||||
LIGHTNING_INVOICE_EXPIRY=3600
|
||||
@@ -61,6 +129,20 @@ CLICHE_ENDPOINT=ws://127.0.0.1:12000
|
||||
SPARK_URL=http://localhost:9737/rpc
|
||||
SPARK_TOKEN=myaccesstoken
|
||||
|
||||
#CLNRest (using runes)
|
||||
CLNREST_URL=https://127.0.0.1:3010
|
||||
CLNREST_CA=/home/lightningd/.lightning/bitcoin/ca.pem
|
||||
CLNREST_CERT=/home/lightningd/.lightning/bitcoin/server.pem
|
||||
# CLNREST_CA = cat ca.pem | awk '{printf "%s\\n", $0} END {printf "\n"}'
|
||||
# CLNREST_CERT = cat server.pem | awk '{printf "%s\\n", $0} END {printf "\n"}'
|
||||
|
||||
CLNREST_READONLY_RUNE=lightning-cli createrune restrictions='[["method=listfunds", "method=listpays", "method=listinvoices", "method=getinfo", "method=summary", "method=waitanyinvoice"]]' | jq -r .rune
|
||||
CLNREST_INVOICE_RUNE=lightning-cli createrune restrictions='[["method=invoice"], ["pnameamount_msat<1000001"], ["pnamelabel^LNbits"], ["rate=60"]]' | jq -r .rune
|
||||
CLNREST_PAY_RUNE=lightning-cli createrune restrictions='[["method=pay"], ["pinvbolt11_amount<1001"], ["pnamelabel^LNbits"], ["rate=1"]]' | jq -r .rune
|
||||
#CLNREST_RENEPAY_RUNE=lightning-cli createrune restrictions='[["method=renepay"], ["pinvinvstring_amount<1001"], ["pnamelabel^LNbits"], ["rate=1"]]' | jq -r .rune
|
||||
#CLNREST_LAST_PAY_INDEX='lightning-cli listinvoices | jq -r '.invoices | map(.created_index) | max'
|
||||
#CLNREST_NODEID=lightning-cli getinfo | jq -r .id # only required for v23.08
|
||||
|
||||
# CoreLightningWallet
|
||||
CORELIGHTNING_RPC="/home/bob/.lightning/bitcoin/lightning-rpc"
|
||||
|
||||
@@ -101,8 +183,10 @@ ALBY_ACCESS_TOKEN=ALBY_ACCESS_TOKEN
|
||||
|
||||
# BoltzWallet
|
||||
BOLTZ_CLIENT_ENDPOINT=127.0.0.1:9002
|
||||
BOLTZ_CLIENT_MACAROON="/home/bob/.boltz/macaroon" # or HEXSTRING
|
||||
BOLTZ_CLIENT_CERT="/home/bob/.boltz/tls.cert" # or HEXSTRING
|
||||
# HEXSTRING instead of path also possible
|
||||
BOLTZ_CLIENT_MACAROON="/home/bob/.boltz/macaroons/admin.macaroon"
|
||||
# HEXSTRING instead of path also possible
|
||||
BOLTZ_CLIENT_CERT="/home/bob/.boltz/tls.cert"
|
||||
BOLTZ_CLIENT_WALLET="lnbits"
|
||||
|
||||
# StrikeWallet
|
||||
@@ -151,6 +235,12 @@ 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_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
|
||||
# Make sure that the authorized redirect URIs contain https://{domain}/api/v1/auth/google/token
|
||||
@@ -173,11 +263,6 @@ KEYCLOAK_CLIENT_CUSTOM_ICON=""
|
||||
|
||||
######################################
|
||||
|
||||
# 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="*"
|
||||
|
||||
# Server security, rate limiting ips, blocked ips, allowed ips
|
||||
LNBITS_RATE_LIMIT_NO="200"
|
||||
LNBITS_RATE_LIMIT_UNIT="minute"
|
||||
@@ -188,8 +273,7 @@ LNBITS_BLOCKED_IPS=""
|
||||
# if set new users will not be able to create accounts
|
||||
LNBITS_ALLOWED_USERS=""
|
||||
LNBITS_ADMIN_USERS=""
|
||||
# ID of the super user. The user ID must exist.
|
||||
# SUPER_USER=""
|
||||
|
||||
|
||||
# Extensions only admin can access
|
||||
LNBITS_ADMIN_EXTENSIONS="ngrok, nostrclient"
|
||||
@@ -222,26 +306,6 @@ LNBITS_DEFAULT_WALLET_NAME="LNbits wallet"
|
||||
# Hides wallet api, extensions can choose to honor
|
||||
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)
|
||||
LNBITS_SERVICE_FEE=0.0
|
||||
# the wallet where fees go to
|
||||
@@ -270,16 +334,3 @@ LNBITS_RESERVE_FEE_PERCENT=1.0
|
||||
###### Logging and Development #######
|
||||
######################################
|
||||
|
||||
DEBUG=false
|
||||
DEBUG_DATABASE=false
|
||||
BUNDLE_ASSETS=true
|
||||
|
||||
# logging into LNBITS_DATA_FOLDER/logs/
|
||||
ENABLE_LOG_TO_FILE=true
|
||||
|
||||
# https://loguru.readthedocs.io/en/stable/api/logger.html#file
|
||||
LOG_ROTATION="100 MB"
|
||||
LOG_RETENTION="3 months"
|
||||
|
||||
# for database cleanup commands
|
||||
# CLEANUP_WALLETS_DAYS=90
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
custom: https://demo.lnbits.com/lnurlp/link/fH59GD
|
||||
custom: https://demo.lnbits.com/tipjar/DwaUiE4kBX6mUW6pj3X5Kg
|
||||
|
||||
@@ -5,9 +5,6 @@ inputs:
|
||||
description: "Python Version"
|
||||
required: true
|
||||
default: "3.10"
|
||||
poetry-version:
|
||||
description: "Poetry Version"
|
||||
default: "1.8.5"
|
||||
node-version:
|
||||
description: "Node Version"
|
||||
default: "20.x"
|
||||
@@ -24,33 +21,16 @@ runs:
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ inputs.python-version }}
|
||||
# cache poetry install via pip
|
||||
cache: "pip"
|
||||
|
||||
- name: Set up Poetry ${{ inputs.poetry-version }}
|
||||
uses: abatilo/actions-poetry@v2
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v6
|
||||
with:
|
||||
poetry-version: ${{ inputs.poetry-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') }}
|
||||
enable-cache: true
|
||||
python-version: ${{ inputs.python-version }}
|
||||
|
||||
- name: Install the project dependencies
|
||||
shell: bash
|
||||
run: |
|
||||
poetry env use python${{ inputs.python-version }}
|
||||
poetry install
|
||||
# needed for conv tests
|
||||
poetry add psycopg2-binary
|
||||
run: uv sync --locked --all-extras --dev
|
||||
|
||||
- name: Use Node.js ${{ inputs.node-version }}
|
||||
if: ${{ (inputs.npm == 'true') }}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
name: Build LNbits AppImage
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
tag_name:
|
||||
description: 'The tag name for the release'
|
||||
required: true
|
||||
type: string
|
||||
upload_url:
|
||||
description: 'The upload URL for the release'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag_name:
|
||||
description: 'The tag name for the release'
|
||||
required: true
|
||||
type: string
|
||||
upload_url:
|
||||
description: 'The upload URL for the release'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
build-linux-package:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install system deps and uv
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libfuse2
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
echo "$HOME/.local/bin" >> $GITHUB_PATH
|
||||
shell: bash
|
||||
|
||||
- name: Cache uv and venv
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cache/uv
|
||||
.venv
|
||||
key: ${{ runner.os }}-uv-${{ hashFiles('uv.lock', 'pyproject.toml') }}
|
||||
|
||||
- name: Prepare packaging & clone LNbits
|
||||
run: |
|
||||
mv .github/packaging packaging
|
||||
mkdir -p packaging/linux/AppDir/usr
|
||||
git clone https://github.com/lnbits/lnbits.git packaging/linux/AppDir/usr/lnbits
|
||||
shell: bash
|
||||
|
||||
- name: Build LNbits binary (uv + PyInstaller)
|
||||
run: |
|
||||
cd packaging/linux/AppDir/usr/lnbits
|
||||
uv sync --all-extras --no-dev
|
||||
uv pip install pyinstaller
|
||||
uv run pyinstaller \
|
||||
--onefile \
|
||||
--name lnbits \
|
||||
--hidden-import=embit \
|
||||
--collect-all embit \
|
||||
--collect-all lnbits \
|
||||
--collect-all sqlalchemy \
|
||||
--collect-all breez_sdk \
|
||||
--collect-binaries breez_sdk \
|
||||
--collect-all breez_sdk_liquid \
|
||||
--collect-binaries breez_sdk_liquid \
|
||||
--collect-all aiosqlite \
|
||||
--hidden-import=passlib.handlers.bcrypt \
|
||||
"$(uv run which lnbits)"
|
||||
|
||||
cd ../../../../..
|
||||
chmod +x packaging/linux/AppDir/AppRun
|
||||
chmod +x packaging/linux/AppDir/lnbits.desktop
|
||||
chmod +x packaging/linux/AppDir/usr/lnbits/dist/lnbits
|
||||
|
||||
# keep AppDir slim
|
||||
find packaging/linux/AppDir/usr/lnbits -mindepth 1 -maxdepth 1 \
|
||||
! -name 'dist' \
|
||||
! -name 'lnbits' \
|
||||
-exec rm -rf {} +
|
||||
|
||||
# Build AppImage
|
||||
wget https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage
|
||||
chmod +x appimagetool-x86_64.AppImage
|
||||
TAG_NAME=${{ inputs.tag_name }}
|
||||
APPIMAGE_NAME="LNbits-${TAG_NAME}.AppImage"
|
||||
./appimagetool-x86_64.AppImage \
|
||||
--updateinformation "gh-releases-zsync|lnbits|lnbits|latest|*.AppImage.zsync" \
|
||||
packaging/linux/AppDir "$APPIMAGE_NAME"
|
||||
chmod +x "$APPIMAGE_NAME"
|
||||
echo "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV
|
||||
|
||||
# 🔎 quick audit: show glibc versions referenced by the binary
|
||||
echo "Runner glibc:"
|
||||
ldd --version | head -n1 || true
|
||||
echo "Symbols needed by lnbits:"
|
||||
strings "$APPIMAGE_NAME" | grep -o 'GLIBC_[0-9.]*' | sort -u || true
|
||||
shell: bash
|
||||
|
||||
- name: Upload Linux Release Asset
|
||||
uses: actions/upload-release-asset@v1
|
||||
with:
|
||||
upload_url: ${{ inputs.upload_url }}
|
||||
asset_path: ${{ env.APPIMAGE_NAME }}
|
||||
asset_name: ${{ env.APPIMAGE_NAME }}
|
||||
asset_content_type: application/octet-stream
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
db-url: ["", "postgres://lnbits:lnbits@0.0.0.0:5432/lnbits"]
|
||||
uses: ./.github/workflows/tests.yml
|
||||
with:
|
||||
custom-pytest: "poetry run pytest tests/api"
|
||||
custom-pytest: "uv run pytest tests/api"
|
||||
python-version: ${{ matrix.python-version }}
|
||||
db-url: ${{ matrix.db-url }}
|
||||
secrets:
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
db-url: ["", "postgres://lnbits:lnbits@0.0.0.0:5432/lnbits"]
|
||||
uses: ./.github/workflows/tests.yml
|
||||
with:
|
||||
custom-pytest: "poetry run pytest tests/wallets"
|
||||
custom-pytest: "uv run pytest tests/wallets"
|
||||
python-version: ${{ matrix.python-version }}
|
||||
db-url: ${{ matrix.db-url }}
|
||||
secrets:
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
db-url: ["", "postgres://lnbits:lnbits@0.0.0.0:5432/lnbits"]
|
||||
uses: ./.github/workflows/tests.yml
|
||||
with:
|
||||
custom-pytest: "poetry run pytest tests/unit"
|
||||
custom-pytest: "uv run pytest tests/unit"
|
||||
python-version: ${{ matrix.python-version }}
|
||||
db-url: ${{ matrix.db-url }}
|
||||
secrets:
|
||||
@@ -77,7 +77,7 @@ jobs:
|
||||
python-version: ["3.10"]
|
||||
backend-wallet-class: ["LndRestWallet", "LndWallet", "CoreLightningWallet", "CoreLightningRestWallet", "LNbitsWallet", "EclairWallet"]
|
||||
with:
|
||||
custom-pytest: "poetry run pytest tests/regtest"
|
||||
custom-pytest: "uv run pytest tests/regtest"
|
||||
python-version: ${{ matrix.python-version }}
|
||||
backend-wallet-class: ${{ matrix.backend-wallet-class }}
|
||||
secrets:
|
||||
@@ -88,8 +88,6 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.10"]
|
||||
poetry-version: ["1.5.1"]
|
||||
uses: ./.github/workflows/jmeter.yml
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
poetry-version: ${{ matrix.poetry-version }}
|
||||
|
||||
@@ -51,3 +51,14 @@ jobs:
|
||||
platforms: linux/amd64,linux/arm64
|
||||
cache-from: type=local,src=/tmp/.buildx-cache
|
||||
cache-to: type=local,dest=/tmp/.buildx-cache
|
||||
|
||||
- name: Build and push boltz
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile.boltz
|
||||
push: true
|
||||
tags: ${{ secrets.DOCKER_USERNAME }}/lnbits-boltz:${{ inputs.tag }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
cache-from: type=local,src=/tmp/.buildx-cache
|
||||
cache-to: type=local,dest=/tmp/.buildx-cache
|
||||
|
||||
@@ -8,11 +8,6 @@ on:
|
||||
required: true
|
||||
default: "3.10"
|
||||
type: string
|
||||
poetry-version:
|
||||
description: "Poetry Version"
|
||||
required: true
|
||||
default: "1.5.1"
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
jmeter:
|
||||
@@ -30,7 +25,7 @@ jobs:
|
||||
LNBITS_EXTENSIONS_DEFAULT_INSTALL: "watchonly, satspay, tipjar, tpos, lnurlp, withdraw"
|
||||
LNBITS_BACKEND_WALLET_CLASS: FakeWallet
|
||||
run: |
|
||||
poetry run lnbits &
|
||||
uv run lnbits &
|
||||
sleep 10
|
||||
|
||||
- name: setup java version
|
||||
|
||||
@@ -53,3 +53,6 @@ jobs:
|
||||
with:
|
||||
make: checkbundle
|
||||
npm: true
|
||||
|
||||
poetry:
|
||||
uses: ./.github/workflows/poetry.yml
|
||||
|
||||
@@ -14,14 +14,14 @@ on:
|
||||
- 'flake.nix'
|
||||
- 'flake.lock'
|
||||
- 'pyproject.toml'
|
||||
- 'poetry.lock'
|
||||
- 'uv.lock'
|
||||
- '.github/workflows/nix.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'flake.nix'
|
||||
- 'flake.lock'
|
||||
- 'pyproject.toml'
|
||||
- 'poetry.lock'
|
||||
- 'uv.lock'
|
||||
|
||||
jobs:
|
||||
nix:
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
name: Build LNbits AppImage DMG
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
|
||||
build-linux-package:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# Step 1: Checkout the repository
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
# Step 2: Install Dependencies
|
||||
- name: Install Dependencies
|
||||
run: |
|
||||
curl -sSL https://install.python-poetry.org | python3 -
|
||||
echo "$HOME/.local/bin" >> $GITHUB_PATH
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libfuse2
|
||||
shell: bash
|
||||
|
||||
# Step 3: Clone LNbits Repository
|
||||
- name: Clone LNbits
|
||||
run: |
|
||||
mv .github/packaging packaging
|
||||
mkdir -p packaging/linux/AppDir/usr
|
||||
git clone https://github.com/lnbits/lnbits.git packaging/linux/AppDir/usr/lnbits
|
||||
shell: bash
|
||||
|
||||
# Step 4: Make the AppImage Asset
|
||||
- name: Make Asset
|
||||
run: |
|
||||
cd packaging/linux/AppDir/usr/lnbits
|
||||
poetry install
|
||||
poetry run pip install pyinstaller
|
||||
|
||||
# Build the LNbits binary
|
||||
poetry run pyinstaller --onefile --name lnbits --hidden-import=embit --collect-all embit --collect-all lnbits --collect-all sqlalchemy --collect-all aiosqlite --hidden-import=passlib.handlers.bcrypt $(poetry run which lnbits)
|
||||
cd ../../../../..
|
||||
chmod +x packaging/linux/AppDir/AppRun
|
||||
chmod +x packaging/linux/AppDir/lnbits.desktop
|
||||
chmod +x packaging/linux/AppDir/usr/lnbits/dist/lnbits
|
||||
|
||||
find packaging/linux/AppDir/usr/lnbits -mindepth 1 -maxdepth 1 \
|
||||
! -name 'dist' \
|
||||
! -name 'lnbits' \
|
||||
-exec rm -rf {} +
|
||||
|
||||
wget https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage
|
||||
chmod +x appimagetool-x86_64.AppImage
|
||||
TAG_NAME=${{ github.event.release.tag_name }}
|
||||
APPIMAGE_NAME="LNbits-${TAG_NAME}.AppImage"
|
||||
./appimagetool-x86_64.AppImage --updateinformation "gh-releases-zsync|lnbits|lnbits|latest|*.AppImage.zsync" packaging/linux/AppDir "$APPIMAGE_NAME"
|
||||
chmod +x "$APPIMAGE_NAME"
|
||||
echo "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV
|
||||
shell: bash
|
||||
|
||||
# Step 5: Upload Linux Release Asset
|
||||
- name: Upload Linux Release Asset
|
||||
uses: actions/upload-release-asset@v1
|
||||
with:
|
||||
upload_url: ${{ github.event.release.upload_url }}
|
||||
asset_path: ${{ env.APPIMAGE_NAME }}
|
||||
asset_name: ${{ env.APPIMAGE_NAME }}
|
||||
asset_content_type: application/octet-stream
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -0,0 +1,24 @@
|
||||
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,6 +32,10 @@ jobs:
|
||||
run: |
|
||||
docker build -t lnbits/lnbits .
|
||||
|
||||
- uses: ./.github/actions/prepare
|
||||
with:
|
||||
python-version: ${{ inputs.python-version }}
|
||||
|
||||
- name: Setup Regtest
|
||||
run: |
|
||||
git clone https://github.com/lnbits/legend-regtest-enviroment.git docker
|
||||
@@ -40,10 +44,6 @@ jobs:
|
||||
./tests
|
||||
sudo chmod -R a+rwx .
|
||||
|
||||
- uses: ./.github/actions/prepare
|
||||
with:
|
||||
python-version: ${{ inputs.python-version }}
|
||||
|
||||
- name: Run pytest
|
||||
uses: pavelzw/pytest-action@v2
|
||||
env:
|
||||
|
||||
@@ -13,6 +13,8 @@ jobs:
|
||||
|
||||
release:
|
||||
runs-on: ubuntu-24.04
|
||||
outputs:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Create github release
|
||||
@@ -20,7 +22,8 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
tag: ${{ github.ref_name }}
|
||||
run: |
|
||||
gh release create "$tag" --generate-notes --draft
|
||||
upload_url=$(gh release create "$tag" --generate-notes --draft --json upload_url -q '.upload_url')
|
||||
echo "upload_url=$upload_url" >> "$GITHUB_OUTPUT"
|
||||
|
||||
docker:
|
||||
needs: [ release ]
|
||||
@@ -52,3 +55,10 @@ jobs:
|
||||
uses: JRubics/poetry-publish@v1.15
|
||||
with:
|
||||
pypi_token: ${{ secrets.PYPI_API_KEY }}
|
||||
|
||||
appimage:
|
||||
needs: [ release ]
|
||||
uses: ./.github/workflows/appimage.yml
|
||||
with:
|
||||
tag_name: ${{ github.ref_name }}
|
||||
upload_url: ${{ needs.release.outputs.upload_url }}
|
||||
|
||||
@@ -7,6 +7,7 @@ __pycache__
|
||||
.mypy_cache
|
||||
.vscode
|
||||
*-lock.json
|
||||
.python-version
|
||||
|
||||
*.egg
|
||||
*.egg-info
|
||||
@@ -60,3 +61,4 @@ dist
|
||||
|
||||
# jetbrains
|
||||
.idea
|
||||
.venv
|
||||
|
||||
@@ -2,7 +2,7 @@ exclude: '^lnbits/static/bundle.*|^docs/.*|^lnbits/static/vendor/.*|^lnbits/exte
|
||||
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v4.3.0
|
||||
rev: v6.0.0
|
||||
hooks:
|
||||
- id: trailing-whitespace
|
||||
- id: end-of-file-fixer
|
||||
@@ -14,16 +14,16 @@ repos:
|
||||
- id: mixed-line-ending
|
||||
- id: check-case-conflict
|
||||
- repo: https://github.com/psf/black
|
||||
rev: 24.2.0
|
||||
rev: 25.1.0
|
||||
hooks:
|
||||
- id: black
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.3.2
|
||||
rev: v0.12.10
|
||||
hooks:
|
||||
- id: ruff
|
||||
args: [ --fix, --exit-non-zero-on-fix ]
|
||||
- repo: https://github.com/pre-commit/mirrors-prettier
|
||||
rev: "v4.0.0-alpha.8"
|
||||
- repo: https://github.com/rbubley/mirrors-prettier
|
||||
rev: v3.6.2
|
||||
hooks:
|
||||
- id: prettier
|
||||
types_or: [css, javascript, html, json]
|
||||
|
||||
+8
-20
@@ -2,25 +2,20 @@ FROM python:3.12-slim-bookworm AS builder
|
||||
|
||||
RUN apt-get clean
|
||||
RUN apt-get update
|
||||
RUN apt-get install -y curl pkg-config build-essential libnss-myhostname
|
||||
RUN apt-get install -y curl pkg-config build-essential libnss-myhostname automake
|
||||
|
||||
RUN curl -sSL https://install.python-poetry.org | python3 - --version 1.8.5
|
||||
RUN curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
ENV PATH="/root/.local/bin:$PATH"
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Only copy the files required to install the dependencies
|
||||
COPY pyproject.toml poetry.lock ./
|
||||
COPY pyproject.toml uv.lock ./
|
||||
RUN touch README.md
|
||||
|
||||
RUN mkdir data
|
||||
|
||||
ENV POETRY_NO_INTERACTION=1 \
|
||||
POETRY_VIRTUALENVS_IN_PROJECT=1 \
|
||||
POETRY_VIRTUALENVS_CREATE=1 \
|
||||
POETRY_CACHE_DIR=/tmp/poetry_cache
|
||||
|
||||
ARG POETRY_INSTALL_ARGS="--only main"
|
||||
RUN poetry install ${POETRY_INSTALL_ARGS}
|
||||
RUN uv sync --all-extras
|
||||
|
||||
FROM python:3.12-slim-bookworm
|
||||
|
||||
@@ -33,26 +28,19 @@ RUN apt-get update && apt-get -y upgrade && \
|
||||
apt-get -y install postgresql-client-14 postgresql-client-common && \
|
||||
apt-get clean all && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN curl -sSL https://install.python-poetry.org | python3 - --version 1.8.5
|
||||
RUN curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
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
|
||||
|
||||
COPY . .
|
||||
COPY --from=builder /app/.venv .venv
|
||||
|
||||
ARG POETRY_INSTALL_ARGS="--only main"
|
||||
RUN poetry install ${POETRY_INSTALL_ARGS}
|
||||
RUN uv sync --all-extras
|
||||
|
||||
ENV LNBITS_PORT="5000"
|
||||
ENV LNBITS_HOST="0.0.0.0"
|
||||
|
||||
EXPOSE 5000
|
||||
|
||||
CMD ["sh", "-c", "poetry run lnbits --port $LNBITS_PORT --host $LNBITS_HOST --forwarded-allow-ips='*'"]
|
||||
CMD ["sh", "-c", "uv run lnbits --port $LNBITS_PORT --host $LNBITS_HOST --forwarded-allow-ips='*'"]
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
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
|
||||
|
||||
prettier:
|
||||
poetry run ./node_modules/.bin/prettier --write .
|
||||
uv run ./node_modules/.bin/prettier --write .
|
||||
|
||||
pyright:
|
||||
poetry run ./node_modules/.bin/pyright
|
||||
uv run ./node_modules/.bin/pyright
|
||||
|
||||
mypy:
|
||||
poetry run mypy
|
||||
uv run mypy
|
||||
|
||||
black:
|
||||
poetry run black .
|
||||
uv run black .
|
||||
|
||||
ruff:
|
||||
poetry run ruff check . --fix
|
||||
uv run ruff check . --fix
|
||||
|
||||
checkruff:
|
||||
poetry run ruff check .
|
||||
uv run ruff check .
|
||||
|
||||
checkprettier:
|
||||
poetry run ./node_modules/.bin/prettier --check .
|
||||
uv run ./node_modules/.bin/prettier --check .
|
||||
|
||||
checkblack:
|
||||
poetry run black --check .
|
||||
uv run black --check .
|
||||
|
||||
checkeditorconfig:
|
||||
editorconfig-checker
|
||||
|
||||
dev:
|
||||
poetry run lnbits --reload
|
||||
uv run lnbits --reload
|
||||
|
||||
docker:
|
||||
docker build -t lnbits/lnbits .
|
||||
@@ -46,27 +46,27 @@ test-wallets:
|
||||
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
DEBUG=true \
|
||||
poetry run pytest tests/wallets
|
||||
uv run pytest tests/wallets
|
||||
|
||||
test-unit:
|
||||
LNBITS_DATA_FOLDER="./tests/data" \
|
||||
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
DEBUG=true \
|
||||
poetry run pytest tests/unit
|
||||
uv run pytest tests/unit
|
||||
|
||||
test-api:
|
||||
LNBITS_DATA_FOLDER="./tests/data" \
|
||||
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
DEBUG=true \
|
||||
poetry run pytest tests/api
|
||||
uv run pytest tests/api
|
||||
|
||||
test-regtest:
|
||||
LNBITS_DATA_FOLDER="./tests/data" \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
DEBUG=true \
|
||||
poetry run pytest tests/regtest
|
||||
uv run pytest tests/regtest
|
||||
|
||||
test-migration:
|
||||
LNBITS_ADMIN_UI=True \
|
||||
@@ -74,18 +74,18 @@ test-migration:
|
||||
HOST=0.0.0.0 \
|
||||
PORT=5002 \
|
||||
LNBITS_DATA_FOLDER="./tests/data" \
|
||||
timeout 5s poetry run lnbits --host 0.0.0.0 --port 5002 || code=$?; if [[ $code -ne 124 && $code -ne 0 ]]; then exit $code; fi
|
||||
timeout 5s uv 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 \
|
||||
PORT=5002 \
|
||||
LNBITS_DATABASE_URL="postgres://lnbits:lnbits@localhost:5432/migration" \
|
||||
LNBITS_ADMIN_UI=False \
|
||||
timeout 5s poetry run lnbits --host 0.0.0.0 --port 5002 || code=$?; if [[ $code -ne 124 && $code -ne 0 ]]; then exit $code; fi
|
||||
timeout 5s uv run lnbits --host 0.0.0.0 --port 5002 || code=$?; if [[ $code -ne 124 && $code -ne 0 ]]; then exit $code; fi
|
||||
LNBITS_DATA_FOLDER="./tests/data" \
|
||||
LNBITS_DATABASE_URL="postgres://lnbits:lnbits@localhost:5432/migration" \
|
||||
poetry run python tools/conv.py
|
||||
uv run python tools/conv.py
|
||||
|
||||
migration:
|
||||
poetry run python tools/conv.py
|
||||
uv run python tools/conv.py
|
||||
|
||||
openapi:
|
||||
LNBITS_ADMIN_UI=False \
|
||||
@@ -94,9 +94,9 @@ openapi:
|
||||
PYTHONUNBUFFERED=1 \
|
||||
HOST=0.0.0.0 \
|
||||
PORT=5003 \
|
||||
poetry run lnbits &
|
||||
uv run lnbits &
|
||||
sleep 15
|
||||
curl -s http://0.0.0.0:5003/openapi.json | poetry run openapi-spec-validator --errors=all -
|
||||
curl -s http://0.0.0.0:5003/openapi.json | uv run openapi-spec-validator --errors=all -
|
||||
# kill -9 %1
|
||||
|
||||
bak:
|
||||
@@ -109,7 +109,7 @@ sass:
|
||||
bundle:
|
||||
npm install
|
||||
npm run bundle
|
||||
poetry run ./node_modules/.bin/prettier -w ./lnbits/static/vendor.json
|
||||
uv run ./node_modules/.bin/prettier -w ./lnbits/static/vendor.json
|
||||
|
||||
checkbundle:
|
||||
cp lnbits/static/bundle.min.js lnbits/static/bundle.min.js.old
|
||||
@@ -126,8 +126,8 @@ checkbundle:
|
||||
|
||||
install-pre-commit-hook:
|
||||
@echo "Installing pre-commit hook to git"
|
||||
@echo "Uninstall the hook with poetry run pre-commit uninstall"
|
||||
poetry run pre-commit install
|
||||
@echo "Uninstall the hook with uv run pre-commit uninstall"
|
||||
uv run pre-commit install
|
||||
|
||||
pre-commit:
|
||||
poetry run pre-commit run --all-files
|
||||
uv run pre-commit run --all-files
|
||||
|
||||
@@ -1,39 +1,47 @@
|
||||
<picture >
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://i.imgur.com/QE6SIrs.png" style="width:300px">
|
||||
<img src="https://i.imgur.com/fyKPgVT.png" style="width:300px">
|
||||
</picture>
|
||||
<a href="https://lnbits.com" target="_blank" rel="noopener noreferrer">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://i.imgur.com/QE6SIrs.png">
|
||||
<img src="https://i.imgur.com/fyKPgVT.png" alt="LNbits" style="width:300px">
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
 [![license-badge]](LICENSE) [![docs-badge]][docs]  [<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits) [<img src="https://img.shields.io/badge/supported_by-%3E__OpenSats-f97316">](https://opensats.org)
|
||||

|
||||
 [![license-badge]](LICENSE) [![docs-badge]][docs]  [](https://extensions.lnbits.com/) [](https://shop.lnbits.com/) [<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits) [<img src="https://img.shields.io/badge/supported_by-%3E__OpenSats-f97316">](https://opensats.org)
|
||||
<img width="2000" height="203" alt="lnbits_head" src="https://github.com/user-attachments/assets/77669718-ac10-43c7-ae95-6ce236c77401" />
|
||||
[](https://demo.lnbits.com/tipjar/DwaUiE4kBX6mUW6pj3X5Kg)
|
||||
|
||||
# The world's most powerful suite of bitcoin tools.
|
||||
# LNbits — The most powerful Bitcoin & Lightning toolkit
|
||||
|
||||
## Run for yourself, for others, or as part of a stack.
|
||||
> Run it for yourself, for your community, or as part of a larger stack.
|
||||
|
||||
LNbits is beta, for responsible disclosure of any concerns please contact an admin in the community chat.
|
||||
## What is LNbits?
|
||||
|
||||
LNbits is a Python server that sits on top of any funding source. It can be used as:
|
||||
LNbits is a lightweight Python server that sits on top of your Lightning funding source. It gives you safe, isolated wallets, a clean API, and an extension system for rapidly adding features - without locking you into a single node implementation. The Inspiration for LNBits came from ideas pioneered by **OpenNode** and **LNPay** — both today work as funding sources for LNbits.
|
||||
|
||||
- Accounts system to mitigate the risk of exposing applications to your full balance via unique API keys for each wallet
|
||||
- Extendable platform for exploring Lightning network functionality via the LNbits extension framework
|
||||
- Part of a development stack via LNbits API
|
||||
- Fallback wallet for the LNURL scheme
|
||||
- Instant wallet for LN demonstrations
|
||||
## What you can do with LNbits
|
||||
|
||||
LNbits can run on top of almost all Lightning funding sources.
|
||||
- **Harden app security:** Create per-wallet API keys so individual apps never touch your full balance.
|
||||
- **Extend functionality fast:** Install extensions to explore and ship Lightning features with minimal code.
|
||||
- **Build into your stack:** Use the LNbits HTTP API to integrate payments, wallets, and accounting.
|
||||
- **Cover LNURL flows:** Use LNbits as a reliable fallback wallet for LNURL.
|
||||
- **Demo in minutes:** Spin up instant wallets for workshops, proofs-of-concept, and user testing.
|
||||
|
||||
See [LNbits manual](https://docs.lnbits.org/guide/wallets.html) for more detailed documentation about each funding source.
|
||||
## Funding sources
|
||||
|
||||
Checkout the LNbits [YouTube](https://www.youtube.com/playlist?list=PLPj3KCksGbSYG0ciIQUWJru1dWstPHshe) video series.
|
||||
LNbits runs on top of most Lightning backends. Choose the one you already operate - or swap later without changing your app architecture.
|
||||
|
||||
LNbits is inspired by all the great work of [opennode.com](https://www.opennode.com/), and in particular [lnpay.co](https://lnpay.co/). Both work as funding sources for LNbits.
|
||||
- Read the [funding source guide](https://docs.lnbits.org/guide/wallets.html)
|
||||
|
||||
## Learn more
|
||||
|
||||
- Video series on [Youtube](https://www.youtube.com/@lnbits)
|
||||
- Introduction Video [LNBits V1](https://www.youtube.com/watch?v=PFAHKxvgI9Y&t=19s)
|
||||
|
||||
## Running LNbits
|
||||
|
||||
Test on our demo server [demo.lnbits.com](https://demo.lnbits.com), or on [lnbits.com](https://lnbits.com) software as a service, where you can spin up an LNbits instance for 21sats per hr.
|
||||
|
||||
See the [install guide](https://github.com/lnbits/lnbits/blob/main/docs/guide/installation.md) for details on installation and setup.
|
||||
|
||||
Get yourself familiar and test on our demo server [demo.lnbits.com](https://demo.lnbits.com), or on [lnbits.com](https://lnbits.com) software as a service, where you can spin up an LNbits instance for 21sats per hr.
|
||||
|
||||
## LNbits account system
|
||||
|
||||
LNbits is packaged with tools to help manage funds, such as a table of transactions, line chart of spending, export to csv. Each wallet also comes with its own API keys, to help partition the exposure of your funding source.
|
||||
@@ -66,9 +74,15 @@ As well as working great in a browser, LNbits has native IoS and Android apps as
|
||||
|
||||
<img src="https://i.imgur.com/J96EbRf.png" style="width:800px">
|
||||
|
||||
## Tip us
|
||||
## Powered by LNbits
|
||||
|
||||
If you like this project [send some tip love](https://demo.lnbits.com/lnurlp/link/fH59GD)!
|
||||
LNbits empowers everyone with modular, open-source tools for building Bitcoin-based systems — fast, free, and extendable.
|
||||
|
||||
[](https://shop.lnbits.com/)
|
||||
[](https://shop.lnbits.com/)
|
||||
[](https://my.lnbits.com/login)
|
||||
[](https://news.lnbits.com/)
|
||||
[](https://extensions.lnbits.com/) [](https://demo.lnbits.com/tipjar/DwaUiE4kBX6mUW6pj3X5Kg)
|
||||
|
||||
[docs]: https://github.com/lnbits/lnbits/wiki
|
||||
[docs-badge]: https://img.shields.io/badge/docs-lnbits.org-673ab7.svg
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
#!/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='*'
|
||||
@@ -4,6 +4,7 @@ color_scheme: dark
|
||||
logo: "/logos/lnbits-full-inverse.png"
|
||||
search_enabled: true
|
||||
url: https://docs.lnbits.org
|
||||
markdown: gfm
|
||||
aux_links:
|
||||
"LNbits on GitHub":
|
||||
- "//github.com/lnbits/lnbits"
|
||||
|
||||
@@ -11,13 +11,13 @@ Thanks for contributing :)
|
||||
|
||||
# Run
|
||||
|
||||
Follow the [Basic installation: Option 1 (recommended): poetry](https://docs.lnbits.org/guide/installation.html#option-1-recommended-poetry)
|
||||
guide to install poetry and other dependencies.
|
||||
Follow the [Option 2 (recommended): UV](https://docs.lnbits.org/guide/installation.html)
|
||||
guide to install uv and other dependencies.
|
||||
|
||||
Then you can start LNbits uvicorn server with:
|
||||
|
||||
```bash
|
||||
poetry run lnbits
|
||||
uv run lnbits
|
||||
```
|
||||
|
||||
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
|
||||
make dev
|
||||
# or
|
||||
poetry run lnbits --reload
|
||||
uv run lnbits --reload
|
||||
```
|
||||
|
||||
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:
|
||||
|
||||
```bash
|
||||
poetry install
|
||||
uv sync --all-extras --dev
|
||||
npm i
|
||||
```
|
||||
|
||||
@@ -69,7 +69,7 @@ make format
|
||||
Run mypy checks:
|
||||
|
||||
```bash
|
||||
poetry run mypy
|
||||
make mypy
|
||||
```
|
||||
|
||||
Run everything:
|
||||
|
||||
@@ -42,14 +42,10 @@ 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.
|
||||
|
||||
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>
|
||||
```
|
||||
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`:
|
||||
|
||||
**But we need an extra step to make sure LNbits doesn't break in production.**
|
||||
Dependencies need to be added to `pyproject.toml`, then tested by running on `poetry` compatibility can be tested with `nix build .#checks.x86_64-linux.vmTest`.
|
||||
Dependencies need to be added to `pyproject.toml`, then tested by running on `uv` and `poetry` compatibility can be tested with `nix build .#checks.x86_64-linux.vmTest`.
|
||||
|
||||
## SQLite to PostgreSQL migration
|
||||
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
---
|
||||
layout: default
|
||||
title: FastAPI extension upgrade
|
||||
nav_order: 1
|
||||
---
|
||||
|
||||
## Defining a route with path parameters
|
||||
|
||||
**old:**
|
||||
+105
-49
@@ -1,78 +1,134 @@
|
||||
---
|
||||
layout: default
|
||||
title: Admin UI
|
||||
nav_order: 4
|
||||
nav_order: 1
|
||||
---
|
||||
|
||||
# Admin UI
|
||||
<a href="https://lnbits.com" target="_blank" rel="noopener noreferrer">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://i.imgur.com/QE6SIrs.png">
|
||||
<img src="https://i.imgur.com/fyKPgVT.png" alt="LNbits" style="width:300px">
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
The LNbits Admin UI lets you change LNbits settings via the LNbits frontend.
|
||||
It is disabled by default and the first time you set the environment variable `LNBITS_ADMIN_UI=true`
|
||||
the settings are initialized and saved to the database and will be used from there as long the UI is enabled.
|
||||
From there on the settings from the database are used.
|
||||

|
||||

|
||||
[<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits)
|
||||
[<img src="https://img.shields.io/badge/supported_by-%3E__OpenSats-f97316">](https://opensats.org)
|
||||
|
||||
# Super User
|
||||
# LNBits Admin UI
|
||||
|
||||
With the Admin UI we introduced the super user, it is created with the initialisation of the Admin UI and will be shown with a success message in the server logs.
|
||||
The super user has access to the server and can change settings that may crash the server and make it unresponsive via the frontend and api, like changing funding sources.
|
||||
[What you can do](#what-you-can-do-with-the-admin-ui) · [First Run](#first-run-and-super-user-id) · [Enable/Disable](#enabling-or-disabling-the-admin-ui) · [Reset](#reset-to-defaults) · [Allowed Users](#allowed-users) · [Guides](#additional-guides)
|
||||
|
||||
Also only the super user can brrrr satoshis to different wallets.
|
||||
**We introduced the Admin UI as the new default to make setup simpler and more straightforward**. Instead of hand editing the `.env` file, you configure key server settings directly in the frontend with clear labels and guardrails.
|
||||
|
||||
The super user is only stored inside the settings table of the database and after the settings are "reset to defaults" and a restart happened,
|
||||
a new super user is created.
|
||||
<ins>On a fresh install the Admin UI is enabled by default</ins>, and at first launch you are prompted to create **Super User** credentials so that sensitive operations, such as switching funding sources, remain in trusted hands. When the Admin UI is enabled, configuration is written to and read from the database; for all settings managed by the UI, the parameters in `.env` are largely no longer used. If you disable the Admin UI, the `.env` file becomes the single source of truth again.
|
||||
|
||||
The super user is never sent over the api and the frontend only receives a bool if you are super user or not.
|
||||
For privileged actions and role details see **[Super User](./super_user.md)** & [User Roles](./user_roles.md)
|
||||
For a complete reference of legacy variables consult **[.env.example](../../.env.example)**.
|
||||
|
||||
We also added a decorator for the API routes to check for super user.
|
||||
<img width="900" height="640" alt="grafik" src="https://github.com/user-attachments/assets/d8852b4b-21be-446f-a1e7-d3eb794d3505" />
|
||||
|
||||
There is also the possibility of posting the super user via webhook to another service when it is created. you can look it up here https://github.com/lnbits/lnbits/blob/main/lnbits/settings.py `class SaaSSettings`
|
||||
> [!WARNING]
|
||||
> Some settings remain `.env` only. Use **[.env.example](../../.env.example#L3-L87)** as the authoritative reference for those variables.
|
||||
|
||||
# Admin Users
|
||||
## What you can do with the Admin UI
|
||||
|
||||
environment variable: `LNBITS_ADMIN_USERS`, comma-separated list of user ids
|
||||
Admin Users can change settings in the admin ui as well, with the exception of funding source settings, because they require e server restart and could potentially make the server inaccessible. Also they have access to all the extension defined in `LNBITS_ADMIN_EXTENSIONS`.
|
||||
- Switch funding sources and other server level settings
|
||||
- Manage who can access LNbits (**[Allowed Users](#allowed-users)**)
|
||||
- Promote or demote Admin Users
|
||||
- Gate extensions to Admins only or disable them globally
|
||||
- Adjust balances with credit or debit
|
||||
- Adjust site customization
|
||||
|
||||
# Allowed Users
|
||||
> [!NOTE]
|
||||
> See **[Super User](./super_user.md)** for the role and permission differences compared to Admin Users.
|
||||
|
||||
environment variable: `LNBITS_ALLOWED_USERS`, comma-separated list of user ids
|
||||
By defining this users, LNbits will no longer be usable by the public, only defined users and admins can then access the LNbits frontend.
|
||||
## First run and Super User ID
|
||||
|
||||
Setting this environment variable also disables account creation.
|
||||
Account creation can be also disabled by setting `LNBITS_ALLOW_NEW_ACCOUNTS=false`
|
||||
On first start with the Admin UI enabled you will be prompted to generate a Super User.
|
||||
|
||||
# How to activate
|
||||
<img width="1573" height="976" alt="Admin_UI_first_install" src="https://github.com/user-attachments/assets/05aa634f-06ec-4a4d-a5c6-d90927c90991" />
|
||||
|
||||
```
|
||||
$ sudo systemctl stop lnbits.service
|
||||
$ cd ~/lnbits
|
||||
$ sudo nano .env
|
||||
```
|
||||
If you need to read it from disk later:
|
||||
|
||||
-> set: `LNBITS_ADMIN_UI=true`
|
||||
|
||||
Now start LNbits once in the terminal window
|
||||
|
||||
```
|
||||
$ poetry run lnbits
|
||||
```
|
||||
|
||||
You can now `cat` the Super User ID:
|
||||
|
||||
```
|
||||
$ cat data/.super_user
|
||||
```bash
|
||||
cat /lnbits/data/.super_user
|
||||
# example
|
||||
123de4bfdddddbbeb48c8bc8382fe123
|
||||
```
|
||||
|
||||
You can access your super user account at `/wallet?usr=super_user_id`. You just have to append it to your normal LNbits web domain.
|
||||
> [!WARNING]
|
||||
> For security reasons, Super Users and Admin users must authenticate with credentials (username and password).
|
||||
|
||||
After that you will find the **`Admin` / `Manage Server`** between `Wallets` and `Extensions`
|
||||
After login you will see **Settings** and **Users** in the sidebar between **Wallets** and **Extensions**, plus a role badge in the top left.
|
||||
|
||||
Here you can design the interface, it has credit/debit to change wallets balances and you can restrict access rights to extensions only for admins or generally deactivated for everyone. You can make users admins or set up Allowed Users if you want to restrict access. And of course the classic settings of the .env file, e.g. to change the funding source wallet or set a charge fee.
|
||||
<img width="1353" height="914" alt="grafik" src="https://github.com/user-attachments/assets/06bb4f36-a23a-4058-87ec-60440d322c25" />
|
||||
|
||||
Do not forget
|
||||
## Enabling or disabling the Admin UI
|
||||
|
||||
```
|
||||
sudo systemctl start lnbits.service
|
||||
```
|
||||
The Admin UI is enabled by default on new installs. To change the state:
|
||||
|
||||
A little hint, if you set `RESET TO DEFAULTS`, then a new Super User Account will also be created. The old one is then no longer valid.
|
||||
1. Stop LNbits
|
||||
|
||||
```bash
|
||||
sudo systemctl stop lnbits.service
|
||||
```
|
||||
|
||||
2. Edit your `.env`
|
||||
|
||||
```
|
||||
cd ~/lnbits
|
||||
sudo nano .env
|
||||
```
|
||||
|
||||
3. Set one of
|
||||
|
||||
```
|
||||
# Enable Admin UI
|
||||
LNBITS_ADMIN_UI=true
|
||||
|
||||
# Disable Admin UI
|
||||
LNBITS_ADMIN_UI=false
|
||||
```
|
||||
|
||||
4. Start LNbits
|
||||
|
||||
```
|
||||
sudo systemctl start lnbits.service
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> With the Admin UI enabled, config is DB-backed and UI-managed settings ignore .env. Disable it to revert to [.env](../../.env.example) as the single source of truth.
|
||||
|
||||
## Reset to defaults
|
||||
|
||||
Using `Reset to defaults` in the Admin UI wipes stored settings. After a restart, a new `Super User` is created and the old one is no longer valid.
|
||||
|
||||
## Allowed Users
|
||||
|
||||
When set **at least one**, LNbits becomes private: only the listed users and Admins can access the frontend. Account creation is disabled automatically. You can also disable account creation explicitly.
|
||||
|
||||
<img width="1889" height="870" alt="grafik" src="https://github.com/user-attachments/assets/89011b75-a267-44ea-971a-1517968b7af5" />
|
||||
|
||||
> [!WARNING]
|
||||
> Assign your own account first when enabling **Allowed Users** to avoid locking yourself out. If you do get locked out, use your Super User to recover access.
|
||||
|
||||
## Additional Guides
|
||||
|
||||
- **[Backend Wallets](./wallets.md)** — Explore options to fund your LNbits instance.
|
||||
- **[User Roles](./user_roles.md)** — Overview of existing roles in LNbits.
|
||||
- **[Funding sources](./funding-sources-table.md)** — What is available and how to configure each.
|
||||
- **[Install LNBits](./installation.md)** — Choose your prefared way to install LNBits.
|
||||
|
||||
## Powered by LNbits
|
||||
|
||||
LNbits empowers everyone with modular, open source tools for building Bitcoin based systems — fast, free, and extendable.
|
||||
|
||||
If you like this project, [send some tip love](https://demo.lnbits.com/tipjar/DwaUiE4kBX6mUW6pj3X5Kg) or visit our [Shop](https://shop.lnbits.de)
|
||||
|
||||
[](https://shop.lnbits.com/)
|
||||
[](https://shop.lnbits.com/)
|
||||
[](https://my.lnbits.com/login)
|
||||
[](https://news.lnbits.com/)
|
||||
[](https://extensions.lnbits.com/)
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
---
|
||||
layout: default
|
||||
title: Extension Install
|
||||
nav_order: 1
|
||||
---
|
||||
|
||||
# Extension Install
|
||||
|
||||
Anyone can create an extension by following the [example extension](https://github.com/lnbits/example) and [making extensions](https://github.com/lnbits/lnbits/blob/main/docs/devs/extensions.md) dev guide.
|
||||
|
||||
@@ -1,30 +1,85 @@
|
||||
# LNbits Funding Sources Comparison Table
|
||||
---
|
||||
layout: default
|
||||
title: Wallet comparison
|
||||
nav_order: 1
|
||||
---
|
||||
|
||||
LNbits can use a number of different Lightning Network funding source.
|
||||
<a href="https://lnbits.com" target="_blank" rel="noopener noreferrer">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://i.imgur.com/QE6SIrs.png">
|
||||
<img src="https://i.imgur.com/fyKPgVT.png" alt="LNbits" style="width:300px">
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
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.
|
||||

|
||||

|
||||
[<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits)
|
||||
[<img src="https://img.shields.io/badge/supported_by-%3E__OpenSats-f97316">](https://opensats.org)
|
||||
|
||||
The table below offers a comparison of the different Lightning Network funding sources that can be used with LNbits.
|
||||
# Backend Wallet Comparison Table
|
||||
|
||||
LNbits lets you choose **how your wallets are funded** — from fully self-custodial nodes to simple hosted services. You can switch the funding source **without touching your apps, users, or extensions**. That means you can start fast, learn, and later upgrade to more control and privacy when you are ready.
|
||||
|
||||
**Why this matters**
|
||||
|
||||
- **Flexibility:** Pick the backend that fits your skills and constraints today, change it later with minimal friction.
|
||||
- **Speed to ship:** Use a hosted option to get live quickly; move to a node when you need more control.
|
||||
- **Scalability:** Match cost and maintenance to your stage — from hobby to production.
|
||||
- **Privacy and compliance:** Choose between self-custody and provider-managed options depending on your requirements.
|
||||
|
||||
Below is a side-by-side comparison of Lightning funding sources you can use with LNbits.
|
||||
|
||||
> [!NOTE]
|
||||
> “Backend Wallet” and “Funding Source” mean the same thing — the wallet or service that funds your LNbits.
|
||||
|
||||
## LNbits Lightning Network Funding Sources Comparison Table
|
||||
|
||||
| **Funding Source** | **Custodial Type** | **KYC Required** | **Technical Knowledge Needed** | **Node Hosting Required** | **Privacy Level** | **Liquidity Management** | **Ease of Setup** | **Maintenance Effort** | **Cost Implications** | **Scalability** | **Notes** |
|
||||
| -------------------------- | ------------------ | ------------------- | ------------------------------ | ------------------------- | ----------------- | ------------------------ | ----------------- | ---------------------- | -------------------------------------------- | --------------- | ---------------------------------------------------------------- |
|
||||
| LND (gRPC) | Self-custodial | ❌ | Higher | ✅ | High | Manual | Moderate | High | Infrastructure cost and channel opening fees | High | gRPC interface for LND; suitable for advanced integrations. |
|
||||
| CoreLightning (CLN) | Self-custodial | ❌ | Higher | ✅ | High | Manual | Moderate | High | Infrastructure cost and channel opening fees | High | Requires setting up and managing your own CLN node. |
|
||||
| Phoenixd | Self-custodial | ❌ | Medium | ❌ | Medium | Automatic | Moderate | Low | Minimal fees | Medium | Mobile wallet backend; suitable for mobile integrations. |
|
||||
| Nostr Wallet Connect (NWC) | Custodial | Depends on provider | Low | ❌ | Variable | Provider-managed | Easy | Low | May incur fees | Medium | Connects via Nostr protocol; depends on provider's policies. |
|
||||
| Boltz | Self-custodial | ❌ | Medium | ❌ | Medium | Provider-managed | Moderate | Moderate | Minimal fees | Medium | Uses submarine swaps; connects to Boltz client. |
|
||||
| LND (REST) | Self-custodial | ❌ | Higher | ✅ | High | Manual | Moderate | High | Infrastructure cost and channel opening fees | High | REST interface for LND; suitable for web integrations. |
|
||||
| CoreLightning REST | Self-custodial | ❌ | Higher | ✅ | High | Manual | Moderate | High | Infrastructure cost and channel opening fees | High | REST interface for CLN; suitable for web integrations. |
|
||||
| LNbits (another instance) | Custodial | Depends on host | Low | ❌ | Variable | Provider-managed | Easy | Low | May incur hosting fees | Medium | Connects to another LNbits instance; depends on host's policies. |
|
||||
| Alby | Custodial | ✅ | Low | ❌ | Low | Provider-managed | Easy | Low | Transaction fees apply | Medium | Browser extension wallet; suitable for web users. |
|
||||
| Breez SDK | Self-custodial | ❌ | Medium | ❌ | High | Automatic | Moderate | Low | Minimal fees | Medium | SDK for integrating Breez wallet functionalities. |
|
||||
| OpenNode | Custodial | ✅ | Low | ❌ | Low | Provider-managed | Easy | Low | Transaction fees apply | Medium | Third-party service; suitable for merchants. |
|
||||
| Blink | Custodial | ✅ | Low | ❌ | Low | Provider-managed | Easy | Low | Transaction fees apply | Medium | Third-party service; focuses on mobile integrations. |
|
||||
| ZBD | Custodial | ✅ | Low | ❌ | Low | Provider-managed | Easy | Low | Transaction fees apply | Medium | Gaming-focused payment platform. |
|
||||
| Spark (CLN) | Self-custodial | ❌ | Higher | ✅ | High | Manual | Moderate | High | Infrastructure cost and channel opening fees | High | Web interface for CLN; requires Spark server setup. |
|
||||
| Cliche Wallet | Self-custodial | ❌ | Medium | ❌ | Medium | Manual | Moderate | Moderate | Minimal fees | Medium | Lightweight wallet; suitable for embedded systems. |
|
||||
| Strike | Custodial | ✅ | Low | ❌ | Low | Provider-managed | Easy | Low | Transaction fees apply | Medium | Third-party service; suitable for quick setups. |
|
||||
| LNPay | Custodial | ✅ | Low | ❌ | Low | Provider-managed | Easy | Low | Transaction fees apply | Medium | Third-party service; suitable for quick setups. |
|
||||
| **Funding Source** | **Custodial Type** | **KYC Required** | **Technical Knowledge Needed** | **Node Hosting Required** | **Privacy Level** | **Liquidity Management** | **Ease of Setup** | **Maintenance Effort** | **Cost Implications** | **Scalability** | **Notes** |
|
||||
| ------------------------------ | ------------------------ | ------------------- | ------------------------------ | ------------------------- | ----------------- | ------------------------ | ----------------- | ---------------------- | -------------------------------------------- | --------------- | ------------------------------------------------------------------------------------------ |
|
||||
| **LND (gRPC)** | Self-custodial | ❌ | Higher | ✅ | High | Manual | Moderate | High | Infrastructure cost and channel opening fees | High | gRPC interface for LND; suitable for advanced integrations. |
|
||||
| **CoreLightning (CLN)** | Self-custodial | ❌ | Higher | ✅ | High | Manual | Moderate | High | Infrastructure cost and channel opening fees | High | Requires setting up and managing your own CLN node. |
|
||||
| **Phoenixd** | Self-custodial | ❌ | Medium | ❌ | Medium | Automatic | Moderate | Low | Minimal fees | Medium | Mobile wallet backend; suitable for mobile integrations. |
|
||||
| **Nostr Wallet Connect (NWC)** | Custodial | Depends on provider | Low | ❌ | Variable | Provider-managed | Easy | Low | May incur fees | Medium | Connects via Nostr protocol; depends on provider's policies. |
|
||||
| **Boltz** | Self-custodial | ❌ | Medium | ❌ | Medium | Provider-managed | Moderate | Moderate | Minimal fees | Medium | Uses submarine swaps; connects to Boltz client. |
|
||||
| **LND (REST)** | Self-custodial | ❌ | Higher | ✅ | High | Manual | Moderate | High | Infrastructure cost and channel opening fees | High | REST interface for LND; suitable for web integrations. |
|
||||
| **CoreLightning REST** | Self-custodial | ❌ | Higher | ✅ | High | Manual | Moderate | High | Infrastructure cost and channel opening fees | High | REST interface for CLN; suitable for web integrations. |
|
||||
| **LNbits (another instance)** | Custodial | Depends on host | Low | ❌ | Variable | Provider-managed | Easy | Low | May incur hosting fees | Medium | Connects to another LNbits instance; depends on host's policies. |
|
||||
| **Alby** | Custodial | ✅ | Low | ❌ | Low | Provider-managed | Easy | Low | Transaction fees apply | Medium | Browser extension wallet; suitable for web users. |
|
||||
| **Breez SDK** | Self-custodial | ❌ | Medium | ❌ | High | Automatic | Moderate | Low | Minimal fees | Medium | SDK for integrating Breez wallet functionalities. |
|
||||
| **OpenNode** | Custodial | ✅ | Low | ❌ | Low | Provider-managed | Easy | Low | Transaction fees apply | Medium | Third-party service; suitable for merchants. |
|
||||
| **Blink** | Custodial | ✅ | Low | ❌ | Low | Provider-managed | Easy | Low | Transaction fees apply | Medium | Third-party service; focuses on mobile integrations. |
|
||||
| **ZBD** | Custodial | ✅ | Low | ❌ | Low | Provider-managed | Easy | Low | Transaction fees apply | Medium | Gaming-focused payment platform. |
|
||||
| **Spark (CLN)** | Self-custodial | ❌ | Higher | ✅ | High | Manual | Moderate | High | Infrastructure cost and channel opening fees | High | Web interface for CLN; requires Spark server setup. |
|
||||
| **Cliche Wallet** | Self-custodial | ❌ | Medium | ❌ | Medium | Manual | Moderate | Moderate | Minimal fees | Medium | Lightweight wallet; suitable for embedded systems. |
|
||||
| **Strike** | Custodial | ✅ | Low | ❌ | Low | Provider-managed | Easy | Low | Transaction fees apply | Medium | Third-party service; suitable for quick setups. |
|
||||
| **LNPay** | Custodial | ✅ | Low | ❌ | Low | Provider-managed | Easy | Low | Transaction fees apply | Medium | Third-party service; suitable for quick setups. |
|
||||
| **Eclair (ACINQ)** | Self-custodial | ❌ | Higher | ✅ | High | Manual | Moderate | High | Infrastructure cost and channel opening fees | High | Connects via API; you run and manage your Eclair node. |
|
||||
| **LN.tips** | Custodial/Self-Custodial | Depends on provider | Medium | ❌ | Low | Provider-managed | Moderate | Low | Transaction fees may apply | Medium | Simple hosted service; use LN.tips API as your backend. |
|
||||
| **Fake Wallet** | Testing (simulated) | ❌ | Low | ❌ | N/A | N/A | Easy | Low | None (test only) | N/A | For testing only; mints accounting units in LNbits (no real sats, unit name configurable). |
|
||||
|
||||
---
|
||||
|
||||
### Notes for readers
|
||||
|
||||
- These are typical characteristics; your exact experience may vary by configuration and provider policy.
|
||||
- Pick based on your constraints: compliance (KYC), privacy, ops effort, and time-to-ship.
|
||||
|
||||
---
|
||||
|
||||
## Additional Guides
|
||||
|
||||
- **[Admin UI](./admin_ui.md)** — Manage server settings via a clean UI (avoid editing `.env` by hand).
|
||||
- **[User Roles](./User_Roles.md)** — Quick Overview of existing Roles in LNBits.
|
||||
- **[Funding sources](./funding-sources_table.md)** — What’s available and how to enable/configure each.
|
||||
|
||||
## Powered by LNbits
|
||||
|
||||
LNbits empowers everyone with modular, open-source tools for building Bitcoin-based systems — fast, free, and extendable.
|
||||
|
||||
If you like this project, [send some tip love](https://demo.lnbits.com/tipjar/DwaUiE4kBX6mUW6pj3X5Kg) or visit our [Shop](https://shop.lnbits.de)
|
||||
|
||||
[](https://shop.lnbits.com/)
|
||||
[](https://shop.lnbits.com/)
|
||||
[](https://my.lnbits.com/login)
|
||||
[](https://news.lnbits.com/)
|
||||
[](https://extensions.lnbits.com/)
|
||||
|
||||
+468
-168
@@ -1,37 +1,142 @@
|
||||
---
|
||||
layout: default
|
||||
title: Basic installation
|
||||
nav_order: 2
|
||||
title: Installation
|
||||
nav_order: 1
|
||||
---
|
||||
|
||||
<a href="https://lnbits.com" target="_blank" rel="noopener noreferrer">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://i.imgur.com/QE6SIrs.png">
|
||||
<img src="https://i.imgur.com/fyKPgVT.png" alt="LNbits" style="width:300px">
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
   [](https://extensions.lnbits.com/) [<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits) <img src="https://img.shields.io/badge/supported_by-%3E__OpenSats-f97316">
|
||||
|
||||
# Basic installation
|
||||
|
||||
Note that by default LNbits uses SQLite as its database, which is simple and effective but you can configure it to use PostgreSQL instead which is also described in a section below.
|
||||
> [!NOTE]
|
||||
> **Default DB:** LNbits uses SQLite by default (simple & effective). You can switch to PostgreSQL — see the section below.
|
||||
|
||||
## Option 1: AppImage (LInux)
|
||||
## Table of contents
|
||||
|
||||
### AppImage (Linux)
|
||||
- [Option 1: AppImage (Linux)](#option-1-appimage-linux)
|
||||
- [Option 2: UV (recommended for developers)](#option-2-uv-recommended-for-developers)
|
||||
- [Option 2a (Legacy): Poetry — Replaced by UV](#option-2a-legacy-poetry--replaced-by-uv)
|
||||
- [Option 3: Install script (Debian/Ubuntu)](#option-3-install-script-debianubuntu)
|
||||
- [Option 4: Nix](#option-4-nix)
|
||||
- [Option 5: Docker](#option-5-docker)
|
||||
- [Option 6: Fly.io](#option-6-flyio)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
- [Optional: PostgreSQL database](#optional-postgresql-database)
|
||||
- [Using LNbits](#using-lnbits)
|
||||
- [Additional guides](#additional-guides)
|
||||
- [Update LNbits (all methods)](#update-lnbits-all-methods)
|
||||
- [SQLite → PostgreSQL migration](#sqlite--postgresql-migration)
|
||||
- [LNbits as a systemd service](#lnbits-as-a-systemd-service)
|
||||
- [Reverse proxy with automatic HTTPS (Caddy)](#reverse-proxy-with-automatic-https-caddy)
|
||||
- [Apache2 reverse proxy over HTTPS](#apache2-reverse-proxy-over-https)
|
||||
- [Nginx reverse proxy over HTTPS](#nginx-reverse-proxy-over-https)
|
||||
- [HTTPS without a reverse proxy (self-signed)](#https-without-a-reverse-proxy-self-signed)
|
||||
- [LNbits on Umbrel behind Tor](#lnbits-on-umbrel-behind-tor)
|
||||
- [FreeBSD notes](#freebsd-notes)
|
||||
|
||||
Go to [releases](https://github.com/lnbits/lnbits/releases) and pull latest AppImage, or:
|
||||
## Option 1: AppImage (Linux)
|
||||
|
||||
**Quickstart**
|
||||
|
||||
1. Download latest AppImage from [releases](https://github.com/lnbits/lnbits/releases) **or** run:
|
||||
|
||||
```sh
|
||||
sudo apt-get install libfuse2
|
||||
sudo apt-get install jq libfuse2
|
||||
wget $(curl -s https://api.github.com/repos/lnbits/lnbits/releases/latest | jq -r '.assets[] | select(.name | endswith(".AppImage")) | .browser_download_url') -O LNbits-latest.AppImage
|
||||
chmod +x LNbits-latest.AppImage
|
||||
LNBITS_ADMIN_UI=true HOST=0.0.0.0 PORT=5000 ./LNbits-latest.AppImage # most system settings are now in the admin UI, but pass additional .env variables here
|
||||
```
|
||||
|
||||
LNbits will create a folder for db and extension files in the folder the AppImage runs from.
|
||||
- LNbits will create a folder for DB and extension files **in the same directory** as the AppImage.
|
||||
|
||||
## Option 2: Poetry (recommended for developers)
|
||||
> [!NOTE]
|
||||
> **Next steps**
|
||||
> Install complete → **[Running LNbits](#run-the-server)**
|
||||
> Update LNBits → **[Update LNbits (all methods)](#update-lnbits-all-methods)**
|
||||
|
||||
It is recommended to use the latest version of Poetry. Make sure you have Python version `3.12` installed.
|
||||
## Option 2: UV (recommended for developers)
|
||||
|
||||
### Install Python 3.12
|
||||
> [!IMPORTANT]
|
||||
> **It is recommended to use the latest version of UV & Make sure you have Python version 3.12 installed.**
|
||||
|
||||
## Option 2 (recommended): Poetry
|
||||
### Verify Python
|
||||
|
||||
It is recommended to use the latest version of Poetry. Make sure you have Python version 3.9 or higher installed.
|
||||
```sh
|
||||
python3 --version
|
||||
```
|
||||
|
||||
### Install UV
|
||||
|
||||
```sh
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
```
|
||||
|
||||
### Install LNbits
|
||||
|
||||
```sh
|
||||
git clone https://github.com/lnbits/lnbits.git
|
||||
cd lnbits
|
||||
git checkout main
|
||||
uv sync --all-extras
|
||||
|
||||
cp .env.example .env
|
||||
# Optional: set funding source and other options in .env (e.g., `nano .env`)
|
||||
```
|
||||
|
||||
### Run the server
|
||||
|
||||
```sh
|
||||
uv run lnbits
|
||||
# To change port/host: uv run lnbits --port 9000 --host 0.0.0.0
|
||||
# Add --debug to the command above and set DEBUG=true in .env for verbose output
|
||||
```
|
||||
|
||||
### LNbits CLI
|
||||
|
||||
```sh
|
||||
# Useful for superuser ID, updating extensions, etc.
|
||||
uv run lnbits-cli --help
|
||||
```
|
||||
|
||||
### Update LNbits
|
||||
|
||||
```sh
|
||||
cd lnbits
|
||||
# Stop LNbits with Ctrl + X or your service manager
|
||||
# sudo systemctl stop lnbits
|
||||
|
||||
# Update code
|
||||
git pull --rebase
|
||||
|
||||
uv sync --all-extras
|
||||
uv run lnbits
|
||||
```
|
||||
|
||||
#### Use Admin UI → Extensions → "Update All" to bring extensions up to the proper level
|
||||
|
||||
> [!NOTE]
|
||||
> **Next steps**
|
||||
> Install complete → **[Running LNbits](#run-the-server)**
|
||||
> Update LNBits → **[Update LNbits (all methods)](#update-lnbits-all-methods)**
|
||||
|
||||
## Option 2a (Legacy): Poetry — _Replaced by UV_
|
||||
|
||||
<details>
|
||||
<summary><strong>Poetry install and update (legacy workflow)</summary>
|
||||
|
||||
This legacy section is preserved for older environments.
|
||||
**UV is the recommended (and faster) tool** for new installs. Use Poetry only if you have personal preferences or must support an older workflow.
|
||||
|
||||
> 
|
||||
> **It is recommended to use the latest version of Poetry & Make sure you have Python version 3.12 installed.**
|
||||
|
||||
### Verify Python version
|
||||
|
||||
@@ -46,7 +151,7 @@ python3 --version
|
||||
curl -sSL https://install.python-poetry.org | python3 - && export PATH="$HOME/.local/bin:$PATH"
|
||||
```
|
||||
|
||||
### install LNbits
|
||||
### Install LNbits
|
||||
|
||||
```sh
|
||||
git clone https://github.com/lnbits/lnbits.git
|
||||
@@ -62,15 +167,14 @@ cp .env.example .env
|
||||
|
||||
```sh
|
||||
poetry run lnbits
|
||||
# To change port/host pass 'poetry run lnbits --port 9000 --host 0.0.0.0'
|
||||
# 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.
|
||||
# To change port/host: poetry run lnbits --port 9000 --host 0.0.0.0
|
||||
# Add --debug to help troubleshooting (also set DEBUG=true in .env)
|
||||
```
|
||||
|
||||
#### LNbits-cli
|
||||
#### LNbits CLI
|
||||
|
||||
```sh
|
||||
# A very useful terminal client for getting the supersuer ID, updating extensions, etc
|
||||
# A very useful terminal client for getting the superuser ID, updating extensions, etc.
|
||||
poetry run lnbits-cli --help
|
||||
```
|
||||
|
||||
@@ -78,27 +182,37 @@ poetry run lnbits-cli --help
|
||||
|
||||
```sh
|
||||
cd lnbits
|
||||
# Stop LNbits with `ctrl + x` or with service manager
|
||||
# Stop LNbits with Ctrl + X or with your service manager
|
||||
# sudo systemctl stop lnbits
|
||||
|
||||
# Update LNbits
|
||||
git pull --rebase
|
||||
|
||||
# Check your poetry version with
|
||||
# Check your Poetry Python version
|
||||
poetry env list
|
||||
# If version is less 3.12, update it by running
|
||||
# If version is less than 3.12, update it:
|
||||
poetry env use python3.12
|
||||
poetry env remove python3.9
|
||||
poetry env remove python3.X
|
||||
poetry env list
|
||||
|
||||
# Run install and start LNbits with
|
||||
# Reinstall and start
|
||||
poetry install --only main
|
||||
poetry run lnbits
|
||||
|
||||
# use LNbits admin UI Extensions page function "Update All" do get extensions onto proper level
|
||||
```
|
||||
|
||||
## Option 2: Install script (on Debian/Ubuntu)
|
||||
#### Use Admin UI → Extensions → "Update All" to bring extensions up to the proper level
|
||||
|
||||
> 
|
||||
> **Next steps**
|
||||
> Install complete → **[Running LNbits](#run-the-server)**
|
||||
> Update LNBits → **[Update LNbits (all methods)](#update-lnbits-all-methods)**
|
||||
|
||||
</details>
|
||||
|
||||
## Option 3: Install script (Debian/Ubuntu)
|
||||
|
||||
<details>
|
||||
<summary><strong>Show install script</strong> (one-line setup)</summary>
|
||||
|
||||
```sh
|
||||
wget https://raw.githubusercontent.com/lnbits/lnbits/main/lnbits.sh &&
|
||||
@@ -106,29 +220,58 @@ chmod +x lnbits.sh &&
|
||||
./lnbits.sh
|
||||
```
|
||||
|
||||
Now visit `0.0.0.0:5000` to make a super-user account.
|
||||
- You can use `./lnbits.sh` to run, but for more control: `cd lnbits` and use `uv run lnbits` (see Option 2).
|
||||
|
||||
`./lnbits.sh` can be used to run, but for more control `cd lnbits` and use `poetry run lnbits` (see previous option).
|
||||
> 
|
||||
> **Next steps**
|
||||
> Install complete → **[Running LNbits](#run-the-server)**
|
||||
> Update LNBits → **[Update LNbits (all methods)](#update-lnbits-all-methods)**
|
||||
|
||||
## Option 3: Nix
|
||||
</details>
|
||||
|
||||
## Option 4: Nix
|
||||
|
||||
<details>
|
||||
<summary><strong>Show Nix instructions</strong> (flakes, cachix, run)</summary>
|
||||
|
||||
```sh
|
||||
# Install nix. If you have installed via another manager, remove and use this install (from https://nixos.org/download)
|
||||
sh <(curl -L https://nixos.org/nix/install) --daemon
|
||||
sh <(curl --proto '=https' --tlsv1.2 -L https://nixos.org/nix/install) --daemon --yes
|
||||
|
||||
# Enable nix-command and flakes experimental features for nix:
|
||||
echo 'experimental-features = nix-command flakes' >> /etc/nix/nix.conf
|
||||
grep -qxF 'experimental-features = nix-command flakes' /etc/nix/nix.conf || \
|
||||
echo 'experimental-features = nix-command flakes' | sudo tee -a /etc/nix/nix.conf
|
||||
|
||||
# Add user to Nix
|
||||
grep -qxF "trusted-users = root $USER" /etc/nix/nix.conf || \
|
||||
echo "trusted-users = root $USER" | sudo tee -a /etc/nix/nix.conf
|
||||
|
||||
# Restart daemon so changes apply
|
||||
sudo systemctl restart nix-daemon
|
||||
|
||||
# Clone and build LNbits
|
||||
git clone https://github.com/lnbits/lnbits.git
|
||||
cd lnbits
|
||||
|
||||
# Make data directory and persist data/extension folders
|
||||
mkdir data
|
||||
PROJECT_DIR="$(pwd)"
|
||||
{
|
||||
echo "export PYTHONPATH=\"$PROJECT_DIR/ns:\$PYTHONPATH\""
|
||||
echo "export LNBITS_DATA_FOLDER=\"$PROJECT_DIR/data\""
|
||||
echo "export LNBITS_EXTENSIONS_PATH=\"$PROJECT_DIR\""
|
||||
} >> ~/.bashrc
|
||||
grep -qxF '. /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh' ~/.bashrc || \
|
||||
echo '. /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh' >> ~/.bashrc
|
||||
. ~/.bashrc
|
||||
|
||||
# Add cachix for cached binaries
|
||||
nix-env -iA cachix -f https://cachix.org/api/v1/install
|
||||
cachix use lnbits
|
||||
|
||||
# Clone and build LNbits
|
||||
git clone https://github.com/lnbits/lnbits.git
|
||||
cd lnbits
|
||||
# Build LNbits
|
||||
nix build
|
||||
|
||||
mkdir data
|
||||
```
|
||||
|
||||
#### Running the server
|
||||
@@ -142,24 +285,42 @@ but you can also set the env variables or pass command line arguments:
|
||||
|
||||
```sh
|
||||
# .env variables are currently passed when running, but LNbits can be managed with the admin UI.
|
||||
LNBITS_ADMIN_UI=true ./result/bin/lnbits --port 9000
|
||||
LNBITS_ADMIN_UI=true ./result/bin/lnbits --port 9000 --host 0.0.0.0
|
||||
|
||||
# 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
|
||||
```
|
||||
|
||||
## Option 4: Docker
|
||||
> 
|
||||
> **Next steps**
|
||||
> Update LNBits → **[Update LNbits (all methods)](#update-lnbits-all-methods)**
|
||||
|
||||
Use latest version from Docker Hub.
|
||||
</details>
|
||||
|
||||
## Option 5: Docker
|
||||
|
||||
<details>
|
||||
<summary><strong>Show Docker instructions</strong> (official image, volumes, extensions)</summary>
|
||||
|
||||
**Use latest image**
|
||||
|
||||
```sh
|
||||
docker pull lnbits/lnbits
|
||||
wget https://raw.githubusercontent.com/lnbits/lnbits/main/.env.example -O .env
|
||||
mkdir data
|
||||
docker run --detach --publish 5000:5000 --name lnbits --volume ${PWD}/.env:/app/.env --volume ${PWD}/data/:/app/data lnbits/lnbits
|
||||
docker run --detach --publish 5000:5000 --name lnbits \
|
||||
--volume ${PWD}/.env:/app/.env \
|
||||
--volume ${PWD}/data/:/app/data \
|
||||
lnbits/lnbits
|
||||
```
|
||||
|
||||
Build the image yourself.
|
||||
- The LNbits Docker image ships **without any extensions**; by default, any extensions you install are stored **inside the container** and will be **lost** when the container is removed, so you should set `LNBITS_EXTENSIONS_PATH` to a directory that’s **mapped to a persistent host volume** so extensions **survive rebuilds/recreates**—for example:
|
||||
|
||||
```sh
|
||||
docker run ... -e "LNBITS_EXTENSIONS_PATH='/app/data/extensions'" --volume ${PWD}/data/:/app/data ...
|
||||
```
|
||||
|
||||
**Build image yourself**
|
||||
|
||||
```sh
|
||||
git clone https://github.com/lnbits/lnbits.git
|
||||
@@ -167,24 +328,39 @@ cd lnbits
|
||||
docker build -t lnbits/lnbits .
|
||||
cp .env.example .env
|
||||
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:
|
||||
You can optionally override the install extras for both **Poetry** and **UV** to include optional features during build or setup:
|
||||
|
||||
- with Poetry, pass extras via the `POETRY_INSTALL_ARGS` Docker build-arg (e.g., to enable the **Breez** funding source: `docker build --build-arg POETRY_INSTALL_ARGS="-E breez" -t lnbits/lnbits .`);
|
||||
- with UV, enable extras during environment sync (e.g., locally run `uv sync --extra breez` or `uv sync --all-extras`), and—**if your Dockerfile supports it**—you can mirror the same at build time via a build-arg such as `UV_SYNC_ARGS` (example pattern: `docker build --build-arg UV_SYNC_ARGS="--extra breez" -t lnbits/lnbits .`).
|
||||
|
||||
**Enable Breez funding source at build**
|
||||
|
||||
```sh
|
||||
docker build --build-arg POETRY_INSTALL_ARGS="-E breez" -t lnbits/lnbits .
|
||||
```
|
||||
|
||||
## Option 5: Fly.io
|
||||
> 
|
||||
> **Next steps**
|
||||
> Install complete → **[Running LNbits](#run-the-server)**
|
||||
> Update LNBits → **[Update LNbits (all methods)](#update-lnbits-all-methods)**
|
||||
|
||||
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.
|
||||
</details>
|
||||
|
||||
First, sign up for an account at [Fly.io](https://fly.io) (no credit card required).
|
||||
## Option 6: Fly.io
|
||||
|
||||
Then, install the Fly.io CLI onto your device [here](https://fly.io/docs/getting-started/installing-flyctl/).
|
||||
<details>
|
||||
<summary><strong>Deploy LNbits on Fly.io (free tier friendly)</summary>
|
||||
|
||||
After install is complete, the command will output a command you should copy/paste/run to get `fly` into your `$PATH`. Something like:
|
||||
**Fly.io is a docker container hosting platform that has a generous free tier. You can host LNbits for free on Fly.io for personal use.**
|
||||
|
||||
1. Create an account at [Fly.io](https://fly.io).
|
||||
2. Install the Fly.io CLI ([guide](https://fly.io/docs/getting-started/installing-flyctl/)).
|
||||
|
||||
```
|
||||
flyctl was installed successfully to /home/ubuntu/.fly/bin/flyctl
|
||||
@@ -193,9 +369,9 @@ Manually add the directory to your $HOME/.bash_profile (or similar)
|
||||
export PATH="$FLYCTL_INSTALL/bin:$PATH"
|
||||
```
|
||||
|
||||
You can either run those commands, then `source ~/.bash_profile` or, if you don't, you'll have to call Fly from `~/.fly/bin/flyctl`.
|
||||
3. You can either run those commands, then `source ~/.bash_profile` or, if you don't, you'll have to call Fly from `~/.fly/bin/flyctl`.
|
||||
|
||||
Once installed, run the following commands.
|
||||
- Once installed, run the following commands.
|
||||
|
||||
```
|
||||
git clone https://github.com/lnbits/lnbits.git
|
||||
@@ -209,9 +385,16 @@ You'll be prompted to enter an app name, region, postgres (choose no), deploy no
|
||||
|
||||
You'll now find a file in the directory called `fly.toml`. Open that file and modify/add the following settings.
|
||||
|
||||
Note: Be sure to replace `${PUT_YOUR_LNBITS_ENV_VARS_HERE}` with all relevant environment variables in `.env` or `.env.example`. Environment variable strings should be quoted here, so if in `.env` you have `LNBITS_ENDPOINT=https://demo.lnbits.com` in `fly.toml` you should have `LNBITS_ENDPOINT="https://demo.lnbits.com"`.
|
||||
> 
|
||||
> Be sure to replace `${PUT_YOUR_LNBITS_ENV_VARS_HERE}` with all relevant environment variables in `.env` or `.env.example`.
|
||||
> Environment variable strings should be quoted here. For example, if `.env` has
|
||||
> `LNBITS_ENDPOINT=https://demo.lnbits.com`, then in `fly.toml` use
|
||||
> `LNBITS_ENDPOINT="https://demo.lnbits.com"`.
|
||||
|
||||
Note: Don't enter secret environment variables here. Fly.io offers secrets (via the `fly secrets` command) that are exposed as environment variables in your runtime. So, for example, if using the LND_REST funding source, you can run `fly secrets set LND_REST_MACAROON=<hex_macaroon_data>`.
|
||||
> 
|
||||
> Don't enter secret environment variables here. Fly.io offers **secrets** (via `fly secrets`) that are exposed as env vars at runtime.
|
||||
> Example (LND REST funding source):
|
||||
> `fly secrets set LND_REST_MACAROON=<hex_macaroon_data>`
|
||||
|
||||
```
|
||||
...
|
||||
@@ -266,26 +449,49 @@ sudo apt install python3.10-dev gcc build-essential
|
||||
poetry add setuptools wheel
|
||||
```
|
||||
|
||||
### Optional: PostgreSQL database
|
||||
> 
|
||||
>
|
||||
> **Next steps**
|
||||
> Install complete → **[Running LNbits](#run-the-server)**
|
||||
> Update LNbits → **[Update LNbits (all methods)](#update-lnbits-all-methods)**
|
||||
|
||||
If you want to use LNbits at scale, we recommend using PostgreSQL as the backend database. Install Postgres and setup a database for LNbits:
|
||||
## Troubleshooting
|
||||
|
||||
```sh
|
||||
# on debian/ubuntu 'sudo apt-get -y install postgresql'
|
||||
# or follow instructions at https://www.postgresql.org/download/linux/
|
||||
sudo apt install pkg-config libffi-dev libpq-dev
|
||||
|
||||
# Postgres doesn't have a default password, so we'll create one.
|
||||
# build essentials (Debian/Ubuntu)
|
||||
sudo apt install python3.10-dev gcc build-essential
|
||||
|
||||
# if secp256k1 build fails and you used poetry
|
||||
poetry add setuptools wheel
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
</details>
|
||||
|
||||
## Optional: PostgreSQL database
|
||||
|
||||
> [!TIP]
|
||||
> If you want to use LNbits at scale, we recommend using PostgreSQL as the backend database. Install Postgres and set up a database for LNbits.
|
||||
|
||||
```sh
|
||||
# Debian/Ubuntu: sudo apt-get -y install postgresql
|
||||
# or see https://www.postgresql.org/download/linux/
|
||||
|
||||
# Create a password for the postgres user
|
||||
sudo -i -u postgres
|
||||
psql
|
||||
# on psql
|
||||
ALTER USER postgres PASSWORD 'myPassword'; # choose whatever password you want
|
||||
# in psql
|
||||
ALTER USER postgres PASSWORD 'myPassword';
|
||||
\q
|
||||
# on postgres user
|
||||
# back as postgres user
|
||||
createdb lnbits
|
||||
exit
|
||||
```
|
||||
|
||||
You need to edit the `.env` file.
|
||||
**Configure LNbits**
|
||||
|
||||
```sh
|
||||
# add the database connection string to .env 'nano .env' LNBITS_DATABASE_URL=
|
||||
@@ -296,44 +502,189 @@ LNBITS_DATABASE_URL="postgres://postgres:postgres@localhost:5432/lnbits"
|
||||
|
||||
# Using LNbits
|
||||
|
||||
Now you can visit your LNbits at http://localhost:5000/.
|
||||
Visit **[http://localhost:5000/](http://localhost:5000/)** (or `0.0.0.0:5000`).
|
||||
|
||||
Now modify the `.env` file with any settings you prefer and add a proper [funding source](./wallets.md) by modifying the value of `LNBITS_BACKEND_WALLET_CLASS` and providing the extra information and credentials related to the chosen funding source.
|
||||
### Option A — First-run setup in the Browser (UI)
|
||||
|
||||
Then you can restart it and it will be using the new settings.
|
||||
1. On the **first start**, LNbits will **prompt you to Setup a SuperUser**.
|
||||
2. After creating it, you’ll be **redirected to the Admin UI as SuperUser**.
|
||||
3. In the Admin UI, **set your funding source** (backend wallet) and other preferences.
|
||||
4. **Restart LNbits** if prompted or after changing critical settings.
|
||||
|
||||
You might also need to install additional packages or perform additional setup steps, depending on the chosen backend. See [the short guide](./wallets.md) on each different funding source.
|
||||
> [!IMPORTANT]
|
||||
> Use the **SuperUser only** for initial setup and instance settings (funding source, configuration, Topup).
|
||||
> For maintenance, create a separate **Admin** account. For everyday usage (payments, wallets, etc.), **do not use the SuperUser** — use admin or regular user accounts instead. Its a bad behaviour.
|
||||
> Read more about [SuperUser](./super_user.md) and [Admin UI](./admin_ui.md)
|
||||
|
||||
Take a look at [Polar](https://lightningpolar.com/) for an excellent way of spinning up a Lightning Network dev environment.
|
||||
### Option B — Configure via `.env`
|
||||
|
||||
1. Edit your `.env` with preferred settings (funding, base URL, etc.).
|
||||
2. Set a funding source by configuring:
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`
|
||||
- plus the required credentials for your chosen backend (see **[wallets.md](./wallets.md)**).
|
||||
|
||||
3. **Restart LNbits** to apply changes.
|
||||
|
||||
---
|
||||
|
||||
> [!NOTE]
|
||||
> **Paths overview**
|
||||
>
|
||||
> - **SuperUser file:** `<lnbits_root>/data/.super_user`
|
||||
> Example: `~/lnbits/data/.super_user` • View: `cat ~/lnbits/data/.super_user`
|
||||
> - **Environment file:** `<lnbits_root>/.env` (for bare-metal installs)
|
||||
> - **Docker:** bind a host directory to `/app/data`.
|
||||
> On the host the SuperUser file is at `<host_data_dir>/.super_user`.
|
||||
> The container reads `/app/.env` (usually bind-mounted from your project root).
|
||||
|
||||
> [!TIP]
|
||||
> **Local Lightning test network**
|
||||
> Use **Polar** to spin up a safe local Lightning environment and test LNbits without touching your live setup.
|
||||
> https://lightningpolar.com/
|
||||
|
||||
> [!TIP]
|
||||
> **API comparison before updates**
|
||||
> Use **TableTown** to diff your LNbits instance against another (dev vs prod) or the upstream dev branch. Spot endpoint changes before updating.
|
||||
> Crafted by [Arbadacarbayk](https://github.com/arbadacarbaYK) - a standout contribution that makes pre-release reviews fast and reliable.
|
||||
> https://arbadacarbayk.github.io/LNbits_TableTown/
|
||||
|
||||
# Additional guides
|
||||
|
||||
## SQLite to PostgreSQL migration
|
||||
## Update LNbits (all methods)
|
||||
|
||||
If you already have LNbits installed and running, on an SQLite database, we **highly** recommend you migrate to postgres if you are planning to run LNbits on scale.
|
||||
> After updating, open **Admin UI → Extensions → “Update All”** to make sure extensions match the core version.
|
||||
|
||||
There's a script included that can do the migration easy. You should have Postgres already installed and there should be a password for the user (see Postgres install guide above). Additionally, your LNbits instance should run once on postgres to implement the database schema before the migration works:
|
||||
<details>
|
||||
<summary><strong>UV (recommended)</strong></summary>
|
||||
|
||||
```sh
|
||||
cd lnbits
|
||||
git pull --rebase
|
||||
uv sync --all-extras
|
||||
# restart (dev)
|
||||
uv run lnbits
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Poetry (legacy)</strong></summary>
|
||||
|
||||
```sh
|
||||
cd lnbits
|
||||
git pull --rebase
|
||||
# Optional: ensure Python 3.12
|
||||
poetry env list
|
||||
poetry env use python3.12
|
||||
poetry install --only main
|
||||
# restart (dev)
|
||||
poetry run lnbits
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>AppImage</strong></summary>
|
||||
|
||||
Download the latest AppImage from Releases and replace your old file **in the same directory** to keep the `./data` folder (DB, extensions).
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Install script (Debian/Ubuntu)</strong></summary>
|
||||
|
||||
```sh
|
||||
# If you installed via lnbits.sh:
|
||||
cd lnbits
|
||||
git pull --rebase
|
||||
# then use your chosen runner (UV recommended)
|
||||
uv sync --all-extras
|
||||
uv run lnbits
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Nix</strong></summary>
|
||||
|
||||
```sh
|
||||
cd lnbits
|
||||
git pull --rebase
|
||||
nix build
|
||||
# restart
|
||||
nix run
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Docker (official image)</strong></summary>
|
||||
|
||||
```sh
|
||||
docker pull lnbits/lnbits
|
||||
docker stop lnbits && docker rm lnbits
|
||||
docker run --detach --publish 5000:5000 --name lnbits \
|
||||
--volume ${PWD}/.env:/app/.env \
|
||||
--volume ${PWD}/data/:/app/data \
|
||||
lnbits/lnbits
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Docker (build yourself)</strong></summary>
|
||||
|
||||
```sh
|
||||
cd lnbits
|
||||
git pull --rebase
|
||||
docker build -t lnbits/lnbits .
|
||||
docker stop lnbits && docker rm lnbits
|
||||
docker run --detach --publish 5000:5000 --name lnbits \
|
||||
--volume ${PWD}/.env:/app/.env \
|
||||
--volume ${PWD}/data/:/app/data \
|
||||
lnbits/lnbits
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Fly.io</strong></summary>
|
||||
|
||||
```sh
|
||||
# If using Dockerfile in repo (recommended)
|
||||
cd lnbits
|
||||
git pull --rebase
|
||||
fly deploy
|
||||
# Logs & shell if needed
|
||||
fly logs
|
||||
fly ssh console
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## SQLite → PostgreSQL migration
|
||||
|
||||
> [!TIP]
|
||||
> If you run on SQLite and plan to scale, migrate to Postgres.
|
||||
|
||||
```sh
|
||||
# STOP LNbits
|
||||
|
||||
# add the database connection string to .env 'nano .env' LNBITS_DATABASE_URL=
|
||||
# postgres://<user>:<password>@<host>/<database> - alter line bellow with your user, password and db name
|
||||
# Edit .env with Postgres URL
|
||||
LNBITS_DATABASE_URL="postgres://postgres:postgres@localhost/lnbits"
|
||||
# save and exit
|
||||
|
||||
# START LNbits
|
||||
# STOP LNbits
|
||||
poetry run python tools/conv.py
|
||||
# START then STOP LNbits once to apply schema
|
||||
uv run python tools/conv.py
|
||||
# or
|
||||
make migration
|
||||
```
|
||||
|
||||
Hopefully, everything works and get migrated... Launch LNbits again and check if everything is working properly.
|
||||
- Launch LNbits again and verify.
|
||||
|
||||
## LNbits as a systemd service
|
||||
|
||||
Systemd is great for taking care of your LNbits instance. It will start it on boot and restart it in case it crashes. If you want to run LNbits as a systemd service on your Debian/Ubuntu/Raspbian server, create a file at `/etc/systemd/system/lnbits.service` with the following content:
|
||||
Create `/etc/systemd/system/lnbits.service`:
|
||||
|
||||
```
|
||||
# Systemd unit for lnbits
|
||||
@@ -341,17 +692,14 @@ Systemd is great for taking care of your LNbits instance. It will start it on bo
|
||||
|
||||
[Unit]
|
||||
Description=LNbits
|
||||
# you can uncomment these lines if you know what you're doing
|
||||
# it will make sure that lnbits starts after lnd (replace with your own backend service)
|
||||
# Optional: start after your backend
|
||||
#Wants=lnd.service
|
||||
#After=lnd.service
|
||||
|
||||
[Service]
|
||||
# replace with the absolute path of your lnbits installation
|
||||
WorkingDirectory=/home/lnbits/lnbits
|
||||
# same here. run `which poetry` if you can't find the poetry binary
|
||||
ExecStart=/home/lnbits/.local/bin/poetry run lnbits
|
||||
# replace with the user that you're running lnbits on
|
||||
# Find uv path via `which uv`
|
||||
ExecStart=/home/lnbits/.local/bin/uv run lnbits
|
||||
User=lnbits
|
||||
Restart=always
|
||||
TimeoutSec=120
|
||||
@@ -362,33 +710,23 @@ Environment=PYTHONUNBUFFERED=1
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
Save the file and run the following commands:
|
||||
Enable & start:
|
||||
|
||||
```sh
|
||||
sudo systemctl enable lnbits.service
|
||||
sudo systemctl start lnbits.service
|
||||
```
|
||||
|
||||
## Reverse proxy with automatic HTTPS using Caddy
|
||||
## Reverse proxy with automatic HTTPS (Caddy)
|
||||
|
||||
Use Caddy to make your LNbits install accessible over clearnet with a domain and https cert.
|
||||
Point your domain A-record to your server IP. Install Caddy: [Caddy install guide](https://caddyserver.com/docs/install#debian-ubuntu-raspbian)
|
||||
|
||||
Point your domain at the IP of the server you're running LNbits on, by making an `A` record.
|
||||
|
||||
Install Caddy on the server
|
||||
https://caddyserver.com/docs/install#debian-ubuntu-raspbian
|
||||
|
||||
```
|
||||
```sh
|
||||
sudo caddy stop
|
||||
```
|
||||
|
||||
Create a Caddyfile
|
||||
|
||||
```
|
||||
sudo nano Caddyfile
|
||||
```
|
||||
|
||||
Assuming your LNbits is running on port `5000` add:
|
||||
Add:
|
||||
|
||||
```
|
||||
yourdomain.com {
|
||||
@@ -398,28 +736,21 @@ yourdomain.com {
|
||||
}
|
||||
```
|
||||
|
||||
Save and exit `CTRL + x`
|
||||
Save (Ctrl+X) and start:
|
||||
|
||||
```
|
||||
```sh
|
||||
sudo caddy start
|
||||
```
|
||||
|
||||
## Running behind an Apache2 reverse proxy over HTTPS
|
||||
|
||||
Install Apache2 and enable Apache2 mods:
|
||||
## Apache2 reverse proxy over HTTPS
|
||||
|
||||
```sh
|
||||
apt-get install apache2 certbot
|
||||
a2enmod headers ssl proxy proxy_http
|
||||
```
|
||||
|
||||
Create a SSL certificate with LetsEncrypt:
|
||||
|
||||
```sh
|
||||
certbot certonly --webroot --agree-tos --non-interactive --webroot-path /var/www/html -d lnbits.org
|
||||
```
|
||||
|
||||
Create an Apache2 vhost at: `/etc/apache2/sites-enabled/lnbits.conf`:
|
||||
Create `/etc/apache2/sites-enabled/lnbits.conf`:
|
||||
|
||||
```sh
|
||||
cat <<EOF > /etc/apache2/sites-enabled/lnbits.conf
|
||||
@@ -446,27 +777,20 @@ cat <<EOF > /etc/apache2/sites-enabled/lnbits.conf
|
||||
EOF
|
||||
```
|
||||
|
||||
Restart Apache2:
|
||||
Restart:
|
||||
|
||||
```sh
|
||||
service apache2 restart
|
||||
```
|
||||
|
||||
## Running behind an Nginx reverse proxy over HTTPS
|
||||
|
||||
Install nginx:
|
||||
## Nginx reverse proxy over HTTPS
|
||||
|
||||
```sh
|
||||
apt-get install nginx certbot
|
||||
```
|
||||
|
||||
Create a SSL certificate with LetsEncrypt:
|
||||
|
||||
```sh
|
||||
certbot certonly --nginx --agree-tos -d lnbits.org
|
||||
```
|
||||
|
||||
Create an nginx vhost at `/etc/nginx/sites-enabled/lnbits.org`:
|
||||
Create `/etc/nginx/sites-enabled/lnbits.org`:
|
||||
|
||||
```sh
|
||||
cat <<EOF > /etc/nginx/sites-enabled/lnbits.org
|
||||
@@ -500,23 +824,22 @@ server {
|
||||
EOF
|
||||
```
|
||||
|
||||
Restart nginx:
|
||||
Restart:
|
||||
|
||||
```sh
|
||||
service nginx restart
|
||||
```
|
||||
|
||||
## Using https without reverse proxy
|
||||
---
|
||||
|
||||
The most common way of using LNbits via https is to use a reverse proxy such as Caddy, nginx, or ngriok. However, you can also run LNbits via https without additional software. This is useful for development purposes or if you want to use LNbits in your local network.
|
||||
## HTTPS without a reverse proxy (self-signed)
|
||||
|
||||
We have to create a self-signed certificate using `mkcert`. Note that this certificate is not "trusted" by most browsers but that's fine (since you know that you have created it) and encryption is always better than clear text.
|
||||
Create a self-signed cert (useful for local/dev). Browsers won’t trust it by default.
|
||||
|
||||
#### Install mkcert
|
||||
### Install mkcert
|
||||
|
||||
You can find the install instructions for `mkcert` [here](https://github.com/FiloSottile/mkcert).
|
||||
|
||||
Install mkcert on Ubuntu:
|
||||
- Install instructions: [mkcert README](https://github.com/FiloSottile/mkcert)
|
||||
- Ubuntu example:
|
||||
|
||||
```sh
|
||||
sudo apt install libnss3-tools
|
||||
@@ -525,70 +848,47 @@ chmod +x mkcert-v*-linux-amd64
|
||||
sudo cp mkcert-v*-linux-amd64 /usr/local/bin/mkcert
|
||||
```
|
||||
|
||||
#### Create certificate
|
||||
### Create certificate
|
||||
|
||||
To create a certificate, first `cd` into your LNbits folder and execute the following command on Linux:
|
||||
**OpenSSL**
|
||||
|
||||
```sh
|
||||
openssl req -new -newkey rsa:4096 -x509 -sha256 -days 3650 -nodes -out cert.pem -keyout key.pem
|
||||
```
|
||||
|
||||
This will create two new files (`key.pem` and `cert.pem `).
|
||||
|
||||
Alternatively, you can use mkcert ([more info](https://kifarunix.com/how-to-create-self-signed-ssl-certificate-with-mkcert-on-ubuntu-18-04/)):
|
||||
**mkcert** (alternative)
|
||||
|
||||
```sh
|
||||
# add your local IP (192.x.x.x) as well if you want to use it in your local network
|
||||
# include your local IP (e.g., 192.x.x.x) if needed
|
||||
mkcert localhost 127.0.0.1 ::1
|
||||
```
|
||||
|
||||
You can then pass the certificate files to uvicorn when you start LNbits:
|
||||
**Run with certs**
|
||||
|
||||
```sh
|
||||
poetry run uvicorn lnbits.__main__:app --host 0.0.0.0 --port 5000 --ssl-keyfile ./key.pem --ssl-certfile ./cert.pem
|
||||
```
|
||||
|
||||
## LNbits running on Umbrel behind Tor
|
||||
## LNbits on Umbrel behind Tor
|
||||
|
||||
If you want to run LNbits on your Umbrel but want it to be reached through clearnet, _Uxellodunum_ made an extensive [guide](https://community.getumbrel.com/t/guide-lnbits-without-tor/604) on how to do it.
|
||||
See this community [guide](https://community.getumbrel.com/t/guide-lnbits-without-tor/604).
|
||||
|
||||
## Docker installation
|
||||
## FreeBSD notes
|
||||
|
||||
To install using docker you first need to build the docker image as:
|
||||
Issue with secp256k1 0.14.0 on FreeBSD (thanks @GitKalle):
|
||||
|
||||
```
|
||||
git clone https://github.com/lnbits/lnbits.git
|
||||
cd lnbits
|
||||
docker build -t lnbits/lnbits .
|
||||
```
|
||||
1. Install `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` with `poetry lock`.
|
||||
4. Follow install instructions with Poetry.
|
||||
|
||||
You can launch the docker in a different directory, but make sure to copy `.env.example` from lnbits there
|
||||
---
|
||||
|
||||
```
|
||||
cp <lnbits_repo>/.env.example .env
|
||||
```
|
||||
## Powered by LNbits
|
||||
|
||||
and change the configuration in `.env` as required.
|
||||
LNbits empowers everyone with modular, open-source tools for building Bitcoin-based systems — fast, free, and extendable.
|
||||
|
||||
Then create the data directory
|
||||
If you like this project [send some tip love](https://demo.lnbits.com/tipjar/DwaUiE4kBX6mUW6pj3X5Kg) or visiting our [Shop](https://shop.lnbits.com)
|
||||
|
||||
```
|
||||
mkdir data
|
||||
```
|
||||
|
||||
Then the image can be run as:
|
||||
|
||||
```
|
||||
docker run --detach --publish 5000:5000 --name lnbits -e "LNBITS_BACKEND_WALLET_CLASS='FakeWallet'" --volume ${PWD}/.env:/app/.env --volume ${PWD}/data/:/app/data lnbits
|
||||
```
|
||||
|
||||
Finally you can access your lnbits on your machine at port 5000.
|
||||
|
||||
### 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
|
||||
[](https://shop.lnbits.com/)
|
||||
[](https://shop.lnbits.com/) [](https://my.lnbits.com/login) [](https://news.lnbits.com/) [](https://extensions.lnbits.com/)
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
---
|
||||
layout: default
|
||||
title: Super user
|
||||
nav_order: 1
|
||||
---
|
||||
|
||||
<a href="https://lnbits.com" target="_blank" rel="noopener noreferrer">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://i.imgur.com/QE6SIrs.png">
|
||||
<img src="https://i.imgur.com/fyKPgVT.png" alt="LNbits" style="width:300px">
|
||||
</picture>
|
||||
</a>
|
||||
|
||||

|
||||

|
||||
[<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits)
|
||||
[<img src="https://img.shields.io/badge/supported_by-%3E__OpenSats-f97316">](https://opensats.org)
|
||||
|
||||
# LNbits Super User (SU)
|
||||
|
||||
**Table of Contents**
|
||||
|
||||
- [What is the Super User?](#what-is-the-super-user)
|
||||
- [When is the Super User created?](#when-is-the-super-user-created)
|
||||
- [Disabeling the Admin UI](#disabeling-the-admin-ui)
|
||||
- [Super User identity and storage](#super-user-identity-and-storage)
|
||||
- [Security model since v1](#security-model-since-v1)
|
||||
- [Admin vs Super User](#admin-vs-super-user)
|
||||
- [Operational guidance](#operational-guidance)
|
||||
- [Additional guides](#additional-guides)
|
||||
|
||||
<details>
|
||||
<summary><strong>TLDR</strong></summary>
|
||||
|
||||
- **No Admin UI → No Super User.** The Super User (SU) exists only when `LNBITS_ADMIN_UI=true`.
|
||||
- **Why SU exists:** SU can do a few high impact actions regular admins cannot, like **changing the funding source**, **restarting the server from the UI**, and **crediting or debiting accounts**.
|
||||
- **Login changes since v1:** Logging in by **user ID** for SU and admins is **disabled**. On first visit after enabling the Admin UI you will be prompted to set a **username and password** for the SU.
|
||||
- **Trust model:** Admins and the SU share about **99 percent of the same powers**, but the SU is the one trusted with funding source control and cannot be demoted by regular admins.
|
||||
|
||||
</details>
|
||||
|
||||
## What is the Super User?
|
||||
|
||||
The **Super User** is the owner-operator account of an LNbits instance. Think of it as your “break glass” operator with a few capabilities that are intentionally reserved for the person ultimately responsible for the server and the funding rails.
|
||||
|
||||
The SU is created alongside the [Admin UI](./admin_ui.md) and is meant to keep enviroment operations pleasant in the UI while keeping the most sensitive knobs in trusted hands.
|
||||
|
||||
**Key SU capabilities**
|
||||
|
||||
- **Change the funding source** for the instance
|
||||
- **Restart the LNbits server** from the web UI
|
||||
- **Credit or debit accounts** for operational corrections
|
||||
|
||||
> Note
|
||||
> These are separated from regular admin tasks on purpose. It helps maintain least privilege and reduces the chance of accidental or malicious changes.
|
||||
|
||||
## Admin vs Super User
|
||||
|
||||
| Capability | Admin | Super User |
|
||||
| ------------------------ | ---------- | ---------- |
|
||||
| View Admin UI | If enabled | If enabled |
|
||||
| Change funding source | — | ✓ |
|
||||
| Credit or debit accounts | — | ✓ |
|
||||
| Restart server from UI | — | ✓ |
|
||||
| Manage users and wallets | ✓ | ✓ |
|
||||
| Instance-level settings | ✓ | ✓ |
|
||||
| Manage notifications | ✓ | ✓ |
|
||||
| Exchange rates | ✓ | ✓ |
|
||||
| View all Payments | ✓ | ✓ |
|
||||
|
||||
**Why both roles?**
|
||||
In many teams the person running the server prefers to **delegate day-to-day admin work** while keeping funding and final authority safe. Admins can do almost everything; the SU retains the last few high risk powers.
|
||||
|
||||
## When is the Super User created?
|
||||
|
||||
- The SU is created **only** when you enable the Admin UI: `LNBITS_ADMIN_UI=true`.
|
||||
- If the Admin UI is **disabled**, there is **no SU** and all SU-only UI is hidden.
|
||||
|
||||
## Disabeling the Admin UI
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Read the [Admin UI guide](./admin_ui.md) before Disabeling. You are turning on a management surface; do it deliberately.
|
||||
|
||||
Set the environment variable in your deployment:
|
||||
|
||||
```bash
|
||||
# .env
|
||||
LNBITS_ADMIN_UI=false
|
||||
```
|
||||
|
||||
## Super User identity and storage
|
||||
|
||||
LNbits stores the **Super User ID** at:
|
||||
|
||||
```
|
||||
/lnbits/data/.super_user
|
||||
```
|
||||
|
||||
- Back this up along with the rest of `/lnbits/data` as part of your secure backup routine.
|
||||
- **Changing who is the SU** can only be done by someone with **CLI access to the host OS** where LNbits runs. **Regular admins cannot revoke or replace the SU in the Admin UI.**
|
||||
|
||||
## Security model since v1
|
||||
|
||||
- **User-ID logins are disabled** for SU and admin roles.
|
||||
- **Credentialed login is required:** set a **username and password** for the SU at first run of the Admin UI.
|
||||
- **SU secrecy:** Regular users and admins **cannot discover the SU user ID** through normal UI flows.
|
||||
|
||||
## Operational guidance
|
||||
|
||||
These are practical tips for running a safe and friendly instance.
|
||||
|
||||
- It is normal to **delegate admin** duties to trusted people. Admins have about **99 percent** of SU powers for day-to-day work.
|
||||
- Keep the **SU** reserved for the person legally or operationally responsible for the **funding source**.
|
||||
- Use admin roles for regular day-to-day management and keep the SU for reserved SU tasks only.
|
||||
|
||||
## Additional guides
|
||||
|
||||
- **[Admin UI](./admin_ui.md)** — Manage server settings in the browser instead of editing `.env` or using the CLI for routine tasks.
|
||||
- **[User Roles](./user_roles.md)** — Overview of roles and what they can do.
|
||||
- **[Funding sources](./funding-sources-table.md)** — Available options and how to enable and configure them.
|
||||
- **[Install LNBits](./installation.md)** — Choose your prefared way to install LNBits.
|
||||
|
||||
## Powered by LNbits
|
||||
|
||||
LNbits empowers everyone with modular, open source tools for building Bitcoin-based systems — fast, free, and extendable.
|
||||
|
||||
If you like this project, [send some tip love](https://demo.lnbits.com/tipjar/DwaUiE4kBX6mUW6pj3X5Kg) or visit our [Shop](https://shop.lnbits.de)
|
||||
|
||||
[](https://shop.lnbits.com/)
|
||||
[](https://shop.lnbits.com/)
|
||||
[](https://my.lnbits.com/login)
|
||||
[](https://news.lnbits.com/)
|
||||
[](https://extensions.lnbits.com/)
|
||||
@@ -0,0 +1,102 @@
|
||||
---
|
||||
layout: default
|
||||
title: User Roles
|
||||
nav_order: 1
|
||||
---
|
||||
|
||||
<a href="https://lnbits.com" target="_blank" rel="noopener noreferrer">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://i.imgur.com/QE6SIrs.png">
|
||||
<img src="https://i.imgur.com/fyKPgVT.png" alt="LNbits" style="width:300px">
|
||||
</picture>
|
||||
</a>
|
||||
|
||||

|
||||

|
||||
[<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits)
|
||||
[<img src="https://img.shields.io/badge/supported_by-%3E__OpenSats-f97316">](https://opensats.org)
|
||||
|
||||
# LNbits Roles: A Quick Overview
|
||||
|
||||
### Understand **who can do what** in seconds: `Super User`, `Admin`, and `Regular User`.
|
||||
|
||||
**Jump to:**
|
||||
[Roles at a Glance](#roles-at-a-glance) •
|
||||
[Super User](#super-user--master-control) •
|
||||
[Admin](#admin--day-to-day-manager) •
|
||||
[Regular User](#regular-user--everyday-use) •
|
||||
[Best Practices](#best-practices) •
|
||||
[Additional Guides](#additional-guides)
|
||||
|
||||
---
|
||||
|
||||
## Roles at a Glance
|
||||
|
||||
| Capability | **Super User** (owner) | **Admin** (manager) | **Regular User** (end user) |
|
||||
| -------------------------------- | :--------------------: | :-----------------: | :-------------------------: |
|
||||
| Change **funding source** | ✅ | ❌ | ❌ |
|
||||
| Credit/Debit any wallet | ✅ | ❌ | ❌ |
|
||||
| Manage Admins & Users | ✅ | ✅ | ❌ |
|
||||
| Enable/disable extensions | ✅ | ✅ | ❌ |
|
||||
| Use wallets & allowed extensions | ✅ | ✅ | ✅ |
|
||||
|
||||
> **Plain talk:** **Super User** = Owner • **Admin** = Trusted manager • **Regular User** = End user
|
||||
|
||||
## Role Snapshots
|
||||
|
||||
### Super User — Master Control
|
||||
|
||||
For initial setup and rare, high-impact changes.
|
||||
|
||||
- Configure **server-level settings** (e.g., funding source).
|
||||
- **Credit/Debit** any wallet.
|
||||
- Create and manage initial **Admin(s)**.
|
||||
|
||||
> **Sign-in:** username + password (v1+). The old query-string login is retired.
|
||||
|
||||
### Admin — Day-to-Day Manager
|
||||
|
||||
For running the service without touching the most sensitive knobs.
|
||||
|
||||
- Manage **Users**, **Admins**, and **Extensions** in the Admin UI.
|
||||
- Adjust security-related settings (e.g., `rate_limiter`, `ip_blocker`).
|
||||
- Handle operations settings (e.g., `service_fee`, `invoice_expiry`).
|
||||
- Build brand design in **Site Customization**.
|
||||
- Update user accounts.
|
||||
|
||||
**Typical tasks:** onboarding users, enabling extensions, tidying wallets, reviewing activity.
|
||||
|
||||
> **Sign-in:** username + password (v1+). The old query-string login is retired.
|
||||
|
||||
### Regular User — Everyday Use
|
||||
|
||||
For using LNbits, not administering it.
|
||||
|
||||
- Access **personal wallets** and **allowed extensions**.
|
||||
- No server/admin privileges.
|
||||
|
||||
**Typical tasks:** receive and send payments, use enabled extensions.
|
||||
|
||||
## Best Practices
|
||||
|
||||
- **Minimize risk:** Reserve **Super User** for rare, sensitive actions (funding source, debit/credit). Use **Admin** for daily operations.
|
||||
- **Keep access tidy:** Review your Admin list occasionally; remove unused accounts.
|
||||
- **Change management:** Test risky changes (like funding) in a staging setup first.
|
||||
|
||||
## Additional Guides
|
||||
|
||||
- **[Admin UI](./admin_ui.md)** — Manage server settings via a clean UI (avoid editing `.env` by hand).
|
||||
- **[Super User](./super_user.md)** — Deep dive on responsibilities and safe usage patterns.
|
||||
- **[Funding sources](./funding-sources-table.md)** — What’s available and how to enable/configure each.
|
||||
|
||||
## Powered by LNbits
|
||||
|
||||
LNbits empowers everyone with modular, open-source tools for building Bitcoin-based systems—fast, free, and extendable.
|
||||
|
||||
If you like this project, [send some tip love](https://demo.lnbits.com/tipjar/DwaUiE4kBX6mUW6pj3X5Kg) or visit our [Shop](https://shop.lnbits.de)
|
||||
|
||||
[](https://shop.lnbits.com/)
|
||||
[](https://shop.lnbits.com/)
|
||||
[](https://my.lnbits.com/login)
|
||||
[](https://news.lnbits.com/)
|
||||
[](https://extensions.lnbits.com/)
|
||||
+313
-92
@@ -4,146 +4,367 @@ title: Backend wallets
|
||||
nav_order: 3
|
||||
---
|
||||
|
||||
<a href="https://lnbits.com" target="_blank" rel="noopener noreferrer">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://i.imgur.com/QE6SIrs.png">
|
||||
<img src="https://i.imgur.com/fyKPgVT.png" alt="LNbits" style="width:300px">
|
||||
</picture>
|
||||
</a>
|
||||
|
||||

|
||||

|
||||
[<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits)
|
||||
[<img src="https://img.shields.io/badge/supported_by-%3E__OpenSats-f97316">](https://opensats.org)
|
||||
|
||||
# Backend wallets
|
||||
|
||||
LNbits can run on top of many Lightning Network funding sources with more being added regularly.
|
||||
**LNbits is modular**: You can switch the funding source (backend wallet) **without changing anything else** in your setup. Keep your extensions, apps, users, and config as-is — just point LNbits to a different backend via environment variables.
|
||||
|
||||
A backend wallet can be configured using the following LNbits environment variables:
|
||||
**What stays the same when you switch backends**
|
||||
|
||||
You can [compare the LNbits compatible Lightning Network funding sources here](wallets.md).
|
||||
- Your LNbits setup and extensions
|
||||
- Your API keys and endpoints
|
||||
- Your server and deployment setup
|
||||
|
||||
### CoreLightning
|
||||
A backend wallet is selected and configured entirely through LNbits environment variables. See the options and variables below, and compare them here: [Funding-Source-Table.md](funding-sources-table.md)
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: **CoreLightningWallet**
|
||||
- `CORELIGHTNING_RPC`: /file/path/lightning-rpc
|
||||
> [!NOTE]
|
||||
> **Terminology:** “Backend Wallet” and “Funding Source” mean the same thing — the wallet or service that funds your LNbits.
|
||||
|
||||
### CoreLightning REST
|
||||
## Funding Sources
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: **CoreLightningRestWallet**
|
||||
- `CORELIGHTNING_REST_URL`: http://127.0.0.1:8185/
|
||||
- `CORELIGHTNING_REST_MACAROON`: /file/path/admin.macaroon or Base64/Hex
|
||||
- `CORELIGHTNING_REST_CERT`: /home/lightning/clnrest/tls.cert
|
||||
## Funding Sources
|
||||
|
||||
### Spark (Core Lightning)
|
||||
| | | |
|
||||
| ----------------------------------------------- | ------------------------------------- | ------------------------------------------------- |
|
||||
| [CLNRest (runes)](#clnrest-runes) | [LND (REST)](#lnd-rest) | [OpenNode](#opennode) |
|
||||
| [CoreLightning](#corelightning) | [LND (gRPC)](#lnd-grpc) | [Blink](#blink) |
|
||||
| [CoreLightning REST](#corelightning-rest) | [LNbits](#lnbits) | [Alby](#alby) |
|
||||
| [Spark (Core Lightning)](#spark-core-lightning) | [LNPay](#lnpay) | [Boltz](#boltz) |
|
||||
| [Cliche Wallet](#cliche-wallet) | [ZBD](#zbd) | [Phoenixd](#phoenixd) |
|
||||
| [Breez SDK](#breez-sdk) | [Breez Liquid SDK](#breez-liquid-sdk) | [Nostr Wallet Connect](#nostr-wallet-connect-nwc) |
|
||||
| [Strike](#strike) | [Eclair (ACINQ)](#eclair-acinq) | [LN.tips](#lntips) |
|
||||
| [Fake Wallet](#fake-wallet) | | |
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: **SparkWallet**
|
||||
- `SPARK_URL`: http://10.147.17.230:9737/rpc
|
||||
- `SPARK_TOKEN`: secret_access_key
|
||||
---
|
||||
|
||||
### LND (REST)
|
||||
<a id="clnrest-runes"></a>
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: **LndRestWallet**
|
||||
- `LND_REST_ENDPOINT`: http://10.147.17.230:8080/
|
||||
- `LND_REST_CERT`: /file/path/tls.cert
|
||||
- `LND_REST_MACAROON`: /file/path/admin.macaroon or Base64/Hex
|
||||
### CLNRest (using [runes](https://docs.corelightning.org/reference/lightning-createrune))
|
||||
|
||||
or
|
||||
[Core Lightning REST API docs](https://docs.corelightning.org/docs/rest)
|
||||
Should also work with the [Rust version of CLNRest](https://github.com/daywalker90/clnrest-rs)
|
||||
|
||||
- `LND_REST_MACAROON_ENCRYPTED`: eNcRyPtEdMaCaRoOn
|
||||
**Environment variables**
|
||||
|
||||
### LND (gRPC)
|
||||
- `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 file)
|
||||
- `CLNREST_CERT`: `/home/lightningd/.lightning/bitcoin/server.pem` (or the content of the file)
|
||||
- `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)
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: **LndWallet**
|
||||
- `LND_GRPC_ENDPOINT`: ip_address
|
||||
- `LND_GRPC_PORT`: port
|
||||
- `LND_GRPC_CERT`: /file/path/tls.cert
|
||||
- `LND_GRPC_MACAROON`: /file/path/admin.macaroon or Base64/Hex
|
||||
**Create runes (copy/paste)**
|
||||
|
||||
You can also use an AES-encrypted macaroon (more info) instead by using
|
||||
```bash
|
||||
# Read-only: funds, pays, invoices, info, summary, and invoice listener
|
||||
lightning-cli createrune \
|
||||
restrictions='[["method=listfunds","method=listpays","method=listinvoices","method=getinfo","method=summary","method=waitanyinvoice"]]' \
|
||||
| jq -r .rune
|
||||
```
|
||||
|
||||
- `LND_GRPC_MACAROON_ENCRYPTED`: eNcRyPtEdMaCaRoOn
|
||||
```bash
|
||||
# Invoice: max 1,000,001 msat, label must start with "LNbits", 60 req/min
|
||||
lightning-cli createrune \
|
||||
restrictions='[["method=invoice"], ["pnameamount_msat<1000001"], ["pnamelabel^LNbits"], ["rate=60"]]' \
|
||||
| jq -r .rune
|
||||
```
|
||||
|
||||
To encrypt your macaroon, run `poetry run lnbits-cli encrypt macaroon`.
|
||||
```bash
|
||||
# Pay: bolt11 amount < 1001 (msat), label must start with "LNbits", 1 req/min
|
||||
lightning-cli createrune \
|
||||
restrictions='[["method=pay"], ["pinvbolt11_amount<1001"], ["pnamelabel^LNbits"], ["rate=1"]]' \
|
||||
| jq -r .rune
|
||||
```
|
||||
|
||||
### LNbits
|
||||
```bash
|
||||
# Renepay: invstring amount < 1001 (msat), label must start with "LNbits", 1 req/min
|
||||
lightning-cli createrune \
|
||||
restrictions='[["method=renepay"], ["pinvinvstring_amount<1001"], ["pnamelabel^LNbits"], ["rate=1"]]' \
|
||||
| jq -r .rune
|
||||
```
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: **LNbitsWallet**
|
||||
- `LNBITS_ENDPOINT`: e.g. https://lnbits.com
|
||||
- `LNBITS_KEY`: lnbitsAdminKey
|
||||
Set the resulting values into:
|
||||
|
||||
### LNPay
|
||||
- `CLNREST_READONLY_RUNE`
|
||||
- `CLNREST_INVOICE_RUNE`
|
||||
- `CLNREST_PAY_RUNE`
|
||||
- `CLNREST_RENEPAY_RUNE`
|
||||
|
||||
For the invoice listener to work you have a publicly accessible URL in your LNbits and must set up [LNPay webhooks](https://dashboard.lnpay.co/webhook/) pointing to `<your LNbits host>/wallet/webhook` with the "Wallet Receive" event and no secret. For example, `https://mylnbits/wallet/webhook` will be the Endpoint Url that gets notified about the payment.
|
||||
## CoreLightning
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: **LNPayWallet**
|
||||
- `LNPAY_API_ENDPOINT`: https://api.lnpay.co/v1/
|
||||
- `LNPAY_API_KEY`: sak_apiKey
|
||||
- `LNPAY_WALLET_KEY`: waka_apiKey
|
||||
**Required env vars**
|
||||
|
||||
### OpenNode
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: `CoreLightningWallet`
|
||||
- `CORELIGHTNING_RPC`: `/file/path/lightning-rpc`
|
||||
|
||||
For the invoice to work you must have a publicly accessible URL in your LNbits. No manual webhook setting is necessary.
|
||||
## CoreLightning REST
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: **OpenNodeWallet**
|
||||
- `OPENNODE_API_ENDPOINT`: https://api.opennode.com/
|
||||
- `OPENNODE_KEY`: opennodeAdminApiKey
|
||||
Old REST interface using [RTL c-lightning-REST](https://github.com/Ride-The-Lightning/c-lightning-REST)
|
||||
|
||||
### Blink
|
||||
**Required env vars**
|
||||
|
||||
For the invoice to work you must have a publicly accessible URL in your LNbits. No manual webhook setting is necessary. You can generate a Blink API key after logging in or creating a new Blink account at: https://dashboard.blink.sv. For more info visit: https://dev.blink.sv/api/auth#create-an-api-key```
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: `CoreLightningRestWallet`
|
||||
- `CORELIGHTNING_REST_URL`: `http://127.0.0.1:8185/`
|
||||
- `CORELIGHTNING_REST_MACAROON`: `/file/path/admin.macaroon` or Base64/Hex
|
||||
- `CORELIGHTNING_REST_CERT`: `/home/lightning/clnrest/tls.cert`
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: **BlinkWallet**
|
||||
- `BLINK_API_ENDPOINT`: https://api.blink.sv/graphql
|
||||
- `BLINK_WS_ENDPOINT`: wss://ws.blink.sv/graphql
|
||||
- `BLINK_TOKEN`: BlinkToken
|
||||
## Spark (Core Lightning)
|
||||
|
||||
### Alby
|
||||
**Required env vars**
|
||||
|
||||
For the invoice to work you must have a publicly accessible URL in your LNbits. No manual webhook setting is necessary. You can generate an alby access token here: https://getalby.com/developer/access_tokens/new
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: `SparkWallet`
|
||||
- `SPARK_URL`: `http://10.147.17.230:9737/rpc`
|
||||
- `SPARK_TOKEN`: `secret_access_key`
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: **AlbyWallet**
|
||||
- `ALBY_API_ENDPOINT`: https://api.getalby.com/
|
||||
- `ALBY_ACCESS_TOKEN`: AlbyAccessToken
|
||||
## LND (REST)
|
||||
|
||||
### Boltz
|
||||
**Required env vars**
|
||||
|
||||
This funding source connects to a running [boltz-client](https://docs.boltz.exchange/v/boltz-client) and handles all lightning payments through submarine swaps on the liquid network.
|
||||
You can configure the daemon to run in standalone mode by `standalone = True` in the config file or using the cli flag (`boltzd --standalone`).
|
||||
Once running, you can create a liquid wallet using `boltzcli wallet create lnbits lbtc`.
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: `LndRestWallet`
|
||||
- `LND_REST_ENDPOINT`: `http://10.147.17.230:8080/`
|
||||
- `LND_REST_CERT`: `/file/path/tls.cert`
|
||||
- `LND_REST_MACAROON`: `/file/path/admin.macaroon` or Base64/Hex
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: **BoltzWallet**
|
||||
- `BOLTZ_CLIENT_ENDPOINT`: 127.0.0.1:9002
|
||||
- `BOLTZ_CLIENT_MACAROON`: /home/bob/.boltz/macaroons/admin.macaroon or Base64/Hex
|
||||
- `BOLTZ_CLIENT_CERT`: /home/bob/.boltz/tls.cert or Base64/Hex
|
||||
- `BOLTZ_CLIENT_WALLET`: lnbits
|
||||
or:
|
||||
|
||||
### ZBD
|
||||
- `LND_REST_MACAROON_ENCRYPTED`: `eNcRyPtEdMaCaRoOn`
|
||||
|
||||
For the invoice to work you must have a publicly accessible URL in your LNbits. No manual webhook setting is necessary. You can generate an ZBD API Key here: https://zbd.dev/docs/dashboard/projects/api
|
||||
## LND (gRPC)
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: **ZBDWallet**
|
||||
- `ZBD_API_ENDPOINT`: https://api.zebedee.io/v0/
|
||||
- `ZBD_API_KEY`: ZBDApiKey
|
||||
**Required env vars**
|
||||
|
||||
### Phoenixd
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: `LndWallet`
|
||||
- `LND_GRPC_ENDPOINT`: `ip_address`
|
||||
- `LND_GRPC_PORT`: `port`
|
||||
- `LND_GRPC_CERT`: `/file/path/tls.cert`
|
||||
- `LND_GRPC_MACAROON`: `/file/path/admin.macaroon` or Base64/Hex
|
||||
|
||||
For the invoice to work you must have a publicly accessible URL in your LNbits. You can get a phoenixd API key from the install
|
||||
~/.phoenix/phoenix.conf, also see the documentation for phoenixd.
|
||||
You can also use an AES-encrypted macaroon instead:
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: **PhoenixdWallet**
|
||||
- `PHOENIXD_API_ENDPOINT`: http://localhost:9740/
|
||||
- `PHOENIXD_API_PASSWORD`: PhoenixdApiPassword
|
||||
- `LND_GRPC_MACAROON_ENCRYPTED`: `eNcRyPtEdMaCaRoOn`
|
||||
|
||||
### Breez SDK
|
||||
To encrypt your macaroon:
|
||||
|
||||
A Greenlight invite code or Greenlight partner certificate/key can be used to register a new node with Greenlight. If the Greenlight node already exists, neither are required.
|
||||
```bash
|
||||
uv run lnbits-cli encrypt macaroon
|
||||
```
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: **BreezSdkWallet**
|
||||
- `BREEZ_API_KEY`: ...
|
||||
- `BREEZ_GREENLIGHT_SEED`: ...
|
||||
- `BREEZ_GREENLIGHT_INVITE_CODE`: ...
|
||||
- `BREEZ_GREENLIGHT_DEVICE_KEY`: /path/to/breezsdk/device.pem or Base64/Hex
|
||||
- `BREEZ_GREENLIGHT_DEVICE_CERT`: /path/to/breezsdk/device.crt or Base64/Hex
|
||||
## LNbits
|
||||
|
||||
### Cliche Wallet
|
||||
**Required env vars**
|
||||
|
||||
- `CLICHE_ENDPOINT`: ws://127.0.0.1:12000
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: `LNbitsWallet`
|
||||
- `LNBITS_ENDPOINT`: for example `https://lnbits.com`
|
||||
- `LNBITS_KEY`: `lnbitsAdminKey`
|
||||
|
||||
### Nostr Wallet Connect (NWC)
|
||||
## LNPay
|
||||
|
||||
To use NWC as funding source in LNbits you'll need a pairing URL (also known as pairing secret) from a NWC service provider. You can find a list of providers [here](https://github.com/getAlby/awesome-nwc?tab=readme-ov-file#nwc-wallets).
|
||||
For the invoice listener to work you must have a publicly accessible URL in your LNbits and set up [LNPay webhooks](https://dashboard.lnpay.co/webhook/) pointing to `<your LNbits host>/wallet/webhook` with the event **Wallet Receive** and no secret. Example: [https://mylnbits/wallet/webhook](`https://mylnbits/wallet/webhook).
|
||||
|
||||
You can configure Nostr Wallet Connect in the admin ui or using the following environment variables:
|
||||
**Required env vars**
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: **NWCWallet**
|
||||
- `NWC_PAIRING_URL`: **nostr+walletconnect://...your...pairing...secret...**
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: `LNPayWallet`
|
||||
- `LNPAY_API_ENDPOINT`: `https://api.lnpay.co/v1/`
|
||||
- `LNPAY_API_KEY`: `sak_apiKey`
|
||||
- `LNPAY_WALLET_KEY`: `waka_apiKey`
|
||||
|
||||
## OpenNode
|
||||
|
||||
For the invoice to work you must have a publicly accessible URL in your LNbits. No manual webhook configuration required.
|
||||
|
||||
**Required env vars**
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: `OpenNodeWallet`
|
||||
- `OPENNODE_API_ENDPOINT`: `https://api.opennode.com/`
|
||||
- `OPENNODE_KEY`: `opennodeAdminApiKey`
|
||||
|
||||
## Blink
|
||||
|
||||
For the invoice to work you must have a publicly accessible URL in your LNbits. No manual webhook configuration required.
|
||||
|
||||
You can generate a Blink API key at [https://dashboard.blink.sv](https://dashboard.blink.sv). More info: [https://dev.blink.sv/api/auth#create-an-api-key](https://dev.blink.sv/api/auth#create-an-api-key)
|
||||
|
||||
**Required env vars**
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: `BlinkWallet`
|
||||
- `BLINK_API_ENDPOINT`: `https://api.blink.sv/graphql`
|
||||
- `BLINK_WS_ENDPOINT`: `wss://ws.blink.sv/graphql`
|
||||
- `BLINK_TOKEN`: `BlinkToken`
|
||||
|
||||
## Alby
|
||||
|
||||
For the invoice to work you must have a publicly accessible URL in your LNbits. No manual webhook configuration required.
|
||||
|
||||
Generate an Alby access token here: [https://getalby.com/developer/access_tokens/new](https://getalby.com/developer/access_tokens/new)
|
||||
|
||||
**Required env vars**
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: `AlbyWallet`
|
||||
- `ALBY_API_ENDPOINT`: `https://api.getalby.com/`
|
||||
- `ALBY_ACCESS_TOKEN`: `AlbyAccessToken`
|
||||
|
||||
## Boltz
|
||||
|
||||
This connects to a running [boltz-client](https://docs.boltz.exchange/v/boltz-client) and handles Lightning payments through submarine swaps on the Liquid network.
|
||||
|
||||
You can run the daemon in standalone mode via `standalone = True` in the config or `boltzd --standalone`. Create a Liquid wallet with:
|
||||
|
||||
```bash
|
||||
boltzcli wallet create lnbits lbtc
|
||||
```
|
||||
|
||||
**Required env vars**
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: `BoltzWallet`
|
||||
- `BOLTZ_CLIENT_ENDPOINT`: `127.0.0.1:9002`
|
||||
- `BOLTZ_CLIENT_MACAROON`: `/home/bob/.boltz/macaroons/admin.macaroon` or Base64/Hex
|
||||
- `BOLTZ_CLIENT_CERT`: `/home/bob/.boltz/tls.cert` or Base64/Hex
|
||||
- `BOLTZ_CLIENT_WALLET`: `lnbits`
|
||||
|
||||
## ZBD
|
||||
|
||||
For the invoice to work you must have a publicly accessible URL in your LNbits. No manual webhook configuration required.
|
||||
|
||||
Generate a ZBD API key here: [https://zbd.dev/docs/dashboard/projects/api](https://zbd.dev/docs/dashboard/projects/api)
|
||||
|
||||
**Required env vars**
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: `ZBDWallet`
|
||||
- `ZBD_API_ENDPOINT`: `https://api.zebedee.io/v0/`
|
||||
- `ZBD_API_KEY`: `ZBDApiKey`
|
||||
|
||||
## Phoenixd
|
||||
|
||||
For the invoice to work you must have a publicly accessible URL in your LNbits.
|
||||
|
||||
You can get a phoenixd API key from `~/.phoenix/phoenix.conf`. See the phoenixd documentation for details.
|
||||
|
||||
**Required env vars**
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: `PhoenixdWallet`
|
||||
- `PHOENIXD_API_ENDPOINT`: `http://localhost:9740/`
|
||||
- `PHOENIXD_API_PASSWORD`: `PhoenixdApiPassword`
|
||||
|
||||
## Eclair (ACINQ)
|
||||
|
||||
<a id="eclair-acinq"></a>
|
||||
|
||||
Connect to an existing Eclair node so your backend handles invoices and payments.
|
||||
|
||||
**Required env vars**
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: `EclairWallet`
|
||||
- `ECLAIR_URL`: `http://127.0.0.1:8283`
|
||||
- `ECLAIR_PASSWORD`: `eclairpw`
|
||||
|
||||
## Fake Wallet
|
||||
|
||||
<a id="fake-wallet"></a>
|
||||
|
||||
A testing-only backend that mints accounting units inside LNbits accounting (no real sats).
|
||||
|
||||
**Required env vars**
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: `FakeWallet`
|
||||
- `FAKE_SECRET`: `ToTheMoon1`
|
||||
- `FAKE_UNIT`: `sats`
|
||||
|
||||
## LN.tips
|
||||
|
||||
<a id="lntips"></a>
|
||||
|
||||
As the initinal LN.tips bot is no longer active the code still exists and is widly used. Connect one of custodial services as your backend to create and pay Lightning invoices through their API or selfhost this service and run it as funding source.
|
||||
Resources: https://github.com/massmux/SatsMobiBot
|
||||
|
||||
**Required env vars**
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: `LNTipsWallet`
|
||||
- `LNTIPS_API_ENDPOINT`: `https://ln.tips`
|
||||
- `LNTIPS_API_KEY`: `LNTIPS_ADMIN_KEY`
|
||||
|
||||
## Breez SDK
|
||||
|
||||
A Greenlight invite code or Greenlight partner certificate/key can register a new node with Greenlight. If the Greenlight node already exists, neither is required.
|
||||
|
||||
**Required env vars**
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: `BreezSdkWallet`
|
||||
- `BREEZ_API_KEY`: `...`
|
||||
- `BREEZ_GREENLIGHT_SEED`: `...`
|
||||
- `BREEZ_GREENLIGHT_INVITE_CODE`: `...`
|
||||
- `BREEZ_GREENLIGHT_DEVICE_KEY`: `/path/to/breezsdk/device.pem` or Base64/Hex
|
||||
- `BREEZ_GREENLIGHT_DEVICE_CERT`: `/path/to/breezsdk/device.crt` or Base64/Hex
|
||||
|
||||
## Breez Liquid SDK
|
||||
|
||||
This uses the [Breez SDK - Liquid](https://sdk-doc-liquid.breez.technology/) to manage Lightning payments via submarine swaps on the Liquid network. Provide a mnemonic seed phrase (for example, generate one with a Liquid wallet like [Blockstream Green](https://blockstream.com/green/)) and set it in the environment or admin UI.
|
||||
|
||||
**Required env vars**
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: `BreezLiquidSdkWallet`
|
||||
- `BREEZ_LIQUID_SEED`: `...`
|
||||
|
||||
Fees apply for each submarine swap. You may need to increase the reserve fee under **Settings → Funding** or via:
|
||||
|
||||
- `LNBITS_RESERVE_FEE_MIN`: `...`
|
||||
- `LNBITS_RESERVE_FEE_PERCENT`: `...`
|
||||
|
||||
## Cliche Wallet
|
||||
|
||||
**Required env vars**
|
||||
|
||||
- `CLICHE_ENDPOINT`: `ws://127.0.0.1:12000`
|
||||
|
||||
## Nostr Wallet Connect (NWC)
|
||||
|
||||
To use NWC as a funding source you need a pairing URL (pairing secret) from an NWC provider. See providers here:
|
||||
[https://github.com/getAlby/awesome-nwc?tab=readme-ov-file#nwc-wallets](https://github.com/getAlby/awesome-nwc?tab=readme-ov-file#nwc-wallets)
|
||||
|
||||
Configure in the admin UI or via env vars:
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: `NWCWallet`
|
||||
- `NWC_PAIRING_URL`: `nostr+walletconnect://...your...pairing...secret...`
|
||||
|
||||
<a id="strike"></a>
|
||||
|
||||
## Strike (alpha)
|
||||
|
||||
Custodial provider integrated via **Strike OAuth Connect** (OAuth 2.0 / OIDC). Authenticate a Strike user in your app, then call Strike APIs on the user’s behalf once scopes are granted. Requires a Strike business account, registered OAuth client, minimal scopes, and login/logout redirect URLs.
|
||||
|
||||
Get more info here [https://docs.strike.me/strike-oauth-connect/](https://docs.strike.me/strike-oauth-connect/)
|
||||
|
||||
**Integration endpoints**
|
||||
|
||||
- `STRIKE_API_ENDPOINT`: `https://api.strike.me/v1`
|
||||
- `STRIKE_API_KEY`: `YOUR_STRIKE_API_KEY`
|
||||
|
||||
---
|
||||
|
||||
## Additional Guides
|
||||
|
||||
- **[Admin UI](./admin_ui.md)** — Manage server settings via a clean UI (avoid editing `.env` by hand).
|
||||
- **[User Roles](./user_roles.md)** — Quick Overview of existing Roles in LNBits.
|
||||
- **[Funding sources](./funding-sources-table.md)** — What’s available and how to enable/configure each.
|
||||
|
||||
## Powered by LNbits
|
||||
|
||||
LNbits empowers everyone with modular, open-source tools for building Bitcoin-based systems — fast, free, and extendable.
|
||||
|
||||
If you like this project, [send some tip love](https://demo.lnbits.com/tipjar/DwaUiE4kBX6mUW6pj3X5Kg) or visit our [Shop](https://shop.lnbits.de)
|
||||
|
||||
[](https://shop.lnbits.com/)
|
||||
[](https://shop.lnbits.com/)
|
||||
[](https://my.lnbits.com/login)
|
||||
[](https://news.lnbits.com/)
|
||||
[](https://extensions.lnbits.com/)
|
||||
|
||||
Generated
+82
-67
@@ -1,15 +1,39 @@
|
||||
{
|
||||
"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": {
|
||||
"inputs": {
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1710146030,
|
||||
"narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=",
|
||||
"lastModified": 1731533236,
|
||||
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a",
|
||||
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -18,71 +42,49 @@
|
||||
"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": {
|
||||
"locked": {
|
||||
"lastModified": 1735563628,
|
||||
"narHash": "sha256-OnSAY7XDSx7CtDoqNh8jwVwh4xNL/2HaJxGjryLWzX8=",
|
||||
"owner": "nixos",
|
||||
"lastModified": 1751274312,
|
||||
"narHash": "sha256-/bVBlRpECLVzjV19t5KMdMFWSwKLtb5RyXdjz3LJT+g=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "b134951a4c9f3c995fd7be05f3243f8ecd65d798",
|
||||
"rev": "50ab793786d9de88ee30ec4e4c24fb4236fc2674",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nixos",
|
||||
"ref": "nixos-24.05",
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-24.11",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"poetry2nix": {
|
||||
"pyproject-nix": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils",
|
||||
"nix-github-actions": "nix-github-actions",
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
],
|
||||
"systems": "systems_2",
|
||||
"treefmt-nix": "treefmt-nix"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1724134185,
|
||||
"narHash": "sha256-nDqpGjz7cq3ThdC98BPe1ANCNlsJds/LLZ3/MdIXjA0=",
|
||||
"owner": "nix-community",
|
||||
"repo": "poetry2nix",
|
||||
"rev": "5ee730a8752264e463c0eaf06cc060fd07f6dae9",
|
||||
"lastModified": 1754923840,
|
||||
"narHash": "sha256-QSKpYg+Ts9HYF155ltlj40iBex39c05cpOF8gjoE2EM=",
|
||||
"owner": "pyproject-nix",
|
||||
"repo": "pyproject.nix",
|
||||
"rev": "023cd4be230eacae52635be09eef100c37ef78da",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-community",
|
||||
"repo": "poetry2nix",
|
||||
"owner": "pyproject-nix",
|
||||
"repo": "pyproject.nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"build-system-pkgs": "build-system-pkgs",
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": "nixpkgs",
|
||||
"poetry2nix": "poetry2nix"
|
||||
"pyproject-nix": "pyproject-nix",
|
||||
"uv2nix": "uv2nix_2"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
@@ -100,38 +102,51 @@
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"systems_2": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"id": "systems",
|
||||
"type": "indirect"
|
||||
}
|
||||
},
|
||||
"treefmt-nix": {
|
||||
"uv2nix": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"poetry2nix",
|
||||
"build-system-pkgs",
|
||||
"nixpkgs"
|
||||
],
|
||||
"pyproject-nix": [
|
||||
"build-system-pkgs",
|
||||
"pyproject-nix"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1719749022,
|
||||
"narHash": "sha256-ddPKHcqaKCIFSFc/cvxS14goUhCOAwsM1PbMr0ZtHMg=",
|
||||
"owner": "numtide",
|
||||
"repo": "treefmt-nix",
|
||||
"rev": "8df5ff62195d4e67e2264df0b7f5e8c9995fd0bd",
|
||||
"lastModified": 1755210905,
|
||||
"narHash": "sha256-WnoFEk79ysjL85TNP7bvImzhxvQw9B6uNtnLd4oJntw=",
|
||||
"owner": "pyproject-nix",
|
||||
"repo": "uv2nix",
|
||||
"rev": "87bcba013ef304bbfd67c8e8a257aee634ed5a4c",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "treefmt-nix",
|
||||
"owner": "pyproject-nix",
|
||||
"repo": "uv2nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"uv2nix_2": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
],
|
||||
"pyproject-nix": [
|
||||
"pyproject-nix"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1755485731,
|
||||
"narHash": "sha256-k8kxwVs8Oze6q/jAaRa3RvZbb50I/K0b5uptlsh0HXI=",
|
||||
"owner": "pyproject-nix",
|
||||
"repo": "uv2nix",
|
||||
"rev": "bebbd80bf56110fcd20b425589814af28f1939eb",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "pyproject-nix",
|
||||
"repo": "uv2nix",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,70 +1,151 @@
|
||||
{
|
||||
description = "LNbits, free and open-source Lightning wallet and accounts system";
|
||||
description = "LNbits, free and open-source Lightning wallet and accounts system (uv2nix)";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:nixos/nixpkgs/nixos-24.05";
|
||||
poetry2nix = {
|
||||
url = "github:nix-community/poetry2nix";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11";
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
|
||||
pyproject-nix.url = "github:pyproject-nix/pyproject.nix";
|
||||
uv2nix.url = "github:pyproject-nix/uv2nix";
|
||||
build-system-pkgs.url = "github:pyproject-nix/build-system-pkgs";
|
||||
|
||||
pyproject-nix.inputs.nixpkgs.follows = "nixpkgs";
|
||||
uv2nix.inputs.nixpkgs.follows = "nixpkgs";
|
||||
build-system-pkgs.inputs.nixpkgs.follows = "nixpkgs";
|
||||
uv2nix.inputs.pyproject-nix.follows = "pyproject-nix";
|
||||
build-system-pkgs.inputs.pyproject-nix.follows = "pyproject-nix";
|
||||
};
|
||||
outputs = { self, nixpkgs, poetry2nix }@inputs:
|
||||
let
|
||||
supportedSystems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ];
|
||||
forSystems = systems: f:
|
||||
nixpkgs.lib.genAttrs systems
|
||||
(system: f system (import nixpkgs { inherit system; overlays = [ poetry2nix.overlays.default self.overlays.default ]; }));
|
||||
forAllSystems = forSystems supportedSystems;
|
||||
projectName = "lnbits";
|
||||
in
|
||||
{
|
||||
overlays = {
|
||||
default = final: prev: {
|
||||
${projectName} = self.packages.${prev.stdenv.hostPlatform.system}.${projectName};
|
||||
};
|
||||
};
|
||||
packages = forAllSystems (system: pkgs: {
|
||||
default = self.packages.${system}.${projectName};
|
||||
${projectName} = pkgs.poetry2nix.mkPoetryApplication {
|
||||
projectDir = ./.;
|
||||
meta.rev = self.dirtyRev or self.rev;
|
||||
meta.mainProgram = projectName;
|
||||
overrides = pkgs.poetry2nix.overrides.withDefaults (final: prev: {
|
||||
coincurve = prev.coincurve.override { preferWheel = true; };
|
||||
protobuf = prev.protobuf.override { preferWheel = true; };
|
||||
ruff = prev.ruff.override { preferWheel = true; };
|
||||
wallycore = prev.wallycore.override { preferWheel = true; };
|
||||
tlv8 = prev.tlv8.overrideAttrs (old: {
|
||||
nativeBuildInputs = (old.nativeBuildInputs or [ ]) ++ [
|
||||
prev.setuptools
|
||||
];
|
||||
|
||||
outputs = { self, nixpkgs, flake-utils, uv2nix, pyproject-nix, build-system-pkgs, ... }:
|
||||
flake-utils.lib.eachSystem [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ]
|
||||
(system:
|
||||
let
|
||||
pkgs = import nixpkgs { inherit system; };
|
||||
lib = pkgs.lib;
|
||||
|
||||
python = pkgs.python312;
|
||||
|
||||
# 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 ];
|
||||
});
|
||||
pynostr = prev.pynostr.overrideAttrs (old: {
|
||||
nativeBuildInputs = (old.nativeBuildInputs or [ ]) ++ [
|
||||
prev.setuptools-scm
|
||||
|
||||
# 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 ];
|
||||
});
|
||||
});
|
||||
};
|
||||
});
|
||||
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;
|
||||
|
||||
# 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";
|
||||
|
||||
# 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
|
||||
pkgs.lib.optionalAttrs pkgs.stdenv.isLinux vmTests # vmTests can only be ran on Linux, so append them only if on Linux.
|
||||
//
|
||||
{
|
||||
# Other checks here...
|
||||
}
|
||||
);
|
||||
};
|
||||
# nix build → produces the venv in ./result
|
||||
packages.default = runtimeVenv;
|
||||
packages.${projectName} = runtimeVenv;
|
||||
|
||||
# nix run . → launches via wrapper that imports from source tree
|
||||
apps.default = { type = "app"; program = "${lnbitsApp}/bin/lnbits"; };
|
||||
apps.${projectName} = self.apps.${system}.default;
|
||||
apps."${projectName}-cli" = { type = "app"; program = "${lnbitsCliApp}/bin/lnbits-cli"; };
|
||||
|
||||
# dev shell with locked deps + tools
|
||||
devShells.default = pkgs.mkShell {
|
||||
packages = [
|
||||
runtimeVenv
|
||||
pkgs.uv
|
||||
pkgs.ruff
|
||||
pkgs.black
|
||||
pkgs.mypy
|
||||
pkgs.pre-commit
|
||||
pkgs.openapi-generator-cli
|
||||
];
|
||||
};
|
||||
|
||||
overlays.default = final: prev: {
|
||||
${projectName} = self.packages.${final.stdenv.hostPlatform.system}.${projectName};
|
||||
replaceVars = prev.replaceVars or (path: vars: prev.substituteAll ({ src = path; } // vars));
|
||||
};
|
||||
|
||||
# System-specific nixos modules to avoid circular dependency
|
||||
nixosModules.default = { pkgs, lib, config, ... }: {
|
||||
imports = [ "${./nix/modules/lnbits-service.nix}" ];
|
||||
nixpkgs.overlays = [ self.overlays.${system}.default ];
|
||||
};
|
||||
|
||||
checks =
|
||||
import ./nix/tests { inherit pkgs; flake = self; };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,54 +1,75 @@
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Check install has not already run
|
||||
if [ ! -d lnbits/data ]; then
|
||||
# --- Config you might tweak ---
|
||||
REPO_URL="https://github.com/lnbits/lnbits.git"
|
||||
BRANCH="main"
|
||||
APP_DIR="${PWD}/lnbits"
|
||||
HOST="${HOST:-0.0.0.0}"
|
||||
PORT="${PORT:-5000}"
|
||||
ADMIN_UI="${LNBITS_ADMIN_UI:-true}"
|
||||
# -------------------------------
|
||||
|
||||
# Update package list and install prerequisites non-interactively
|
||||
sudo apt update -y
|
||||
sudo apt install -y software-properties-common
|
||||
|
||||
# Add the deadsnakes PPA repository non-interactively
|
||||
sudo add-apt-repository -y ppa:deadsnakes/ppa
|
||||
|
||||
# Install Python 3.9 and distutils non-interactively
|
||||
sudo apt install -y python3.9 python3.9-distutils
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
# Install Poetry
|
||||
curl -sSL https://install.python-poetry.org | python3.9 -
|
||||
|
||||
# Add Poetry to PATH for the current session
|
||||
export PATH="/home/$USER/.local/bin:$PATH"
|
||||
|
||||
if [ ! -d lnbits/wallets ]; then
|
||||
# Clone the LNbits repository
|
||||
git clone https://github.com/lnbits/lnbits.git
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Failed to clone the repository ... FAIL"
|
||||
exit 1
|
||||
fi
|
||||
# Ensure we are in the lnbits directory
|
||||
cd lnbits || { echo "Failed to cd into lnbits ... FAIL"; exit 1; }
|
||||
fi
|
||||
|
||||
git checkout main
|
||||
# Make data folder
|
||||
mkdir data
|
||||
|
||||
# Copy the .env.example to .env
|
||||
cp .env.example .env
|
||||
|
||||
elif [ ! -d lnbits/wallets ]; then
|
||||
# cd into lnbits
|
||||
cd lnbits || { echo "Failed to cd into lnbits ... FAIL"; exit 1; }
|
||||
# Ensure basic tooling
|
||||
if ! command -v curl >/dev/null 2>&1 || ! command -v git >/dev/null 2>&1; then
|
||||
sudo apt-get update -y
|
||||
sudo apt-get install -y curl git
|
||||
fi
|
||||
|
||||
# Install the dependencies using Poetry
|
||||
poetry env use python3.9
|
||||
poetry install --only main
|
||||
# System build deps and secp headers
|
||||
if command -v apt-get >/dev/null 2>&1; then
|
||||
sudo apt-get update -y
|
||||
sudo apt-get install -y \
|
||||
pkg-config \
|
||||
build-essential \
|
||||
libsecp256k1-dev \
|
||||
automake \
|
||||
autoconf \
|
||||
libtool \
|
||||
m4
|
||||
fi
|
||||
|
||||
# Set environment variables for LNbits
|
||||
export LNBITS_ADMIN_UI=true
|
||||
export HOST=0.0.0.0
|
||||
# Install uv (if missing)
|
||||
if ! command -v uv >/dev/null 2>&1; then
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
fi
|
||||
# Ensure PATH for current session
|
||||
if [[ ":$PATH:" != *":$HOME/.local/bin:"* ]]; then
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
fi
|
||||
|
||||
# Run LNbits
|
||||
poetry run lnbits
|
||||
# Clone or reuse repo
|
||||
if [[ ! -d "$APP_DIR/.git" ]]; then
|
||||
git clone "$REPO_URL" "$APP_DIR"
|
||||
fi
|
||||
|
||||
cd "$APP_DIR"
|
||||
git fetch --all --prune
|
||||
git checkout "$BRANCH"
|
||||
git pull --ff-only || true
|
||||
|
||||
# First-run setup
|
||||
mkdir -p data
|
||||
[[ -f .env ]] || cp .env.example .env || true
|
||||
|
||||
# Prefer system libsecp256k1 (avoid autotools path)
|
||||
export SECP_BUNDLED=0
|
||||
|
||||
# Sync dependencies with Python 3.12
|
||||
uv sync --python 3.12 --all-extras --no-dev
|
||||
|
||||
# Environment
|
||||
export LNBITS_ADMIN_UI="$ADMIN_UI"
|
||||
export HOST="$HOST"
|
||||
export PORT="$PORT"
|
||||
|
||||
# Open firewall (optional)
|
||||
if command -v ufw >/dev/null 2>&1; then
|
||||
sudo ufw allow "$PORT"/tcp || true
|
||||
fi
|
||||
|
||||
# Run LNbits with Python 3.12 via uv
|
||||
exec uv run --python 3.12 lnbits
|
||||
|
||||
+5
-8
@@ -9,16 +9,13 @@ from .decorators import (
|
||||
from .exceptions import InvoiceError, PaymentError
|
||||
|
||||
__all__ = [
|
||||
# decorators
|
||||
"require_admin_key",
|
||||
"require_invoice_key",
|
||||
"InvoiceError",
|
||||
"PaymentError",
|
||||
"check_admin",
|
||||
"check_super_user",
|
||||
"check_user_exists",
|
||||
# services
|
||||
"pay_invoice",
|
||||
"create_invoice",
|
||||
# exceptions
|
||||
"PaymentError",
|
||||
"InvoiceError",
|
||||
"pay_invoice",
|
||||
"require_admin_key",
|
||||
"require_invoice_key",
|
||||
]
|
||||
|
||||
+17
-18
@@ -4,9 +4,10 @@ import importlib
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
@@ -26,7 +27,8 @@ from lnbits.core.crud.extensions import create_installed_extension
|
||||
from lnbits.core.helpers import migrate_extension_database
|
||||
from lnbits.core.models.notifications import NotificationType
|
||||
from lnbits.core.services.extensions import deactivate_extension, get_valid_extensions
|
||||
from lnbits.core.services.notifications import enqueue_notification
|
||||
from lnbits.core.services.notifications import enqueue_admin_notification
|
||||
from lnbits.core.services.payments import check_pending_payments
|
||||
from lnbits.core.tasks import (
|
||||
audit_queue,
|
||||
collect_exchange_rates_data,
|
||||
@@ -65,17 +67,13 @@ from .middleware import (
|
||||
add_ip_block_middleware,
|
||||
add_ratelimit_middleware,
|
||||
)
|
||||
from .requestvars import g
|
||||
from .tasks import (
|
||||
check_pending_payments,
|
||||
internal_invoice_listener,
|
||||
invoice_listener,
|
||||
)
|
||||
from .tasks import internal_invoice_listener, invoice_listener, run_interval
|
||||
|
||||
|
||||
async def startup(app: FastAPI):
|
||||
logger.info(f"Starting LNbits Version: {settings.version}")
|
||||
start = time.perf_counter()
|
||||
settings.lnbits_running = True
|
||||
|
||||
# wait till migration is done
|
||||
await migrate_databases()
|
||||
|
||||
@@ -106,7 +104,7 @@ async def startup(app: FastAPI):
|
||||
# initialize tasks
|
||||
register_async_tasks()
|
||||
|
||||
enqueue_notification(
|
||||
enqueue_admin_notification(
|
||||
NotificationType.server_start_stop,
|
||||
{
|
||||
"message": "LNbits server started.",
|
||||
@@ -114,10 +112,13 @@ async def startup(app: FastAPI):
|
||||
},
|
||||
)
|
||||
|
||||
end = time.perf_counter()
|
||||
logger.success(f"LNbits started in {end - start:.2f} seconds.")
|
||||
|
||||
|
||||
async def shutdown():
|
||||
logger.warning("LNbits shutting down...")
|
||||
enqueue_notification(
|
||||
enqueue_admin_notification(
|
||||
NotificationType.server_start_stop,
|
||||
{
|
||||
"message": "LNbits server shutting down...",
|
||||
@@ -171,8 +172,6 @@ def create_app() -> FastAPI:
|
||||
name="library",
|
||||
)
|
||||
|
||||
g().base_url = f"http://{settings.host}:{settings.port}"
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]
|
||||
)
|
||||
@@ -280,8 +279,8 @@ async def check_installed_extensions(app: FastAPI):
|
||||
logger.info(f"{ext.id} ({ext.installed_version})")
|
||||
|
||||
|
||||
async def build_all_installed_extensions_list(
|
||||
include_deactivated: Optional[bool] = True,
|
||||
async def build_all_installed_extensions_list( # noqa: C901
|
||||
include_deactivated: bool | None = True,
|
||||
) -> list[InstallableExtension]:
|
||||
"""
|
||||
Returns a list of all the installed extensions plus the extensions that
|
||||
@@ -454,7 +453,7 @@ def register_ext_routes(app: FastAPI, ext: Extension) -> None:
|
||||
app.include_router(router=ext_route, prefix=prefix)
|
||||
|
||||
|
||||
async def check_and_register_extensions(app: FastAPI):
|
||||
async def check_and_register_extensions(app: FastAPI) -> None:
|
||||
await check_installed_extensions(app)
|
||||
for ext in await get_valid_extensions(False):
|
||||
try:
|
||||
@@ -464,12 +463,12 @@ async def check_and_register_extensions(app: FastAPI):
|
||||
logger.error(f"Could not load extension `{ext.code}`: {exc!s}")
|
||||
|
||||
|
||||
def register_async_tasks():
|
||||
def register_async_tasks() -> None:
|
||||
|
||||
create_permanent_task(wait_for_audit_data)
|
||||
create_permanent_task(wait_notification_messages)
|
||||
|
||||
create_permanent_task(check_pending_payments)
|
||||
create_permanent_task(run_interval(30 * 60, check_pending_payments))
|
||||
create_permanent_task(invoice_listener)
|
||||
create_permanent_task(internal_invoice_listener)
|
||||
create_permanent_task(cache.invalidate_forever)
|
||||
|
||||
+39
-40
@@ -5,7 +5,6 @@ import time
|
||||
from functools import wraps
|
||||
from getpass import getpass
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from uuid import uuid4
|
||||
|
||||
import click
|
||||
@@ -96,7 +95,7 @@ def decrypt():
|
||||
"""
|
||||
|
||||
|
||||
def get_super_user() -> Optional[str]:
|
||||
def get_super_user() -> str | None:
|
||||
"""Get the superuser"""
|
||||
superuser_file = Path(settings.lnbits_data_folder, ".super_user")
|
||||
if not superuser_file.exists() or not superuser_file.is_file():
|
||||
@@ -155,7 +154,7 @@ async def db_versions():
|
||||
@db.command("cleanup-wallets")
|
||||
@click.argument("days", type=int, required=False)
|
||||
@coro
|
||||
async def database_cleanup_wallets(days: Optional[int] = None):
|
||||
async def database_cleanup_wallets(days: int | None = None):
|
||||
"""Delete all wallets that never had any transaction"""
|
||||
async with core_db.connect() as conn:
|
||||
delta = days or settings.cleanup_wallets_days
|
||||
@@ -212,10 +211,10 @@ async def database_revert_payment(checking_id: str):
|
||||
@click.option("-v", "--verbose", is_flag=True, help="Detailed log.")
|
||||
@coro
|
||||
async def check_invalid_payments(
|
||||
days: Optional[int] = None,
|
||||
limit: Optional[int] = None,
|
||||
wallet: Optional[str] = None,
|
||||
verbose: Optional[bool] = False,
|
||||
days: int | None = None,
|
||||
limit: int | None = None,
|
||||
wallet: str | None = None,
|
||||
verbose: bool | None = False,
|
||||
):
|
||||
"""Check payments that are settled in the DB but pending on the Funding Source"""
|
||||
await check_admin_settings()
|
||||
@@ -303,7 +302,7 @@ async def create_user(username: str, password: str):
|
||||
@users.command("cleanup-accounts")
|
||||
@click.argument("days", type=int, required=False)
|
||||
@coro
|
||||
async def database_cleanup_accounts(days: Optional[int] = None):
|
||||
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
|
||||
@@ -320,9 +319,8 @@ async def extensions_list():
|
||||
from lnbits.app import build_all_installed_extensions_list
|
||||
|
||||
for ext in await build_all_installed_extensions_list():
|
||||
assert (
|
||||
ext.meta and ext.meta.installed_release
|
||||
), f"Extension {ext.id} has no installed_release"
|
||||
if not ext.meta or not ext.meta.installed_release:
|
||||
raise ValueError(f"Extension {ext.id} has no installed_release")
|
||||
click.echo(f" - {ext.id} ({ext.meta.installed_release.version})")
|
||||
|
||||
|
||||
@@ -353,13 +351,13 @@ async def extensions_list():
|
||||
help="Admin user ID (must have permissions to install extensions).",
|
||||
)
|
||||
@coro
|
||||
async def extensions_update(
|
||||
extension: Optional[str] = None,
|
||||
all_extensions: Optional[bool] = False,
|
||||
repo_index: Optional[str] = None,
|
||||
source_repo: Optional[str] = None,
|
||||
url: Optional[str] = None,
|
||||
admin_user: Optional[str] = None,
|
||||
async def extensions_update( # noqa: C901
|
||||
extension: str | None = None,
|
||||
all_extensions: bool | None = False,
|
||||
repo_index: str | None = None,
|
||||
source_repo: str | None = None,
|
||||
url: str | None = None,
|
||||
admin_user: str | None = None,
|
||||
):
|
||||
"""
|
||||
Update extension to the latest version.
|
||||
@@ -444,10 +442,10 @@ async def extensions_update(
|
||||
@coro
|
||||
async def extensions_install(
|
||||
extension: str,
|
||||
repo_index: Optional[str] = None,
|
||||
source_repo: Optional[str] = None,
|
||||
url: Optional[str] = None,
|
||||
admin_user: Optional[str] = None,
|
||||
repo_index: str | None = None,
|
||||
source_repo: str | None = None,
|
||||
url: str | None = None,
|
||||
admin_user: str | None = None,
|
||||
):
|
||||
"""Install a extension"""
|
||||
click.echo(f"Installing {extension}... {repo_index}")
|
||||
@@ -474,7 +472,7 @@ async def extensions_install(
|
||||
)
|
||||
@coro
|
||||
async def extensions_uninstall(
|
||||
extension: str, url: Optional[str] = None, admin_user: Optional[str] = None
|
||||
extension: str, url: str | None = None, admin_user: str | None = None
|
||||
):
|
||||
"""Uninstall a extension"""
|
||||
click.echo(f"Uninstalling '{extension}'...")
|
||||
@@ -563,10 +561,10 @@ if __name__ == "__main__":
|
||||
|
||||
async def install_extension(
|
||||
extension: str,
|
||||
repo_index: Optional[str] = None,
|
||||
source_repo: Optional[str] = None,
|
||||
url: Optional[str] = None,
|
||||
admin_user: Optional[str] = None,
|
||||
repo_index: str | None = None,
|
||||
source_repo: str | None = None,
|
||||
url: str | None = None,
|
||||
admin_user: str | None = None,
|
||||
) -> tuple[bool, str]:
|
||||
try:
|
||||
release = await _select_release(extension, repo_index, source_repo)
|
||||
@@ -592,10 +590,10 @@ async def install_extension(
|
||||
|
||||
async def update_extension(
|
||||
extension: str,
|
||||
repo_index: Optional[str] = None,
|
||||
source_repo: Optional[str] = None,
|
||||
url: Optional[str] = None,
|
||||
admin_user: Optional[str] = None,
|
||||
repo_index: str | None = None,
|
||||
source_repo: str | None = None,
|
||||
url: str | None = None,
|
||||
admin_user: str | None = None,
|
||||
) -> tuple[bool, str]:
|
||||
try:
|
||||
click.echo(f"Updating '{extension}' extension.")
|
||||
@@ -608,9 +606,10 @@ async def update_extension(
|
||||
|
||||
click.echo(f"Current '{extension}' version: {installed_ext.installed_version}.")
|
||||
|
||||
assert (
|
||||
installed_ext.meta and installed_ext.meta.installed_release
|
||||
), "Cannot find previously installed release. Please uninstall first."
|
||||
if not installed_ext.meta or not installed_ext.meta.installed_release:
|
||||
raise ValueError(
|
||||
"Cannot find previously installed release. Please uninstall first."
|
||||
)
|
||||
|
||||
release = await _select_release(extension, repo_index, source_repo)
|
||||
if not release:
|
||||
@@ -644,9 +643,9 @@ async def update_extension(
|
||||
|
||||
async def _select_release(
|
||||
extension: str,
|
||||
repo_index: Optional[str] = None,
|
||||
source_repo: Optional[str] = None,
|
||||
) -> Optional[ExtensionRelease]:
|
||||
repo_index: str | None = None,
|
||||
source_repo: str | None = None,
|
||||
) -> ExtensionRelease | None:
|
||||
all_releases = await InstallableExtension.get_extension_releases(extension)
|
||||
if len(all_releases) == 0:
|
||||
click.echo(f"No repository found for extension '{extension}'.")
|
||||
@@ -706,7 +705,7 @@ def _get_latest_release_per_repo(all_releases):
|
||||
|
||||
|
||||
async def _call_install_extension(
|
||||
data: CreateExtension, url: Optional[str], user_id: Optional[str] = None
|
||||
data: CreateExtension, url: str | None, user_id: str | None = None
|
||||
):
|
||||
if url:
|
||||
user_id = user_id or get_super_user()
|
||||
@@ -720,7 +719,7 @@ async def _call_install_extension(
|
||||
|
||||
|
||||
async def _call_uninstall_extension(
|
||||
extension: str, url: Optional[str], user_id: Optional[str] = None
|
||||
extension: str, url: str | None, user_id: str | None = None
|
||||
):
|
||||
if url:
|
||||
user_id = user_id or get_super_user()
|
||||
@@ -756,7 +755,7 @@ async def _can_run_operation(url) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
async def _is_lnbits_started(url: Optional[str]):
|
||||
async def _is_lnbits_started(url: str | None):
|
||||
try:
|
||||
url = url or f"http://{settings.host}:{settings.port}/api/v1/health"
|
||||
async with httpx.AsyncClient() as client:
|
||||
|
||||
@@ -3,12 +3,17 @@ from fastapi import APIRouter, FastAPI
|
||||
from .db import core_app_extra, db
|
||||
from .views.admin_api import admin_router
|
||||
from .views.api import api_router
|
||||
from .views.asset_api import asset_router
|
||||
from .views.audit_api import audit_router
|
||||
from .views.auth_api import auth_router
|
||||
from .views.callback_api import callback_router
|
||||
from .views.extension_api import extension_router
|
||||
from .views.extensions_builder_api import extension_builder_router
|
||||
from .views.fiat_api import fiat_router
|
||||
|
||||
# this compat is needed for usermanager extension
|
||||
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.payment_api import payment_router
|
||||
from .views.tinyurl_api import tinyurl_router
|
||||
@@ -28,16 +33,21 @@ def init_core_routers(app: FastAPI):
|
||||
app.include_router(admin_router)
|
||||
app.include_router(node_router)
|
||||
app.include_router(extension_router)
|
||||
app.include_router(extension_builder_router)
|
||||
app.include_router(super_node_router)
|
||||
app.include_router(public_node_router)
|
||||
app.include_router(payment_router)
|
||||
app.include_router(wallet_router)
|
||||
app.include_router(api_router)
|
||||
app.include_router(websocket_router)
|
||||
app.include_router(callback_router)
|
||||
app.include_router(tinyurl_router)
|
||||
app.include_router(webpush_router)
|
||||
app.include_router(users_router)
|
||||
app.include_router(audit_router)
|
||||
app.include_router(asset_router)
|
||||
app.include_router(fiat_router)
|
||||
app.include_router(lnurl_router)
|
||||
|
||||
|
||||
__all__ = ["core_app", "core_app_extra", "db"]
|
||||
|
||||
@@ -85,87 +85,78 @@ from .webpush import (
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# audit
|
||||
"create_audit_entry",
|
||||
# db_versions
|
||||
"get_db_version",
|
||||
"get_db_versions",
|
||||
"update_migration_version",
|
||||
"delete_dbversion",
|
||||
# extensions
|
||||
"create_installed_extension",
|
||||
"create_user_extension",
|
||||
"delete_installed_extension",
|
||||
"drop_extension_db",
|
||||
"get_installed_extension",
|
||||
"get_installed_extensions",
|
||||
"get_user_active_extensions_ids",
|
||||
"get_user_extension",
|
||||
"update_installed_extension",
|
||||
"update_installed_extension_state",
|
||||
"update_user_extension",
|
||||
"get_user_extensions",
|
||||
# payments
|
||||
"DateTrunc",
|
||||
"check_internal",
|
||||
"create_payment",
|
||||
"delete_expired_invoices",
|
||||
"delete_wallet_payment",
|
||||
"get_latest_payments_by_extension",
|
||||
"get_payment",
|
||||
"get_payments",
|
||||
"get_payments_history",
|
||||
"get_payments_paginated",
|
||||
"get_standalone_payment",
|
||||
"get_wallet_payment",
|
||||
"is_internal_status_success",
|
||||
"mark_webhook_sent",
|
||||
"update_payment",
|
||||
"update_payment_checking_id",
|
||||
"update_payment_extra",
|
||||
# settings
|
||||
"create_admin_settings",
|
||||
"delete_admin_settings",
|
||||
"get_admin_settings",
|
||||
"get_super_settings",
|
||||
"update_admin_settings",
|
||||
"update_super_user",
|
||||
"reset_core_settings",
|
||||
# tinyurl
|
||||
"create_tinyurl",
|
||||
"delete_tinyurl",
|
||||
"get_tinyurl",
|
||||
"get_tinyurl_by_url",
|
||||
# users
|
||||
"create_account",
|
||||
"create_admin_settings",
|
||||
"create_audit_entry",
|
||||
"create_installed_extension",
|
||||
"create_payment",
|
||||
"create_tinyurl",
|
||||
"create_user_extension",
|
||||
"create_wallet",
|
||||
"create_webpush_subscription",
|
||||
"delete_account",
|
||||
"delete_accounts_no_wallets",
|
||||
"delete_admin_settings",
|
||||
"delete_dbversion",
|
||||
"delete_expired_invoices",
|
||||
"delete_installed_extension",
|
||||
"delete_tinyurl",
|
||||
"delete_unused_wallets",
|
||||
"delete_wallet",
|
||||
"delete_wallet_by_id",
|
||||
"delete_wallet_payment",
|
||||
"delete_webpush_subscription",
|
||||
"delete_webpush_subscriptions",
|
||||
"drop_extension_db",
|
||||
"force_delete_wallet",
|
||||
"get_account",
|
||||
"get_account_by_email",
|
||||
"get_account_by_pubkey",
|
||||
"get_account_by_username",
|
||||
"get_account_by_username_or_email",
|
||||
"get_accounts",
|
||||
"get_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_admin_settings",
|
||||
"get_db_version",
|
||||
"get_db_versions",
|
||||
"get_installed_extension",
|
||||
"get_installed_extensions",
|
||||
"get_latest_payments_by_extension",
|
||||
"get_payment",
|
||||
"get_payments",
|
||||
"get_payments_history",
|
||||
"get_payments_paginated",
|
||||
"get_standalone_payment",
|
||||
"get_super_settings",
|
||||
"get_tinyurl",
|
||||
"get_tinyurl_by_url",
|
||||
"get_total_balance",
|
||||
"get_user",
|
||||
"get_user_access_control_lists",
|
||||
"get_user_active_extensions_ids",
|
||||
"get_user_extension",
|
||||
"get_user_extensions",
|
||||
"get_user_from_account",
|
||||
"get_wallet",
|
||||
"get_wallet_for_key",
|
||||
"get_wallet_payment",
|
||||
"get_wallets",
|
||||
"remove_deleted_wallets",
|
||||
"update_wallet",
|
||||
# webpush
|
||||
"create_webpush_subscription",
|
||||
"delete_webpush_subscription",
|
||||
"delete_webpush_subscriptions",
|
||||
"get_webpush_subscription",
|
||||
"get_webpush_subscriptions_for_user",
|
||||
"is_internal_status_success",
|
||||
"mark_webhook_sent",
|
||||
"remove_deleted_wallets",
|
||||
"reset_core_settings",
|
||||
"update_account",
|
||||
"update_admin_settings",
|
||||
"update_installed_extension",
|
||||
"update_installed_extension_state",
|
||||
"update_migration_version",
|
||||
"update_payment",
|
||||
"update_payment_checking_id",
|
||||
"update_payment_extra",
|
||||
"update_super_user",
|
||||
"update_user_extension",
|
||||
"update_wallet",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
from lnbits.core.db import db
|
||||
from lnbits.core.models.assets import Asset, AssetFilters, AssetInfo
|
||||
from lnbits.db import Connection, Filters, Page
|
||||
|
||||
|
||||
async def create_asset(
|
||||
entry: Asset,
|
||||
conn: Connection | None = None,
|
||||
) -> None:
|
||||
await (conn or db).insert("assets", entry)
|
||||
|
||||
|
||||
async def get_user_asset_info(
|
||||
user_id: str,
|
||||
asset_id: str,
|
||||
conn: Connection | None = None,
|
||||
) -> AssetInfo | None:
|
||||
return await (conn or db).fetchone(
|
||||
query="SELECT * from assets WHERE id = :asset_id AND user_id = :user_id",
|
||||
values={"asset_id": asset_id, "user_id": user_id},
|
||||
model=AssetInfo,
|
||||
)
|
||||
|
||||
|
||||
async def get_asset_info(
|
||||
asset_id: str, conn: Connection | None = None
|
||||
) -> AssetInfo | None:
|
||||
return await (conn or db).fetchone(
|
||||
query="SELECT * from assets WHERE id = :asset_id",
|
||||
values={"asset_id": asset_id},
|
||||
model=AssetInfo,
|
||||
)
|
||||
|
||||
|
||||
async def get_user_asset(
|
||||
user_id: str,
|
||||
asset_id: str,
|
||||
conn: Connection | None = None,
|
||||
) -> Asset | None:
|
||||
return await (conn or db).fetchone(
|
||||
query="SELECT * from assets WHERE id = :asset_id AND user_id = :user_id",
|
||||
values={"asset_id": asset_id, "user_id": user_id},
|
||||
model=Asset,
|
||||
)
|
||||
|
||||
|
||||
async def get_public_asset(
|
||||
asset_id: str,
|
||||
conn: Connection | None = None,
|
||||
) -> Asset | None:
|
||||
return await (conn or db).fetchone(
|
||||
query="SELECT * from assets WHERE id = :asset_id AND is_public = true",
|
||||
values={"asset_id": asset_id},
|
||||
model=Asset,
|
||||
)
|
||||
|
||||
|
||||
async def get_public_asset_info(
|
||||
asset_id: str,
|
||||
conn: Connection | None = None,
|
||||
) -> AssetInfo | None:
|
||||
return await (conn or db).fetchone(
|
||||
query="SELECT * from assets WHERE id = :asset_id AND is_public = true",
|
||||
values={"asset_id": asset_id},
|
||||
model=AssetInfo,
|
||||
)
|
||||
|
||||
|
||||
async def update_user_asset_info(
|
||||
asset: AssetInfo,
|
||||
) -> AssetInfo:
|
||||
await db.update("assets", asset)
|
||||
return asset
|
||||
|
||||
|
||||
async def delete_user_asset(
|
||||
user_id: str, asset_id: str, conn: Connection | None = None
|
||||
) -> None:
|
||||
await (conn or db).execute(
|
||||
query="DELETE FROM assets WHERE id = :asset_id AND user_id = :user_id",
|
||||
values={"asset_id": asset_id, "user_id": user_id},
|
||||
)
|
||||
|
||||
|
||||
async def get_user_assets(
|
||||
user_id: str,
|
||||
filters: Filters[AssetFilters] | None = None,
|
||||
conn: Connection | None = None,
|
||||
) -> Page[AssetInfo]:
|
||||
filters = filters or Filters()
|
||||
filters.sortby = filters.sortby or "created_at"
|
||||
return await (conn or db).fetch_page(
|
||||
query="SELECT * FROM assets",
|
||||
where=["user_id = :user_id"],
|
||||
values={"user_id": user_id},
|
||||
filters=filters,
|
||||
model=AssetInfo,
|
||||
table_name="assets",
|
||||
)
|
||||
|
||||
|
||||
async def get_user_assets_count(user_id: str) -> int:
|
||||
result = await db.execute(
|
||||
query="SELECT COUNT(*) as count FROM assets WHERE user_id = :user_id",
|
||||
values={"user_id": user_id},
|
||||
)
|
||||
row = result.mappings().first()
|
||||
return row.get("count", 0)
|
||||
+19
-14
@@ -1,5 +1,3 @@
|
||||
from typing import Optional
|
||||
|
||||
from lnbits.core.db import db
|
||||
from lnbits.core.models import AuditEntry, AuditFilters
|
||||
from lnbits.core.models.audit import AuditCountStat
|
||||
@@ -8,39 +6,41 @@ from lnbits.db import Connection, Filters, Page
|
||||
|
||||
async def create_audit_entry(
|
||||
entry: AuditEntry,
|
||||
conn: Optional[Connection] = None,
|
||||
conn: Connection | None = None,
|
||||
) -> None:
|
||||
await (conn or db).insert("audit", entry)
|
||||
|
||||
|
||||
async def get_audit_entries(
|
||||
filters: Optional[Filters[AuditFilters]] = None,
|
||||
conn: Optional[Connection] = None,
|
||||
filters: Filters[AuditFilters] | None = None,
|
||||
conn: Connection | None = None,
|
||||
) -> Page[AuditEntry]:
|
||||
return await (conn or db).fetch_page(
|
||||
"SELECT * from audit",
|
||||
"SELECT * FROM audit",
|
||||
[],
|
||||
{},
|
||||
filters=filters,
|
||||
model=AuditEntry,
|
||||
table_name="audit",
|
||||
)
|
||||
|
||||
|
||||
async def delete_expired_audit_entries(
|
||||
conn: Optional[Connection] = None,
|
||||
conn: Connection | None = None,
|
||||
):
|
||||
await (conn or db).execute(
|
||||
# Timestamp placeholder is safe from SQL injection (not user input)
|
||||
f"""
|
||||
DELETE from audit
|
||||
WHERE delete_at < {db.timestamp_now}
|
||||
""",
|
||||
""", # noqa: S608
|
||||
)
|
||||
|
||||
|
||||
async def get_count_stats(
|
||||
field: str,
|
||||
filters: Optional[Filters[AuditFilters]] = None,
|
||||
conn: Optional[Connection] = None,
|
||||
filters: Filters[AuditFilters] | None = None,
|
||||
conn: Connection | None = None,
|
||||
) -> list[AuditCountStat]:
|
||||
if field not in ["request_method", "component", "response_code"]:
|
||||
return []
|
||||
@@ -48,13 +48,16 @@ async def get_count_stats(
|
||||
filters = Filters()
|
||||
clause = filters.where()
|
||||
data = await (conn or db).fetchall(
|
||||
# SQL injection vectors safety:
|
||||
# - `field` is a static string, not user input
|
||||
# - `clause` is generated from filters, which are validated and sanitized
|
||||
query=f"""
|
||||
SELECT {field} as field, count({field}) as total
|
||||
FROM audit
|
||||
{clause}
|
||||
GROUP BY {field}
|
||||
ORDER BY {field}
|
||||
""",
|
||||
""", # noqa: S608
|
||||
values=filters.values(),
|
||||
model=AuditCountStat,
|
||||
)
|
||||
@@ -63,20 +66,22 @@ async def get_count_stats(
|
||||
|
||||
|
||||
async def get_long_duration_stats(
|
||||
filters: Optional[Filters[AuditFilters]] = None,
|
||||
conn: Optional[Connection] = None,
|
||||
filters: Filters[AuditFilters] | None = None,
|
||||
conn: Connection | None = None,
|
||||
) -> list[AuditCountStat]:
|
||||
if not filters:
|
||||
filters = Filters()
|
||||
clause = filters.where()
|
||||
long_duration_paths = await (conn or db).fetchall(
|
||||
# This query is safe from SQL injection
|
||||
# The `clause` is constructed from sanitized inputs
|
||||
query=f"""
|
||||
SELECT path as field, max(duration) as total FROM audit
|
||||
{clause}
|
||||
GROUP BY path
|
||||
ORDER BY total DESC
|
||||
LIMIT 5
|
||||
""",
|
||||
""", # noqa: S608
|
||||
values=filters.values(),
|
||||
model=AuditCountStat,
|
||||
)
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
from typing import Optional
|
||||
|
||||
from lnbits.core.db import db
|
||||
from lnbits.db import Connection
|
||||
|
||||
@@ -7,8 +5,8 @@ from ..models import DbVersion
|
||||
|
||||
|
||||
async def get_db_version(
|
||||
ext_id: str, conn: Optional[Connection] = None
|
||||
) -> Optional[DbVersion]:
|
||||
ext_id: str, conn: Connection | None = None
|
||||
) -> DbVersion | None:
|
||||
return await (conn or db).fetchone(
|
||||
"SELECT * FROM dbversions WHERE db = :ext_id",
|
||||
{"ext_id": ext_id},
|
||||
@@ -16,7 +14,7 @@ async def get_db_version(
|
||||
)
|
||||
|
||||
|
||||
async def get_db_versions(conn: Optional[Connection] = None) -> list[DbVersion]:
|
||||
async def get_db_versions(conn: Connection | None = None) -> list[DbVersion]:
|
||||
return await (conn or db).fetchall("SELECT * FROM dbversions", model=DbVersion)
|
||||
|
||||
|
||||
@@ -30,7 +28,7 @@ async def update_migration_version(conn, db_name, version):
|
||||
)
|
||||
|
||||
|
||||
async def delete_dbversion(*, ext_id: str, conn: Optional[Connection] = None) -> None:
|
||||
async def delete_dbversion(*, ext_id: str, conn: Connection | None = None) -> None:
|
||||
await (conn or db).execute(
|
||||
"""
|
||||
DELETE FROM dbversions WHERE db = :ext
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
from typing import Optional
|
||||
|
||||
from lnbits.core.db import db
|
||||
from lnbits.core.models.extensions import (
|
||||
InstallableExtension,
|
||||
@@ -10,20 +8,20 @@ from lnbits.db import Connection, Database
|
||||
|
||||
async def create_installed_extension(
|
||||
ext: InstallableExtension,
|
||||
conn: Optional[Connection] = None,
|
||||
conn: Connection | None = None,
|
||||
) -> None:
|
||||
await (conn or db).insert("installed_extensions", ext)
|
||||
|
||||
|
||||
async def update_installed_extension(
|
||||
ext: InstallableExtension,
|
||||
conn: Optional[Connection] = None,
|
||||
conn: Connection | None = None,
|
||||
) -> None:
|
||||
await (conn or db).update("installed_extensions", ext)
|
||||
|
||||
|
||||
async def update_installed_extension_state(
|
||||
*, ext_id: str, active: bool, conn: Optional[Connection] = None
|
||||
*, ext_id: str, active: bool, conn: Connection | None = None
|
||||
) -> None:
|
||||
await (conn or db).execute(
|
||||
"""
|
||||
@@ -34,7 +32,7 @@ async def update_installed_extension_state(
|
||||
|
||||
|
||||
async def delete_installed_extension(
|
||||
*, ext_id: str, conn: Optional[Connection] = None
|
||||
*, ext_id: str, conn: Connection | None = None
|
||||
) -> None:
|
||||
await (conn or db).execute(
|
||||
"""
|
||||
@@ -44,13 +42,14 @@ async def delete_installed_extension(
|
||||
)
|
||||
|
||||
|
||||
async def drop_extension_db(ext_id: str, conn: Optional[Connection] = None) -> None:
|
||||
async def drop_extension_db(ext_id: str, conn: Connection | None = None) -> None:
|
||||
row: dict = await (conn or db).fetchone(
|
||||
"SELECT * FROM dbversions WHERE db = :id",
|
||||
{"id": ext_id},
|
||||
)
|
||||
# Check that 'ext_id' is a valid extension id and not a malicious string
|
||||
assert row, f"Extension '{ext_id}' db version cannot be found"
|
||||
if not row:
|
||||
raise Exception(f"Extension '{ext_id}' db version cannot be found")
|
||||
|
||||
is_file_based_db = await Database.clean_ext_db_files(ext_id)
|
||||
if is_file_based_db:
|
||||
@@ -64,8 +63,8 @@ async def drop_extension_db(ext_id: str, conn: Optional[Connection] = None) -> N
|
||||
|
||||
|
||||
async def get_installed_extension(
|
||||
ext_id: str, conn: Optional[Connection] = None
|
||||
) -> Optional[InstallableExtension]:
|
||||
ext_id: str, conn: Connection | None = None
|
||||
) -> InstallableExtension | None:
|
||||
extension = await (conn or db).fetchone(
|
||||
"SELECT * FROM installed_extensions WHERE id = :id",
|
||||
{"id": ext_id},
|
||||
@@ -75,13 +74,16 @@ async def get_installed_extension(
|
||||
|
||||
|
||||
async def get_installed_extensions(
|
||||
active: Optional[bool] = None,
|
||||
conn: Optional[Connection] = None,
|
||||
active: bool | None = None,
|
||||
conn: Connection | None = None,
|
||||
) -> list[InstallableExtension]:
|
||||
where = "WHERE active = :active" if active is not None else ""
|
||||
query = "SELECT * FROM installed_extensions"
|
||||
if active is not None:
|
||||
query += " WHERE active = :active"
|
||||
|
||||
values = {"active": active} if active is not None else {}
|
||||
all_extensions = await (conn or db).fetchall(
|
||||
f"SELECT * FROM installed_extensions {where}",
|
||||
query,
|
||||
values,
|
||||
model=InstallableExtension,
|
||||
)
|
||||
@@ -89,8 +91,8 @@ async def get_installed_extensions(
|
||||
|
||||
|
||||
async def get_user_extension(
|
||||
user_id: str, extension: str, conn: Optional[Connection] = None
|
||||
) -> Optional[UserExtension]:
|
||||
user_id: str, extension: str, conn: Connection | None = None
|
||||
) -> UserExtension | None:
|
||||
return await (conn or db).fetchone(
|
||||
"""
|
||||
SELECT * FROM extensions
|
||||
@@ -102,7 +104,7 @@ async def get_user_extension(
|
||||
|
||||
|
||||
async def get_user_extensions(
|
||||
user_id: str, conn: Optional[Connection] = None
|
||||
user_id: str, conn: Connection | None = None
|
||||
) -> list[UserExtension]:
|
||||
return await (conn or db).fetchall(
|
||||
"""SELECT * FROM extensions WHERE "user" = :user""",
|
||||
@@ -112,20 +114,20 @@ async def get_user_extensions(
|
||||
|
||||
|
||||
async def create_user_extension(
|
||||
user_extension: UserExtension, conn: Optional[Connection] = None
|
||||
user_extension: UserExtension, conn: Connection | None = None
|
||||
) -> None:
|
||||
await (conn or db).insert("extensions", user_extension)
|
||||
|
||||
|
||||
async def update_user_extension(
|
||||
user_extension: UserExtension, conn: Optional[Connection] = None
|
||||
user_extension: UserExtension, conn: Connection | None = None
|
||||
) -> None:
|
||||
where = """WHERE extension = :extension AND "user" = :user"""
|
||||
await (conn or db).update("extensions", user_extension, where)
|
||||
|
||||
|
||||
async def get_user_active_extensions_ids(
|
||||
user_id: str, conn: Optional[Connection] = None
|
||||
user_id: str, conn: Connection | None = None
|
||||
) -> list[str]:
|
||||
exts = await (conn or db).fetchall(
|
||||
"""
|
||||
|
||||
+101
-69
@@ -1,5 +1,5 @@
|
||||
from time import time
|
||||
from typing import Any, Optional, Tuple
|
||||
from typing import Any
|
||||
|
||||
from lnbits.core.crud.wallets import get_total_balance, get_wallet, get_wallets_ids
|
||||
from lnbits.core.db import db
|
||||
@@ -23,7 +23,7 @@ def update_payment_extra():
|
||||
pass
|
||||
|
||||
|
||||
async def get_payment(checking_id: str, conn: Optional[Connection] = None) -> Payment:
|
||||
async def get_payment(checking_id: str, conn: Connection | None = None) -> Payment:
|
||||
return await (conn or db).fetchone(
|
||||
"SELECT * FROM apipayments WHERE checking_id = :checking_id",
|
||||
{"checking_id": checking_id},
|
||||
@@ -33,13 +33,12 @@ async def get_payment(checking_id: str, conn: Optional[Connection] = None) -> Pa
|
||||
|
||||
async def get_standalone_payment(
|
||||
checking_id_or_hash: str,
|
||||
conn: Optional[Connection] = None,
|
||||
incoming: Optional[bool] = False,
|
||||
wallet_id: Optional[str] = None,
|
||||
) -> Optional[Payment]:
|
||||
conn: Connection | None = None,
|
||||
incoming: bool | None = False,
|
||||
wallet_id: str | None = None,
|
||||
) -> Payment | None:
|
||||
clause: str = "checking_id = :checking_id OR payment_hash = :hash"
|
||||
values = {
|
||||
"wallet_id": wallet_id,
|
||||
"checking_id": checking_id_or_hash,
|
||||
"hash": checking_id_or_hash,
|
||||
}
|
||||
@@ -47,14 +46,20 @@ async def get_standalone_payment(
|
||||
clause = f"({clause}) AND amount > 0"
|
||||
|
||||
if wallet_id:
|
||||
wallet = await get_wallet(wallet_id)
|
||||
if not wallet or not wallet.can_view_payments:
|
||||
return None
|
||||
values["wallet_id"] = wallet.source_wallet_id
|
||||
clause = f"({clause}) AND wallet_id = :wallet_id"
|
||||
|
||||
row = await (conn or db).fetchone(
|
||||
# This query is safe from SQL injection
|
||||
# The `clause` is constructed from sanitized inputs
|
||||
f"""
|
||||
SELECT * FROM apipayments
|
||||
WHERE {clause}
|
||||
ORDER BY amount LIMIT 1
|
||||
""",
|
||||
""", # noqa: S608
|
||||
values,
|
||||
Payment,
|
||||
)
|
||||
@@ -62,15 +67,18 @@ async def get_standalone_payment(
|
||||
|
||||
|
||||
async def get_wallet_payment(
|
||||
wallet_id: str, payment_hash: str, conn: Optional[Connection] = None
|
||||
) -> Optional[Payment]:
|
||||
wallet_id: str, payment_hash: str, conn: Connection | None = None
|
||||
) -> Payment | None:
|
||||
wallet = await get_wallet(wallet_id)
|
||||
if not wallet or not wallet.can_view_payments:
|
||||
return None
|
||||
payment = await (conn or db).fetchone(
|
||||
"""
|
||||
SELECT *
|
||||
FROM apipayments
|
||||
WHERE wallet_id = :wallet AND payment_hash = :hash
|
||||
""",
|
||||
{"wallet": wallet_id, "hash": payment_hash},
|
||||
{"wallet": wallet.source_wallet_id, "hash": payment_hash},
|
||||
Payment,
|
||||
)
|
||||
return payment
|
||||
@@ -80,31 +88,37 @@ async def get_latest_payments_by_extension(
|
||||
ext_name: str, ext_id: str, limit: int = 5
|
||||
) -> list[Payment]:
|
||||
return await db.fetchall(
|
||||
# This query is safe from SQL injection
|
||||
# The limtit is an integer and not user input
|
||||
f"""
|
||||
SELECT * FROM apipayments
|
||||
WHERE status = '{PaymentState.SUCCESS}'
|
||||
WHERE status = :status
|
||||
AND extra LIKE :ext_name
|
||||
AND extra LIKE :ext_id
|
||||
ORDER BY time DESC LIMIT {int(limit)}
|
||||
""",
|
||||
{"ext_name": f"%{ext_name}%", "ext_id": f"%{ext_id}%"},
|
||||
""", # noqa: S608
|
||||
{
|
||||
"status": f"{PaymentState.SUCCESS}",
|
||||
"ext_name": f"%{ext_name}%",
|
||||
"ext_id": f"%{ext_id}%",
|
||||
},
|
||||
Payment,
|
||||
)
|
||||
|
||||
|
||||
async def get_payments_paginated(
|
||||
async def get_payments_paginated( # noqa: C901
|
||||
*,
|
||||
wallet_id: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
wallet_id: str | None = None,
|
||||
user_id: str | None = None,
|
||||
complete: bool = False,
|
||||
pending: bool = False,
|
||||
failed: bool = False,
|
||||
outgoing: bool = False,
|
||||
incoming: bool = False,
|
||||
since: Optional[int] = None,
|
||||
since: int | None = None,
|
||||
exclude_uncheckable: bool = False,
|
||||
filters: Optional[Filters[PaymentFilters]] = None,
|
||||
conn: Optional[Connection] = None,
|
||||
filters: Filters[PaymentFilters] | None = None,
|
||||
conn: Connection | None = None,
|
||||
) -> Page[Payment]:
|
||||
"""
|
||||
Filters payments to be returned by:
|
||||
@@ -120,7 +134,11 @@ async def get_payments_paginated(
|
||||
clause.append(f"time > {db.timestamp_placeholder('time')}")
|
||||
|
||||
if wallet_id:
|
||||
values["wallet_id"] = wallet_id
|
||||
wallet = await get_wallet(wallet_id)
|
||||
if not wallet or not wallet.can_view_payments:
|
||||
return Page(data=[], total=0)
|
||||
|
||||
values["wallet_id"] = wallet.source_wallet_id
|
||||
clause.append("wallet_id = :wallet_id")
|
||||
elif user_id:
|
||||
only_user_wallets = await _only_user_wallets_statement(user_id, conn=conn)
|
||||
@@ -163,22 +181,23 @@ async def get_payments_paginated(
|
||||
values,
|
||||
filters=filters,
|
||||
model=Payment,
|
||||
table_name="apipayments",
|
||||
)
|
||||
|
||||
|
||||
async def get_payments(
|
||||
*,
|
||||
wallet_id: Optional[str] = None,
|
||||
wallet_id: str | None = None,
|
||||
complete: bool = False,
|
||||
pending: bool = False,
|
||||
outgoing: bool = False,
|
||||
incoming: bool = False,
|
||||
since: Optional[int] = None,
|
||||
since: int | None = None,
|
||||
exclude_uncheckable: bool = False,
|
||||
filters: Optional[Filters[PaymentFilters]] = None,
|
||||
conn: Optional[Connection] = None,
|
||||
limit: Optional[int] = None,
|
||||
offset: Optional[int] = None,
|
||||
filters: Filters[PaymentFilters] | None = None,
|
||||
conn: Connection | None = None,
|
||||
limit: int | None = None,
|
||||
offset: int | None = None,
|
||||
) -> list[Payment]:
|
||||
"""
|
||||
Filters payments to be returned by complete | pending | outgoing | incoming.
|
||||
@@ -222,26 +241,28 @@ async def get_payments_status_count() -> PaymentsStatusCount:
|
||||
|
||||
|
||||
async def delete_expired_invoices(
|
||||
conn: Optional[Connection] = None,
|
||||
conn: Connection | None = None,
|
||||
) -> None:
|
||||
# first we delete all invoices older than one month
|
||||
|
||||
await (conn or db).execute(
|
||||
# Timestamp placeholder is safe from SQL injection (not user input)
|
||||
f"""
|
||||
DELETE FROM apipayments
|
||||
WHERE status = '{PaymentState.PENDING}' AND amount > 0
|
||||
WHERE status = :status AND amount > 0
|
||||
AND time < {db.timestamp_placeholder("delta")}
|
||||
""",
|
||||
{"delta": int(time() - 2592000)},
|
||||
""", # noqa: S608
|
||||
{"status": f"{PaymentState.PENDING}", "delta": int(time() - 2592000)},
|
||||
)
|
||||
# then we delete all invoices whose expiry date is in the past
|
||||
await (conn or db).execute(
|
||||
# Timestamp placeholder is safe from SQL injection (not user input)
|
||||
f"""
|
||||
DELETE FROM apipayments
|
||||
WHERE status = '{PaymentState.PENDING}' AND amount > 0
|
||||
WHERE status = :status AND amount > 0
|
||||
AND expiry < {db.timestamp_placeholder("now")}
|
||||
""",
|
||||
{"now": int(time())},
|
||||
""", # noqa: S608
|
||||
{"status": f"{PaymentState.PENDING}", "now": int(time())},
|
||||
)
|
||||
|
||||
|
||||
@@ -249,12 +270,13 @@ async def create_payment(
|
||||
checking_id: str,
|
||||
data: CreatePayment,
|
||||
status: PaymentState = PaymentState.PENDING,
|
||||
conn: Optional[Connection] = None,
|
||||
conn: Connection | None = None,
|
||||
) -> Payment:
|
||||
# we don't allow the creation of the same invoice twice
|
||||
# note: this can be removed if the db uniqueness constraints are set appropriately
|
||||
previous_payment = await get_standalone_payment(checking_id, conn=conn)
|
||||
assert previous_payment is None, "Payment already exists"
|
||||
if previous_payment is not None:
|
||||
raise ValueError("Payment already exists")
|
||||
extra = data.extra or {}
|
||||
|
||||
payment = Payment(
|
||||
@@ -268,9 +290,10 @@ async def create_payment(
|
||||
preimage=data.preimage,
|
||||
expiry=data.expiry,
|
||||
webhook=data.webhook,
|
||||
fee=data.fee,
|
||||
fee=-abs(data.fee),
|
||||
tag=extra.get("tag", None),
|
||||
extra=extra,
|
||||
labels=data.labels or [],
|
||||
)
|
||||
|
||||
await (conn or db).insert("apipayments", payment)
|
||||
@@ -279,7 +302,7 @@ async def create_payment(
|
||||
|
||||
|
||||
async def update_payment_checking_id(
|
||||
checking_id: str, new_checking_id: str, conn: Optional[Connection] = None
|
||||
checking_id: str, new_checking_id: str, conn: Connection | None = None
|
||||
) -> None:
|
||||
await (conn or db).execute(
|
||||
"UPDATE apipayments SET checking_id = :new_id WHERE checking_id = :old_id",
|
||||
@@ -289,8 +312,8 @@ async def update_payment_checking_id(
|
||||
|
||||
async def update_payment(
|
||||
payment: Payment,
|
||||
new_checking_id: Optional[str] = None,
|
||||
conn: Optional[Connection] = None,
|
||||
new_checking_id: str | None = None,
|
||||
conn: Connection | None = None,
|
||||
) -> None:
|
||||
await (conn or db).update(
|
||||
"apipayments", payment, "WHERE checking_id = :checking_id"
|
||||
@@ -300,16 +323,16 @@ async def update_payment(
|
||||
|
||||
|
||||
async def get_payments_history(
|
||||
wallet_id: Optional[str] = None,
|
||||
wallet_id: str | None = None,
|
||||
group: DateTrunc = "day",
|
||||
filters: Optional[Filters] = None,
|
||||
filters: Filters | None = None,
|
||||
) -> list[PaymentHistoryPoint]:
|
||||
if not filters:
|
||||
filters = Filters()
|
||||
|
||||
date_trunc = db.datetime_grouping(group)
|
||||
|
||||
values = {
|
||||
values: dict[str, Any] = {
|
||||
"wallet_id": wallet_id,
|
||||
}
|
||||
# count outgoing payments if they are still pending
|
||||
@@ -321,24 +344,28 @@ async def get_payments_history(
|
||||
)
|
||||
"""
|
||||
]
|
||||
clause = filters.where(where)
|
||||
transactions: list[dict] = await db.fetchall(
|
||||
# This query is safe from SQL injection:
|
||||
# - `date_trunc` is a static string, not user input
|
||||
# - `clause` is generated from filters, which are validated and sanitized
|
||||
f"""
|
||||
SELECT {date_trunc} date,
|
||||
SUM(CASE WHEN amount > 0 THEN amount ELSE 0 END) income,
|
||||
SUM(CASE WHEN amount < 0 THEN abs(amount) + abs(fee) ELSE 0 END) spending
|
||||
FROM apipayments
|
||||
{filters.where(where)}
|
||||
{clause}
|
||||
GROUP BY date
|
||||
ORDER BY date DESC
|
||||
""",
|
||||
""", # noqa: S608
|
||||
filters.values(values),
|
||||
)
|
||||
if wallet_id:
|
||||
wallet = await get_wallet(wallet_id)
|
||||
if wallet:
|
||||
balance = wallet.balance_msat
|
||||
else:
|
||||
raise ValueError("Unknown wallet")
|
||||
if not wallet or not wallet.can_view_payments:
|
||||
return []
|
||||
balance = wallet.balance_msat
|
||||
values["wallet_id"] = wallet.source_wallet_id
|
||||
else:
|
||||
balance = await get_total_balance()
|
||||
|
||||
@@ -361,9 +388,9 @@ async def get_payments_history(
|
||||
|
||||
async def get_payment_count_stats(
|
||||
field: PaymentCountField,
|
||||
filters: Optional[Filters[PaymentFilters]] = None,
|
||||
user_id: Optional[str] = None,
|
||||
conn: Optional[Connection] = None,
|
||||
filters: Filters[PaymentFilters] | None = None,
|
||||
user_id: str | None = None,
|
||||
conn: Connection | None = None,
|
||||
) -> list[PaymentCountStat]:
|
||||
|
||||
if not filters:
|
||||
@@ -376,13 +403,16 @@ async def get_payment_count_stats(
|
||||
|
||||
clause = filters.where(extra_stmts)
|
||||
data = await (conn or db).fetchall(
|
||||
# SQL injection vectors safety:
|
||||
# - `field` is a static string, not user input
|
||||
# - `clause` is generated from filters, which are validated and sanitized
|
||||
query=f"""
|
||||
SELECT {field} as field, count(*) as total
|
||||
FROM apipayments
|
||||
{clause}
|
||||
GROUP BY {field}
|
||||
ORDER BY {field}
|
||||
""",
|
||||
""", # noqa: S608
|
||||
values=filters.values(),
|
||||
model=PaymentCountStat,
|
||||
)
|
||||
@@ -391,10 +421,10 @@ async def get_payment_count_stats(
|
||||
|
||||
|
||||
async def get_daily_stats(
|
||||
filters: Optional[Filters[PaymentFilters]] = None,
|
||||
user_id: Optional[str] = None,
|
||||
conn: Optional[Connection] = None,
|
||||
) -> Tuple[list[PaymentDailyStats], list[PaymentDailyStats]]:
|
||||
filters: Filters[PaymentFilters] | None = None,
|
||||
user_id: str | None = None,
|
||||
conn: Connection | None = None,
|
||||
) -> tuple[list[PaymentDailyStats], list[PaymentDailyStats]]:
|
||||
|
||||
if not filters:
|
||||
filters = Filters()
|
||||
@@ -441,9 +471,9 @@ async def get_daily_stats(
|
||||
|
||||
|
||||
async def get_wallets_stats(
|
||||
filters: Optional[Filters[PaymentFilters]] = None,
|
||||
user_id: Optional[str] = None,
|
||||
conn: Optional[Connection] = None,
|
||||
filters: Filters[PaymentFilters] | None = None,
|
||||
user_id: str | None = None,
|
||||
conn: Connection | None = None,
|
||||
) -> list[PaymentWalletStats]:
|
||||
|
||||
if not filters:
|
||||
@@ -468,6 +498,8 @@ async def get_wallets_stats(
|
||||
clauses = filters.where(where_stmts)
|
||||
|
||||
data = await (conn or db).fetchall(
|
||||
# This query is safe from SQL injection
|
||||
# The `clauses` is constructed from sanitized inputs
|
||||
query=f"""
|
||||
SELECT apipayments.wallet_id,
|
||||
MAX(wallets.name) AS wallet_name,
|
||||
@@ -479,7 +511,7 @@ async def get_wallets_stats(
|
||||
{clauses}
|
||||
GROUP BY apipayments.wallet_id
|
||||
ORDER BY payments_count
|
||||
""",
|
||||
""", # noqa: S608
|
||||
values=filters.values(),
|
||||
model=PaymentWalletStats,
|
||||
)
|
||||
@@ -488,7 +520,7 @@ async def get_wallets_stats(
|
||||
|
||||
|
||||
async def delete_wallet_payment(
|
||||
checking_id: str, wallet_id: str, conn: Optional[Connection] = None
|
||||
checking_id: str, wallet_id: str, conn: Connection | None = None
|
||||
) -> None:
|
||||
await (conn or db).execute(
|
||||
"DELETE FROM apipayments WHERE checking_id = :checking_id AND wallet = :wallet",
|
||||
@@ -497,24 +529,24 @@ async def delete_wallet_payment(
|
||||
|
||||
|
||||
async def check_internal(
|
||||
payment_hash: str, conn: Optional[Connection] = None
|
||||
) -> Optional[Payment]:
|
||||
payment_hash: str, conn: Connection | None = None
|
||||
) -> Payment | None:
|
||||
"""
|
||||
Returns the checking_id of the internal payment if it exists,
|
||||
otherwise None
|
||||
"""
|
||||
return await (conn or db).fetchone(
|
||||
f"""
|
||||
"""
|
||||
SELECT * FROM apipayments
|
||||
WHERE payment_hash = :hash AND status = '{PaymentState.PENDING}' AND amount > 0
|
||||
WHERE payment_hash = :hash AND status = :status AND amount > 0
|
||||
""",
|
||||
{"hash": payment_hash},
|
||||
{"status": f"{PaymentState.PENDING}", "hash": payment_hash},
|
||||
Payment,
|
||||
)
|
||||
|
||||
|
||||
async def is_internal_status_success(
|
||||
payment_hash: str, conn: Optional[Connection] = None
|
||||
payment_hash: str, conn: Connection | None = None
|
||||
) -> bool:
|
||||
"""
|
||||
Returns True if the internal payment was found and is successful,
|
||||
@@ -543,7 +575,7 @@ async def mark_webhook_sent(payment_hash: str, status: str) -> None:
|
||||
|
||||
|
||||
async def _only_user_wallets_statement(
|
||||
user_id: str, conn: Optional[Connection] = None
|
||||
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"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import json
|
||||
from typing import Any, Optional
|
||||
from typing import Any
|
||||
|
||||
from loguru import logger
|
||||
|
||||
@@ -14,7 +14,7 @@ from lnbits.settings import (
|
||||
)
|
||||
|
||||
|
||||
async def get_super_settings() -> Optional[SuperSettings]:
|
||||
async def get_super_settings() -> SuperSettings | None:
|
||||
data = await get_settings_by_tag("core")
|
||||
if data:
|
||||
super_user = await get_settings_field("super_user")
|
||||
@@ -24,7 +24,7 @@ async def get_super_settings() -> Optional[SuperSettings]:
|
||||
return None
|
||||
|
||||
|
||||
async def get_admin_settings(is_super_user: bool = False) -> Optional[AdminSettings]:
|
||||
async def get_admin_settings(is_super_user: bool = False) -> AdminSettings | None:
|
||||
sets = await get_super_settings()
|
||||
if not sets:
|
||||
return None
|
||||
@@ -41,7 +41,7 @@ async def get_admin_settings(is_super_user: bool = False) -> Optional[AdminSetti
|
||||
|
||||
|
||||
async def update_admin_settings(
|
||||
data: EditableSettings, tag: Optional[str] = "core"
|
||||
data: EditableSettings, tag: str | None = "core"
|
||||
) -> None:
|
||||
editable_settings = await get_settings_by_tag("core") or {}
|
||||
editable_settings.update(data.dict(exclude_unset=True))
|
||||
@@ -56,11 +56,12 @@ async def update_admin_settings(
|
||||
async def update_super_user(super_user: str) -> SuperSettings:
|
||||
await set_settings_field("super_user", super_user)
|
||||
settings = await get_super_settings()
|
||||
assert settings, "updated super_user settings could not be retrieved"
|
||||
if not settings:
|
||||
raise ValueError("updated super_user settings could not be retrieved")
|
||||
return settings
|
||||
|
||||
|
||||
async def delete_admin_settings(tag: Optional[str] = "core") -> None:
|
||||
async def delete_admin_settings(tag: str | None = "core") -> None:
|
||||
await db.execute(
|
||||
"DELETE FROM system_settings WHERE tag = :tag",
|
||||
{"tag": tag},
|
||||
@@ -86,13 +87,14 @@ async def create_admin_settings(super_user: str, new_settings: dict) -> SuperSet
|
||||
await set_settings_field(key, value)
|
||||
|
||||
settings = await get_super_settings()
|
||||
assert settings, "created admin settings could not be retrieved"
|
||||
if not settings:
|
||||
raise ValueError("created admin settings could not be retrieved")
|
||||
return settings
|
||||
|
||||
|
||||
async def get_settings_field(
|
||||
id_: str, tag: Optional[str] = "core"
|
||||
) -> Optional[SettingsField]:
|
||||
id_: str, tag: str | None = "core"
|
||||
) -> SettingsField | None:
|
||||
|
||||
row: dict = await db.fetchone(
|
||||
"""
|
||||
@@ -106,9 +108,7 @@ async def get_settings_field(
|
||||
return SettingsField(id=row["id"], value=json.loads(row["value"]), tag=row["tag"])
|
||||
|
||||
|
||||
async def set_settings_field(
|
||||
id_: str, value: Optional[Any], tag: Optional[str] = "core"
|
||||
):
|
||||
async def set_settings_field(id_: str, value: Any | None, tag: str | None = "core"):
|
||||
value = json.dumps(value) if value is not None else None
|
||||
await db.execute(
|
||||
"""
|
||||
@@ -120,7 +120,7 @@ async def set_settings_field(
|
||||
)
|
||||
|
||||
|
||||
async def get_settings_by_tag(tag: str) -> Optional[dict[str, Any]]:
|
||||
async def get_settings_by_tag(tag: str) -> dict[str, Any] | None:
|
||||
rows: list[dict] = await db.fetchall(
|
||||
"SELECT * FROM system_settings WHERE tag = :tag", {"tag": tag}
|
||||
)
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
from typing import Optional
|
||||
|
||||
import shortuuid
|
||||
|
||||
from lnbits.core.db import db
|
||||
@@ -19,7 +17,7 @@ async def create_tinyurl(domain: str, endless: bool, wallet: str):
|
||||
return await get_tinyurl(tinyurl_id)
|
||||
|
||||
|
||||
async def get_tinyurl(tinyurl_id: str) -> Optional[TinyURL]:
|
||||
async def get_tinyurl(tinyurl_id: str) -> TinyURL | None:
|
||||
return await db.fetchone(
|
||||
"SELECT * FROM tiny_url WHERE id = :tinyurl",
|
||||
{"tinyurl": tinyurl_id},
|
||||
|
||||
+45
-34
@@ -1,10 +1,10 @@
|
||||
from datetime import datetime, timezone
|
||||
from time import time
|
||||
from typing import Any, Optional
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from lnbits.core.crud.extensions import get_user_active_extensions_ids
|
||||
from lnbits.core.crud.wallets import get_wallets
|
||||
from lnbits.core.crud.wallets import create_wallet, get_wallets
|
||||
from lnbits.core.db import db
|
||||
from lnbits.core.models import UserAcls
|
||||
from lnbits.db import Connection, Filters, Page
|
||||
@@ -18,8 +18,8 @@ from ..models import (
|
||||
|
||||
|
||||
async def create_account(
|
||||
account: Optional[Account] = None,
|
||||
conn: Optional[Connection] = None,
|
||||
account: Account | None = None,
|
||||
conn: Connection | None = None,
|
||||
) -> Account:
|
||||
if account:
|
||||
account.validate_fields()
|
||||
@@ -36,7 +36,7 @@ async def update_account(account: Account) -> Account:
|
||||
return account
|
||||
|
||||
|
||||
async def delete_account(user_id: str, conn: Optional[Connection] = None) -> None:
|
||||
async def delete_account(user_id: str, conn: Connection | None = None) -> None:
|
||||
await (conn or db).execute(
|
||||
"DELETE from accounts WHERE id = :user",
|
||||
{"user": user_id},
|
||||
@@ -44,8 +44,8 @@ async def delete_account(user_id: str, conn: Optional[Connection] = None) -> Non
|
||||
|
||||
|
||||
async def get_accounts(
|
||||
filters: Optional[Filters[AccountFilters]] = None,
|
||||
conn: Optional[Connection] = None,
|
||||
filters: Filters[AccountFilters] | None = None,
|
||||
conn: Connection | None = None,
|
||||
) -> Page[AccountOverview]:
|
||||
where_clauses = []
|
||||
values: dict[str, Any] = {}
|
||||
@@ -68,6 +68,7 @@ async def get_accounts(
|
||||
accounts.username,
|
||||
accounts.email,
|
||||
accounts.pubkey,
|
||||
accounts.external_id,
|
||||
SUM(COALESCE((
|
||||
SELECT balance FROM balances WHERE wallet_id = wallets.id
|
||||
), 0)) as balance_msat,
|
||||
@@ -88,12 +89,11 @@ async def get_accounts(
|
||||
filters=filters,
|
||||
model=AccountOverview,
|
||||
group_by=["accounts.id"],
|
||||
table_name="accounts",
|
||||
)
|
||||
|
||||
|
||||
async def get_account(
|
||||
user_id: str, conn: Optional[Connection] = None
|
||||
) -> Optional[Account]:
|
||||
async def get_account(user_id: str, conn: Connection | None = None) -> Account | None:
|
||||
if len(user_id) == 0:
|
||||
return None
|
||||
return await (conn or db).fetchone(
|
||||
@@ -105,10 +105,11 @@ async def get_account(
|
||||
|
||||
async def delete_accounts_no_wallets(
|
||||
time_delta: int,
|
||||
conn: Optional[Connection] = None,
|
||||
conn: Connection | None = None,
|
||||
) -> None:
|
||||
delta = int(time()) - time_delta
|
||||
await (conn or db).execute(
|
||||
# Timestamp placeholder is safe from SQL injection (not user input)
|
||||
f"""
|
||||
DELETE FROM accounts
|
||||
WHERE NOT EXISTS (
|
||||
@@ -117,56 +118,59 @@ async def delete_accounts_no_wallets(
|
||||
(updated_at is null AND created_at < :delta)
|
||||
OR updated_at < {db.timestamp_placeholder("delta")}
|
||||
)
|
||||
""",
|
||||
""", # noqa: S608
|
||||
{"delta": delta},
|
||||
)
|
||||
|
||||
|
||||
async def get_account_by_username(
|
||||
username: str, conn: Optional[Connection] = None
|
||||
) -> Optional[Account]:
|
||||
username: str, conn: Connection | None = None
|
||||
) -> Account | None:
|
||||
if len(username) == 0:
|
||||
return None
|
||||
return await (conn or db).fetchone(
|
||||
"SELECT * FROM accounts WHERE username = :username",
|
||||
{"username": username},
|
||||
"SELECT * FROM accounts WHERE LOWER(username) = :username",
|
||||
{"username": username.lower()},
|
||||
Account,
|
||||
)
|
||||
|
||||
|
||||
async def get_account_by_pubkey(
|
||||
pubkey: str, conn: Optional[Connection] = None
|
||||
) -> Optional[Account]:
|
||||
pubkey: str, conn: Connection | None = None
|
||||
) -> Account | None:
|
||||
return await (conn or db).fetchone(
|
||||
"SELECT * FROM accounts WHERE pubkey = :pubkey",
|
||||
{"pubkey": pubkey},
|
||||
"SELECT * FROM accounts WHERE LOWER(pubkey) = :pubkey",
|
||||
{"pubkey": pubkey.lower()},
|
||||
Account,
|
||||
)
|
||||
|
||||
|
||||
async def get_account_by_email(
|
||||
email: str, conn: Optional[Connection] = None
|
||||
) -> Optional[Account]:
|
||||
email: str, conn: Connection | None = None
|
||||
) -> Account | None:
|
||||
if len(email) == 0:
|
||||
return None
|
||||
return await (conn or db).fetchone(
|
||||
"SELECT * FROM accounts WHERE email = :email",
|
||||
{"email": email},
|
||||
"SELECT * FROM accounts WHERE LOWER(email) = :email",
|
||||
{"email": email.lower()},
|
||||
Account,
|
||||
)
|
||||
|
||||
|
||||
async def get_account_by_username_or_email(
|
||||
username_or_email: str, conn: Optional[Connection] = None
|
||||
) -> Optional[Account]:
|
||||
username_or_email: str, conn: Connection | None = None
|
||||
) -> Account | None:
|
||||
return await (conn or db).fetchone(
|
||||
"SELECT * FROM accounts WHERE email = :value or username = :value",
|
||||
{"value": username_or_email},
|
||||
"""
|
||||
SELECT * FROM accounts
|
||||
WHERE LOWER(email) = :value or LOWER(username) = :value
|
||||
""",
|
||||
{"value": username_or_email.lower()},
|
||||
Account,
|
||||
)
|
||||
|
||||
|
||||
async def get_user(user_id: str, conn: Optional[Connection] = None) -> Optional[User]:
|
||||
async def get_user(user_id: str, conn: Connection | None = None) -> User | None:
|
||||
account = await get_account(user_id, conn)
|
||||
if not account:
|
||||
return None
|
||||
@@ -174,15 +178,21 @@ async def get_user(user_id: str, conn: Optional[Connection] = None) -> Optional[
|
||||
|
||||
|
||||
async def get_user_from_account(
|
||||
account: Account, conn: Optional[Connection] = None
|
||||
) -> Optional[User]:
|
||||
extensions = await get_user_active_extensions_ids(account.id, conn)
|
||||
wallets = await get_wallets(account.id, False, conn=conn)
|
||||
account: Account, conn: Connection | None = None
|
||||
) -> User | None:
|
||||
extensions = await get_user_active_extensions_ids(account.id, conn=conn)
|
||||
wallets = await get_wallets(account.id, deleted=False, conn=conn)
|
||||
|
||||
if len(wallets) == 0:
|
||||
wallet = await create_wallet(user_id=account.id, conn=conn)
|
||||
wallets.append(wallet)
|
||||
|
||||
return User(
|
||||
id=account.id,
|
||||
email=account.email,
|
||||
username=account.username,
|
||||
pubkey=account.pubkey,
|
||||
external_id=account.external_id,
|
||||
extra=account.extra,
|
||||
created_at=account.created_at,
|
||||
updated_at=account.updated_at,
|
||||
@@ -190,6 +200,7 @@ async def get_user_from_account(
|
||||
wallets=wallets,
|
||||
admin=account.is_admin,
|
||||
super_user=account.is_super_user,
|
||||
fiat_providers=account.fiat_providers,
|
||||
has_password=account.password_hash is not None,
|
||||
)
|
||||
|
||||
@@ -200,7 +211,7 @@ async def update_user_access_control_list(user_acls: UserAcls):
|
||||
|
||||
|
||||
async def get_user_access_control_lists(
|
||||
user_id: str, conn: Optional[Connection] = None
|
||||
user_id: str, conn: Connection | None = None
|
||||
) -> UserAcls:
|
||||
user_acls = await (conn or db).fetchone(
|
||||
"SELECT id, access_control_list FROM accounts WHERE id = :id",
|
||||
|
||||
+122
-52
@@ -1,10 +1,9 @@
|
||||
from datetime import datetime, timezone
|
||||
from time import time
|
||||
from typing import Optional
|
||||
from uuid import uuid4
|
||||
|
||||
from lnbits.core.db import db
|
||||
from lnbits.core.models.wallets import WalletsFilters
|
||||
from lnbits.core.models.wallets import WalletsFilters, WalletType
|
||||
from lnbits.db import Connection, Filters, Page
|
||||
from lnbits.settings import settings
|
||||
|
||||
@@ -14,26 +13,31 @@ from ..models import Wallet
|
||||
async def create_wallet(
|
||||
*,
|
||||
user_id: str,
|
||||
wallet_name: Optional[str] = None,
|
||||
conn: Optional[Connection] = None,
|
||||
wallet_name: str | None = None,
|
||||
wallet_type: WalletType = WalletType.LIGHTNING,
|
||||
shared_wallet_id: str | None = None,
|
||||
conn: Connection | None = None,
|
||||
) -> Wallet:
|
||||
wallet_id = uuid4().hex
|
||||
wallet = Wallet(
|
||||
id=wallet_id,
|
||||
name=wallet_name or settings.lnbits_default_wallet_name,
|
||||
wallet_type=wallet_type.value,
|
||||
shared_wallet_id=shared_wallet_id,
|
||||
user=user_id,
|
||||
adminkey=uuid4().hex,
|
||||
inkey=uuid4().hex,
|
||||
currency=settings.lnbits_default_accounting_currency or "USD",
|
||||
)
|
||||
|
||||
await (conn or db).insert("wallets", wallet)
|
||||
return wallet
|
||||
|
||||
|
||||
async def update_wallet(
|
||||
wallet: Wallet,
|
||||
conn: Optional[Connection] = None,
|
||||
) -> Optional[Wallet]:
|
||||
conn: Connection | None = None,
|
||||
) -> Wallet:
|
||||
wallet.updated_at = datetime.now(timezone.utc)
|
||||
await (conn or db).update("wallets", wallet)
|
||||
return wallet
|
||||
@@ -44,22 +48,21 @@ async def delete_wallet(
|
||||
user_id: str,
|
||||
wallet_id: str,
|
||||
deleted: bool = True,
|
||||
conn: Optional[Connection] = None,
|
||||
conn: Connection | None = None,
|
||||
) -> None:
|
||||
now = int(time())
|
||||
await (conn or db).execute(
|
||||
# Timestamp placeholder is safe from SQL injection (not user input)
|
||||
f"""
|
||||
UPDATE wallets
|
||||
SET deleted = :deleted, updated_at = {db.timestamp_placeholder('now')}
|
||||
WHERE id = :wallet AND "user" = :user
|
||||
""",
|
||||
""", # noqa: S608
|
||||
{"wallet": wallet_id, "user": user_id, "deleted": deleted, "now": now},
|
||||
)
|
||||
|
||||
|
||||
async def force_delete_wallet(
|
||||
wallet_id: str, conn: Optional[Connection] = None
|
||||
) -> None:
|
||||
async def force_delete_wallet(wallet_id: str, conn: Connection | None = None) -> None:
|
||||
await (conn or db).execute(
|
||||
"DELETE FROM wallets WHERE id = :wallet",
|
||||
{"wallet": wallet_id},
|
||||
@@ -67,27 +70,28 @@ async def force_delete_wallet(
|
||||
|
||||
|
||||
async def delete_wallet_by_id(
|
||||
wallet_id: str, conn: Optional[Connection] = None
|
||||
) -> Optional[int]:
|
||||
wallet_id: str, conn: Connection | None = None
|
||||
) -> int | None:
|
||||
now = int(time())
|
||||
result = await (conn or db).execute(
|
||||
# Timestamp placeholder is safe from SQL injection (not user input)
|
||||
f"""
|
||||
UPDATE wallets
|
||||
SET deleted = true, updated_at = {db.timestamp_placeholder('now')}
|
||||
WHERE id = :wallet
|
||||
""",
|
||||
""", # noqa: S608
|
||||
{"wallet": wallet_id, "now": now},
|
||||
)
|
||||
return result.rowcount
|
||||
|
||||
|
||||
async def remove_deleted_wallets(conn: Optional[Connection] = None) -> None:
|
||||
async def remove_deleted_wallets(conn: Connection | None = None) -> None:
|
||||
await (conn or db).execute("DELETE FROM wallets WHERE deleted = true")
|
||||
|
||||
|
||||
async def delete_unused_wallets(
|
||||
time_delta: int,
|
||||
conn: Optional[Connection] = None,
|
||||
conn: Connection | None = None,
|
||||
) -> None:
|
||||
delta = int(time()) - time_delta
|
||||
await (conn or db).execute(
|
||||
@@ -104,49 +108,76 @@ async def delete_unused_wallets(
|
||||
)
|
||||
|
||||
|
||||
async def get_wallet(
|
||||
wallet_id: str, deleted: Optional[bool] = None, conn: Optional[Connection] = None
|
||||
) -> Optional[Wallet]:
|
||||
where = "AND deleted = :deleted" if deleted is not None else ""
|
||||
async def get_standalone_wallet(
|
||||
wallet_id: str, deleted: bool | None = False, conn: Connection | None = None
|
||||
) -> Wallet | None:
|
||||
query = """
|
||||
SELECT *, COALESCE((
|
||||
SELECT balance FROM balances WHERE wallet_id = wallets.id
|
||||
), 0) AS balance_msat FROM wallets
|
||||
WHERE id = :wallet
|
||||
"""
|
||||
if deleted is not None:
|
||||
query += " AND deleted = :deleted "
|
||||
return await (conn or db).fetchone(
|
||||
f"""
|
||||
SELECT *, COALESCE((
|
||||
SELECT balance FROM balances WHERE wallet_id = wallets.id
|
||||
), 0) AS balance_msat FROM wallets
|
||||
WHERE id = :wallet {where}
|
||||
""",
|
||||
query,
|
||||
{"wallet": wallet_id, "deleted": deleted},
|
||||
Wallet,
|
||||
)
|
||||
|
||||
|
||||
async def get_wallet(
|
||||
wallet_id: str, deleted: bool | None = False, conn: Connection | None = None
|
||||
) -> Wallet | None:
|
||||
wallet = await get_standalone_wallet(wallet_id, deleted, conn)
|
||||
if not wallet:
|
||||
return None
|
||||
if wallet.is_lightning_shared_wallet:
|
||||
return await get_source_wallet(wallet, conn)
|
||||
|
||||
return wallet
|
||||
|
||||
|
||||
async def get_wallets(
|
||||
user_id: str, deleted: Optional[bool] = None, conn: Optional[Connection] = None
|
||||
user_id: str,
|
||||
deleted: bool | None = False,
|
||||
wallet_type: WalletType | None = None,
|
||||
conn: Connection | None = None,
|
||||
) -> list[Wallet]:
|
||||
where = "AND deleted = :deleted" if deleted is not None else ""
|
||||
return await (conn or db).fetchall(
|
||||
f"""
|
||||
SELECT *, COALESCE((
|
||||
SELECT balance FROM balances WHERE wallet_id = wallets.id
|
||||
), 0) AS balance_msat FROM wallets
|
||||
WHERE "user" = :user {where}
|
||||
""",
|
||||
{"user": user_id, "deleted": deleted},
|
||||
query = """
|
||||
SELECT *, COALESCE((
|
||||
SELECT balance FROM balances WHERE wallet_id = wallets.id
|
||||
), 0) AS balance_msat FROM wallets
|
||||
WHERE "user" = :user
|
||||
"""
|
||||
if deleted is not None:
|
||||
query += " AND deleted = :deleted "
|
||||
if wallet_type is not None:
|
||||
query += " AND wallet_type = :wallet_type "
|
||||
wallets = await (conn or db).fetchall(
|
||||
query,
|
||||
{
|
||||
"user": user_id,
|
||||
"deleted": deleted,
|
||||
"wallet_type": wallet_type.value if wallet_type else None,
|
||||
},
|
||||
Wallet,
|
||||
)
|
||||
|
||||
return await get_source_wallets(wallets, conn)
|
||||
|
||||
|
||||
async def get_wallets_paginated(
|
||||
user_id: str,
|
||||
deleted: Optional[bool] = None,
|
||||
filters: Optional[Filters[WalletsFilters]] = None,
|
||||
conn: Optional[Connection] = None,
|
||||
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(
|
||||
wallets = await (conn or db).fetch_page(
|
||||
"""
|
||||
SELECT *, COALESCE((
|
||||
SELECT balance FROM balances WHERE wallet_id = wallets.id
|
||||
@@ -156,21 +187,27 @@ async def get_wallets_paginated(
|
||||
values={"user": user_id, "deleted": deleted},
|
||||
filters=filters,
|
||||
model=Wallet,
|
||||
table_name="wallets",
|
||||
)
|
||||
|
||||
wallets.data = await get_source_wallets(wallets.data, conn)
|
||||
return wallets
|
||||
|
||||
|
||||
async def get_wallets_ids(
|
||||
user_id: str, deleted: Optional[bool] = None, conn: Optional[Connection] = None
|
||||
user_id: str, deleted: bool | None = False, conn: Connection | None = None
|
||||
) -> list[str]:
|
||||
where = "AND deleted = :deleted" if deleted is not None else ""
|
||||
result: list[dict] = await (conn or db).fetchall(
|
||||
f"""
|
||||
SELECT id FROM wallets
|
||||
WHERE "user" = :user {where}
|
||||
""",
|
||||
query = """SELECT * FROM wallets WHERE "user" = :user"""
|
||||
if deleted is not None:
|
||||
query += " AND deleted = :deleted "
|
||||
wallets = await (conn or db).fetchall(
|
||||
query,
|
||||
{"user": user_id, "deleted": deleted},
|
||||
Wallet,
|
||||
)
|
||||
return [row["id"] for row in result]
|
||||
|
||||
wallets = await get_source_wallets(wallets, conn)
|
||||
return [w.source_wallet_id for w in wallets if w.can_view_payments]
|
||||
|
||||
|
||||
async def get_wallets_count():
|
||||
@@ -181,9 +218,9 @@ async def get_wallets_count():
|
||||
|
||||
async def get_wallet_for_key(
|
||||
key: str,
|
||||
conn: Optional[Connection] = None,
|
||||
) -> Optional[Wallet]:
|
||||
return await (conn or db).fetchone(
|
||||
conn: Connection | None = None,
|
||||
) -> Wallet | None:
|
||||
wallet = await (conn or db).fetchone(
|
||||
"""
|
||||
SELECT *, COALESCE((
|
||||
SELECT balance FROM balances WHERE wallet_id = wallets.id
|
||||
@@ -194,9 +231,42 @@ async def get_wallet_for_key(
|
||||
{"key": key},
|
||||
Wallet,
|
||||
)
|
||||
if not wallet:
|
||||
return None
|
||||
|
||||
if wallet.is_lightning_shared_wallet:
|
||||
mw = await get_source_wallet(wallet, conn)
|
||||
return mw
|
||||
return wallet
|
||||
|
||||
|
||||
async def get_total_balance(conn: Optional[Connection] = None):
|
||||
async def get_source_wallet(
|
||||
wallet: Wallet, conn: Connection | None = None
|
||||
) -> Wallet | None:
|
||||
if not wallet.is_lightning_shared_wallet:
|
||||
return wallet
|
||||
if not wallet.shared_wallet_id:
|
||||
return None
|
||||
|
||||
shared_wallet = await get_standalone_wallet(wallet.shared_wallet_id, False, conn)
|
||||
if not shared_wallet:
|
||||
return None
|
||||
wallet.mirror_shared_wallet(shared_wallet)
|
||||
return wallet
|
||||
|
||||
|
||||
async def get_source_wallets(
|
||||
wallet: list[Wallet], conn: Connection | None = None
|
||||
) -> list[Wallet]:
|
||||
source_wallets = []
|
||||
for w in wallet:
|
||||
source_wallet = await get_source_wallet(w, conn)
|
||||
if source_wallet:
|
||||
source_wallets.append(source_wallet)
|
||||
return source_wallets
|
||||
|
||||
|
||||
async def get_total_balance(conn: Connection | None = None):
|
||||
result = await (conn or db).execute("SELECT SUM(balance) as balance FROM balances")
|
||||
row = result.mappings().first()
|
||||
return row.get("balance", 0) or 0
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
from typing import Optional
|
||||
|
||||
from lnbits.core.db import db
|
||||
|
||||
from ..models import WebPushSubscription
|
||||
@@ -7,7 +5,7 @@ from ..models import WebPushSubscription
|
||||
|
||||
async def get_webpush_subscription(
|
||||
endpoint: str, user: str
|
||||
) -> Optional[WebPushSubscription]:
|
||||
) -> WebPushSubscription | None:
|
||||
return await db.fetchone(
|
||||
"""
|
||||
SELECT * FROM webpush_subscriptions
|
||||
@@ -37,7 +35,8 @@ async def create_webpush_subscription(
|
||||
{"endpoint": endpoint, "user": user, "data": data, "host": host},
|
||||
)
|
||||
subscription = await get_webpush_subscription(endpoint, user)
|
||||
assert subscription, "Newly created webpush subscription couldn't be retrieved"
|
||||
if not subscription:
|
||||
raise ValueError("Newly created webpush subscription couldn't be retrieved")
|
||||
return subscription
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import importlib
|
||||
import re
|
||||
from typing import Any, Optional
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
from uuid import UUID
|
||||
|
||||
@@ -20,7 +20,7 @@ from lnbits.settings import settings
|
||||
|
||||
|
||||
async def migrate_extension_database(
|
||||
ext: InstallableExtension, current_version: Optional[DbVersion] = None
|
||||
ext: InstallableExtension, current_version: DbVersion | None = None
|
||||
):
|
||||
|
||||
try:
|
||||
@@ -38,7 +38,7 @@ async def run_migration(
|
||||
db: Connection,
|
||||
migrations_module: Any,
|
||||
db_name: str,
|
||||
current_version: Optional[DbVersion] = None,
|
||||
current_version: DbVersion | None = None,
|
||||
):
|
||||
matcher = re.compile(r"^m(\d\d\d)_")
|
||||
|
||||
|
||||
@@ -214,6 +214,7 @@ async def m007_set_invoice_expiries(db: Connection):
|
||||
"""
|
||||
try:
|
||||
result = await db.execute(
|
||||
# Timestamp placeholder is safe from SQL injection (not user input)
|
||||
f"""
|
||||
SELECT bolt11, checking_id
|
||||
FROM apipayments
|
||||
@@ -222,7 +223,7 @@ async def m007_set_invoice_expiries(db: Connection):
|
||||
AND bolt11 IS NOT NULL
|
||||
AND expiry IS NULL
|
||||
AND time < {db.timestamp_now}
|
||||
"""
|
||||
""" # noqa: S608
|
||||
)
|
||||
rows = result.mappings().all()
|
||||
if len(rows):
|
||||
@@ -242,13 +243,15 @@ async def m007_set_invoice_expiries(db: Connection):
|
||||
f" {invoice.payment_hash} to {expiration_date}"
|
||||
)
|
||||
await db.execute(
|
||||
# Timestamp placeholder is safe from SQL injection (not user input)
|
||||
f"""
|
||||
UPDATE apipayments SET expiry = {db.timestamp_placeholder('expiry')}
|
||||
WHERE checking_id = :checking_id AND amount > 0
|
||||
""",
|
||||
""", # noqa: S608
|
||||
{"expiry": expiration_date, "checking_id": checking_id},
|
||||
)
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
logger.debug(exc)
|
||||
continue
|
||||
except OperationalError:
|
||||
# this is necessary now because it may be the case that this migration will
|
||||
@@ -371,7 +374,8 @@ async def m014_set_deleted_wallets(db: Connection):
|
||||
"wallet": row.get("id"),
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
logger.debug(exc)
|
||||
continue
|
||||
except OperationalError:
|
||||
# this is necessary now because it may be the case that this migration will
|
||||
@@ -454,17 +458,19 @@ async def m017_add_timestamp_columns_to_accounts_and_wallets(db: Connection):
|
||||
# set all to now where they are null
|
||||
now = int(time())
|
||||
await db.execute(
|
||||
# Timestamp placeholder is safe from SQL injection (not user input)
|
||||
f"""
|
||||
UPDATE wallets SET created_at = {db.timestamp_placeholder('now')}
|
||||
WHERE created_at IS NULL
|
||||
""",
|
||||
""", # noqa: S608
|
||||
{"now": now},
|
||||
)
|
||||
await db.execute(
|
||||
# Timestamp placeholder is safe from SQL injection (not user input)
|
||||
f"""
|
||||
UPDATE accounts SET created_at = {db.timestamp_placeholder('now')}
|
||||
WHERE created_at IS NULL
|
||||
""",
|
||||
""", # noqa: S608
|
||||
{"now": now},
|
||||
)
|
||||
|
||||
@@ -616,7 +622,12 @@ async def m027_update_apipayments_data(db: Connection):
|
||||
logger.info(f"Updating {offset} to {offset+limit}")
|
||||
|
||||
result = await db.execute(
|
||||
f"SELECT * FROM apipayments ORDER BY time LIMIT {limit} OFFSET {offset}"
|
||||
# Limit and Offset safe from SQL injection
|
||||
# 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()
|
||||
logger.info(f"Payments count: {len(payments)}")
|
||||
@@ -629,11 +640,12 @@ async def m027_update_apipayments_data(db: Connection):
|
||||
tag = extra.get("tag")
|
||||
tsph = db.timestamp_placeholder("created_at")
|
||||
await db.execute(
|
||||
# Timestamp placeholder is safe from SQL injection (not user input)
|
||||
f"""
|
||||
UPDATE apipayments
|
||||
SET tag = :tag, created_at = {tsph}, updated_at = {tsph}
|
||||
WHERE checking_id = :checking_id
|
||||
""",
|
||||
""", # noqa: S608
|
||||
{
|
||||
"tag": tag,
|
||||
"created_at": created_at,
|
||||
@@ -711,3 +723,66 @@ async def m031_add_color_and_icon_to_wallets(db: Connection):
|
||||
Adds icon and color columns to wallets.
|
||||
"""
|
||||
await db.execute("ALTER TABLE wallets ADD COLUMN extra TEXT")
|
||||
|
||||
|
||||
async def m032_add_external_id_to_accounts(db: Connection):
|
||||
"""
|
||||
Adds external_id column to accounts.
|
||||
Used for external account linking.
|
||||
"""
|
||||
await db.execute("ALTER TABLE accounts ADD COLUMN external_id TEXT")
|
||||
|
||||
|
||||
async def m033_update_payment_table(db: Connection):
|
||||
await db.execute("ALTER TABLE apipayments ADD COLUMN fiat_provider TEXT")
|
||||
|
||||
|
||||
async def m034_add_stored_paylinks_to_wallet(db: Connection):
|
||||
await db.execute(
|
||||
"""
|
||||
ALTER TABLE wallets ADD COLUMN stored_paylinks TEXT
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
async def m035_add_wallet_type_column(db: Connection):
|
||||
await db.execute(
|
||||
"""
|
||||
ALTER TABLE wallets ADD COLUMN wallet_type TEXT DEFAULT 'lightning'
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
async def m036_add_shared_wallet_column(db: Connection):
|
||||
await db.execute(
|
||||
"""
|
||||
ALTER TABLE wallets ADD COLUMN shared_wallet_id TEXT
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
async def m037_create_assets_table(db: Connection):
|
||||
await db.execute(
|
||||
f"""
|
||||
CREATE TABLE IF NOT EXISTS assets (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
mime_type TEXT NOT NULL,
|
||||
is_public BOOLEAN NOT NULL DEFAULT false,
|
||||
name TEXT NOT NULL,
|
||||
size_bytes INT NOT NULL,
|
||||
thumbnail_base64 TEXT,
|
||||
thumbnail {db.blob},
|
||||
data {db.blob} NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
async def m038_add_labels_for_payments(db: Connection):
|
||||
await db.execute(
|
||||
"""
|
||||
ALTER TABLE apipayments ADD COLUMN labels TEXT
|
||||
"""
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from .audit import AuditEntry, AuditFilters
|
||||
from .lnurl import CreateLnurl, CreateLnurlAuth, PayLnurlWData
|
||||
from .lnurl import CreateLnurlPayment, CreateLnurlWithdraw
|
||||
from .misc import (
|
||||
BalanceDelta,
|
||||
Callback,
|
||||
@@ -9,6 +9,7 @@ from .misc import (
|
||||
SimpleStatus,
|
||||
)
|
||||
from .payments import (
|
||||
CancelInvoice,
|
||||
CreateInvoice,
|
||||
CreatePayment,
|
||||
DecodePayment,
|
||||
@@ -23,6 +24,7 @@ from .payments import (
|
||||
PaymentsStatusCount,
|
||||
PaymentState,
|
||||
PaymentWalletStats,
|
||||
SettleInvoice,
|
||||
)
|
||||
from .tinyurl import TinyURL
|
||||
from .users import (
|
||||
@@ -48,62 +50,55 @@ from .wallets import BaseWallet, CreateWallet, KeyType, Wallet, WalletTypeInfo
|
||||
from .webpush import CreateWebPushSubscription, WebPushSubscription
|
||||
|
||||
__all__ = [
|
||||
# audit
|
||||
"AccessTokenPayload",
|
||||
"Account",
|
||||
"AccountFilters",
|
||||
"AccountOverview",
|
||||
"AuditEntry",
|
||||
"AuditFilters",
|
||||
# lnurl
|
||||
"CreateLnurl",
|
||||
"CreateLnurlAuth",
|
||||
"PayLnurlWData",
|
||||
# misc
|
||||
"BalanceDelta",
|
||||
"BaseWallet",
|
||||
"Callback",
|
||||
"CancelInvoice",
|
||||
"ConversionData",
|
||||
"CoreAppExtra",
|
||||
"DbVersion",
|
||||
"SimpleStatus",
|
||||
# payments
|
||||
"CreateInvoice",
|
||||
"CreateLnurlPayment",
|
||||
"CreateLnurlWithdraw",
|
||||
"CreatePayment",
|
||||
"CreateUser",
|
||||
"CreateWallet",
|
||||
"CreateWebPushSubscription",
|
||||
"DbVersion",
|
||||
"DecodePayment",
|
||||
"KeyType",
|
||||
"LoginUsernamePassword",
|
||||
"LoginUsr",
|
||||
"PayInvoice",
|
||||
"Payment",
|
||||
"PaymentCountField",
|
||||
"PaymentCountStat",
|
||||
"PaymentDailyStats",
|
||||
"PaymentsStatusCount",
|
||||
"PaymentWalletStats",
|
||||
"PaymentExtra",
|
||||
"PaymentFilters",
|
||||
"PaymentHistoryPoint",
|
||||
"PaymentState",
|
||||
# tinyurl
|
||||
"TinyURL",
|
||||
# users
|
||||
"AccessTokenPayload",
|
||||
"Account",
|
||||
"AccountFilters",
|
||||
"AccountOverview",
|
||||
"UserAcls",
|
||||
"CreateUser",
|
||||
"PaymentWalletStats",
|
||||
"PaymentsStatusCount",
|
||||
"RegisterUser",
|
||||
"LoginUsernamePassword",
|
||||
"LoginUsr",
|
||||
"ResetUserPassword",
|
||||
"SettleInvoice",
|
||||
"SimpleStatus",
|
||||
"TinyURL",
|
||||
"UpdateBalance",
|
||||
"UpdateSuperuserPassword",
|
||||
"UpdateUser",
|
||||
"UpdateUserPassword",
|
||||
"UpdateUserPubkey",
|
||||
"User",
|
||||
"UserAcls",
|
||||
"UserExtra",
|
||||
# wallets
|
||||
"BaseWallet",
|
||||
"CreateWallet",
|
||||
"KeyType",
|
||||
"Wallet",
|
||||
"WalletTypeInfo",
|
||||
# webpush
|
||||
"CreateWebPushSubscription",
|
||||
"WebPushSubscription",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from lnbits.db import FilterModel
|
||||
|
||||
|
||||
class AssetInfo(BaseModel):
|
||||
id: str
|
||||
mime_type: str
|
||||
name: str
|
||||
is_public: bool = False
|
||||
size_bytes: int
|
||||
thumbnail_base64: str | None = None
|
||||
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class Asset(AssetInfo):
|
||||
user_id: str
|
||||
data: bytes
|
||||
|
||||
|
||||
class AssetUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
is_public: bool | None = None
|
||||
|
||||
|
||||
class AssetFilters(FilterModel):
|
||||
__search_fields__ = ["name"]
|
||||
__sort_fields__ = [
|
||||
"created_at",
|
||||
"name",
|
||||
]
|
||||
|
||||
name: str | None = None
|
||||
@@ -39,6 +39,7 @@ class ExplicitRelease(BaseModel):
|
||||
info_notification: str | None
|
||||
critical_notification: str | None
|
||||
details_link: str | None
|
||||
paid_features: str | None
|
||||
pay_link: str | None
|
||||
|
||||
def is_version_compatible(self):
|
||||
@@ -187,6 +188,7 @@ class ExtensionRelease(BaseModel):
|
||||
icon: str | None = None
|
||||
details_link: str | None = None
|
||||
|
||||
paid_features: str | None = None
|
||||
pay_link: str | None = None
|
||||
cost_sats: int | None = None
|
||||
paid_sats: int | None = 0
|
||||
@@ -211,7 +213,8 @@ class ExtensionRelease(BaseModel):
|
||||
self, amount: int | None = None
|
||||
) -> ReleasePaymentInfo | None:
|
||||
url = f"{self.pay_link}?amount={amount}" if amount else self.pay_link
|
||||
assert url, "Missing URL for payment info."
|
||||
if not url:
|
||||
raise ValueError("Missing URL for payment info.")
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(url)
|
||||
@@ -255,6 +258,7 @@ class ExtensionRelease(BaseModel):
|
||||
html_url=e.html_url,
|
||||
details_link=e.details_link,
|
||||
pay_link=e.pay_link,
|
||||
paid_features=e.paid_features,
|
||||
repo=e.repo,
|
||||
icon=e.icon,
|
||||
)
|
||||
@@ -307,6 +311,9 @@ class ExtensionMeta(BaseModel):
|
||||
dependencies: list[str] = []
|
||||
archive: str | None = None
|
||||
featured: bool = False
|
||||
paid_features: str | None = None
|
||||
has_paid_release: bool = False
|
||||
has_free_release: bool = False
|
||||
|
||||
|
||||
class InstallableExtension(BaseModel):
|
||||
@@ -376,9 +383,8 @@ class InstallableExtension(BaseModel):
|
||||
if ext_zip_file.is_file():
|
||||
os.remove(ext_zip_file)
|
||||
try:
|
||||
assert (
|
||||
self.meta and self.meta.installed_release
|
||||
), "installed_release is none."
|
||||
if not self.meta or not self.meta.installed_release:
|
||||
raise ValueError("No installed release.")
|
||||
|
||||
self._restore_payment_info()
|
||||
|
||||
@@ -409,7 +415,6 @@ class InstallableExtension(BaseModel):
|
||||
|
||||
tmp_dir = Path(settings.lnbits_data_folder, "unzip-temp", self.hash)
|
||||
shutil.rmtree(tmp_dir, True)
|
||||
|
||||
with zipfile.ZipFile(self.zip_path, "r") as zip_ref:
|
||||
zip_ref.extractall(tmp_dir)
|
||||
generated_dir_name = os.listdir(tmp_dir)[0]
|
||||
@@ -452,9 +457,23 @@ class InstallableExtension(BaseModel):
|
||||
|
||||
shutil.rmtree(self.ext_upgrade_dir, True)
|
||||
|
||||
def check_latest_version(self, release: ExtensionRelease | None):
|
||||
def check_release_updates(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:
|
||||
return
|
||||
if not release.is_version_compatible:
|
||||
return
|
||||
if not self.meta or not self.meta.latest_release:
|
||||
meta = self.meta or ExtensionMeta()
|
||||
meta.latest_release = release
|
||||
@@ -465,13 +484,19 @@ class InstallableExtension(BaseModel):
|
||||
):
|
||||
self.meta.latest_release = release
|
||||
|
||||
def find_existing_payment(self, pay_link: str | None) -> ReleasePaymentInfo | None:
|
||||
if not pay_link or not self.meta or not self.meta.payments:
|
||||
return None
|
||||
return next(
|
||||
(p for p in self.meta.payments if p.pay_link == pay_link),
|
||||
None,
|
||||
)
|
||||
def _check_payment_link(self, release: ExtensionRelease | None):
|
||||
if not release:
|
||||
return
|
||||
if not release.is_version_compatible:
|
||||
return
|
||||
if not self.meta:
|
||||
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):
|
||||
if (
|
||||
@@ -597,7 +622,7 @@ class InstallableExtension(BaseModel):
|
||||
(ee for ee in extension_list if ee.id == r.id), None
|
||||
)
|
||||
if existing_ext and ext.meta:
|
||||
existing_ext.check_latest_version(ext.meta.latest_release)
|
||||
existing_ext.check_release_updates(ext.meta.latest_release)
|
||||
continue
|
||||
|
||||
meta = ext.meta or ExtensionMeta()
|
||||
@@ -611,10 +636,10 @@ class InstallableExtension(BaseModel):
|
||||
(ee for ee in extension_list if ee.id == e.id), None
|
||||
)
|
||||
if existing_ext:
|
||||
existing_ext.check_latest_version(release)
|
||||
existing_ext.check_release_updates(release)
|
||||
continue
|
||||
ext = InstallableExtension.from_explicit_release(e)
|
||||
ext.check_latest_version(release)
|
||||
ext.check_release_updates(release)
|
||||
meta = ext.meta or ExtensionMeta()
|
||||
meta.featured = ext.id in manifest.featured
|
||||
ext.meta = meta
|
||||
@@ -628,8 +653,11 @@ class InstallableExtension(BaseModel):
|
||||
@classmethod
|
||||
async def get_extension_releases(cls, ext_id: str) -> list[ExtensionRelease]:
|
||||
extension_releases: list[ExtensionRelease] = []
|
||||
|
||||
for url in settings.lnbits_extensions_manifests:
|
||||
all_manifests = [
|
||||
*settings.lnbits_extensions_manifests,
|
||||
settings.lnbits_extensions_builder_manifest_url,
|
||||
]
|
||||
for url in all_manifests:
|
||||
try:
|
||||
manifest = await cls.fetch_manifest(url)
|
||||
for r in manifest.repos:
|
||||
|
||||
@@ -0,0 +1,460 @@
|
||||
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
|
||||
+23
-12
@@ -1,20 +1,31 @@
|
||||
from typing import Optional
|
||||
from time import time
|
||||
|
||||
from pydantic import BaseModel
|
||||
from lnurl import LnAddress, Lnurl, LnurlPayResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class CreateLnurl(BaseModel):
|
||||
description_hash: str
|
||||
callback: str
|
||||
class CreateLnurlPayment(BaseModel):
|
||||
res: LnurlPayResponse | None = None
|
||||
lnurl: Lnurl | LnAddress | None = None
|
||||
amount: int
|
||||
comment: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
unit: Optional[str] = None
|
||||
comment: str | None = None
|
||||
unit: str | None = None
|
||||
internal_memo: str | None = None
|
||||
|
||||
|
||||
class CreateLnurlAuth(BaseModel):
|
||||
callback: str
|
||||
class CreateLnurlWithdraw(BaseModel):
|
||||
lnurl_w: Lnurl
|
||||
|
||||
|
||||
class PayLnurlWData(BaseModel):
|
||||
lnurl_w: str
|
||||
class LnurlScan(BaseModel):
|
||||
lnurl: Lnurl | LnAddress
|
||||
|
||||
|
||||
class StoredPayLink(BaseModel):
|
||||
lnurl: str
|
||||
label: str
|
||||
last_used: int = Field(default_factory=lambda: int(time()))
|
||||
|
||||
|
||||
class StoredPayLinks(BaseModel):
|
||||
links: list[StoredPayLink] = []
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable
|
||||
from collections.abc import Callable
|
||||
|
||||
import bcrypt
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
@@ -11,15 +9,6 @@ def _do_nothing(*_):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class SolveBugBcryptWarning:
|
||||
__version__: str = getattr(bcrypt, "__version__") # noqa
|
||||
|
||||
|
||||
# fix annoying warning in the logs
|
||||
setattr(bcrypt, "__about__", SolveBugBcryptWarning()) # noqa
|
||||
|
||||
|
||||
class CoreAppExtra:
|
||||
register_new_ext_routes: Callable = _do_nothing
|
||||
register_new_ratelimiter: Callable
|
||||
|
||||
@@ -2,6 +2,8 @@ from enum import Enum
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from lnbits.core.models.users import UserNotifications
|
||||
|
||||
|
||||
class NotificationType(Enum):
|
||||
server_status = "server_status"
|
||||
@@ -18,6 +20,7 @@ class NotificationType(Enum):
|
||||
class NotificationMessage(BaseModel):
|
||||
message_type: NotificationType
|
||||
values: dict
|
||||
user_notifications: UserNotifications | None = None
|
||||
|
||||
|
||||
NOTIFICATION_TEMPLATES = {
|
||||
|
||||
@@ -5,9 +5,17 @@ from enum import Enum
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import Query
|
||||
from lnurl import LnurlWithdrawResponse
|
||||
from pydantic import BaseModel, Field, validator
|
||||
|
||||
from lnbits.db import FilterModel
|
||||
from lnbits.fiat import get_fiat_provider
|
||||
from lnbits.fiat.base import (
|
||||
FiatPaymentFailedStatus,
|
||||
FiatPaymentPendingStatus,
|
||||
FiatPaymentStatus,
|
||||
FiatPaymentSuccessStatus,
|
||||
)
|
||||
from lnbits.utils.exchange_rates import allowed_currencies
|
||||
from lnbits.wallets import get_funding_source
|
||||
from lnbits.wallets.base import (
|
||||
@@ -51,6 +59,7 @@ class CreatePayment(BaseModel):
|
||||
expiry: datetime | None = None
|
||||
webhook: str | None = None
|
||||
fee: int = 0
|
||||
labels: list[str] | None = None
|
||||
|
||||
|
||||
class Payment(BaseModel):
|
||||
@@ -60,6 +69,8 @@ class Payment(BaseModel):
|
||||
amount: int
|
||||
fee: int
|
||||
bolt11: str
|
||||
payment_request: str | None = Field(default=None, no_database=True)
|
||||
fiat_provider: str | None = None
|
||||
status: str = PaymentState.PENDING
|
||||
memo: str | None = None
|
||||
expiry: datetime | None = None
|
||||
@@ -71,8 +82,16 @@ class Payment(BaseModel):
|
||||
time: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
labels: list[str] = []
|
||||
extra: dict = {}
|
||||
|
||||
def __init__(self, **data):
|
||||
super().__init__(**data)
|
||||
if "fiat_payment_request" in self.extra:
|
||||
self.payment_request = self.extra["fiat_payment_request"]
|
||||
else:
|
||||
self.payment_request = self.bolt11
|
||||
|
||||
@property
|
||||
def pending(self) -> bool:
|
||||
return self.status == PaymentState.PENDING.value
|
||||
@@ -107,14 +126,23 @@ class Payment(BaseModel):
|
||||
|
||||
@property
|
||||
def is_internal(self) -> bool:
|
||||
return self.checking_id.startswith("internal_")
|
||||
return self.checking_id.startswith("internal_") or self.checking_id.startswith(
|
||||
"fiat_"
|
||||
)
|
||||
|
||||
async def check_status(self) -> PaymentStatus:
|
||||
async def check_status(
|
||||
self, skip_internal_payment_notifications: bool | None = False
|
||||
) -> PaymentStatus:
|
||||
if self.is_internal:
|
||||
if self.success:
|
||||
return PaymentSuccessStatus()
|
||||
if self.failed:
|
||||
return PaymentFailedStatus()
|
||||
if self.is_in and self.fiat_provider:
|
||||
fiat_status = await self.check_fiat_status(
|
||||
skip_internal_payment_notifications
|
||||
)
|
||||
return PaymentStatus(paid=fiat_status.paid)
|
||||
return PaymentPendingStatus()
|
||||
funding_source = get_funding_source()
|
||||
if self.is_out:
|
||||
@@ -123,9 +151,50 @@ class Payment(BaseModel):
|
||||
status = await funding_source.get_invoice_status(self.checking_id)
|
||||
return status
|
||||
|
||||
async def check_fiat_status(
|
||||
self, skip_internal_payment_notifications: bool | None = False
|
||||
) -> FiatPaymentStatus:
|
||||
if not self.is_internal:
|
||||
return FiatPaymentPendingStatus()
|
||||
if self.success:
|
||||
return FiatPaymentSuccessStatus()
|
||||
if self.failed:
|
||||
return FiatPaymentFailedStatus()
|
||||
|
||||
if not self.fiat_provider:
|
||||
return FiatPaymentPendingStatus()
|
||||
|
||||
checking_id = self.extra.get("fiat_checking_id")
|
||||
if not checking_id:
|
||||
return FiatPaymentPendingStatus()
|
||||
|
||||
fiat_provider = await get_fiat_provider(self.fiat_provider)
|
||||
if not fiat_provider:
|
||||
return FiatPaymentPendingStatus()
|
||||
fiat_status = await fiat_provider.get_invoice_status(checking_id)
|
||||
|
||||
if skip_internal_payment_notifications:
|
||||
return fiat_status
|
||||
|
||||
if fiat_status.success:
|
||||
# notify receivers asynchronously
|
||||
from lnbits.tasks import internal_invoice_queue
|
||||
|
||||
await internal_invoice_queue.put(self.checking_id)
|
||||
|
||||
return fiat_status
|
||||
|
||||
|
||||
class PaymentFilters(FilterModel):
|
||||
__search_fields__ = ["memo", "amount", "wallet_id", "tag", "status", "time"]
|
||||
__search_fields__ = [
|
||||
"memo",
|
||||
"amount",
|
||||
"wallet_id",
|
||||
"tag",
|
||||
"status",
|
||||
"time",
|
||||
"labels",
|
||||
]
|
||||
|
||||
__sort_fields__ = ["created_at", "amount", "fee", "memo", "time", "tag"]
|
||||
|
||||
@@ -139,6 +208,7 @@ class PaymentFilters(FilterModel):
|
||||
preimage: str | None
|
||||
payment_hash: str | None
|
||||
wallet_id: str | None
|
||||
labels: str | None
|
||||
|
||||
|
||||
class PaymentDataPoint(BaseModel):
|
||||
@@ -201,11 +271,25 @@ class CreateInvoice(BaseModel):
|
||||
memo: str | None = Query(None, max_length=640)
|
||||
description_hash: 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
|
||||
extra: dict | None = None
|
||||
webhook: str | None = None
|
||||
bolt11: str | None = None
|
||||
lnurl_callback: str | None = None
|
||||
lnurl_withdraw: LnurlWithdrawResponse | None = None
|
||||
fiat_provider: str | None = None
|
||||
labels: list[str] = []
|
||||
|
||||
@validator("payment_hash")
|
||||
def check_hex(cls, v):
|
||||
if v:
|
||||
_ = bytes.fromhex(v)
|
||||
return v
|
||||
|
||||
@validator("unit")
|
||||
@classmethod
|
||||
@@ -220,3 +304,35 @@ class PaymentsStatusCount(BaseModel):
|
||||
outgoing: int = 0
|
||||
failed: int = 0
|
||||
pending: int = 0
|
||||
|
||||
|
||||
class SettleInvoice(BaseModel):
|
||||
preimage: str = Field(
|
||||
...,
|
||||
description="The preimage of the payment hash to settle the invoice.",
|
||||
min_length=64,
|
||||
max_length=64,
|
||||
)
|
||||
|
||||
@validator("preimage")
|
||||
def check_hex(cls, v):
|
||||
_ = bytes.fromhex(v)
|
||||
return v
|
||||
|
||||
|
||||
class CancelInvoice(BaseModel):
|
||||
payment_hash: str = Field(
|
||||
...,
|
||||
description="The payment hash of the invoice to cancel.",
|
||||
min_length=64,
|
||||
max_length=64,
|
||||
)
|
||||
|
||||
@validator("payment_hash")
|
||||
def check_hex(cls, v):
|
||||
_ = bytes.fromhex(v)
|
||||
return v
|
||||
|
||||
|
||||
class UpdatePaymentLabels(BaseModel):
|
||||
labels: list[str] = []
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
"""Keycloak SSO Login Helper
|
||||
"""
|
||||
"""Keycloak SSO Login Helper"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
|
||||
+119
-15
@@ -3,18 +3,48 @@ from __future__ import annotations
|
||||
from datetime import datetime, timezone
|
||||
from uuid import UUID
|
||||
|
||||
from bcrypt import checkpw, gensalt, hashpw
|
||||
from fastapi import Query
|
||||
from passlib.context import CryptContext
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from lnbits.core.models.misc import SimpleItem
|
||||
from lnbits.db import FilterModel
|
||||
from lnbits.helpers import is_valid_email_address, is_valid_pubkey, is_valid_username
|
||||
from lnbits.helpers import (
|
||||
is_valid_email_address,
|
||||
is_valid_external_id,
|
||||
is_valid_label,
|
||||
is_valid_pubkey,
|
||||
is_valid_username,
|
||||
)
|
||||
from lnbits.settings import settings
|
||||
|
||||
from .wallets import Wallet
|
||||
|
||||
|
||||
class UserNotifications(BaseModel):
|
||||
nostr_identifier: str | None = None
|
||||
telegram_chat_id: str | None = None
|
||||
email_address: str | None = None
|
||||
excluded_wallets: list[str] = []
|
||||
outgoing_payments_sats: int = 0
|
||||
incoming_payments_sats: int = 0
|
||||
|
||||
|
||||
class WalletInviteRequest(BaseModel):
|
||||
request_id: str
|
||||
from_user_name: str | None = None
|
||||
to_wallet_id: str
|
||||
to_wallet_name: str
|
||||
|
||||
|
||||
class UserLabel(BaseModel):
|
||||
name: str = Field(regex=r"([A-Za-z0-9 ._-]{1,100}$)")
|
||||
description: str | None = Field(default=None, max_length=250)
|
||||
color: str | None = Field(
|
||||
default=None, regex=r"^#[0-9A-Fa-f]{6}$"
|
||||
) # e.g., "#RRGGBB"
|
||||
|
||||
|
||||
class UserExtra(BaseModel):
|
||||
email_verified: bool | None = False
|
||||
first_name: str | None = None
|
||||
@@ -30,6 +60,57 @@ class UserExtra(BaseModel):
|
||||
# how many wallets are shown in the user interface
|
||||
visible_wallet_count: int | None = 10
|
||||
|
||||
notifications: UserNotifications = UserNotifications()
|
||||
|
||||
wallet_invite_requests: list[WalletInviteRequest] = []
|
||||
|
||||
labels: list[UserLabel] = []
|
||||
|
||||
def add_wallet_invite_request(
|
||||
self,
|
||||
request_id: str,
|
||||
to_wallet_id: str,
|
||||
to_wallet_name: str,
|
||||
from_user_name: str | None = None,
|
||||
) -> WalletInviteRequest:
|
||||
self.remove_wallet_invite_request(request_id)
|
||||
invite = WalletInviteRequest(
|
||||
request_id=request_id,
|
||||
from_user_name=from_user_name,
|
||||
to_wallet_id=to_wallet_id,
|
||||
to_wallet_name=to_wallet_name,
|
||||
)
|
||||
self.wallet_invite_requests.append(invite)
|
||||
return invite
|
||||
|
||||
def find_wallet_invite_request(self, request_id: str) -> WalletInviteRequest | None:
|
||||
for invite in self.wallet_invite_requests:
|
||||
if invite.request_id == request_id:
|
||||
return invite
|
||||
return None
|
||||
|
||||
def validate_labels(self):
|
||||
seen_labels = set()
|
||||
for label in self.labels:
|
||||
if not label.name:
|
||||
raise ValueError("Label name cannot be empty.")
|
||||
# apply the same rule for labels as for usernames
|
||||
if not is_valid_label(label.name):
|
||||
raise ValueError(f"Invalid label name: {label.name}")
|
||||
if label.name in seen_labels:
|
||||
raise ValueError(f"Duplicate label name: {label.name}")
|
||||
seen_labels.add(label.name)
|
||||
|
||||
def remove_wallet_invite_request(
|
||||
self,
|
||||
request_id: str,
|
||||
):
|
||||
self.wallet_invite_requests = [
|
||||
invite
|
||||
for invite in self.wallet_invite_requests
|
||||
if invite.request_id != request_id
|
||||
]
|
||||
|
||||
|
||||
class EndpointAccess(BaseModel):
|
||||
path: str
|
||||
@@ -93,6 +174,7 @@ class UserAcls(BaseModel):
|
||||
|
||||
class Account(BaseModel):
|
||||
id: str
|
||||
external_id: str | None = None # for external account linking
|
||||
username: str | None = None
|
||||
password_hash: str | None = None
|
||||
pubkey: str | None = None
|
||||
@@ -104,24 +186,28 @@ class Account(BaseModel):
|
||||
|
||||
is_super_user: bool = Field(default=False, no_database=True)
|
||||
is_admin: bool = Field(default=False, no_database=True)
|
||||
fiat_providers: list[str] = Field(default=[], no_database=True)
|
||||
|
||||
def __init__(self, **data):
|
||||
super().__init__(**data)
|
||||
self.is_super_user = settings.is_super_user(self.id)
|
||||
self.is_admin = settings.is_admin_user(self.id)
|
||||
self.fiat_providers = settings.get_fiat_providers_for_user(self.id)
|
||||
|
||||
def hash_password(self, password: str) -> str:
|
||||
"""sets and returns the hashed password"""
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
self.password_hash = pwd_context.hash(password)
|
||||
salt = gensalt()
|
||||
hashed_pw = hashpw(password.encode(), salt)
|
||||
if not hashed_pw:
|
||||
raise ValueError("Password hashing failed.")
|
||||
self.password_hash = hashed_pw.decode()
|
||||
return self.password_hash
|
||||
|
||||
def verify_password(self, password: str) -> bool:
|
||||
"""returns True if the password matches the hash"""
|
||||
if not self.password_hash:
|
||||
return False
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
return pwd_context.verify(password, self.password_hash)
|
||||
return checkpw(password.encode(), self.password_hash.encode())
|
||||
|
||||
def validate_fields(self):
|
||||
if self.username and not is_valid_username(self.username):
|
||||
@@ -130,10 +216,17 @@ class Account(BaseModel):
|
||||
raise ValueError("Invalid email.")
|
||||
if self.pubkey and not is_valid_pubkey(self.pubkey):
|
||||
raise ValueError("Invalid pubkey.")
|
||||
if self.external_id and not is_valid_external_id(self.external_id):
|
||||
raise ValueError(
|
||||
"Invalid external id. Max length is 256 characters. "
|
||||
"Space and newlines are not allowed."
|
||||
)
|
||||
user_uuid4 = UUID(hex=self.id, version=4)
|
||||
if user_uuid4.hex != self.id:
|
||||
raise ValueError("User ID is not valid UUID4 hex string.")
|
||||
|
||||
self.extra.validate_labels()
|
||||
|
||||
|
||||
class AccountOverview(Account):
|
||||
transaction_count: int | None = 0
|
||||
@@ -143,20 +236,29 @@ class AccountOverview(Account):
|
||||
|
||||
|
||||
class AccountFilters(FilterModel):
|
||||
__search_fields__ = ["user", "email", "username", "pubkey", "wallet_id"]
|
||||
__sort_fields__ = [
|
||||
"balance_msat",
|
||||
__search_fields__ = [
|
||||
"id",
|
||||
"email",
|
||||
"username",
|
||||
"transaction_count",
|
||||
"wallet_count",
|
||||
"last_payment",
|
||||
"pubkey",
|
||||
"external_id",
|
||||
"wallet_id",
|
||||
]
|
||||
__sort_fields__ = [
|
||||
"id",
|
||||
"email",
|
||||
"username",
|
||||
"pubkey",
|
||||
"external_id",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
email: str | None = None
|
||||
user: str | None = None
|
||||
id: str | None = None
|
||||
username: str | None = None
|
||||
email: str | None = None
|
||||
pubkey: str | None = None
|
||||
external_id: str | None = None
|
||||
wallet_id: str | None = None
|
||||
|
||||
|
||||
@@ -167,10 +269,12 @@ class User(BaseModel):
|
||||
email: str | None = None
|
||||
username: str | None = None
|
||||
pubkey: str | None = None
|
||||
external_id: str | None = None # for external account linking
|
||||
extensions: list[str] = []
|
||||
wallets: list[Wallet] = []
|
||||
admin: bool = False
|
||||
super_user: bool = False
|
||||
fiat_providers: list[str] = []
|
||||
has_password: bool = False
|
||||
extra: UserExtra = UserExtra()
|
||||
|
||||
@@ -207,13 +311,13 @@ class CreateUser(BaseModel):
|
||||
password: str | None = Query(default=None, min_length=8, max_length=50)
|
||||
password_repeat: str | None = Query(default=None, min_length=8, max_length=50)
|
||||
pubkey: str = Query(default=None, max_length=64)
|
||||
external_id: str = Query(default=None, max_length=256)
|
||||
extensions: list[str] | None = None
|
||||
extra: UserExtra | None = None
|
||||
|
||||
|
||||
class UpdateUser(BaseModel):
|
||||
user_id: str
|
||||
email: str | None = Query(default=None)
|
||||
username: str | None = Query(default=..., min_length=2, max_length=20)
|
||||
extra: UserExtra | None = None
|
||||
|
||||
|
||||
+160
-17
@@ -1,17 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from enum import Enum
|
||||
|
||||
from ecdsa import SECP256k1, SigningKey
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from lnbits.core.models.lnurl import StoredPayLinks
|
||||
from lnbits.db import FilterModel
|
||||
from lnbits.helpers import url_for
|
||||
from lnbits.lnurl import encode as lnurl_encode
|
||||
from lnbits.settings import settings
|
||||
|
||||
|
||||
@@ -23,10 +19,95 @@ class BaseWallet(BaseModel):
|
||||
balance_msat: int
|
||||
|
||||
|
||||
class WalletType(Enum):
|
||||
LIGHTNING = "lightning"
|
||||
LIGHTNING_SHARED = "lightning-shared"
|
||||
|
||||
|
||||
class WalletPermission(Enum):
|
||||
VIEW_PAYMENTS = "view-payments"
|
||||
RECEIVE_PAYMENTS = "receive-payments"
|
||||
SEND_PAYMENTS = "send-payments"
|
||||
|
||||
def __str__(self):
|
||||
return self.value
|
||||
|
||||
|
||||
class WalletShareStatus(Enum):
|
||||
INVITE_SENT = "invite_sent"
|
||||
APPROVED = "approved"
|
||||
|
||||
|
||||
class WalletSharePermission(BaseModel):
|
||||
# unique identifier for this share request
|
||||
request_id: str | None = None
|
||||
# username of the invited user
|
||||
username: str
|
||||
# ID of the wallet being shared with
|
||||
shared_with_wallet_id: str | None = None
|
||||
# permissions being granted
|
||||
permissions: list[WalletPermission] = []
|
||||
# status of the share request
|
||||
status: WalletShareStatus
|
||||
comment: str | None = None
|
||||
|
||||
def approve(
|
||||
self,
|
||||
permissions: list[WalletPermission] | None = None,
|
||||
shared_with_wallet_id: str | None = None,
|
||||
):
|
||||
self.status = WalletShareStatus.APPROVED
|
||||
if permissions is not None:
|
||||
self.permissions = permissions
|
||||
if shared_with_wallet_id is not None:
|
||||
self.shared_with_wallet_id = shared_with_wallet_id
|
||||
|
||||
@property
|
||||
def is_approved(self) -> bool:
|
||||
return self.status == WalletShareStatus.APPROVED
|
||||
|
||||
|
||||
class WalletExtra(BaseModel):
|
||||
icon: str = "flash_on"
|
||||
color: str = "primary"
|
||||
pinned: bool = False
|
||||
# What permissions this wallet grants when it's shared with other users
|
||||
shared_with: list[WalletSharePermission] = []
|
||||
|
||||
def invite_user_to_shared_wallet(
|
||||
self,
|
||||
request_id: str,
|
||||
request_type: WalletShareStatus,
|
||||
username: str,
|
||||
permissions: list[WalletPermission] | None = None,
|
||||
) -> WalletSharePermission:
|
||||
share = WalletSharePermission(
|
||||
request_id=request_id,
|
||||
username=username,
|
||||
status=request_type,
|
||||
permissions=permissions or [],
|
||||
)
|
||||
self.shared_with.append(share)
|
||||
return share
|
||||
|
||||
def find_share_by_id(self, request_id: str) -> WalletSharePermission | None:
|
||||
for share in self.shared_with:
|
||||
if share.request_id == request_id:
|
||||
return share
|
||||
return None
|
||||
|
||||
def find_share_for_wallet(
|
||||
self, shared_with_wallet_id: str
|
||||
) -> WalletSharePermission | None:
|
||||
for share in self.shared_with:
|
||||
if share.shared_with_wallet_id == shared_with_wallet_id:
|
||||
return share
|
||||
return None
|
||||
|
||||
def remove_share_by_id(self, request_id: str):
|
||||
self.shared_with = [
|
||||
share for share in self.shared_with if share.request_id != request_id
|
||||
]
|
||||
|
||||
|
||||
class Wallet(BaseModel):
|
||||
@@ -35,12 +116,75 @@ class Wallet(BaseModel):
|
||||
name: str
|
||||
adminkey: str
|
||||
inkey: str
|
||||
wallet_type: str = WalletType.LIGHTNING.value
|
||||
# Must be set only for shared wallets
|
||||
shared_wallet_id: str | None = None
|
||||
deleted: bool = False
|
||||
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
currency: str | None = None
|
||||
balance_msat: int = Field(default=0, no_database=True)
|
||||
extra: WalletExtra = WalletExtra()
|
||||
stored_paylinks: StoredPayLinks = StoredPayLinks()
|
||||
# What permission this wallet has when it's a shared wallet
|
||||
share_permissions: list[WalletPermission] = Field(default=[], no_database=True)
|
||||
|
||||
def __init__(self, **data):
|
||||
super().__init__(**data)
|
||||
self._validate_data()
|
||||
|
||||
def mirror_shared_wallet(
|
||||
self,
|
||||
shared_wallet: Wallet,
|
||||
):
|
||||
if not shared_wallet.is_lightning_wallet:
|
||||
return None
|
||||
|
||||
self.wallet_type = WalletType.LIGHTNING_SHARED.value
|
||||
self.shared_wallet_id = shared_wallet.id
|
||||
self.name = shared_wallet.name
|
||||
self.share_permissions = shared_wallet.get_share_permissions(self.id)
|
||||
|
||||
if len(self.share_permissions):
|
||||
self.currency = shared_wallet.currency
|
||||
self.balance_msat = shared_wallet.balance_msat
|
||||
|
||||
self.stored_paylinks = shared_wallet.stored_paylinks
|
||||
self.extra.icon = shared_wallet.extra.icon
|
||||
self.extra.color = shared_wallet.extra.color
|
||||
|
||||
def get_share_permissions(self, wallet_id: str) -> list[WalletPermission]:
|
||||
for share in self.extra.shared_with:
|
||||
if share.shared_with_wallet_id == wallet_id and share.is_approved:
|
||||
return share.permissions
|
||||
return []
|
||||
|
||||
def has_permission(self, permission: WalletPermission) -> bool:
|
||||
if self.is_lightning_wallet:
|
||||
return True
|
||||
if self.is_lightning_shared_wallet:
|
||||
return permission in self.share_permissions
|
||||
|
||||
return False
|
||||
|
||||
@property
|
||||
def source_wallet_id(self) -> str:
|
||||
"""For shared wallets return the original wallet ID, else return own ID."""
|
||||
if self.is_lightning_shared_wallet and len(self.share_permissions):
|
||||
return self.shared_wallet_id or self.id
|
||||
return self.id
|
||||
|
||||
@property
|
||||
def can_receive_payments(self) -> bool:
|
||||
return self.has_permission(WalletPermission.RECEIVE_PAYMENTS)
|
||||
|
||||
@property
|
||||
def can_send_payments(self) -> bool:
|
||||
return self.has_permission(WalletPermission.SEND_PAYMENTS)
|
||||
|
||||
@property
|
||||
def can_view_payments(self) -> bool:
|
||||
return self.has_permission(WalletPermission.VIEW_PAYMENTS)
|
||||
|
||||
@property
|
||||
def balance(self) -> int:
|
||||
@@ -51,24 +195,23 @@ class Wallet(BaseModel):
|
||||
return self.balance_msat - settings.fee_reserve(self.balance_msat)
|
||||
|
||||
@property
|
||||
def lnurlwithdraw_full(self) -> str:
|
||||
url = url_for("/withdraw", external=True, usr=self.user, wal=self.id)
|
||||
try:
|
||||
return lnurl_encode(url)
|
||||
except Exception:
|
||||
return ""
|
||||
def is_lightning_wallet(self) -> bool:
|
||||
return self.wallet_type == WalletType.LIGHTNING.value
|
||||
|
||||
def lnurlauth_key(self, domain: str) -> SigningKey:
|
||||
hashing_key = hashlib.sha256(self.id.encode()).digest()
|
||||
linking_key = hmac.digest(hashing_key, domain.encode(), "sha256")
|
||||
@property
|
||||
def is_lightning_shared_wallet(self) -> bool:
|
||||
return self.wallet_type == WalletType.LIGHTNING_SHARED.value
|
||||
|
||||
return SigningKey.from_string(
|
||||
linking_key, curve=SECP256k1, hashfunc=hashlib.sha256
|
||||
)
|
||||
def _validate_data(self):
|
||||
if self.is_lightning_shared_wallet:
|
||||
if not self.shared_wallet_id:
|
||||
raise ValueError("Shared wallet ID must be set for shared wallets.")
|
||||
|
||||
|
||||
class CreateWallet(BaseModel):
|
||||
name: str | None = None
|
||||
wallet_type: WalletType = WalletType.LIGHTNING
|
||||
shared_wallet_id: str | None = None
|
||||
|
||||
|
||||
class KeyType(Enum):
|
||||
|
||||
@@ -2,17 +2,24 @@ from .funding_source import (
|
||||
get_balance_delta,
|
||||
switch_to_voidwallet,
|
||||
)
|
||||
from .lnurl import perform_lnurlauth, redeem_lnurl_withdraw
|
||||
from .notifications import enqueue_notification, send_payment_notification
|
||||
from .lnurl import fetch_lnurl_pay_request, get_pr_from_lnurl, perform_withdraw
|
||||
from .notifications import enqueue_admin_notification, send_payment_notification
|
||||
from .payments import (
|
||||
calculate_fiat_amounts,
|
||||
cancel_hold_invoice,
|
||||
check_transaction_status,
|
||||
check_wallet_limits,
|
||||
create_fiat_invoice,
|
||||
create_invoice,
|
||||
create_payment_request,
|
||||
create_wallet_invoice,
|
||||
fee_reserve,
|
||||
fee_reserve_total,
|
||||
get_payments_daily_stats,
|
||||
pay_invoice,
|
||||
service_fee,
|
||||
settle_hold_invoice,
|
||||
update_pending_payment,
|
||||
update_pending_payments,
|
||||
update_wallet_balance,
|
||||
)
|
||||
@@ -30,36 +37,37 @@ from .users import (
|
||||
from .websockets import websocket_manager, websocket_updater
|
||||
|
||||
__all__ = [
|
||||
# funding source
|
||||
"get_balance_delta",
|
||||
"switch_to_voidwallet",
|
||||
# lnurl
|
||||
"redeem_lnurl_withdraw",
|
||||
"perform_lnurlauth",
|
||||
# notifications
|
||||
"enqueue_notification",
|
||||
"send_payment_notification",
|
||||
# payments
|
||||
"calculate_fiat_amounts",
|
||||
"cancel_hold_invoice",
|
||||
"check_admin_settings",
|
||||
"check_transaction_status",
|
||||
"check_wallet_limits",
|
||||
"create_invoice",
|
||||
"fee_reserve",
|
||||
"fee_reserve_total",
|
||||
"pay_invoice",
|
||||
"service_fee",
|
||||
"update_pending_payments",
|
||||
"update_wallet_balance",
|
||||
# settings
|
||||
"check_webpush_settings",
|
||||
"update_cached_settings",
|
||||
# users
|
||||
"check_admin_settings",
|
||||
"create_fiat_invoice",
|
||||
"create_invoice",
|
||||
"create_payment_request",
|
||||
"create_user_account",
|
||||
"create_user_account_no_ckeck",
|
||||
"create_wallet_invoice",
|
||||
"enqueue_admin_notification",
|
||||
"fee_reserve",
|
||||
"fee_reserve_total",
|
||||
"fetch_lnurl_pay_request",
|
||||
"get_balance_delta",
|
||||
"get_payments_daily_stats",
|
||||
"get_pr_from_lnurl",
|
||||
"pay_invoice",
|
||||
"perform_withdraw",
|
||||
"send_payment_notification",
|
||||
"service_fee",
|
||||
"settle_hold_invoice",
|
||||
"switch_to_voidwallet",
|
||||
"update_cached_settings",
|
||||
"update_pending_payment",
|
||||
"update_pending_payments",
|
||||
"update_user_account",
|
||||
"update_user_extensions",
|
||||
# websockets
|
||||
"update_wallet_balance",
|
||||
"websocket_manager",
|
||||
"websocket_updater",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import base64
|
||||
import io
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import UploadFile
|
||||
from PIL import Image
|
||||
|
||||
from lnbits.core.crud.assets import create_asset, get_user_assets_count
|
||||
from lnbits.core.models.assets import Asset
|
||||
from lnbits.settings import settings
|
||||
|
||||
|
||||
async def create_user_asset(user_id: str, file: UploadFile, is_public: bool) -> Asset:
|
||||
if not file.content_type:
|
||||
raise ValueError("File must have a content type.")
|
||||
if file.content_type.lower() not in settings.lnbits_assets_allowed_mime_types:
|
||||
raise ValueError(f"File type '{file.content_type}' not allowed.")
|
||||
|
||||
if user_id not in settings.lnbits_assets_no_limit_users:
|
||||
user_assets_count = await get_user_assets_count(user_id)
|
||||
if user_assets_count >= settings.lnbits_max_assets_per_user:
|
||||
raise ValueError(
|
||||
f"Max upload count of {settings.lnbits_max_assets_per_user} exceeded."
|
||||
)
|
||||
|
||||
contents = await file.read()
|
||||
if len(contents) > settings.lnbits_max_asset_size_mb * 1024 * 1024:
|
||||
raise ValueError(
|
||||
f"File limit of {settings.lnbits_max_asset_size_mb}MB exceeded."
|
||||
)
|
||||
|
||||
image = Image.open(io.BytesIO(contents))
|
||||
|
||||
thumbnail_width = min(256, settings.lnbits_asset_thumbnail_width)
|
||||
thumbnail_height = min(256, settings.lnbits_asset_thumbnail_height)
|
||||
image.thumbnail((thumbnail_width, thumbnail_height))
|
||||
|
||||
# Save thumbnail to an in-memory buffer
|
||||
thumb_buffer = io.BytesIO()
|
||||
thumbnail_format = settings.lnbits_asset_thumbnail_format or "PNG"
|
||||
image.save(thumb_buffer, format=thumbnail_format)
|
||||
thumb_buffer.seek(0)
|
||||
|
||||
asset = Asset(
|
||||
id=uuid4().hex,
|
||||
user_id=user_id,
|
||||
mime_type=file.content_type,
|
||||
is_public=is_public,
|
||||
name=file.filename or "unnamed",
|
||||
size_bytes=len(contents),
|
||||
thumbnail_base64=base64.b64encode(thumb_buffer.getvalue()).decode("utf-8"),
|
||||
data=contents,
|
||||
)
|
||||
|
||||
await create_asset(asset)
|
||||
return asset
|
||||
@@ -1,6 +1,5 @@
|
||||
import asyncio
|
||||
import importlib
|
||||
from typing import Optional
|
||||
|
||||
from loguru import logger
|
||||
|
||||
@@ -22,20 +21,24 @@ from lnbits.settings import settings
|
||||
from ..models.extensions import Extension, ExtensionMeta, InstallableExtension
|
||||
|
||||
|
||||
async def install_extension(ext_info: InstallableExtension) -> Extension:
|
||||
async def install_extension(
|
||||
ext_info: InstallableExtension, skip_download: bool | None = False
|
||||
) -> Extension:
|
||||
|
||||
ext_info.meta = ext_info.meta or ExtensionMeta()
|
||||
|
||||
if ext_info.meta.installed_release:
|
||||
assert (
|
||||
ext_info.meta.installed_release.is_version_compatible
|
||||
), "Incompatible extension version"
|
||||
if (
|
||||
ext_info.meta.installed_release
|
||||
and not ext_info.meta.installed_release.is_version_compatible
|
||||
):
|
||||
raise ValueError("Incompatible extension version")
|
||||
|
||||
installed_ext = await get_installed_extension(ext_info.id)
|
||||
if installed_ext and installed_ext.meta:
|
||||
ext_info.meta.payments = installed_ext.meta.payments
|
||||
|
||||
await ext_info.download_archive()
|
||||
if not skip_download:
|
||||
await ext_info.download_archive()
|
||||
|
||||
ext_info.extract_archive()
|
||||
|
||||
@@ -73,11 +76,13 @@ async def uninstall_extension(ext_id: str):
|
||||
async def activate_extension(ext: Extension):
|
||||
core_app_extra.register_new_ext_routes(ext)
|
||||
await update_installed_extension_state(ext_id=ext.code, active=True)
|
||||
await start_extension_background_work(ext.code)
|
||||
|
||||
|
||||
async def deactivate_extension(ext_id: str):
|
||||
settings.deactivate_extension_paths(ext_id)
|
||||
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:
|
||||
@@ -93,9 +98,8 @@ async def stop_extension_background_work(ext_id: str) -> bool:
|
||||
old_module = importlib.import_module(ext.module_name)
|
||||
|
||||
stop_fn_name = f"{ext_id}_stop"
|
||||
assert hasattr(
|
||||
old_module, stop_fn_name
|
||||
), f"No stop function found for '{ext.module_name}'."
|
||||
if not hasattr(old_module, stop_fn_name):
|
||||
raise ValueError(f"No stop function found for '{ext.module_name}'.")
|
||||
|
||||
stop_fn = getattr(old_module, stop_fn_name)
|
||||
if stop_fn:
|
||||
@@ -145,7 +149,7 @@ async def start_extension_background_work(ext_id: str) -> bool:
|
||||
|
||||
|
||||
async def get_valid_extensions(
|
||||
include_deactivated: Optional[bool] = True,
|
||||
include_deactivated: bool | None = True,
|
||||
) -> list[Extension]:
|
||||
installed_extensions = await get_installed_extensions()
|
||||
valid_extensions = [Extension.from_installable_ext(e) for e in installed_extensions]
|
||||
@@ -164,8 +168,8 @@ async def get_valid_extensions(
|
||||
|
||||
|
||||
async def get_valid_extension(
|
||||
ext_id: str, include_deactivated: Optional[bool] = True
|
||||
) -> Optional[Extension]:
|
||||
ext_id: str, include_deactivated: bool | None = True
|
||||
) -> Extension | None:
|
||||
ext = await get_installed_extension(ext_id)
|
||||
if not ext:
|
||||
return None
|
||||
|
||||
@@ -0,0 +1,543 @@
|
||||
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
|
||||
@@ -0,0 +1,173 @@
|
||||
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
|
||||
from lnbits.core.models import CreatePayment, Payment, PaymentState
|
||||
from lnbits.core.models.misc import SimpleStatus
|
||||
from lnbits.db import Connection
|
||||
from lnbits.fiat import get_fiat_provider
|
||||
from lnbits.settings import settings
|
||||
|
||||
|
||||
async def handle_fiat_payment_confirmation(
|
||||
payment: Payment, conn: Connection | None = None
|
||||
):
|
||||
try:
|
||||
await _credit_fiat_service_fee_wallet(payment, conn=conn)
|
||||
except Exception as e:
|
||||
logger.warning(e)
|
||||
|
||||
try:
|
||||
await _debit_fiat_service_faucet_wallet(payment, conn=conn)
|
||||
except Exception as e:
|
||||
logger.warning(e)
|
||||
|
||||
|
||||
def check_stripe_signature(
|
||||
payload: bytes,
|
||||
sig_header: str | None,
|
||||
secret: str | None,
|
||||
tolerance_seconds=300,
|
||||
):
|
||||
if not sig_header:
|
||||
logger.warning("Stripe-Signature header is missing.")
|
||||
raise ValueError("Stripe-Signature header is missing.")
|
||||
|
||||
if not secret:
|
||||
logger.warning("Stripe webhook signing secret is not set.")
|
||||
raise ValueError("Stripe webhook cannot be verified.")
|
||||
|
||||
# Split the Stripe-Signature header
|
||||
items = dict(i.split("=") for i in sig_header.split(","))
|
||||
timestamp = int(items["t"])
|
||||
signature = items["v1"]
|
||||
|
||||
# Check timestamp tolerance
|
||||
if abs(time.time() - timestamp) > tolerance_seconds:
|
||||
logger.warning("Timestamp outside tolerance.")
|
||||
logger.debug(
|
||||
f"Current time: {time.time()}, "
|
||||
f"Timestamp: {timestamp}, "
|
||||
f"Tolerance: {tolerance_seconds} seconds"
|
||||
)
|
||||
|
||||
raise ValueError("Timestamp outside tolerance." f"Timestamp: {timestamp}")
|
||||
|
||||
signed_payload = f"{timestamp}.{payload.decode()}"
|
||||
|
||||
# Compute HMAC SHA256 using the webhook secret
|
||||
computed_signature = hmac.new(
|
||||
key=secret.encode(), msg=signed_payload.encode(), digestmod=hashlib.sha256
|
||||
).hexdigest()
|
||||
|
||||
# Compare signatures using constant time comparison
|
||||
if hmac.compare_digest(computed_signature, signature) is not True:
|
||||
logger.warning("Stripe signature verification failed.")
|
||||
raise ValueError("Stripe signature verification failed.")
|
||||
|
||||
|
||||
async def test_connection(provider: str) -> SimpleStatus:
|
||||
"""
|
||||
Test the connection to Stripe by checking if the API key is valid.
|
||||
This function should be called when setting up or testing the Stripe integration.
|
||||
"""
|
||||
fiat_provider = await get_fiat_provider(provider)
|
||||
if not fiat_provider:
|
||||
return SimpleStatus(
|
||||
success=False,
|
||||
message=f"Fiat provider '{provider}' not found.",
|
||||
)
|
||||
status = await fiat_provider.status()
|
||||
if status.error_message:
|
||||
return SimpleStatus(
|
||||
success=False,
|
||||
message=f"Cconnection test failed: {status.error_message}",
|
||||
)
|
||||
|
||||
return SimpleStatus(
|
||||
success=True,
|
||||
message="Connection test successful." f" Balance: {status.balance}.",
|
||||
)
|
||||
|
||||
|
||||
async def _credit_fiat_service_fee_wallet(
|
||||
payment: Payment, conn: Connection | None = None
|
||||
):
|
||||
fiat_provider_name = payment.fiat_provider
|
||||
if not fiat_provider_name:
|
||||
return
|
||||
if payment.fee == 0:
|
||||
return
|
||||
|
||||
limits = settings.get_fiat_provider_limits(fiat_provider_name)
|
||||
if not limits:
|
||||
return
|
||||
|
||||
if not limits.service_fee_wallet_id:
|
||||
return
|
||||
|
||||
memo = (
|
||||
f"Service fee for fiat payment of "
|
||||
f"{abs(payment.sat)} sats. "
|
||||
f"Provider: {fiat_provider_name}. "
|
||||
f"Wallet: '{payment.wallet_id}'."
|
||||
)
|
||||
create_payment_model = CreatePayment(
|
||||
wallet_id=limits.service_fee_wallet_id,
|
||||
bolt11=payment.bolt11,
|
||||
payment_hash=payment.payment_hash,
|
||||
amount_msat=abs(payment.fee),
|
||||
memo=memo,
|
||||
)
|
||||
await create_payment(
|
||||
checking_id=f"service_fee_{payment.payment_hash}",
|
||||
data=create_payment_model,
|
||||
status=PaymentState.SUCCESS,
|
||||
conn=conn,
|
||||
)
|
||||
|
||||
|
||||
async def _debit_fiat_service_faucet_wallet(
|
||||
payment: Payment, conn: Connection | None = None
|
||||
):
|
||||
fiat_provider_name = payment.fiat_provider
|
||||
if not fiat_provider_name:
|
||||
return
|
||||
|
||||
limits = settings.get_fiat_provider_limits(fiat_provider_name)
|
||||
if not limits:
|
||||
return
|
||||
|
||||
if not limits.service_faucet_wallet_id:
|
||||
return
|
||||
|
||||
faucet_wallet = await get_wallet(limits.service_faucet_wallet_id, conn=conn)
|
||||
if not faucet_wallet:
|
||||
raise ValueError(
|
||||
f"Fiat provider '{fiat_provider_name}' faucet wallet not found."
|
||||
)
|
||||
|
||||
memo = (
|
||||
f"Faucet payment of {abs(payment.sat)} sats. "
|
||||
f"Provider: {fiat_provider_name}. "
|
||||
f"Wallet: '{payment.wallet_id}'."
|
||||
)
|
||||
create_payment_model = CreatePayment(
|
||||
wallet_id=limits.service_faucet_wallet_id,
|
||||
bolt11=payment.bolt11,
|
||||
payment_hash=payment.payment_hash,
|
||||
amount_msat=-abs(payment.amount),
|
||||
memo=memo,
|
||||
extra=payment.extra,
|
||||
)
|
||||
await create_payment(
|
||||
checking_id=f"internal_fiat_{fiat_provider_name}_"
|
||||
f"faucet_{payment.payment_hash}",
|
||||
data=create_payment_model,
|
||||
status=PaymentState.SUCCESS,
|
||||
conn=conn,
|
||||
)
|
||||
@@ -1,7 +1,7 @@
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.core.models.notifications import NotificationType
|
||||
from lnbits.core.services.notifications import enqueue_notification
|
||||
from lnbits.core.services.notifications import enqueue_admin_notification
|
||||
from lnbits.settings import settings
|
||||
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" Switch to void wallet: {use_voidwallet}."
|
||||
)
|
||||
enqueue_notification(
|
||||
enqueue_admin_notification(
|
||||
NotificationType.watchdog_check,
|
||||
{
|
||||
"delta_sats": status.delta_sats,
|
||||
@@ -70,8 +70,9 @@ async def check_balance_delta_changed():
|
||||
if settings.latest_balance_delta_sats is None:
|
||||
settings.latest_balance_delta_sats = status.delta_sats
|
||||
return
|
||||
if status.delta_sats != settings.latest_balance_delta_sats:
|
||||
enqueue_notification(
|
||||
delta_change = abs(status.delta_sats - settings.latest_balance_delta_sats)
|
||||
if delta_change >= settings.notification_balance_delta_threshold_sats:
|
||||
enqueue_admin_notification(
|
||||
NotificationType.balance_delta,
|
||||
{
|
||||
"delta_sats": status.delta_sats,
|
||||
|
||||
+155
-146
@@ -1,158 +1,167 @@
|
||||
import asyncio
|
||||
import json
|
||||
from io import BytesIO
|
||||
from typing import Optional
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
from time import time
|
||||
|
||||
import httpx
|
||||
from fastapi import Depends
|
||||
from lnurl import (
|
||||
LnAddress,
|
||||
Lnurl,
|
||||
LnurlErrorResponse,
|
||||
LnurlPayActionResponse,
|
||||
LnurlPayResponse,
|
||||
LnurlResponseException,
|
||||
LnurlSuccessResponse,
|
||||
LnurlWithdrawResponse,
|
||||
execute_pay_request,
|
||||
execute_withdraw,
|
||||
handle,
|
||||
)
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.db import Connection
|
||||
from lnbits.decorators import (
|
||||
WalletTypeInfo,
|
||||
require_admin_key,
|
||||
)
|
||||
from lnbits.helpers import check_callback_url, url_for
|
||||
from lnbits.lnurl import LnurlErrorResponse
|
||||
from lnbits.lnurl import decode as decode_lnurl
|
||||
from lnbits.core.crud import update_wallet
|
||||
from lnbits.core.models import CreateLnurlPayment, Wallet
|
||||
from lnbits.core.models.lnurl import StoredPayLink
|
||||
from lnbits.helpers import check_callback_url
|
||||
from lnbits.settings import settings
|
||||
|
||||
from .payments import create_invoice
|
||||
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis
|
||||
|
||||
|
||||
async def redeem_lnurl_withdraw(
|
||||
wallet_id: str,
|
||||
lnurl_request: str,
|
||||
memo: Optional[str] = None,
|
||||
extra: Optional[dict] = None,
|
||||
wait_seconds: int = 0,
|
||||
conn: Optional[Connection] = None,
|
||||
) -> None:
|
||||
if not lnurl_request:
|
||||
return None
|
||||
|
||||
res = {}
|
||||
|
||||
headers = {"User-Agent": settings.user_agent}
|
||||
async with httpx.AsyncClient(headers=headers) as client:
|
||||
lnurl = decode_lnurl(lnurl_request)
|
||||
check_callback_url(str(lnurl))
|
||||
r = await client.get(str(lnurl))
|
||||
res = r.json()
|
||||
|
||||
async def perform_withdraw(lnurl: str, payment_request: str) -> None:
|
||||
"""
|
||||
Perform an LNURL withdraw to the given LNURL-withdraw link.
|
||||
:param lnurl: The LNURL-withdraw link. bech32 or lud17 format.
|
||||
:param payment_request: The BOLT11 payment request to pay.
|
||||
:raises LnurlResponseException: If the LNURL-withdraw process fails.
|
||||
"""
|
||||
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:
|
||||
_, payment_request = await create_invoice(
|
||||
wallet_id=wallet_id,
|
||||
amount=int(res["maxWithdrawable"] / 1000),
|
||||
memo=memo or res["defaultDescription"] or "",
|
||||
extra=extra,
|
||||
conn=conn,
|
||||
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."
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
f"failed to create invoice on redeem_lnurl_withdraw "
|
||||
f"from {lnurl}. params: {res}"
|
||||
)
|
||||
return None
|
||||
|
||||
if wait_seconds:
|
||||
await asyncio.sleep(wait_seconds)
|
||||
|
||||
params = {"k1": res["k1"], "pr": payment_request}
|
||||
|
||||
try:
|
||||
params["balanceNotify"] = url_for(
|
||||
f"/withdraw/notify/{urlparse(lnurl_request).netloc}",
|
||||
external=True,
|
||||
wal=wallet_id,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
headers = {"User-Agent": settings.user_agent}
|
||||
async with httpx.AsyncClient(headers=headers) as client:
|
||||
try:
|
||||
check_callback_url(res["callback"])
|
||||
await client.get(res["callback"], params=params)
|
||||
except Exception:
|
||||
pass
|
||||
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 perform_lnurlauth(
|
||||
callback: str,
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||
) -> Optional[LnurlErrorResponse]:
|
||||
cb = urlparse(callback)
|
||||
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.
|
||||
|
||||
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
|
||||
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:
|
||||
|
||||
if res2.disposable is not False:
|
||||
return # do not store disposable LNURL pay links
|
||||
|
||||
logger.debug(f"storing lnurl pay link for wallet {wallet.id}. ")
|
||||
|
||||
stored_paylink = None
|
||||
# If we have only a LnurlPayResponse, we can use its lnaddress
|
||||
# because the lnurl is not available.
|
||||
if not lnurl:
|
||||
for _data in res.metadata.list():
|
||||
if _data[0] == "text/identifier":
|
||||
stored_paylink = StoredPayLink(
|
||||
lnurl=LnAddress(_data[1]), label=res.metadata.text
|
||||
)
|
||||
if not stored_paylink:
|
||||
logger.warning(
|
||||
"No lnaddress found in metadata for LNURL pay link. "
|
||||
"Skipping storage."
|
||||
)
|
||||
return # skip if lnaddress not found in metadata
|
||||
else:
|
||||
if isinstance(lnurl, Lnurl):
|
||||
_lnurl = str(lnurl.lud17 or lnurl.bech32)
|
||||
else:
|
||||
_lnurl = str(lnurl)
|
||||
stored_paylink = StoredPayLink(lnurl=_lnurl, label=res.metadata.text)
|
||||
|
||||
# update last_used if its already stored
|
||||
for pl in wallet.stored_paylinks.links:
|
||||
if pl.lnurl == stored_paylink.lnurl:
|
||||
pl.last_used = int(time())
|
||||
await update_wallet(wallet)
|
||||
logger.debug(
|
||||
"Updated last used time for LNURL "
|
||||
f"pay link {stored_paylink.lnurl} in wallet {wallet.id}."
|
||||
)
|
||||
return
|
||||
|
||||
# if not already stored, append it
|
||||
if not any(stored_paylink.lnurl == pl.lnurl for pl in wallet.stored_paylinks.links):
|
||||
wallet.stored_paylinks.links.append(stored_paylink)
|
||||
await update_wallet(wallet)
|
||||
logger.debug(
|
||||
f"Stored LNURL pay link {stored_paylink.lnurl} for wallet {wallet.id}."
|
||||
)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import asyncio
|
||||
from typing import Tuple
|
||||
|
||||
import httpx
|
||||
from loguru import logger
|
||||
@@ -48,7 +47,7 @@ async def send_nostr_dm(
|
||||
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("@")
|
||||
if not identifier or not domain:
|
||||
raise ValueError("Invalid NIP5 identifier")
|
||||
|
||||
@@ -4,7 +4,6 @@ import smtplib
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
from http import HTTPStatus
|
||||
from typing import Optional, Tuple
|
||||
|
||||
import httpx
|
||||
from loguru import logger
|
||||
@@ -16,23 +15,26 @@ from lnbits.core.crud import (
|
||||
get_webpush_subscriptions_for_user,
|
||||
mark_webhook_sent,
|
||||
)
|
||||
from lnbits.core.crud.users import get_user
|
||||
from lnbits.core.crud.wallets import get_wallet
|
||||
from lnbits.core.models import Payment, Wallet
|
||||
from lnbits.core.models.notifications import (
|
||||
NOTIFICATION_TEMPLATES,
|
||||
NotificationMessage,
|
||||
NotificationType,
|
||||
)
|
||||
from lnbits.core.models.users import UserNotifications
|
||||
from lnbits.core.services.nostr import fetch_nip5_details, send_nostr_dm
|
||||
from lnbits.core.services.websockets import websocket_manager
|
||||
from lnbits.helpers import check_callback_url, is_valid_email_address
|
||||
from lnbits.settings import settings
|
||||
from lnbits.utils.nostr import normalize_private_key
|
||||
|
||||
notifications_queue: asyncio.Queue = asyncio.Queue()
|
||||
notifications_queue: asyncio.Queue[NotificationMessage] = asyncio.Queue()
|
||||
|
||||
|
||||
def enqueue_notification(message_type: NotificationType, values: dict) -> None:
|
||||
if not is_message_type_enabled(message_type):
|
||||
def enqueue_admin_notification(message_type: NotificationType, values: dict) -> None:
|
||||
if not _is_message_type_enabled(message_type):
|
||||
return
|
||||
try:
|
||||
notifications_queue.put_nowait(
|
||||
@@ -42,62 +44,125 @@ def enqueue_notification(message_type: NotificationType, values: dict) -> None:
|
||||
logger.error(f"Error enqueuing notification: {e}")
|
||||
|
||||
|
||||
async def process_next_notification():
|
||||
notification_message: NotificationMessage = await notifications_queue.get()
|
||||
def enqueue_user_notification(
|
||||
message_type: NotificationType, values: dict, user_notifications: UserNotifications
|
||||
) -> 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)
|
||||
await send_notification(text, message_type)
|
||||
user_notifications = notification_message.user_notifications
|
||||
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(
|
||||
telegram_chat_id: str | None,
|
||||
nostr_identifiers: list[str] | None,
|
||||
email_addresses: list[str] | None,
|
||||
message: str,
|
||||
message_type: Optional[str] = None,
|
||||
message_type: str | None = None,
|
||||
) -> None:
|
||||
try:
|
||||
if settings.lnbits_telegram_notifications_enabled:
|
||||
await send_telegram_notification(message)
|
||||
if telegram_chat_id and settings.is_telegram_notifications_configured():
|
||||
await send_telegram_notification(telegram_chat_id, message)
|
||||
logger.debug(f"Sent telegram notification: {message_type}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error sending telegram notification {message_type}: {e}")
|
||||
|
||||
try:
|
||||
if settings.lnbits_nostr_notifications_enabled:
|
||||
await send_nostr_notification(message)
|
||||
if nostr_identifiers and settings.is_nostr_notifications_configured():
|
||||
await send_nostr_notifications(nostr_identifiers, message)
|
||||
logger.debug(f"Sent nostr notification: {message_type}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error sending nostr notification {message_type}: {e}")
|
||||
try:
|
||||
if settings.lnbits_email_notifications_enabled:
|
||||
await send_email_notification(message)
|
||||
if email_addresses and settings.lnbits_email_notifications_enabled:
|
||||
await send_email_notification(email_addresses, message)
|
||||
logger.debug(f"Sent email notification: {message_type}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error sending email notification {message_type}: {e}")
|
||||
|
||||
|
||||
async def send_nostr_notification(message: str) -> dict:
|
||||
for i in settings.lnbits_nostr_notifications_identifiers:
|
||||
async def send_nostr_notifications(identifiers: list[str], message: str) -> list[str]:
|
||||
success_sent: list[str] = []
|
||||
for identifier in identifiers:
|
||||
try:
|
||||
identifier = await fetch_nip5_details(i)
|
||||
user_pubkey = identifier[0]
|
||||
relays = identifier[1]
|
||||
server_private_key = normalize_private_key(
|
||||
settings.lnbits_nostr_notifications_private_key
|
||||
)
|
||||
await send_nostr_dm(
|
||||
server_private_key,
|
||||
user_pubkey,
|
||||
message,
|
||||
relays,
|
||||
)
|
||||
await send_nostr_notification(identifier, message)
|
||||
success_sent.append(identifier)
|
||||
except Exception as e:
|
||||
logger.warning(f"Error notifying identifier {i}: {e}")
|
||||
|
||||
return {"status": "ok"}
|
||||
logger.warning(f"Error notifying identifier {identifier}: {e}")
|
||||
return success_sent
|
||||
|
||||
|
||||
async def send_telegram_notification(message: str) -> dict:
|
||||
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(
|
||||
settings.lnbits_nostr_notifications_private_key
|
||||
)
|
||||
await send_nostr_dm(
|
||||
server_private_key,
|
||||
user_pubkey,
|
||||
message,
|
||||
relays,
|
||||
)
|
||||
|
||||
|
||||
async def send_telegram_notification(chat_id: str, message: str) -> dict:
|
||||
return await send_telegram_message(
|
||||
settings.lnbits_telegram_notifications_access_token,
|
||||
settings.lnbits_telegram_notifications_chat_id,
|
||||
chat_id,
|
||||
message,
|
||||
)
|
||||
|
||||
@@ -112,7 +177,7 @@ async def send_telegram_message(token: str, chat_id: str, message: str) -> dict:
|
||||
|
||||
|
||||
async def send_email_notification(
|
||||
message: str, subject: str = "LNbits Notification"
|
||||
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"}
|
||||
@@ -123,7 +188,7 @@ async def send_email_notification(
|
||||
settings.lnbits_email_notifications_username,
|
||||
settings.lnbits_email_notifications_password,
|
||||
settings.lnbits_email_notifications_email,
|
||||
settings.lnbits_email_notifications_to_emails,
|
||||
to_emails,
|
||||
subject,
|
||||
message,
|
||||
)
|
||||
@@ -163,41 +228,6 @@ async def send_email(
|
||||
return True
|
||||
|
||||
|
||||
def is_message_type_enabled(message_type: NotificationType) -> bool:
|
||||
if message_type == NotificationType.balance_update:
|
||||
return settings.lnbits_notification_credit_debit
|
||||
if message_type == NotificationType.settings_update:
|
||||
return settings.lnbits_notification_settings_update
|
||||
if message_type == NotificationType.watchdog_check:
|
||||
return settings.lnbits_notification_watchdog
|
||||
if message_type == NotificationType.balance_delta:
|
||||
return settings.notification_balance_delta_changed
|
||||
if message_type == NotificationType.server_start_stop:
|
||||
return settings.lnbits_notification_server_start_stop
|
||||
if message_type == NotificationType.server_status:
|
||||
return settings.lnbits_notification_server_status_hours > 0
|
||||
if message_type == NotificationType.incoming_payment:
|
||||
return settings.lnbits_notification_incoming_payment_amount_sats > 0
|
||||
if message_type == NotificationType.outgoing_payment:
|
||||
return settings.lnbits_notification_outgoing_payment_amount_sats > 0
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _notification_message_to_text(
|
||||
notification_message: NotificationMessage,
|
||||
) -> Tuple[str, str]:
|
||||
message_type = notification_message.message_type.value
|
||||
meesage_value = NOTIFICATION_TEMPLATES.get(message_type, message_type)
|
||||
try:
|
||||
text = meesage_value.format(**notification_message.values)
|
||||
except Exception as e:
|
||||
logger.warning(f"Error formatting notification message: {e}")
|
||||
text = meesage_value
|
||||
text = f"""[{settings.lnbits_site_title}]\n{text}"""
|
||||
return message_type, text
|
||||
|
||||
|
||||
async def dispatch_webhook(payment: Payment):
|
||||
"""
|
||||
Dispatches the webhook to the webhook url.
|
||||
@@ -211,6 +241,10 @@ async def dispatch_webhook(payment: Payment):
|
||||
async with httpx.AsyncClient(headers=headers) as client:
|
||||
try:
|
||||
check_callback_url(payment.webhook)
|
||||
except ValueError as exc:
|
||||
await mark_webhook_sent(payment.payment_hash, "-1")
|
||||
logger.warning(f"Invalid webhook URL {payment.webhook}: {exc!s}")
|
||||
try:
|
||||
r = await client.post(payment.webhook, json=payment.json(), timeout=40)
|
||||
r.raise_for_status()
|
||||
await mark_webhook_sent(payment.payment_hash, str(r.status_code))
|
||||
@@ -228,10 +262,16 @@ async def dispatch_webhook(payment: Payment):
|
||||
async def send_payment_notification(wallet: Wallet, payment: Payment):
|
||||
try:
|
||||
await send_ws_payment_notification(wallet, payment)
|
||||
for shared in wallet.extra.shared_with:
|
||||
if not shared.shared_with_wallet_id:
|
||||
continue
|
||||
shared_wallet = await get_wallet(shared.shared_with_wallet_id)
|
||||
if shared_wallet and shared_wallet.can_view_payments:
|
||||
await send_ws_payment_notification(shared_wallet, payment)
|
||||
except Exception as e:
|
||||
logger.error(f"Error sending websocket payment notification {e!s}")
|
||||
try:
|
||||
send_chat_payment_notification(wallet, payment)
|
||||
await send_chat_payment_notification(wallet, payment)
|
||||
except Exception as e:
|
||||
logger.error(f"Error sending chat payment notification {e!s}")
|
||||
try:
|
||||
@@ -248,7 +288,7 @@ async def send_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
|
||||
# await websocket_manager.send_data(payment.json(), wallet.inkey)
|
||||
# await websocket_manager.send(wallet.inkey, payment.json())
|
||||
# TODO: figure out why we send the balance with the payment here.
|
||||
# cleaner would be to have a separate message for the balance
|
||||
# and send it with the id of the wallet so wallets can subscribe to it
|
||||
@@ -259,16 +299,15 @@ async def send_ws_payment_notification(wallet: Wallet, payment: Payment):
|
||||
"payment": json.loads(payment.json()),
|
||||
},
|
||||
)
|
||||
await websocket_manager.send_data(payment_notification, wallet.inkey)
|
||||
await websocket_manager.send_data(payment_notification, wallet.adminkey)
|
||||
|
||||
await websocket_manager.send_data(
|
||||
json.dumps({"pending": payment.pending, "status": payment.status}),
|
||||
await websocket_manager.send(wallet.inkey, payment_notification)
|
||||
await websocket_manager.send(wallet.adminkey, payment_notification)
|
||||
await websocket_manager.send(
|
||||
payment.payment_hash,
|
||||
json.dumps({"pending": payment.pending, "status": payment.status}),
|
||||
)
|
||||
|
||||
|
||||
def send_chat_payment_notification(wallet: Wallet, payment: Payment):
|
||||
async def send_chat_payment_notification(wallet: Wallet, payment: Payment):
|
||||
amount_sats = abs(payment.sat)
|
||||
values: dict = {
|
||||
"wallet_id": wallet.id,
|
||||
@@ -283,10 +322,23 @@ def send_chat_payment_notification(wallet: Wallet, payment: Payment):
|
||||
|
||||
if payment.is_out:
|
||||
if amount_sats >= settings.lnbits_notification_outgoing_payment_amount_sats:
|
||||
enqueue_notification(NotificationType.outgoing_payment, values)
|
||||
else:
|
||||
if amount_sats >= settings.lnbits_notification_incoming_payment_amount_sats:
|
||||
enqueue_notification(NotificationType.incoming_payment, values)
|
||||
enqueue_admin_notification(NotificationType.outgoing_payment, values)
|
||||
elif amount_sats >= settings.lnbits_notification_incoming_payment_amount_sats:
|
||||
enqueue_admin_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):
|
||||
@@ -330,3 +382,38 @@ async def send_push_notification(subscription, title, body, url=""):
|
||||
f"failed sending push notification: "
|
||||
f"{e.response.text if e.response else e}"
|
||||
)
|
||||
|
||||
|
||||
def _is_message_type_enabled(message_type: NotificationType) -> bool:
|
||||
if message_type == NotificationType.balance_update:
|
||||
return settings.lnbits_notification_credit_debit
|
||||
if message_type == NotificationType.settings_update:
|
||||
return settings.lnbits_notification_settings_update
|
||||
if message_type == NotificationType.watchdog_check:
|
||||
return settings.lnbits_notification_watchdog
|
||||
if message_type == NotificationType.balance_delta:
|
||||
return settings.notification_balance_delta_threshold_sats > 0
|
||||
if message_type == NotificationType.server_start_stop:
|
||||
return settings.lnbits_notification_server_start_stop
|
||||
if message_type == NotificationType.server_status:
|
||||
return settings.lnbits_notification_server_status_hours > 0
|
||||
if message_type == NotificationType.incoming_payment:
|
||||
return settings.lnbits_notification_incoming_payment_amount_sats > 0
|
||||
if message_type == NotificationType.outgoing_payment:
|
||||
return settings.lnbits_notification_outgoing_payment_amount_sats > 0
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _notification_message_to_text(
|
||||
notification_message: NotificationMessage,
|
||||
) -> tuple[str, str]:
|
||||
message_type = notification_message.message_type.value
|
||||
meesage_value = NOTIFICATION_TEMPLATES.get(message_type, message_type)
|
||||
try:
|
||||
text = meesage_value.format(**notification_message.values)
|
||||
except Exception as e:
|
||||
logger.warning(f"Error formatting notification message: {e}")
|
||||
text = meesage_value
|
||||
text = f"""[{settings.lnbits_site_title}]\n{text}"""
|
||||
return message_type, text
|
||||
|
||||
+402
-108
@@ -1,25 +1,30 @@
|
||||
import asyncio
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
from bolt11 import Bolt11, MilliSatoshi, Tags
|
||||
from bolt11 import decode as bolt11_decode
|
||||
from bolt11 import encode as bolt11_encode
|
||||
from lnurl import LnurlErrorResponse, LnurlSuccessResponse
|
||||
from lnurl import execute_withdraw as lnurl_withdraw
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.core.crud.payments import get_daily_stats
|
||||
from lnbits.core.db import db
|
||||
from lnbits.core.models import PaymentDailyStats, PaymentFilters
|
||||
from lnbits.core.models.payments import CreateInvoice
|
||||
from lnbits.db import Connection, Filters
|
||||
from lnbits.decorators import check_user_extension_access
|
||||
from lnbits.exceptions import InvoiceError, PaymentError
|
||||
from lnbits.exceptions import InvoiceError, PaymentError, UnsupportedError
|
||||
from lnbits.fiat import get_fiat_provider
|
||||
from lnbits.helpers import check_callback_url
|
||||
from lnbits.settings import settings
|
||||
from lnbits.tasks import create_task
|
||||
from lnbits.utils.crypto import fake_privkey, random_secret_and_hash
|
||||
from lnbits.tasks import create_task, internal_invoice_queue_put
|
||||
from lnbits.utils.crypto import fake_privkey, random_secret_and_hash, verify_preimage
|
||||
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis, satoshis_amount_as_fiat
|
||||
from lnbits.wallets import fake_wallet, get_funding_source
|
||||
from lnbits.wallets.base import (
|
||||
InvoiceResponse,
|
||||
PaymentPendingStatus,
|
||||
PaymentResponse,
|
||||
PaymentStatus,
|
||||
@@ -52,20 +57,27 @@ async def pay_invoice(
|
||||
*,
|
||||
wallet_id: str,
|
||||
payment_request: str,
|
||||
max_sat: Optional[int] = None,
|
||||
extra: Optional[dict] = None,
|
||||
max_sat: int | None = None,
|
||||
extra: dict | None = None,
|
||||
description: str = "",
|
||||
tag: str = "",
|
||||
conn: Optional[Connection] = None,
|
||||
labels: list[str] | None = None,
|
||||
conn: Connection | None = None,
|
||||
) -> Payment:
|
||||
if settings.lnbits_only_allow_incoming_payments:
|
||||
raise PaymentError("Only incoming payments allowed.", status="failed")
|
||||
invoice = _validate_payment_request(payment_request, max_sat)
|
||||
assert invoice.amount_msat
|
||||
if not invoice.amount_msat:
|
||||
raise ValueError("Missig invoice amount.")
|
||||
|
||||
async with db.reuse_conn(conn) if conn else db.connect() as new_conn:
|
||||
amount_msat = invoice.amount_msat
|
||||
wallet = await _check_wallet_for_payment(wallet_id, tag, amount_msat, new_conn)
|
||||
if not wallet.can_send_payments:
|
||||
raise PaymentError(
|
||||
"Wallet does not have permission to pay invoices.",
|
||||
status="failed",
|
||||
)
|
||||
|
||||
if await is_internal_status_success(invoice.payment_hash, new_conn):
|
||||
raise PaymentError("Internal invoice already paid.", status="failed")
|
||||
@@ -73,23 +85,153 @@ async def pay_invoice(
|
||||
_, extra = await calculate_fiat_amounts(amount_msat / 1000, wallet, extra=extra)
|
||||
|
||||
create_payment_model = CreatePayment(
|
||||
wallet_id=wallet_id,
|
||||
wallet_id=wallet.source_wallet_id,
|
||||
bolt11=payment_request,
|
||||
payment_hash=invoice.payment_hash,
|
||||
amount_msat=-amount_msat,
|
||||
expiry=invoice.expiry_date,
|
||||
memo=description or invoice.description or "",
|
||||
extra=extra,
|
||||
labels=labels,
|
||||
)
|
||||
|
||||
payment = await _pay_invoice(wallet.id, create_payment_model, conn)
|
||||
|
||||
service_fee_memo = f"""
|
||||
Service fee for payment of {abs(payment.sat)} sats.
|
||||
Wallet: '{wallet.name}' ({wallet.id})."""
|
||||
payment = await _pay_invoice(wallet.source_wallet_id, create_payment_model, conn)
|
||||
|
||||
async with db.reuse_conn(conn) if conn else db.connect() as new_conn:
|
||||
await _credit_service_fee_wallet(payment, service_fee_memo, new_conn)
|
||||
await _credit_service_fee_wallet(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
|
||||
) -> Payment:
|
||||
fiat_provider_name = invoice_data.fiat_provider
|
||||
if not fiat_provider_name:
|
||||
raise ValueError("Fiat provider is required for fiat invoices.")
|
||||
if not settings.is_fiat_provider_enabled(fiat_provider_name):
|
||||
raise ValueError(
|
||||
f"Fiat provider '{fiat_provider_name}' is not enabled.",
|
||||
)
|
||||
|
||||
if invoice_data.unit == "sat":
|
||||
raise ValueError("Fiat provider cannot be used with satoshis.")
|
||||
amount_sat = await fiat_amount_as_satoshis(invoice_data.amount, invoice_data.unit)
|
||||
await _check_fiat_invoice_limits(amount_sat, fiat_provider_name, conn)
|
||||
|
||||
invoice_data.internal = True # use FakeWallet for fiat invoices
|
||||
if not invoice_data.memo:
|
||||
invoice_data.memo = settings.lnbits_site_title + f" ({fiat_provider_name})"
|
||||
|
||||
internal_payment = await create_wallet_invoice(wallet_id, invoice_data)
|
||||
|
||||
fiat_provider = await get_fiat_provider(fiat_provider_name)
|
||||
if not fiat_provider:
|
||||
raise InvoiceError("No fiat provider found.", status="failed")
|
||||
|
||||
fiat_invoice = await fiat_provider.create_invoice(
|
||||
amount=invoice_data.amount,
|
||||
payment_hash=internal_payment.payment_hash,
|
||||
currency=invoice_data.unit,
|
||||
memo=invoice_data.memo,
|
||||
extra=invoice_data.extra or {},
|
||||
)
|
||||
|
||||
if fiat_invoice.failed:
|
||||
logger.warning(fiat_invoice.error_message)
|
||||
internal_payment.status = PaymentState.FAILED
|
||||
await update_payment(internal_payment, conn=conn)
|
||||
raise ValueError(
|
||||
f"Cannot create payment request for '{fiat_provider_name}'.",
|
||||
)
|
||||
|
||||
internal_payment.fee = -abs(
|
||||
service_fee_fiat(internal_payment.msat, fiat_provider_name)
|
||||
)
|
||||
|
||||
internal_payment.fiat_provider = fiat_provider_name
|
||||
internal_payment.extra["fiat_checking_id"] = fiat_invoice.checking_id
|
||||
# todo: move to payent
|
||||
internal_payment.extra["fiat_payment_request"] = fiat_invoice.payment_request
|
||||
new_checking_id = (
|
||||
f"fiat_{fiat_provider_name}_"
|
||||
f"{fiat_invoice.checking_id or internal_payment.checking_id}"
|
||||
)
|
||||
await update_payment(internal_payment, new_checking_id, conn=conn)
|
||||
internal_payment.checking_id = new_checking_id
|
||||
|
||||
return internal_payment
|
||||
|
||||
|
||||
async def create_wallet_invoice(wallet_id: str, data: CreateInvoice) -> Payment:
|
||||
description_hash = None
|
||||
unhashed_description = None
|
||||
memo = data.memo or settings.lnbits_site_title
|
||||
if data.description_hash or data.unhashed_description:
|
||||
if data.description_hash:
|
||||
try:
|
||||
description_hash = bytes.fromhex(data.description_hash)
|
||||
except ValueError as exc:
|
||||
raise ValueError(
|
||||
"'description_hash' must be a valid hex string"
|
||||
) from exc
|
||||
if data.unhashed_description:
|
||||
try:
|
||||
unhashed_description = bytes.fromhex(data.unhashed_description)
|
||||
except ValueError as exc:
|
||||
raise ValueError(
|
||||
"'unhashed_description' must be a valid hex string",
|
||||
) from exc
|
||||
# do not save memo if description_hash or unhashed_description is set
|
||||
memo = ""
|
||||
|
||||
payment = await create_invoice(
|
||||
wallet_id=wallet_id,
|
||||
amount=data.amount,
|
||||
memo=memo,
|
||||
currency=data.unit,
|
||||
description_hash=description_hash,
|
||||
unhashed_description=unhashed_description,
|
||||
expiry=data.expiry,
|
||||
extra=data.extra,
|
||||
webhook=data.webhook,
|
||||
internal=data.internal,
|
||||
payment_hash=data.payment_hash,
|
||||
labels=data.labels,
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
@@ -98,15 +240,17 @@ async def create_invoice(
|
||||
*,
|
||||
wallet_id: str,
|
||||
amount: float,
|
||||
currency: Optional[str] = "sat",
|
||||
currency: str | None = "sat",
|
||||
memo: str,
|
||||
description_hash: Optional[bytes] = None,
|
||||
unhashed_description: Optional[bytes] = None,
|
||||
expiry: Optional[int] = None,
|
||||
extra: Optional[dict] = None,
|
||||
webhook: Optional[str] = None,
|
||||
internal: Optional[bool] = False,
|
||||
conn: Optional[Connection] = None,
|
||||
description_hash: bytes | None = None,
|
||||
unhashed_description: bytes | None = None,
|
||||
expiry: int | None = None,
|
||||
extra: dict | None = None,
|
||||
webhook: str | None = None,
|
||||
internal: bool | None = False,
|
||||
payment_hash: str | None = None,
|
||||
labels: list[str] | None = None,
|
||||
conn: Connection | None = None,
|
||||
) -> Payment:
|
||||
if not amount > 0:
|
||||
raise InvoiceError("Amountless invoices not supported.", status="failed")
|
||||
@@ -115,6 +259,12 @@ async def create_invoice(
|
||||
if not user_wallet:
|
||||
raise InvoiceError(f"Could not fetch wallet '{wallet_id}'.", status="failed")
|
||||
|
||||
if not user_wallet.can_receive_payments:
|
||||
raise InvoiceError(
|
||||
"Wallet does not have permission to create invoices.",
|
||||
status="failed",
|
||||
)
|
||||
|
||||
invoice_memo = None if description_hash else memo[:640]
|
||||
|
||||
# use the fake wallet if the invoice is for internal use only
|
||||
@@ -139,38 +289,55 @@ async def create_invoice(
|
||||
status="failed",
|
||||
)
|
||||
|
||||
payment_response = await funding_source.create_invoice(
|
||||
amount=amount_sat,
|
||||
memo=invoice_memo,
|
||||
description_hash=description_hash,
|
||||
unhashed_description=unhashed_description,
|
||||
expiry=expiry or settings.lightning_invoice_expiry,
|
||||
)
|
||||
if payment_hash:
|
||||
try:
|
||||
invoice_response = await funding_source.create_hold_invoice(
|
||||
amount=amount_sat,
|
||||
memo=invoice_memo,
|
||||
payment_hash=payment_hash,
|
||||
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,
|
||||
memo=invoice_memo,
|
||||
description_hash=description_hash,
|
||||
unhashed_description=unhashed_description,
|
||||
expiry=expiry or settings.lightning_invoice_expiry,
|
||||
)
|
||||
if (
|
||||
not payment_response.ok
|
||||
or not payment_response.payment_request
|
||||
or not payment_response.checking_id
|
||||
not invoice_response.ok
|
||||
or not invoice_response.payment_request
|
||||
or not invoice_response.checking_id
|
||||
):
|
||||
raise InvoiceError(
|
||||
message=payment_response.error_message or "unexpected backend error.",
|
||||
message=invoice_response.error_message or "unexpected backend error.",
|
||||
status="pending",
|
||||
)
|
||||
invoice = bolt11_decode(payment_response.payment_request)
|
||||
invoice = bolt11_decode(invoice_response.payment_request)
|
||||
|
||||
create_payment_model = CreatePayment(
|
||||
wallet_id=wallet_id,
|
||||
bolt11=payment_response.payment_request,
|
||||
wallet_id=user_wallet.source_wallet_id,
|
||||
bolt11=invoice_response.payment_request,
|
||||
payment_hash=invoice.payment_hash,
|
||||
preimage=payment_response.preimage,
|
||||
preimage=invoice_response.preimage,
|
||||
amount_msat=amount_sat * 1000,
|
||||
expiry=invoice.expiry_date,
|
||||
memo=memo,
|
||||
extra=extra,
|
||||
webhook=webhook,
|
||||
fee=invoice_response.fee_msat or 0,
|
||||
labels=labels,
|
||||
)
|
||||
|
||||
payment = await create_payment(
|
||||
checking_id=payment_response.checking_id,
|
||||
checking_id=invoice_response.checking_id,
|
||||
data=create_payment_model,
|
||||
conn=conn,
|
||||
)
|
||||
@@ -188,17 +355,45 @@ async def update_pending_payments(wallet_id: str):
|
||||
await update_pending_payment(payment)
|
||||
|
||||
|
||||
async def update_pending_payment(payment: Payment) -> bool:
|
||||
async def update_pending_payment(payment: Payment) -> Payment:
|
||||
status = await payment.check_status()
|
||||
if status.failed:
|
||||
payment.status = PaymentState.FAILED
|
||||
await update_payment(payment)
|
||||
return True
|
||||
if status.success:
|
||||
payment.status = PaymentState.SUCCESS
|
||||
await update_payment(payment)
|
||||
return True
|
||||
return False
|
||||
elif status.success:
|
||||
payment = await update_payment_success_status(payment, status)
|
||||
return payment
|
||||
|
||||
|
||||
async def check_pending_payments():
|
||||
"""
|
||||
check_pending_payments is called during startup to check for pending payments with
|
||||
the backend and also to delete expired invoices. Incoming payments will be
|
||||
checked only once, outgoing pending payments will be checked regularly.
|
||||
"""
|
||||
funding_source = get_funding_source()
|
||||
if funding_source.__class__.__name__ == "VoidWallet":
|
||||
logger.warning("Task: skipping pending check for VoidWallet")
|
||||
return
|
||||
start_time = time.time()
|
||||
pending_payments = await get_payments(
|
||||
since=(int(time.time()) - 60 * 60 * 24 * 15), # 15 days ago
|
||||
complete=False,
|
||||
pending=True,
|
||||
exclude_uncheckable=True,
|
||||
)
|
||||
count = len(pending_payments)
|
||||
if count > 0:
|
||||
logger.info(f"Task: checking {count} pending payments of last 15 days...")
|
||||
for i, payment in enumerate(pending_payments):
|
||||
payment = await update_pending_payment(payment)
|
||||
prefix = f"payment ({i+1} / {count})"
|
||||
logger.debug(f"{prefix} {payment.status} {payment.checking_id}")
|
||||
await asyncio.sleep(0.01) # to avoid complete blocking
|
||||
logger.info(
|
||||
f"Task: pending check finished for {count} payments"
|
||||
f" (took {time.time() - start_time:0.3f} s)"
|
||||
)
|
||||
|
||||
|
||||
def fee_reserve_total(amount_msat: int, internal: bool = False) -> int:
|
||||
@@ -226,10 +421,30 @@ def service_fee(amount_msat: int, internal: bool = False) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
def service_fee_fiat(amount_msat: int, fiat_provider_name: str) -> int:
|
||||
"""
|
||||
Calculate the service fee for a fiat provider based on the amount in msat.
|
||||
Return the fee in msat.
|
||||
"""
|
||||
limits = settings.get_fiat_provider_limits(fiat_provider_name)
|
||||
if not limits:
|
||||
return 0
|
||||
amount_msat = abs(amount_msat)
|
||||
fee_max = limits.service_max_fee_sats * 1000
|
||||
if not limits.service_fee_wallet_id:
|
||||
return 0
|
||||
|
||||
fee_percentage = int(amount_msat / 100 * limits.service_fee_percent)
|
||||
if fee_max > 0 and fee_percentage > fee_max:
|
||||
return fee_max
|
||||
else:
|
||||
return fee_percentage
|
||||
|
||||
|
||||
async def update_wallet_balance(
|
||||
wallet: Wallet,
|
||||
amount: int,
|
||||
conn: Optional[Connection] = None,
|
||||
conn: Connection | None = None,
|
||||
):
|
||||
if amount == 0:
|
||||
raise ValueError("Amount cannot be 0.")
|
||||
@@ -257,7 +472,7 @@ async def update_wallet_balance(
|
||||
await create_payment(
|
||||
checking_id=f"internal_{payment_hash}",
|
||||
data=CreatePayment(
|
||||
wallet_id=wallet.id,
|
||||
wallet_id=wallet.source_wallet_id,
|
||||
bolt11=bolt11,
|
||||
payment_hash=payment_hash,
|
||||
amount_msat=amount * 1000,
|
||||
@@ -276,7 +491,7 @@ async def update_wallet_balance(
|
||||
raise ValueError("Balance change failed, amount exceeds maximum balance.")
|
||||
async with db.reuse_conn(conn) if conn else db.connect() as conn:
|
||||
payment = await create_invoice(
|
||||
wallet_id=wallet.id,
|
||||
wallet_id=wallet.source_wallet_id,
|
||||
amount=amount,
|
||||
memo="Admin credit",
|
||||
internal=True,
|
||||
@@ -284,21 +499,18 @@ async def update_wallet_balance(
|
||||
)
|
||||
payment.status = PaymentState.SUCCESS
|
||||
await update_payment(payment, conn=conn)
|
||||
# notify receiver asynchronously
|
||||
from lnbits.tasks import internal_invoice_queue
|
||||
|
||||
await internal_invoice_queue.put(payment.checking_id)
|
||||
await internal_invoice_queue_put(payment.checking_id)
|
||||
|
||||
|
||||
async def check_wallet_limits(
|
||||
wallet_id: str, amount_msat: int, conn: Optional[Connection] = None
|
||||
wallet_id: str, amount_msat: int, conn: Connection | None = None
|
||||
):
|
||||
await check_time_limit_between_transactions(wallet_id, conn)
|
||||
await check_wallet_daily_withdraw_limit(wallet_id, amount_msat, conn)
|
||||
|
||||
|
||||
async def check_time_limit_between_transactions(
|
||||
wallet_id: str, conn: Optional[Connection] = None
|
||||
wallet_id: str, conn: Connection | None = None
|
||||
):
|
||||
limit = settings.lnbits_wallet_limit_secs_between_trans
|
||||
if not limit or limit <= 0:
|
||||
@@ -318,7 +530,7 @@ async def check_time_limit_between_transactions(
|
||||
|
||||
|
||||
async def check_wallet_daily_withdraw_limit(
|
||||
wallet_id: str, amount_msat: int, conn: Optional[Connection] = None
|
||||
wallet_id: str, amount_msat: int, conn: Connection | None = None
|
||||
):
|
||||
limit = settings.lnbits_wallet_limit_daily_max_withdraw
|
||||
if not limit:
|
||||
@@ -351,8 +563,8 @@ async def check_wallet_daily_withdraw_limit(
|
||||
async def calculate_fiat_amounts(
|
||||
amount: float,
|
||||
wallet: Wallet,
|
||||
currency: Optional[str] = None,
|
||||
extra: Optional[dict] = None,
|
||||
currency: str | None = None,
|
||||
extra: dict | None = None,
|
||||
) -> tuple[int, dict]:
|
||||
wallet_currency = wallet.currency or settings.lnbits_default_accounting_currency
|
||||
fiat_amounts: dict = extra or {}
|
||||
@@ -387,9 +599,9 @@ async def calculate_fiat_amounts(
|
||||
|
||||
|
||||
async def check_transaction_status(
|
||||
wallet_id: str, payment_hash: str, conn: Optional[Connection] = None
|
||||
wallet_id: str, payment_hash: str, conn: Connection | None = None
|
||||
) -> PaymentStatus:
|
||||
payment: Optional[Payment] = await get_wallet_payment(
|
||||
payment: Payment | None = await get_wallet_payment(
|
||||
wallet_id, payment_hash, conn=conn
|
||||
)
|
||||
if not payment:
|
||||
@@ -403,7 +615,7 @@ async def check_transaction_status(
|
||||
|
||||
async def get_payments_daily_stats(
|
||||
filters: Filters[PaymentFilters],
|
||||
user_id: Optional[str] = None,
|
||||
user_id: str | None = None,
|
||||
) -> list[PaymentDailyStats]:
|
||||
data_in, data_out = await get_daily_stats(filters, user_id=user_id)
|
||||
balance_total: float = 0
|
||||
@@ -452,7 +664,7 @@ async def get_payments_daily_stats(
|
||||
async def _pay_invoice(
|
||||
wallet_id: str,
|
||||
create_payment_model: CreatePayment,
|
||||
conn: Optional[Connection] = None,
|
||||
conn: Connection | None = None,
|
||||
):
|
||||
async with payment_lock:
|
||||
if wallet_id not in wallets_payments_lock:
|
||||
@@ -475,8 +687,8 @@ async def _pay_invoice(
|
||||
async def _pay_internal_invoice(
|
||||
wallet: Wallet,
|
||||
create_payment_model: CreatePayment,
|
||||
conn: Optional[Connection] = None,
|
||||
) -> Optional[Payment]:
|
||||
conn: Connection | None = None,
|
||||
) -> Payment | None:
|
||||
"""
|
||||
Pay an internal payment.
|
||||
returns None if the payment is not internal.
|
||||
@@ -543,7 +755,7 @@ async def _pay_internal_invoice(
|
||||
async def _pay_external_invoice(
|
||||
wallet: Wallet,
|
||||
create_payment_model: CreatePayment,
|
||||
conn: Optional[Connection] = None,
|
||||
conn: Connection | None = None,
|
||||
) -> Payment:
|
||||
checking_id = create_payment_model.payment_hash
|
||||
amount_msat = create_payment_model.amount_msat
|
||||
@@ -571,57 +783,55 @@ async def _pay_external_invoice(
|
||||
)
|
||||
|
||||
fee_reserve_msat = fee_reserve(amount_msat, internal=False)
|
||||
service_fee_msat = service_fee(amount_msat, internal=False)
|
||||
|
||||
task = create_task(
|
||||
_fundingsource_pay_invoice(checking_id, payment.bolt11, fee_reserve_msat)
|
||||
)
|
||||
|
||||
# make sure a hold invoice or deferred payment is not blocking the server
|
||||
wait_time = max(1, settings.lnbits_funding_source_pay_invoice_wait_seconds)
|
||||
try:
|
||||
wait_time = max(1, settings.lnbits_funding_source_pay_invoice_wait_seconds)
|
||||
payment_response = await asyncio.wait_for(task, wait_time)
|
||||
payment_response = await asyncio.wait_for(task, timeout=wait_time)
|
||||
except asyncio.TimeoutError:
|
||||
# return pending payment on timeout
|
||||
logger.debug(f"payment timeout, {checking_id} is still pending")
|
||||
logger.debug(
|
||||
f"payment timeout after {wait_time}s, {checking_id} is still pending"
|
||||
)
|
||||
return payment
|
||||
|
||||
if payment_response.checking_id and payment_response.checking_id != checking_id:
|
||||
logger.warning(
|
||||
f"backend sent unexpected checking_id (expected: {checking_id} got:"
|
||||
f" {payment_response.checking_id})"
|
||||
)
|
||||
if payment_response.checking_id and payment_response.ok is not False:
|
||||
# payment.ok can be True (paid) or None (pending)!
|
||||
logger.debug(f"updating payment {checking_id}")
|
||||
payment.status = (
|
||||
PaymentState.SUCCESS
|
||||
if payment_response.ok is True
|
||||
else PaymentState.PENDING
|
||||
)
|
||||
payment.fee = -(abs(payment_response.fee_msat or 0) + abs(service_fee_msat))
|
||||
payment.preimage = payment_response.preimage
|
||||
await update_payment(payment, payment_response.checking_id, conn=conn)
|
||||
payment.checking_id = payment_response.checking_id
|
||||
if payment.success:
|
||||
await send_payment_notification(wallet, payment)
|
||||
logger.success(f"payment successful {payment_response.checking_id}")
|
||||
elif payment_response.checking_id is None and payment_response.ok is False:
|
||||
# payment failed
|
||||
logger.debug(f"payment failed {checking_id}, {payment_response.error_message}")
|
||||
# payment failed
|
||||
if (
|
||||
payment_response.checking_id is None
|
||||
or payment_response.ok is False
|
||||
or payment_response.checking_id != checking_id
|
||||
):
|
||||
payment.status = PaymentState.FAILED
|
||||
await update_payment(payment, conn=conn)
|
||||
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}"
|
||||
)
|
||||
message = payment_response.error_message or "without an error message."
|
||||
raise PaymentError(f"Payment failed: {message}", status="failed")
|
||||
|
||||
if payment_response.success:
|
||||
payment = await update_payment_success_status(
|
||||
payment, payment_response, conn=conn
|
||||
)
|
||||
await send_payment_notification(wallet, payment)
|
||||
logger.success(f"payment successful {payment_response.checking_id}")
|
||||
|
||||
payment.checking_id = payment_response.checking_id
|
||||
return payment
|
||||
|
||||
|
||||
async def update_payment_success_status(
|
||||
payment: Payment,
|
||||
status: PaymentStatus,
|
||||
conn: Connection | None = None,
|
||||
) -> Payment:
|
||||
if status.success:
|
||||
service_fee_msat = service_fee(payment.amount, internal=False)
|
||||
payment.status = PaymentState.SUCCESS
|
||||
payment.fee = -(abs(status.fee_msat or 0) + abs(service_fee_msat))
|
||||
payment.preimage = payment.preimage or status.preimage
|
||||
await update_payment(payment, conn=conn)
|
||||
return payment
|
||||
|
||||
|
||||
@@ -638,7 +848,7 @@ async def _fundingsource_pay_invoice(
|
||||
|
||||
|
||||
async def _verify_external_payment(
|
||||
payment: Payment, conn: Optional[Connection] = None
|
||||
payment: Payment, conn: Connection | None = None
|
||||
) -> Payment:
|
||||
# fail on pending payments
|
||||
if payment.pending:
|
||||
@@ -655,8 +865,7 @@ async def _verify_external_payment(
|
||||
|
||||
if status.success:
|
||||
# payment was successful on the fundingsource
|
||||
payment.status = PaymentState.SUCCESS
|
||||
await update_payment(payment, conn=conn)
|
||||
await update_payment_success_status(payment, status, conn=conn)
|
||||
raise PaymentError(
|
||||
"Failed payment was already paid on the fundingsource.",
|
||||
status="success",
|
||||
@@ -670,7 +879,7 @@ async def _check_wallet_for_payment(
|
||||
wallet_id: str,
|
||||
tag: str,
|
||||
amount_msat: int,
|
||||
conn: Optional[Connection] = None,
|
||||
conn: Connection | None = None,
|
||||
):
|
||||
wallet = await get_wallet(wallet_id, conn=conn)
|
||||
if not wallet:
|
||||
@@ -686,7 +895,7 @@ async def _check_wallet_for_payment(
|
||||
|
||||
|
||||
def _validate_payment_request(
|
||||
payment_request: str, max_sat: Optional[int] = None
|
||||
payment_request: str, max_sat: int | None = None
|
||||
) -> Bolt11:
|
||||
try:
|
||||
invoice = bolt11_decode(payment_request)
|
||||
@@ -709,12 +918,16 @@ def _validate_payment_request(
|
||||
|
||||
|
||||
async def _credit_service_fee_wallet(
|
||||
payment: Payment, memo: str, conn: Optional[Connection] = None
|
||||
wallet: Wallet, payment: Payment, conn: Connection | None = None
|
||||
):
|
||||
service_fee_msat = service_fee(payment.amount, internal=payment.is_internal)
|
||||
if not settings.lnbits_service_fee_wallet or not service_fee_msat:
|
||||
return
|
||||
|
||||
memo = f"""
|
||||
Service fee for payment of {abs(payment.sat)} sats.
|
||||
Wallet: '{wallet.name}' ({wallet.source_wallet_id})."""
|
||||
|
||||
create_payment_model = CreatePayment(
|
||||
wallet_id=settings.lnbits_service_fee_wallet,
|
||||
bolt11=payment.bolt11,
|
||||
@@ -728,3 +941,84 @@ async def _credit_service_fee_wallet(
|
||||
status=PaymentState.SUCCESS,
|
||||
conn=conn,
|
||||
)
|
||||
|
||||
|
||||
async def _check_fiat_invoice_limits(
|
||||
amount_sat: int, fiat_provider_name: str, conn: 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,7 +19,8 @@ async def check_webpush_settings():
|
||||
vapid = Vapid()
|
||||
vapid.generate_keys()
|
||||
privkey = vapid.private_pem()
|
||||
assert vapid.public_key, "VAPID public key does not exist"
|
||||
if not vapid.public_key:
|
||||
raise ValueError("VAPID public key does not exist")
|
||||
pubkey = b64urlencode(
|
||||
vapid.public_key.public_bytes(
|
||||
serialization.Encoding.X962,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from uuid import uuid4
|
||||
|
||||
from loguru import logger
|
||||
@@ -37,7 +36,7 @@ from .settings import update_cached_settings
|
||||
|
||||
|
||||
async def create_user_account(
|
||||
account: Optional[Account] = None, wallet_name: Optional[str] = None
|
||||
account: Account | None = None, wallet_name: str | None = None
|
||||
) -> User:
|
||||
if not settings.new_accounts_allowed:
|
||||
raise ValueError("Account creation is disabled.")
|
||||
@@ -46,9 +45,9 @@ async def create_user_account(
|
||||
|
||||
|
||||
async def create_user_account_no_ckeck(
|
||||
account: Optional[Account] = None,
|
||||
wallet_name: Optional[str] = None,
|
||||
default_exts: Optional[list[str]] = None,
|
||||
account: Account | None = None,
|
||||
wallet_name: str | None = None,
|
||||
default_exts: list[str] | None = None,
|
||||
) -> User:
|
||||
|
||||
if account:
|
||||
@@ -80,7 +79,8 @@ async def create_user_account_no_ckeck(
|
||||
logger.error(f"Error enabeling default extension {ext_id}: {e}")
|
||||
|
||||
user = await get_user_from_account(account)
|
||||
assert user, "Cannot find user for account."
|
||||
if not user:
|
||||
raise ValueError("Cannot find user for account.")
|
||||
|
||||
return user
|
||||
|
||||
@@ -164,12 +164,12 @@ async def check_admin_settings():
|
||||
settings.first_install = True
|
||||
|
||||
logger.success(
|
||||
"✔️ Admin UI is enabled. run `poetry run lnbits-cli superuser` "
|
||||
"✔️ Admin UI is enabled. run `uv run lnbits-cli superuser` "
|
||||
"to get the superuser."
|
||||
)
|
||||
|
||||
|
||||
async def init_admin_settings(super_user: Optional[str] = None) -> SuperSettings:
|
||||
async def init_admin_settings(super_user: str | None = None) -> SuperSettings:
|
||||
account = None
|
||||
if super_user:
|
||||
account = await get_account(super_user)
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
from lnbits.core.crud.users import (
|
||||
get_account,
|
||||
get_account_by_username_or_email,
|
||||
update_account,
|
||||
)
|
||||
from lnbits.core.crud.wallets import (
|
||||
create_wallet,
|
||||
force_delete_wallet,
|
||||
get_standalone_wallet,
|
||||
get_wallet,
|
||||
get_wallets,
|
||||
update_wallet,
|
||||
)
|
||||
from lnbits.core.models.misc import SimpleStatus
|
||||
from lnbits.core.models.users import Account
|
||||
from lnbits.core.models.wallets import (
|
||||
Wallet,
|
||||
WalletSharePermission,
|
||||
WalletShareStatus,
|
||||
WalletType,
|
||||
)
|
||||
from lnbits.db import Connection
|
||||
from lnbits.helpers import sha256s
|
||||
|
||||
|
||||
async def invite_to_wallet(
|
||||
source_wallet: Wallet, data: WalletSharePermission
|
||||
) -> WalletSharePermission:
|
||||
if not source_wallet.is_lightning_wallet:
|
||||
raise ValueError("Only lightning wallets can be shared.")
|
||||
if not data.username:
|
||||
raise ValueError("Username or email missing.")
|
||||
invited_user = await get_account_by_username_or_email(data.username)
|
||||
if not invited_user:
|
||||
raise ValueError("Invited user not found.")
|
||||
|
||||
request_id = sha256s(invited_user.id + source_wallet.id)
|
||||
share = source_wallet.extra.find_share_by_id(request_id)
|
||||
if share:
|
||||
raise ValueError("User already invited to this wallet.")
|
||||
|
||||
invite_request = source_wallet.extra.invite_user_to_shared_wallet(
|
||||
request_id=request_id,
|
||||
request_type=WalletShareStatus.INVITE_SENT,
|
||||
username=data.username,
|
||||
permissions=data.permissions,
|
||||
)
|
||||
await update_wallet(source_wallet)
|
||||
|
||||
wallet_owner = await get_account(source_wallet.user)
|
||||
if not wallet_owner:
|
||||
raise ValueError("Cannot find wallet owner.")
|
||||
invited_user.extra.add_wallet_invite_request(
|
||||
request_id=request_id,
|
||||
from_user_name=wallet_owner.username or wallet_owner.email,
|
||||
to_wallet_id=source_wallet.id,
|
||||
to_wallet_name=source_wallet.name,
|
||||
)
|
||||
await update_account(invited_user)
|
||||
|
||||
return invite_request
|
||||
|
||||
|
||||
async def reject_wallet_invitation(invited_user_id: str, share_request_id: str):
|
||||
invited_user = await get_account(invited_user_id)
|
||||
if not invited_user:
|
||||
raise ValueError("Invited user not found.")
|
||||
|
||||
existing_request = invited_user.extra.find_wallet_invite_request(share_request_id)
|
||||
if not existing_request:
|
||||
raise ValueError("Invitation not found.")
|
||||
|
||||
invited_user.extra.remove_wallet_invite_request(share_request_id)
|
||||
await update_account(invited_user)
|
||||
|
||||
|
||||
async def update_wallet_share_permissions(
|
||||
source_wallet: Wallet, data: WalletSharePermission
|
||||
) -> WalletSharePermission:
|
||||
if not source_wallet.is_lightning_wallet:
|
||||
raise ValueError("Only lightning wallets can be shared.")
|
||||
if not data.shared_with_wallet_id:
|
||||
raise ValueError("Wallet ID missing.")
|
||||
|
||||
share = source_wallet.extra.find_share_for_wallet(data.shared_with_wallet_id)
|
||||
if not share:
|
||||
raise ValueError("Share not found")
|
||||
|
||||
if not share.shared_with_wallet_id:
|
||||
raise ValueError("Share does not have a mirror wallet ID.")
|
||||
|
||||
mirror_wallet = await get_wallet(share.shared_with_wallet_id)
|
||||
if not mirror_wallet:
|
||||
raise ValueError("Target wallet not found")
|
||||
if not mirror_wallet.is_lightning_shared_wallet:
|
||||
raise ValueError("Target wallet is not a shared wallet.")
|
||||
if mirror_wallet.shared_wallet_id != source_wallet.id:
|
||||
raise ValueError("Not the owner of the shared wallet.")
|
||||
|
||||
share.approve(permissions=data.permissions)
|
||||
await update_wallet(source_wallet)
|
||||
return share
|
||||
|
||||
|
||||
async def delete_wallet_share(source_wallet: Wallet, request_id: str) -> SimpleStatus:
|
||||
if not source_wallet.is_lightning_wallet:
|
||||
raise ValueError("Source wallet is not a lightning wallet.")
|
||||
|
||||
share = source_wallet.extra.find_share_by_id(request_id)
|
||||
if not share:
|
||||
raise ValueError("Wallet share not found.")
|
||||
source_wallet.extra.remove_share_by_id(request_id)
|
||||
|
||||
invited_user = await get_account_by_username_or_email(share.username)
|
||||
if not invited_user:
|
||||
await update_wallet(source_wallet)
|
||||
return SimpleStatus(
|
||||
success=True, message="Permission removed. Invited user not found."
|
||||
)
|
||||
if invited_user.extra.find_wallet_invite_request(request_id):
|
||||
invited_user.extra.remove_wallet_invite_request(request_id)
|
||||
await update_account(invited_user)
|
||||
|
||||
mirror_wallets = await get_wallets(
|
||||
invited_user.id, wallet_type=WalletType.LIGHTNING_SHARED
|
||||
)
|
||||
mirror_wallet = next(
|
||||
(w for w in mirror_wallets if w.shared_wallet_id == source_wallet.id), None
|
||||
)
|
||||
|
||||
if not mirror_wallet:
|
||||
await update_wallet(source_wallet)
|
||||
return SimpleStatus(
|
||||
success=True, message="Permission removed. Target wallet not found."
|
||||
)
|
||||
|
||||
if not mirror_wallet.is_lightning_shared_wallet:
|
||||
raise ValueError("Target wallet is not a shared lightning wallet.")
|
||||
|
||||
if mirror_wallet.shared_wallet_id != source_wallet.id:
|
||||
raise ValueError("Not the owner of the shared wallet.")
|
||||
|
||||
await force_delete_wallet(mirror_wallet.id)
|
||||
|
||||
await update_wallet(source_wallet)
|
||||
return SimpleStatus(success=True, message="Permission removed.")
|
||||
|
||||
|
||||
async def create_lightning_shared_wallet(
|
||||
user_id: str,
|
||||
source_wallet_id: str,
|
||||
conn: Connection | None = None,
|
||||
) -> Wallet:
|
||||
source_wallet = await get_standalone_wallet(source_wallet_id, conn=conn)
|
||||
if not source_wallet:
|
||||
raise ValueError("Shared wallet does not exist.")
|
||||
|
||||
if not source_wallet.is_lightning_wallet:
|
||||
raise ValueError("Shared wallet is not a lightning wallet.")
|
||||
|
||||
if source_wallet.user == user_id:
|
||||
raise ValueError("Cannot mirror your own wallet.")
|
||||
|
||||
invited_user = await get_account(user_id, conn=conn)
|
||||
if not invited_user:
|
||||
raise ValueError("Cannot find invited user.")
|
||||
|
||||
return await _accept_invitation_to_shared_wallet(
|
||||
invited_user, source_wallet, conn=conn
|
||||
)
|
||||
|
||||
|
||||
async def _accept_invitation_to_shared_wallet(
|
||||
invited_user: Account,
|
||||
source_wallet: Wallet,
|
||||
conn: Connection | None = None,
|
||||
) -> Wallet:
|
||||
request_id = sha256s(invited_user.id + source_wallet.id)
|
||||
existing_request = source_wallet.extra.find_share_by_id(request_id)
|
||||
if not existing_request:
|
||||
raise ValueError("No invitation found for this invited user.")
|
||||
if existing_request.status == WalletShareStatus.APPROVED:
|
||||
raise ValueError("This wallet is already shared with you.")
|
||||
if existing_request.status != WalletShareStatus.INVITE_SENT:
|
||||
raise ValueError("Unknown request type.")
|
||||
|
||||
invited_user.extra.remove_wallet_invite_request(request_id)
|
||||
await update_account(invited_user)
|
||||
|
||||
# todo: double check if user already has a mirror wallet for this source wallet
|
||||
|
||||
mirror_wallet = await create_wallet(
|
||||
user_id=invited_user.id,
|
||||
wallet_name=source_wallet.name,
|
||||
wallet_type=WalletType.LIGHTNING_SHARED,
|
||||
shared_wallet_id=source_wallet.id,
|
||||
conn=conn,
|
||||
)
|
||||
existing_request.approve(shared_with_wallet_id=mirror_wallet.id)
|
||||
await update_wallet(source_wallet, conn=conn)
|
||||
mirror_wallet.mirror_shared_wallet(source_wallet)
|
||||
return mirror_wallet
|
||||
@@ -1,27 +1,65 @@
|
||||
from fastapi import WebSocket
|
||||
from asyncio import Queue
|
||||
from dataclasses import dataclass
|
||||
|
||||
from fastapi import WebSocket, WebSocketDisconnect
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.settings import settings
|
||||
|
||||
|
||||
@dataclass
|
||||
class WebsocketConnection:
|
||||
item_id: str
|
||||
websocket: WebSocket
|
||||
receive_queue: Queue[str]
|
||||
|
||||
|
||||
class WebsocketConnectionManager:
|
||||
def __init__(self) -> None:
|
||||
self.active_connections: list[WebSocket] = []
|
||||
self.active_connections: list[WebsocketConnection] = []
|
||||
|
||||
async def connect(self, websocket: WebSocket, item_id: str):
|
||||
async def connect(self, item_id: str, websocket: WebSocket) -> WebsocketConnection:
|
||||
logger.debug(f"Websocket connected to {item_id}")
|
||||
await websocket.accept()
|
||||
self.active_connections.append(websocket)
|
||||
conn = WebsocketConnection(
|
||||
item_id=item_id,
|
||||
websocket=websocket,
|
||||
receive_queue=Queue(),
|
||||
)
|
||||
self.active_connections.append(conn)
|
||||
return conn
|
||||
|
||||
def disconnect(self, websocket: WebSocket):
|
||||
self.active_connections.remove(websocket)
|
||||
async def listen(self, conn: WebsocketConnection) -> None:
|
||||
while settings.lnbits_running:
|
||||
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
|
||||
|
||||
async def send_data(self, message: str, item_id: str):
|
||||
for connection in self.active_connections:
|
||||
if connection.path_params["item_id"] == item_id:
|
||||
await connection.send_text(message)
|
||||
def get_connections(self, item_id: str) -> list[WebsocketConnection]:
|
||||
conns = []
|
||||
for conn in self.active_connections:
|
||||
if conn.item_id == item_id:
|
||||
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()
|
||||
|
||||
|
||||
async def websocket_updater(item_id: str, data: str):
|
||||
return await websocket_manager.send_data(data, item_id)
|
||||
# deprecated import and use `websocket_manager.send()` instead
|
||||
async def websocket_updater(item_id: str, data: str) -> None:
|
||||
return await websocket_manager.send(item_id, data)
|
||||
|
||||
+14
-28
@@ -1,6 +1,4 @@
|
||||
import asyncio
|
||||
import traceback
|
||||
from typing import Callable, Coroutine
|
||||
|
||||
from loguru import logger
|
||||
|
||||
@@ -12,7 +10,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.users import get_accounts
|
||||
from lnbits.core.crud.wallets import get_wallets_count
|
||||
from lnbits.core.models import AuditEntry
|
||||
from lnbits.core.models.audit import AuditEntry
|
||||
from lnbits.core.models.extensions import InstallableExtension
|
||||
from lnbits.core.models.notifications import NotificationType
|
||||
from lnbits.core.services.funding_source import (
|
||||
@@ -21,24 +19,23 @@ from lnbits.core.services.funding_source import (
|
||||
get_balance_delta,
|
||||
)
|
||||
from lnbits.core.services.notifications import (
|
||||
enqueue_notification,
|
||||
enqueue_admin_notification,
|
||||
process_next_notification,
|
||||
send_payment_notification,
|
||||
)
|
||||
from lnbits.db import Filters
|
||||
from lnbits.settings import settings
|
||||
from lnbits.tasks import create_unique_task
|
||||
from lnbits.utils.exchange_rates import btc_rates
|
||||
|
||||
audit_queue: asyncio.Queue = asyncio.Queue()
|
||||
audit_queue: asyncio.Queue[AuditEntry] = asyncio.Queue()
|
||||
|
||||
|
||||
async def run_by_the_minute_tasks():
|
||||
async def run_by_the_minute_tasks() -> None:
|
||||
minute_counter = 0
|
||||
while settings.lnbits_running:
|
||||
status_minutes = settings.lnbits_notification_server_status_hours * 60
|
||||
|
||||
if settings.notification_balance_delta_changed:
|
||||
if settings.notification_balance_delta_threshold_sats > 0:
|
||||
try:
|
||||
# runs by default every minute, the delta should not change that often
|
||||
await check_balance_delta_changed()
|
||||
@@ -68,7 +65,7 @@ async def run_by_the_minute_tasks():
|
||||
await asyncio.sleep(60)
|
||||
|
||||
|
||||
async def _notify_server_status():
|
||||
async def _notify_server_status() -> None:
|
||||
accounts = await get_accounts(filters=Filters(limit=0))
|
||||
wallets_count = await get_wallets_count()
|
||||
payments = await get_payments_status_count()
|
||||
@@ -86,10 +83,10 @@ async def _notify_server_status():
|
||||
"lnbits_balance_sats": status.lnbits_balance_sats,
|
||||
"node_balance_sats": status.node_balance_sats,
|
||||
}
|
||||
enqueue_notification(NotificationType.server_status, values)
|
||||
enqueue_admin_notification(NotificationType.server_status, values)
|
||||
|
||||
|
||||
async def wait_for_paid_invoices(invoice_paid_queue: asyncio.Queue):
|
||||
async def wait_for_paid_invoices(invoice_paid_queue: asyncio.Queue) -> None:
|
||||
"""
|
||||
This worker dispatches events to all extensions and dispatches webhooks.
|
||||
"""
|
||||
@@ -102,13 +99,13 @@ async def wait_for_paid_invoices(invoice_paid_queue: asyncio.Queue):
|
||||
await send_payment_notification(wallet, payment)
|
||||
|
||||
|
||||
async def wait_for_audit_data():
|
||||
async def wait_for_audit_data() -> None:
|
||||
"""
|
||||
Waits for audit entries to be pushed to the queue.
|
||||
Then it inserts the entries into the DB.
|
||||
"""
|
||||
while settings.lnbits_running:
|
||||
data: AuditEntry = await audit_queue.get()
|
||||
data = await audit_queue.get()
|
||||
try:
|
||||
await create_audit_entry(data)
|
||||
except Exception as ex:
|
||||
@@ -116,17 +113,17 @@ async def wait_for_audit_data():
|
||||
await asyncio.sleep(3)
|
||||
|
||||
|
||||
async def wait_notification_messages():
|
||||
async def wait_notification_messages() -> None:
|
||||
|
||||
while settings.lnbits_running:
|
||||
try:
|
||||
await process_next_notification()
|
||||
except Exception as ex:
|
||||
logger.log("error", ex)
|
||||
logger.warning("Payment notification error", ex)
|
||||
await asyncio.sleep(3)
|
||||
|
||||
|
||||
async def purge_audit_data():
|
||||
async def purge_audit_data() -> None:
|
||||
"""
|
||||
Remove audit entries which have passed their retention period.
|
||||
"""
|
||||
@@ -140,7 +137,7 @@ async def purge_audit_data():
|
||||
await asyncio.sleep(60 * 60)
|
||||
|
||||
|
||||
async def collect_exchange_rates_data():
|
||||
async def collect_exchange_rates_data() -> None:
|
||||
"""
|
||||
Collect exchange rates data. Used for monitoring only.
|
||||
"""
|
||||
@@ -162,14 +159,3 @@ async def collect_exchange_rates_data():
|
||||
else:
|
||||
sleep_time = 60
|
||||
await asyncio.sleep(sleep_time)
|
||||
|
||||
|
||||
def _create_unique_task(name: str, func: Callable):
|
||||
async def _to_coro(func: Callable[[], Coroutine]) -> Coroutine:
|
||||
return await func()
|
||||
|
||||
try:
|
||||
create_unique_task(name, _to_coro(func))
|
||||
except Exception as e:
|
||||
logger.error(f"Error in {name} task", e)
|
||||
logger.error(traceback.format_exc())
|
||||
|
||||
@@ -1,197 +0,0 @@
|
||||
<q-tab-panel name="exchange_providers">
|
||||
<h6 class="q-my-none q-mb-sm">
|
||||
<span v-text="$t('exchange_providers')"></span>
|
||||
</h6>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-8 col-sm-12">
|
||||
<div class="q-pa-sm">
|
||||
<canvas
|
||||
style="
|
||||
width: 100% !important;
|
||||
height: auto !important;
|
||||
min-height: 350px;
|
||||
max-height: 50vh;
|
||||
"
|
||||
ref="exchangeRatesChart"
|
||||
></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4 col-sm-12">
|
||||
<q-input
|
||||
filled
|
||||
v-model="formData.lnbits_exchange_history_refresh_interval_seconds"
|
||||
type="number"
|
||||
label="Refresh Interval (seconds)"
|
||||
hint="How often should the exchange rates be fetched. Set to zero to disable."
|
||||
>
|
||||
</q-input>
|
||||
<q-input
|
||||
filled
|
||||
v-model="formData.lnbits_exchange_history_size"
|
||||
type="number"
|
||||
label="History Size"
|
||||
hint="How many data points should be kept in memory."
|
||||
>
|
||||
</q-input>
|
||||
<ul>
|
||||
<li>
|
||||
<code>Refresh Interval</code> and <code> History Size </code>are for
|
||||
historical purposes only.
|
||||
</li>
|
||||
<li>These two settings do not affect the live price computation.</li>
|
||||
<li>
|
||||
Chart currency:
|
||||
<strong
|
||||
><span
|
||||
v-text="formData.lnbits_default_accounting_currency || 'USD'"
|
||||
></span
|
||||
></strong>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row q-mt-md">
|
||||
<div class="col-6">
|
||||
<q-btn
|
||||
@click="addExchangeProvider()"
|
||||
label="Add Exchange Provider"
|
||||
color="primary"
|
||||
class="q-mb-md"
|
||||
>
|
||||
</q-btn>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<q-btn
|
||||
@click="getDefaultSetting('lnbits_exchange_rate_providers')"
|
||||
flat
|
||||
:label="$t('reset_defaults')"
|
||||
color="primary"
|
||||
class="float-right"
|
||||
>
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<q-table
|
||||
row-key="name"
|
||||
:rows="formData.lnbits_exchange_rate_providers"
|
||||
:columns="exchangesTable.columns"
|
||||
v-model:pagination="exchangesTable.pagination"
|
||||
>
|
||||
<template v-slot:header="props">
|
||||
<q-tr :props="props">
|
||||
<q-th auto-width></q-th>
|
||||
<q-th v-for="col in props.cols" :key="col.name" :props="props">
|
||||
<span v-text="col.label"></span>
|
||||
</q-th>
|
||||
</q-tr>
|
||||
</template>
|
||||
<template v-slot:body="props">
|
||||
<q-tr :props="props">
|
||||
<q-td>
|
||||
<q-btn
|
||||
@click="removeExchangeProvider(props.row)"
|
||||
round
|
||||
icon="delete"
|
||||
size="sm"
|
||||
color="negative"
|
||||
class="q-ml-xs"
|
||||
>
|
||||
</q-btn>
|
||||
</q-td>
|
||||
<q-td>
|
||||
<q-input
|
||||
dense
|
||||
filled
|
||||
v-model="props.row.name"
|
||||
@update:model-value="touchSettings()"
|
||||
type="text"
|
||||
>
|
||||
</q-input>
|
||||
</q-td>
|
||||
<q-td full-width>
|
||||
<q-input
|
||||
dense
|
||||
filled
|
||||
v-model="props.row.api_url"
|
||||
@update:model-value="touchSettings()"
|
||||
type="text"
|
||||
>
|
||||
</q-input
|
||||
></q-td>
|
||||
<q-td>
|
||||
<q-input
|
||||
dense
|
||||
filled
|
||||
v-model="props.row.path"
|
||||
@update:model-value="touchSettings()"
|
||||
type="text"
|
||||
>
|
||||
</q-input>
|
||||
</q-td>
|
||||
<q-td>
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
v-model="props.row.exclude_to"
|
||||
@update:model-value="touchSettings()"
|
||||
multiple
|
||||
:options="{{ currencies | safe }}"
|
||||
></q-select>
|
||||
</q-td>
|
||||
<q-td>
|
||||
<q-btn
|
||||
@click="showTickerConversionDialog(props.row)"
|
||||
round
|
||||
icon="add"
|
||||
size="sm"
|
||||
color="gray"
|
||||
class="q-ml-xs"
|
||||
>
|
||||
</q-btn>
|
||||
<q-chip
|
||||
v-for="ticker, index in props.row.ticker_conversion"
|
||||
:key="ticker"
|
||||
removable
|
||||
dense
|
||||
filled
|
||||
@remove="removeExchangeTickerConversion(props.row, ticker)"
|
||||
color="primary"
|
||||
text-color="white"
|
||||
:label="ticker"
|
||||
class="ellipsis"
|
||||
>
|
||||
</q-chip>
|
||||
</q-td>
|
||||
</q-tr>
|
||||
</template>
|
||||
</q-table>
|
||||
<ul>
|
||||
<li>
|
||||
<code>API URL</code> and <code>JSON Path</code> fields can use the
|
||||
<code>{to}</code> and <code>{TO}</code> placeholders for the code of the
|
||||
currency
|
||||
</li>
|
||||
<li>
|
||||
<code>{TO}</code> is the uppercase code and <code>{to}</code> is the
|
||||
lowercase code
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<q-separator class="q-ma-md"></q-separator>
|
||||
<div class="row">
|
||||
<div class="col-md-4 col-sm-12">
|
||||
<q-input
|
||||
filled
|
||||
v-model="formData.lnbits_exchange_rate_cache_seconds"
|
||||
type="number"
|
||||
label="Exchange rate cache (seconds)"
|
||||
hint="For how many seconds should the exchange rate be cached."
|
||||
>
|
||||
</q-input>
|
||||
</div>
|
||||
<div class="col-md-8 col-sm-12"></div>
|
||||
</div>
|
||||
</q-tab-panel>
|
||||
@@ -1,67 +0,0 @@
|
||||
<q-tab-panel name="library">
|
||||
<q-card-section class="q-pa-none">
|
||||
<h6 class="q-my-none q-mb-sm">
|
||||
<span v-text="$t('image_library')"></span>
|
||||
</h6>
|
||||
<q-btn
|
||||
color="primary"
|
||||
label="Add Image"
|
||||
@click="$refs.imageInput.click()"
|
||||
class="q-mb-md"
|
||||
/>
|
||||
<input
|
||||
type="file"
|
||||
ref="imageInput"
|
||||
accept="image/png, image/jpeg, image/gif"
|
||||
style="display: none"
|
||||
@change="onImageInput"
|
||||
/>
|
||||
</q-card-section>
|
||||
<div class="row q-col-gutter-sm q-pa-sm">
|
||||
<div
|
||||
v-for="image in library_images"
|
||||
:key="image.filename"
|
||||
class="col-6 col-sm-4 col-md-3 col-lg-2"
|
||||
style="max-width: 200px"
|
||||
>
|
||||
<q-card class="q-mb-sm">
|
||||
<q-img :src="image.url" style="height: 150px" />
|
||||
|
||||
<q-card-section
|
||||
class="q-pt-md q-pb-md row items-center justify-between"
|
||||
>
|
||||
<small
|
||||
><div
|
||||
class="text-caption ellipsis"
|
||||
style="max-width: 100px"
|
||||
:title="image.filename"
|
||||
v-text="image.filename"
|
||||
></div
|
||||
></small>
|
||||
<q-btn
|
||||
dense
|
||||
flat
|
||||
size="sm"
|
||||
icon="content_copy"
|
||||
@click="copyText(image.url)"
|
||||
:title="$t('copy')"
|
||||
><q-tooltip>Copy image link</q-tooltip></q-btn
|
||||
>
|
||||
<q-btn
|
||||
dense
|
||||
flat
|
||||
size="sm"
|
||||
icon="delete"
|
||||
color="negative"
|
||||
@click="deleteImage(image.filename)"
|
||||
:title="$t('delete')"
|
||||
><q-tooltip>Delete image</q-tooltip></q-btn
|
||||
>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="library_images.length === 0" class="q-pa-xl">
|
||||
<div class="text-subtitle2 text-grey">No images uploaded yet.</div>
|
||||
</div>
|
||||
</q-tab-panel>
|
||||
@@ -1,243 +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">
|
||||
<div class="row items-center justify-between q-gutter-xs">
|
||||
<div class="col">
|
||||
<q-btn
|
||||
:label="$t('save')"
|
||||
color="primary"
|
||||
@click="updateSettings"
|
||||
:disabled="!checkChanges"
|
||||
>
|
||||
<q-tooltip v-if="checkChanges">
|
||||
<span v-text="$t('save_tooltip')"></span>
|
||||
</q-tooltip>
|
||||
|
||||
<q-badge
|
||||
v-if="checkChanges"
|
||||
color="red"
|
||||
rounded
|
||||
floating
|
||||
style="padding: 6px; border-radius: 6px"
|
||||
/>
|
||||
</q-btn>
|
||||
|
||||
<q-btn
|
||||
v-if="isSuperUser"
|
||||
:label="$t('restart')"
|
||||
color="primary"
|
||||
@click="restartServer"
|
||||
class="q-ml-md"
|
||||
>
|
||||
<q-tooltip v-if="needsRestart">
|
||||
<span v-text="$t('restart_tooltip')"></span>
|
||||
</q-tooltip>
|
||||
|
||||
<q-badge
|
||||
v-if="needsRestart"
|
||||
color="red"
|
||||
rounded
|
||||
floating
|
||||
style="padding: 6px; border-radius: 6px"
|
||||
/>
|
||||
</q-btn>
|
||||
|
||||
<q-btn
|
||||
:label="$t('download_backup')"
|
||||
flat
|
||||
@click="downloadBackup"
|
||||
></q-btn>
|
||||
|
||||
<q-btn
|
||||
flat
|
||||
v-if="isSuperUser"
|
||||
:label="$t('reset_defaults')"
|
||||
color="primary"
|
||||
@click="deleteSettings"
|
||||
class="float-right"
|
||||
>
|
||||
<q-tooltip>
|
||||
<span v-text="$t('reset_defaults_tooltip')"></span>
|
||||
</q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
<div></div>
|
||||
</div>
|
||||
</div>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row q-col-gutter-md justify-center">
|
||||
<div class="col q-gutter-y-md">
|
||||
<q-card>
|
||||
<q-splitter>
|
||||
<template v-slot:before>
|
||||
<q-tabs
|
||||
@update:model-value="showExchangeProvidersTab"
|
||||
v-model="tab"
|
||||
vertical
|
||||
active-color="primary"
|
||||
>
|
||||
<q-tab
|
||||
name="funding"
|
||||
icon="account_balance_wallet"
|
||||
:label="$q.screen.gt.sm ? $t('funding') : null"
|
||||
@update="val => tab = val.name"
|
||||
><q-tooltip v-if="!$q.screen.gt.sm"
|
||||
><span v-text="$t('funding')"></span></q-tooltip
|
||||
></q-tab>
|
||||
<q-tab
|
||||
name="security"
|
||||
icon="security"
|
||||
:label="$q.screen.gt.sm ? $t('security') : null"
|
||||
@update="val => tab = val.name"
|
||||
><q-tooltip v-if="!$q.screen.gt.sm"
|
||||
><span v-text="$t('security')"></span></q-tooltip
|
||||
></q-tab>
|
||||
<q-tab
|
||||
name="server"
|
||||
icon="price_change"
|
||||
:label="$q.screen.gt.sm ? $t('payments') : null"
|
||||
@update="val => tab = val.name"
|
||||
><q-tooltip v-if="!$q.screen.gt.sm"
|
||||
><span v-text="$t('payments')"></span></q-tooltip
|
||||
></q-tab>
|
||||
<q-tab
|
||||
name="exchange_providers"
|
||||
icon="show_chart"
|
||||
:label="$q.screen.gt.sm ? $t('exchanges') : null"
|
||||
><q-tooltip v-if="!$q.screen.gt.sm"
|
||||
><span v-text="$t('exchanges')"></span></q-tooltip
|
||||
></q-tab>
|
||||
<q-tab
|
||||
name="users"
|
||||
icon="group"
|
||||
:label="$q.screen.gt.sm ? $t('users') : null"
|
||||
@update="val => tab = val.name"
|
||||
><q-tooltip v-if="!$q.screen.gt.sm"
|
||||
><span v-text="$t('users')"></span></q-tooltip
|
||||
></q-tab>
|
||||
|
||||
<q-tab
|
||||
name="extensions"
|
||||
icon="extension"
|
||||
:label="$q.screen.gt.sm ? $t('extensions') : null"
|
||||
@update="val => tab = val.name"
|
||||
><q-tooltip v-if="!$q.screen.gt.sm"
|
||||
><span v-text="$t('extensions')"></span></q-tooltip
|
||||
></q-tab>
|
||||
|
||||
<q-tab
|
||||
name="notifications"
|
||||
icon="notifications"
|
||||
:label="$q.screen.gt.sm ? $t('notifications') : null"
|
||||
@update="val => tab = val.name"
|
||||
><q-tooltip v-if="!$q.screen.gt.sm"
|
||||
><span v-text="$t('notifications')"></span></q-tooltip
|
||||
></q-tab>
|
||||
<q-tab
|
||||
name="audit"
|
||||
icon="playlist_add_check_circle"
|
||||
:label="$q.screen.gt.sm ? $t('audit') : null"
|
||||
@update="val => tab = val.name"
|
||||
><q-tooltip v-if="!$q.screen.gt.sm"
|
||||
><span v-text="$t('audit')"></span></q-tooltip
|
||||
></q-tab>
|
||||
<q-tab
|
||||
name="library"
|
||||
icon="image"
|
||||
:label="$q.screen.gt.sm ? $t('library') : null"
|
||||
@update="val => tab = val.name"
|
||||
><q-tooltip v-if="!$q.screen.gt.sm"
|
||||
><span v-text="$t('library')"></span></q-tooltip
|
||||
></q-tab>
|
||||
<q-tab
|
||||
style="word-break: break-all"
|
||||
name="site_customisation"
|
||||
icon="language"
|
||||
:label="$q.screen.gt.sm ? $t('site_customisation') : null"
|
||||
@update="val => tab = val.name"
|
||||
><q-tooltip v-if="!$q.screen.gt.sm"
|
||||
><span v-text="$t('site_customisation')"></span></q-tooltip
|
||||
></q-tab>
|
||||
</q-tabs>
|
||||
</template>
|
||||
|
||||
<template v-slot:after>
|
||||
<q-form name="settings_form" id="settings_form">
|
||||
<q-scroll-area style="height: 100vh">
|
||||
<q-tab-panels
|
||||
v-model="tab"
|
||||
animated
|
||||
swipeable
|
||||
vertical
|
||||
scroll
|
||||
transition-prev="jump-up"
|
||||
transition-next="jump-up"
|
||||
>
|
||||
{% include "admin/_tab_funding.html" %} {% include
|
||||
"admin/_tab_users.html" %} {% include "admin/_tab_server.html"
|
||||
%} {% include "admin/_tab_exchange_providers.html" %} {% include
|
||||
"admin/_tab_extensions.html" %} {% include
|
||||
"admin/_tab_notifications.html" %} {% include
|
||||
"admin/_tab_security.html" %} {% include "admin/_tab_theme.html"
|
||||
%}{% include "admin/_tab_audit.html"%}{% include
|
||||
"admin/_tab_library.html"%}
|
||||
</q-tab-panels>
|
||||
</q-scroll-area>
|
||||
</q-form>
|
||||
</template>
|
||||
</q-splitter>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<q-dialog v-model="exchangeData.showTickerConversion" position="top">
|
||||
<q-card class="q-pa-md q-pt-md lnbits__dialog-card">
|
||||
<div class="q-mb-md">
|
||||
<strong v-text="$t('create_ticker_converter')"></strong>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-12 q-mb-md">
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
v-model="exchangeData.convertFromTicker"
|
||||
label="From Currency"
|
||||
:options="{{ currencies | safe }}"
|
||||
></q-select>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<q-input
|
||||
v-model="exchangeData.convertToTicker"
|
||||
dense
|
||||
filled
|
||||
label="New Ticker"
|
||||
hint="This ticker will be used for the exchange API calls."
|
||||
>
|
||||
</q-input>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
@click="addExchangeTickerConversion()"
|
||||
label="Add Ticker Conversion"
|
||||
color="primary"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
v-close-popup
|
||||
flat
|
||||
color="grey"
|
||||
class="q-ml-auto"
|
||||
v-text="$t('close')"
|
||||
></q-btn>
|
||||
</div>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
{% endblock %}
|
||||
@@ -1,201 +0,0 @@
|
||||
{% if not ajax %} {% extends "base.html" %} {% endif %}
|
||||
<!---->
|
||||
{% from "macros.jinja" import window_vars with context %}
|
||||
<!---->
|
||||
{% block scripts %} {{ window_vars(user) }} {% endblock %} {% block page %}
|
||||
<div class="row q-col-gutter-md q-mb-md">
|
||||
<div class="col-12">
|
||||
<q-card>
|
||||
<div class="q-pa-sm q-pl-lg">
|
||||
<div class="row items-center justify-between q-gutter-xs">
|
||||
<div class="col">
|
||||
<!-- Optional: Add content here if needed -->
|
||||
</div>
|
||||
<div>
|
||||
<q-btn
|
||||
v-if="g.user.admin"
|
||||
flat
|
||||
round
|
||||
icon="settings"
|
||||
to="/admin#audit"
|
||||
>
|
||||
<q-tooltip v-text="$t('admin_settings')"></q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row q-col-gutter-md justify-center q-mb-lg">
|
||||
<div class="col-lg-3 col-md-6 col-sm-12 text-center">
|
||||
<q-card class="q-pt-sm">
|
||||
<strong v-text="$t('components')"></strong>
|
||||
<div style="width: 250px" class="q-pa-sm">
|
||||
<canvas v-if="chartsReady" ref="componentUseChart"></canvas>
|
||||
</div>
|
||||
</q-card>
|
||||
</div>
|
||||
<div class="col-lg-3 col-md-6 col-sm-12 text-center">
|
||||
<q-card class="q-pt-sm">
|
||||
<strong v-text="$t('long_running_endpoints')"></strong>
|
||||
<div style="width: 250px; height: 250px" class="q-pa-sm">
|
||||
<canvas v-if="chartsReady" ref="longDurationChart"></canvas>
|
||||
</div>
|
||||
</q-card>
|
||||
</div>
|
||||
<div class="col-lg-3 col-md-6 col-sm-12 text-center">
|
||||
<q-card class="q-pt-sm">
|
||||
<strong v-text="$t('http_request_methods')"></strong>
|
||||
<div style="width: 250px; height: 250px" class="q-pa-sm">
|
||||
<canvas v-if="chartsReady" ref="requestMethodChart"></canvas>
|
||||
</div>
|
||||
</q-card>
|
||||
</div>
|
||||
<div class="col-lg-3 col-md-6 col-sm-12 text-center">
|
||||
<q-card class="q-pt-sm">
|
||||
<strong v-text="$t('http_response_codes')"></strong>
|
||||
<div style="width: 250px; height: 250px" class="q-pa-sm">
|
||||
<canvas v-if="chartsReady" ref="responseCodeChart"></canvas>
|
||||
</div>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row q-col-gutter-md justify-center">
|
||||
<div class="col">
|
||||
<q-card class="q-pa-md">
|
||||
<q-table
|
||||
row-key="id"
|
||||
:rows="auditEntries"
|
||||
:columns="auditTable.columns"
|
||||
v-model:pagination="auditTable.pagination"
|
||||
:filter="auditTable.search"
|
||||
:loading="auditTable.loading"
|
||||
@request="fetchAudit"
|
||||
>
|
||||
<template v-slot:header="props">
|
||||
<q-tr :props="props">
|
||||
<q-th v-for="col in props.cols" :key="col.name" :props="props">
|
||||
<q-input
|
||||
v-if="['ip_address', 'user_id', 'path',].includes(col.name)"
|
||||
v-model="searchData[col.name]"
|
||||
@keydown.enter="searchAuditBy()"
|
||||
@update:model-value="searchAuditBy()"
|
||||
dense
|
||||
type="text"
|
||||
filled
|
||||
clearable
|
||||
:label="col.label"
|
||||
>
|
||||
<template v-slot:append>
|
||||
<q-icon
|
||||
name="search"
|
||||
@click="searchAuditBy()"
|
||||
class="cursor-pointer"
|
||||
/>
|
||||
</template>
|
||||
</q-input>
|
||||
|
||||
<q-select
|
||||
v-else-if="['component', 'response_code','request_method'].includes(col.name)"
|
||||
v-model="searchData[col.name]"
|
||||
:options="searchOptions[col.name]"
|
||||
@update:model-value="searchAuditBy()"
|
||||
:label="col.label"
|
||||
clearable
|
||||
style="width: 100px"
|
||||
></q-select>
|
||||
|
||||
<span v-else v-text="col.label"></span>
|
||||
</q-th>
|
||||
</q-tr>
|
||||
</template>
|
||||
<template v-slot:body="props">
|
||||
<q-tr auto-width :props="props">
|
||||
<q-td v-for="col in props.cols" :key="col.name" :props="props">
|
||||
<div v-if="col.name == 'created_at'">
|
||||
<q-btn
|
||||
icon="description"
|
||||
:disable="!props.row.request_details"
|
||||
size="sm"
|
||||
flat
|
||||
class="cursor-pointer q-mr-xs"
|
||||
@click="showDetailsDialog(props.row)"
|
||||
>
|
||||
<q-tooltip
|
||||
><span v-text="$t('request_details')"></span
|
||||
></q-tooltip>
|
||||
</q-btn>
|
||||
|
||||
<span v-text="formatDate(props.row.created_at)"></span>
|
||||
<q-tooltip v-if="props.row.delete_at">
|
||||
<span
|
||||
v-text="'Will be deleted at: ' + formatDate(props.row.delete_at)"
|
||||
></span>
|
||||
</q-tooltip>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="['user_id', 'request_details'].includes(col.name)"
|
||||
>
|
||||
<q-btn
|
||||
v-if="props.row[col.name]"
|
||||
icon="content_copy"
|
||||
size="sm"
|
||||
flat
|
||||
class="cursor-pointer q-mr-xs"
|
||||
@click="copyText(props.row[col.name])"
|
||||
>
|
||||
<q-tooltip>Copy</q-tooltip>
|
||||
</q-btn>
|
||||
<span v-text="shortify(props.row[col.name])"> </span>
|
||||
<q-tooltip>
|
||||
<span v-text="props.row[col.name]"></span>
|
||||
</q-tooltip>
|
||||
</div>
|
||||
<span
|
||||
v-else
|
||||
v-text="props.row[col.name]"
|
||||
@click="searchAuditBy(col.name, props.row[col.name])"
|
||||
class="cursor-pointer"
|
||||
></span>
|
||||
</q-td>
|
||||
</q-tr>
|
||||
</template>
|
||||
</q-table>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<q-dialog v-model="auditDetailsDialog.show" position="top">
|
||||
<q-card class="q-pa-md q-pt-md lnbits__dialog-card">
|
||||
<strong v-text="$t('http_request_details')"></strong>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="auditDetailsDialog.data"
|
||||
type="textarea"
|
||||
rows="25"
|
||||
></q-input>
|
||||
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
@click="copyText(auditDetailsDialog.data)"
|
||||
icon="copy_content"
|
||||
color="grey"
|
||||
flat
|
||||
v-text="$t('copy')"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
v-close-popup
|
||||
flat
|
||||
color="grey"
|
||||
class="q-ml-auto"
|
||||
v-text="$t('close')"
|
||||
></q-btn>
|
||||
</div>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
{% endblock %}
|
||||
@@ -1,285 +0,0 @@
|
||||
<q-expansion-item
|
||||
group="extras"
|
||||
icon="vpn_key"
|
||||
:label="$t('api_keys_api_docs')"
|
||||
:content-inset-level="0.5"
|
||||
>
|
||||
<q-card-section>
|
||||
<q-list>
|
||||
<q-item dense class="q-pa-none">
|
||||
<q-item-section>
|
||||
<q-item-label>
|
||||
<strong>Node URL: </strong><em v-text="origin"></em>
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item dense class="q-pa-none">
|
||||
<q-item-section>
|
||||
<q-item-label>
|
||||
<strong>Wallet ID: </strong><em v-text="wallet.id"></em>
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section side>
|
||||
<q-icon
|
||||
name="content_copy"
|
||||
class="cursor-pointer"
|
||||
@click="copyText(wallet.id)"
|
||||
></q-icon>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item dense class="q-pa-none">
|
||||
<q-item-section>
|
||||
<q-item-label>
|
||||
<strong>Admin key: </strong
|
||||
><em
|
||||
v-text="adminkeyHidden ? '****************' : wallet.adminkey"
|
||||
></em>
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section side>
|
||||
<div>
|
||||
<q-icon
|
||||
:name="adminkeyHidden ? 'visibility_off' : 'visibility'"
|
||||
class="cursor-pointer"
|
||||
@click="adminkeyHidden = !adminkeyHidden"
|
||||
></q-icon>
|
||||
<q-icon
|
||||
name="content_copy"
|
||||
class="cursor-pointer q-ml-sm"
|
||||
@click="copyText(wallet.adminkey)"
|
||||
></q-icon>
|
||||
</div>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item dense class="q-pa-none">
|
||||
<q-item-section>
|
||||
<q-item-label>
|
||||
<strong>Invoice/read key: </strong
|
||||
><em v-text="inkeyHidden ? '****************' : wallet.inkey"></em>
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section side>
|
||||
<div>
|
||||
<q-icon
|
||||
:name="inkeyHidden ? 'visibility_off' : 'visibility'"
|
||||
class="cursor-pointer"
|
||||
@click="inkeyHidden = !inkeyHidden"
|
||||
></q-icon>
|
||||
<q-icon
|
||||
name="content_copy"
|
||||
class="cursor-pointer q-ml-sm"
|
||||
@click="copyText(wallet.inkey)"
|
||||
></q-icon>
|
||||
</div>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-card-section>
|
||||
<q-expansion-item
|
||||
group="api"
|
||||
dense
|
||||
expand-separator
|
||||
label="Get wallet details"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code><span class="text-light-green">GET</span> /api/v1/wallet</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code
|
||||
>{"X-Api-Key": "<i
|
||||
v-text="inkeyHidden ? '****************' : wallet.inkey"
|
||||
></i
|
||||
>"}</code
|
||||
><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 200 OK (application/json)
|
||||
</h5>
|
||||
<code
|
||||
>{"id": <string>, "name": <string>, "balance":
|
||||
<int>}</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl <span v-text="baseUrl"></span>api/v1/wallet -H "X-Api-Key:
|
||||
<i v-text="inkeyHidden ? '****************' : wallet.inkey"></i
|
||||
>"</code
|
||||
>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
|
||||
<q-expansion-item
|
||||
group="api"
|
||||
dense
|
||||
expand-separator
|
||||
label="Create an invoice (incoming)"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code><span class="text-light-green">POST</span> /api/v1/payments</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code
|
||||
>{"X-Api-Key": "<i
|
||||
v-text="inkeyHidden ? '****************' : wallet.inkey"
|
||||
></i
|
||||
>"}</code
|
||||
><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||
<code
|
||||
>{"out": false, "amount": <int>, "memo": <string>,
|
||||
"expiry": <int>, "unit": <string>, "webhook":
|
||||
<url:string>, "internal": <bool>}</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 201 CREATED (application/json)
|
||||
</h5>
|
||||
<code
|
||||
>{"payment_hash": <string>, "payment_request":
|
||||
<string>}</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X POST <span v-text="baseUrl"></span>api/v1/payments -d
|
||||
'{"out": false, "amount": <int>, "memo": <string>}' -H
|
||||
"X-Api-Key:
|
||||
<i v-text="inkeyHidden ? '****************' : wallet.inkey"></i>" -H
|
||||
"Content-type: application/json"</code
|
||||
>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item
|
||||
group="api"
|
||||
dense
|
||||
expand-separator
|
||||
label="Pay an invoice (outgoing)"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-light-green">POST</span> /api/v1/payments (reveal
|
||||
admin keys
|
||||
<q-icon
|
||||
:name="adminkeyHidden ? 'visibility_off' : 'visibility'"
|
||||
class="cursor-pointer"
|
||||
@click="adminkeyHidden = !adminkeyHidden"
|
||||
></q-icon
|
||||
>)</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code
|
||||
>{"X-Api-Key": "<i
|
||||
v-text="adminkeyHidden ? '****************' : wallet.adminkey"
|
||||
></i
|
||||
>"}</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||
<code>{"out": true, "bolt11": <string>}</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 201 CREATED (application/json)
|
||||
</h5>
|
||||
<code>{"payment_hash": <string>}</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X POST <span v-text="baseUrl"></span>api/v1/payments -d
|
||||
'{"out": true, "bolt11": <string>}' -H "X-Api-Key:
|
||||
<i v-text="adminkeyHidden ? '****************' : wallet.adminkey"></i
|
||||
>" -H "Content-type: application/json"</code
|
||||
>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
|
||||
<q-expansion-item
|
||||
group="api"
|
||||
dense
|
||||
expand-separator
|
||||
label="Decode an invoice"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-light-green">POST</span>
|
||||
/api/v1/payments/decode</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||
<code>{"data": <string>}</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 200 (application/json)
|
||||
</h5>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X POST <span v-text="baseUrl"></span>api/v1/payments/decode -d
|
||||
'{"data": <bolt11/lnurl, string>}' -H "Content-type:
|
||||
application/json"</code
|
||||
>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item
|
||||
group="api"
|
||||
dense
|
||||
expand-separator
|
||||
label="Check an invoice (incoming or outgoing)"
|
||||
class="q-pb-md"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-light-blue">GET</span>
|
||||
/api/v1/payments/<payment_hash></code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code
|
||||
>{"X-Api-Key": "<i
|
||||
v-text="inkeyHidden ? '****************' : wallet.inkey"
|
||||
></i
|
||||
>"}</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 200 OK (application/json)
|
||||
</h5>
|
||||
<code>{"paid": <bool>}</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X GET
|
||||
<span v-text="baseUrl"></span>api/v1/payments/<payment_hash> -H
|
||||
"X-Api-Key:
|
||||
<i v-text="inkeyHidden ? '****************' : wallet.inkey"></i>" -H
|
||||
"Content-type: application/json"</code
|
||||
>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-pink">WS</span>
|
||||
/api/v1/ws/<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-separator></q-separator>
|
||||
<q-card-section>
|
||||
<p v-text="$t('reset_wallet_keys_desc')"></p>
|
||||
<q-btn
|
||||
unelevated
|
||||
color="red-10"
|
||||
@click="resetKeys()"
|
||||
:label="$t('reset_wallet_keys')"
|
||||
></q-btn>
|
||||
</q-card-section>
|
||||
</q-expansion-item>
|
||||
@@ -1,912 +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>
|
||||
<div class="q-gutter-y-md">
|
||||
<q-tabs v-model="tab" align="left">
|
||||
<q-tab
|
||||
name="user"
|
||||
:label="$t('account_settings')"
|
||||
@update="val => tab = val.name"
|
||||
></q-tab>
|
||||
<q-tab
|
||||
name="theme"
|
||||
:label="$t('look_and_feel')"
|
||||
@update="val => tab = val.name"
|
||||
></q-tab>
|
||||
<q-tab
|
||||
name="api_acls"
|
||||
:label="$t('access_control_list')"
|
||||
@update="val => tab = val.name"
|
||||
></q-tab>
|
||||
</q-tabs>
|
||||
</div>
|
||||
</div>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row q-col-gutter-md">
|
||||
<div v-if="user" class="col-md-12 col-lg-6 q-gutter-y-md">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<q-tab-panels v-model="tab">
|
||||
<q-tab-panel name="user">
|
||||
<div v-if="credentialsData.show">
|
||||
<q-card-section>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<h4 class="q-my-none">
|
||||
<span v-text="$t('password')"></span>
|
||||
</h4>
|
||||
</div>
|
||||
<div class="col">
|
||||
<q-img
|
||||
v-if="user.extra.picture"
|
||||
style="max-width: 100px"
|
||||
:src="user.extra.picture"
|
||||
class="float-right"
|
||||
></q-img>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
<q-card-section>
|
||||
<q-input
|
||||
v-model="credentialsData.username"
|
||||
:label="$t('username')"
|
||||
filled
|
||||
dense
|
||||
:readonly="hasUsername"
|
||||
class="q-mb-md"
|
||||
></q-input>
|
||||
<q-input
|
||||
v-if="user.has_password"
|
||||
v-model="credentialsData.oldPassword"
|
||||
type="password"
|
||||
autocomplete="off"
|
||||
label="Old Password"
|
||||
filled
|
||||
dense
|
||||
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password')]"
|
||||
></q-input>
|
||||
<q-input
|
||||
v-model="credentialsData.newPassword"
|
||||
type="password"
|
||||
autocomplete="off"
|
||||
:label="$t('password')"
|
||||
filled
|
||||
dense
|
||||
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password')]"
|
||||
></q-input>
|
||||
<q-input
|
||||
v-model="credentialsData.newPasswordRepeat"
|
||||
type="password"
|
||||
autocomplete="off"
|
||||
:label="$t('password_repeat')"
|
||||
filled
|
||||
dense
|
||||
class="q-mb-md"
|
||||
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password')]"
|
||||
></q-input>
|
||||
<q-btn
|
||||
@click="updatePassword"
|
||||
:disable="disableUpdatePassword()"
|
||||
unelevated
|
||||
color="primary"
|
||||
class="float-right"
|
||||
:label="$t('update_password')"
|
||||
>
|
||||
</q-btn>
|
||||
</q-card-section>
|
||||
<q-separator class="q-mt-xl"></q-separator>
|
||||
<q-card-section>
|
||||
<div class="col q-mb-sm">
|
||||
<h4 class="q-my-none">
|
||||
Nostr <span v-text="$t('pubkey')"></span>
|
||||
</h4>
|
||||
</div>
|
||||
<q-input
|
||||
v-model="credentialsData.pubkey"
|
||||
type="text"
|
||||
label="Pubkey"
|
||||
filled
|
||||
dense
|
||||
></q-input>
|
||||
<q-btn
|
||||
@click="updatePubkey"
|
||||
unelevated
|
||||
color="primary"
|
||||
class="q-mt-md float-right"
|
||||
:label="$t('update_pubkey')"
|
||||
>
|
||||
</q-btn>
|
||||
</q-card-section>
|
||||
<q-separator class="q-mt-xl"></q-separator>
|
||||
<q-card-section class="q-pb-lg">
|
||||
<q-btn
|
||||
@click="credentialsData.show = false"
|
||||
:label="$t('back')"
|
||||
outline
|
||||
unelevated
|
||||
color="grey"
|
||||
></q-btn>
|
||||
</q-card-section>
|
||||
</div>
|
||||
<div v-else>
|
||||
<q-card-section v-if="user.extra.picture">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<q-img
|
||||
style="max-width: 100px"
|
||||
:src="user.extra.picture"
|
||||
class="float-right"
|
||||
></q-img>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-section>
|
||||
<q-input
|
||||
v-model="user.id"
|
||||
:label="$t('user_id')"
|
||||
filled
|
||||
dense
|
||||
readonly
|
||||
:type="showUserId ? 'text': 'password'"
|
||||
class="q-mb-md"
|
||||
><q-btn
|
||||
@click="showUserId = !showUserId"
|
||||
dense
|
||||
flat
|
||||
:icon="showUserId ? 'visibility_off' : 'visibility'"
|
||||
color="grey"
|
||||
></q-btn>
|
||||
</q-input>
|
||||
<q-input
|
||||
v-model="user.username"
|
||||
:label="$t('username')"
|
||||
filled
|
||||
dense
|
||||
:readonly="hasUsername"
|
||||
class="q-mb-md"
|
||||
>
|
||||
</q-input>
|
||||
<q-input
|
||||
v-model="user.pubkey"
|
||||
:label="$t('pubkey')"
|
||||
filled
|
||||
dense
|
||||
readonly
|
||||
class="q-mb-md"
|
||||
>
|
||||
</q-input>
|
||||
<q-input
|
||||
v-model="user.email"
|
||||
:label="$t('email')"
|
||||
filled
|
||||
dense
|
||||
readonly
|
||||
class="q-mb-md"
|
||||
>
|
||||
</q-input>
|
||||
<div v-if="!user.email" class="row"></div>
|
||||
<div v-if="!user.email" class="row">
|
||||
{% if "google-auth" in LNBITS_AUTH_METHODS or
|
||||
"github-auth" in LNBITS_AUTH_METHODS %}
|
||||
<div class="col q-pa-sm text-h6">
|
||||
<span v-text="$t('verify_email')"></span>:
|
||||
</div>
|
||||
{%endif%} {% if "google-auth" in LNBITS_AUTH_METHODS %}
|
||||
<div class="col q-pa-sm">
|
||||
<q-btn
|
||||
:href="`/api/v1/auth/google?user_id=${user.id}`"
|
||||
type="a"
|
||||
outline
|
||||
no-caps
|
||||
rounded
|
||||
color="grey"
|
||||
class="full-width"
|
||||
>
|
||||
<q-avatar size="32px" class="q-mr-md">
|
||||
<q-img
|
||||
:src="'{{ static_url_for('static', 'images/google-logo.png') }}'"
|
||||
></q-img>
|
||||
</q-avatar>
|
||||
<div>Google</div>
|
||||
</q-btn>
|
||||
</div>
|
||||
{%endif%} {% if "github-auth" in LNBITS_AUTH_METHODS %}
|
||||
<div class="col q-pa-sm">
|
||||
<q-btn
|
||||
:href="`/api/v1/auth/github?user_id=${user.id}`"
|
||||
type="a"
|
||||
outline
|
||||
no-caps
|
||||
color="grey"
|
||||
rounded
|
||||
class="full-width"
|
||||
>
|
||||
<q-avatar size="32px" class="q-mr-md">
|
||||
<q-img
|
||||
:src="'{{ static_url_for('static', 'images/github-logo.png') }}'"
|
||||
></q-img>
|
||||
</q-avatar>
|
||||
<div>GitHub</div>
|
||||
</q-btn>
|
||||
</div>
|
||||
{%endif%}
|
||||
</div>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-section v-if="user.extra">
|
||||
<q-input
|
||||
v-model="user.extra.first_name"
|
||||
:label="$t('first_name')"
|
||||
filled
|
||||
dense
|
||||
class="q-mb-md"
|
||||
>
|
||||
</q-input>
|
||||
<q-input
|
||||
v-model="user.extra.last_name"
|
||||
:label="$t('last_name')"
|
||||
filled
|
||||
dense
|
||||
class="q-mb-md"
|
||||
>
|
||||
</q-input>
|
||||
<q-input
|
||||
v-model="user.extra.provider"
|
||||
:label="$t('auth_provider')"
|
||||
filled
|
||||
dense
|
||||
readonly
|
||||
class="q-mb-md"
|
||||
>
|
||||
</q-input>
|
||||
<q-input
|
||||
v-model="user.extra.picture"
|
||||
:label="$t('picture')"
|
||||
filled
|
||||
class="q-mb-md"
|
||||
>
|
||||
</q-input>
|
||||
</q-card-section>
|
||||
<q-separator></q-separator>
|
||||
<q-card-section>
|
||||
<q-btn @click="updateAccount" unelevated color="primary">
|
||||
<span v-text="$t('update_account')"></span>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
@click="showUpdateCredentials()"
|
||||
:label="$t('change_password')"
|
||||
filled
|
||||
color="primary"
|
||||
class="float-right"
|
||||
></q-btn>
|
||||
</q-card-section>
|
||||
</div>
|
||||
</q-tab-panel>
|
||||
<q-tab-panel name="theme">
|
||||
<q-btn
|
||||
v-if="g.user.admin"
|
||||
class="absolute-top-right"
|
||||
flat
|
||||
round
|
||||
icon="settings"
|
||||
to="/admin#site_customisation"
|
||||
><q-tooltip v-text="$t('admin_settings')"></q-tooltip
|
||||
></q-btn>
|
||||
<div class="row q-mb-md">
|
||||
<div class="col-4"><span v-text="$t('language')"></span></div>
|
||||
<div class="col-8">
|
||||
<q-btn-dropdown
|
||||
dense
|
||||
flat
|
||||
round
|
||||
size="sm"
|
||||
icon="language"
|
||||
class="q-pl-md"
|
||||
>
|
||||
<q-list v-for="(lang, index) in g.langs" :key="index">
|
||||
<q-item
|
||||
clickable
|
||||
v-close-popup
|
||||
:active="activeLanguage(lang.value)"
|
||||
@click="changeLanguage(lang.value)"
|
||||
><q-item-section>
|
||||
<q-item-label
|
||||
v-text="lang.display ?? lang.value.toUpperCase()"
|
||||
></q-item-label>
|
||||
<q-tooltip
|
||||
><span v-text="lang.label"></span
|
||||
></q-tooltip>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-btn-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-mb-md">
|
||||
<div class="col-4">
|
||||
<span v-text="$t('visible_wallet_count')"></span>
|
||||
</div>
|
||||
<div class="col-8">
|
||||
<q-input
|
||||
v-model="user.extra.visible_wallet_count"
|
||||
:label="$t('visible_wallet_count')"
|
||||
filled
|
||||
dense
|
||||
type="number"
|
||||
class="q-mb-md"
|
||||
></q-input>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row q-mb-md">
|
||||
<div class="col-4">
|
||||
<span v-text="$t('color_scheme')"></span>
|
||||
</div>
|
||||
<div class="col-8">
|
||||
<q-btn
|
||||
v-if="g.allowedThemes.includes('classic')"
|
||||
dense
|
||||
flat
|
||||
@click="themeChoiceFunc('classic')"
|
||||
icon="circle"
|
||||
color="deep-purple"
|
||||
size="md"
|
||||
><q-tooltip>classic</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
v-if="g.allowedThemes.includes('bitcoin')"
|
||||
dense
|
||||
flat
|
||||
@click="themeChoiceFunc('bitcoin')"
|
||||
icon="circle"
|
||||
color="deep-orange"
|
||||
size="md"
|
||||
><q-tooltip>bitcoin</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
v-if="g.allowedThemes.includes('mint')"
|
||||
dense
|
||||
flat
|
||||
@click="themeChoiceFunc('mint')"
|
||||
icon="circle"
|
||||
color="green"
|
||||
size="md"
|
||||
><q-tooltip>mint</q-tooltip> </q-btn
|
||||
><q-btn
|
||||
v-if="g.allowedThemes.includes('autumn')"
|
||||
dense
|
||||
flat
|
||||
@click="themeChoiceFunc('autumn')"
|
||||
icon="circle"
|
||||
color="brown"
|
||||
size="md"
|
||||
><q-tooltip>autumn</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
v-if="g.allowedThemes.includes('monochrome')"
|
||||
dense
|
||||
flat
|
||||
@click="themeChoiceFunc('monochrome')"
|
||||
icon="circle"
|
||||
color="grey"
|
||||
size="md"
|
||||
><q-tooltip>monochrome</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
v-if="g.allowedThemes.includes('salvador')"
|
||||
dense
|
||||
flat
|
||||
@click="themeChoiceFunc('salvador')"
|
||||
icon="circle"
|
||||
color="blue-10"
|
||||
size="md"
|
||||
><q-tooltip>elSalvador</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
v-if="g.allowedThemes.includes('freedom')"
|
||||
dense
|
||||
flat
|
||||
@click="themeChoiceFunc('freedom')"
|
||||
icon="circle"
|
||||
color="pink-13"
|
||||
size="md"
|
||||
><q-tooltip>freedom</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
v-if="g.allowedThemes.includes('cyber')"
|
||||
dense
|
||||
flat
|
||||
@click="themeChoiceFunc('cyber')"
|
||||
icon="circle"
|
||||
color="light-green-9"
|
||||
size="md"
|
||||
><q-tooltip>cyber</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
v-if="g.allowedThemes.includes('flamingo')"
|
||||
dense
|
||||
flat
|
||||
@click="themeChoiceFunc('flamingo')"
|
||||
icon="circle"
|
||||
color="pink-3"
|
||||
size="md"
|
||||
><q-tooltip>flamingo</q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-mb-md">
|
||||
<div class="col-4">
|
||||
<span v-text="$t('background_image')"></span>
|
||||
</div>
|
||||
<div class="col-8">
|
||||
<q-input
|
||||
v-model="bgimageSelection"
|
||||
:label="$t('background_image')"
|
||||
@update:model-value="bgimageChoiceFunc"
|
||||
>
|
||||
<q-tooltip
|
||||
><span v-text="$t('background_image')"></span
|
||||
></q-tooltip>
|
||||
</q-input>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-mb-md">
|
||||
<div class="col-4">
|
||||
<span v-text="$t('gradient_background')"></span>
|
||||
</div>
|
||||
<div class="col-8">
|
||||
<q-btn
|
||||
dense
|
||||
flat
|
||||
round
|
||||
@click="toggleGradient"
|
||||
icon="gradient"
|
||||
size="sm"
|
||||
v-model="gradientChoice"
|
||||
>
|
||||
<q-tooltip
|
||||
><span v-text="$t('toggle_gradient')"></span
|
||||
></q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row q-mb-md">
|
||||
<div class="col-4">
|
||||
<span v-text="$t('toggle_darkmode')"></span>
|
||||
</div>
|
||||
<div class="col-8">
|
||||
<q-btn
|
||||
dense
|
||||
flat
|
||||
round
|
||||
@click="toggleDarkMode"
|
||||
:icon="($q.dark.isActive) ? 'brightness_3' : 'wb_sunny'"
|
||||
size="sm"
|
||||
>
|
||||
<q-tooltip
|
||||
><span v-text="$t('toggle_darkmode')"></span
|
||||
></q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-mb-md">
|
||||
<div class="col-4">
|
||||
<span v-text="$t('border_choices')"></span>
|
||||
</div>
|
||||
<div class="col-8">
|
||||
<q-select
|
||||
v-model="borderSelection"
|
||||
:options="borderOptions"
|
||||
label="Borders"
|
||||
@update:model-value="applyBorder"
|
||||
>
|
||||
<q-tooltip
|
||||
><span v-text="$t('border_choices')"></span
|
||||
></q-tooltip>
|
||||
</q-select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-mb-md">
|
||||
<div class="col-4">
|
||||
<span v-text="$t('notifications')"></span>
|
||||
</div>
|
||||
<div class="col-8">
|
||||
<lnbits-notifications-btn
|
||||
v-if="g.user"
|
||||
pubkey="{{ WEBPUSH_PUBKEY }}"
|
||||
></lnbits-notifications-btn>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-mb-md">
|
||||
<div class="col-4">
|
||||
<span v-text="$t('payment_reactions')"></span>
|
||||
</div>
|
||||
<div class="col-8">
|
||||
<q-select
|
||||
v-model="reactionSelection"
|
||||
:options="reactionOptions"
|
||||
label="Reactions"
|
||||
@update:model-value="reactionChoiceFunc"
|
||||
>
|
||||
<q-tooltip
|
||||
><span v-text="$t('payment_reactions')"></span
|
||||
></q-tooltip>
|
||||
</q-select>
|
||||
</div>
|
||||
</div>
|
||||
</q-tab-panel>
|
||||
<q-tab-panel name="api_acls">
|
||||
<div class="row q-mb-md">
|
||||
<q-badge v-if="user.admin">
|
||||
<span
|
||||
v-text="$t('access_control_list_admin_warning')"
|
||||
></span>
|
||||
</q-badge>
|
||||
</div>
|
||||
|
||||
<q-card-section>
|
||||
<div class="row q-mb-md q-gutter-y-md">
|
||||
<div class="col-sm-12 col-md-6">
|
||||
<q-select
|
||||
v-model="selectedApiAcl.id"
|
||||
emit-value
|
||||
map-options
|
||||
@update:model-value="handleApiACLSelected"
|
||||
:options="apiAcl.data.map(t => ({label: t.name, value: t.id}))"
|
||||
:label="$t('access_control_list')"
|
||||
dense
|
||||
>
|
||||
</q-select>
|
||||
</div>
|
||||
<div class="col-sm-12 col-md-6">
|
||||
<q-btn
|
||||
@click="askPasswordAndRunFunction('newApiAclDialog')"
|
||||
filled
|
||||
outline
|
||||
icon="add"
|
||||
:label="$t('access_control_list')"
|
||||
color="grey"
|
||||
class="float-right"
|
||||
></q-btn>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedApiAcl.id">
|
||||
<div class="row q-mb-md">
|
||||
<div class="col-sm-12 col-md-6">
|
||||
<q-select
|
||||
:options="selectedApiAcl.token_id_list.map(t => ({label: t.name, value: t.id}))"
|
||||
v-model="apiAcl.selectedTokenId"
|
||||
emit-value
|
||||
map-options
|
||||
:label="$t('api_tokens')"
|
||||
dense
|
||||
>
|
||||
</q-select>
|
||||
</div>
|
||||
|
||||
<div class="col-sm-12 col-md-6 q-pl-sm">
|
||||
<q-btn
|
||||
v-if="apiAcl.selectedTokenId"
|
||||
@click="askPasswordAndRunFunction('deleteToken')"
|
||||
icon="delete"
|
||||
filled
|
||||
color="negative"
|
||||
class="float-left"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
@click="askPasswordAndRunFunction('newTokenAclDialog')"
|
||||
outline
|
||||
icon="add"
|
||||
:label="$t('api_token')"
|
||||
filled
|
||||
color="grey"
|
||||
class="float-right"
|
||||
></q-btn>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="apiAcl.apiToken" class="row q-mb-md">
|
||||
<div class="col-12">
|
||||
<q-badge>
|
||||
<span>Use this token in the HTTP</span>
|
||||
<strong>
|
||||
<code>Authorization</code>
|
||||
|
||||
</strong>
|
||||
<span> header.</span>
|
||||
</q-badge>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<table
|
||||
class="full-width lnbits__table-bordered"
|
||||
style="
|
||||
border-collapse: collapse;
|
||||
background-color: grey;
|
||||
"
|
||||
>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
<span class="float-left">Header Name</span>
|
||||
</th>
|
||||
<th>
|
||||
<span class="float-left">Header Value</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<strong>Authorization</strong>
|
||||
</td>
|
||||
<td>
|
||||
<div class="row q-pt-sm">
|
||||
<div class="col-2 q-mt-sm">
|
||||
<strong>Bearer </strong>
|
||||
</div>
|
||||
<div class="col-10">
|
||||
<q-input
|
||||
v-model="apiAcl.apiToken"
|
||||
:label="$t('api_token_id')"
|
||||
filled
|
||||
dense
|
||||
readonly
|
||||
:type="selectedApiAcl.showId ? 'text': 'password'"
|
||||
class="q-mb-md"
|
||||
>
|
||||
<q-btn
|
||||
@click="selectedApiAcl.showId = !selectedApiAcl.showId"
|
||||
dense
|
||||
flat
|
||||
:icon="selectedApiAcl.showId ? 'visibility_off' : 'visibility'"
|
||||
color="black"
|
||||
></q-btn>
|
||||
|
||||
<q-btn
|
||||
@click="copyText(apiAcl.apiToken)"
|
||||
icon="content_copy"
|
||||
color="black"
|
||||
flat
|
||||
dense
|
||||
></q-btn>
|
||||
</q-input>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<q-badge>
|
||||
<span
|
||||
>Please store this token. It cannot be later
|
||||
retrieved, only revoked.</span
|
||||
>
|
||||
</q-badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<q-table
|
||||
row-key="path"
|
||||
:rows="selectedApiAcl.endpoints"
|
||||
:columns="apiAcl.columns"
|
||||
v-model:pagination="apiAcl.pagination"
|
||||
>
|
||||
<template v-slot:header="props">
|
||||
<q-tr :props="props">
|
||||
<q-th
|
||||
v-for="col in props.cols"
|
||||
:key="col.name"
|
||||
:props="props"
|
||||
>
|
||||
<q-toggle
|
||||
v-if="col.name == 'read'"
|
||||
v-model="selectedApiAcl.allRead"
|
||||
@update:model-value="handleAllEndpointsReadAccess()"
|
||||
:label="$t('read')"
|
||||
size="sm"
|
||||
></q-toggle>
|
||||
<q-toggle
|
||||
v-else-if="col.name == 'write'"
|
||||
v-model="selectedApiAcl.allWrite"
|
||||
@update:model-value="handleAllEndpointsWriteAccess()"
|
||||
:label="$t('write')"
|
||||
size="sm"
|
||||
></q-toggle>
|
||||
<span v-else v-text="col.label"></span>
|
||||
</q-th>
|
||||
</q-tr>
|
||||
</template>
|
||||
<template v-slot:body="props">
|
||||
<q-tr :props="props">
|
||||
<q-td>
|
||||
<span v-text="props.row.name"></span>
|
||||
</q-td>
|
||||
<q-td>
|
||||
<span v-text="props.row.path"></span>
|
||||
</q-td>
|
||||
<q-td>
|
||||
<q-toggle size="sm" v-model="props.row.read" />
|
||||
</q-td>
|
||||
<q-td>
|
||||
<q-toggle size="sm" v-model="props.row.write" />
|
||||
</q-td>
|
||||
</q-tr>
|
||||
</template>
|
||||
</q-table>
|
||||
<q-separator></q-separator>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedApiAcl.id" class="row q-mt-md">
|
||||
<div class="col-sm-12 col-md-6">
|
||||
<q-btn
|
||||
@click="askPasswordAndRunFunction('updateApiACLs')"
|
||||
:label="$t('update')"
|
||||
filled
|
||||
color="primary"
|
||||
></q-btn>
|
||||
</div>
|
||||
<div class="col-sm-12 col-md-6">
|
||||
<q-btn
|
||||
@click="askPasswordAndRunFunction('deleteApiACL')"
|
||||
:label="$t('delete')"
|
||||
icon="delete"
|
||||
color="negative"
|
||||
class="float-right"
|
||||
>
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-tab-panel>
|
||||
</q-tab-panels>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
<div v-else class="col-12 col-md-6 q-gutter-y-md">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<h4 class="q-my-none"><span v-text="$t('account')"></span></h4>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<q-dialog v-model="apiAcl.showPasswordDialog" position="top">
|
||||
<q-card class="q-pa-md q-pt-md lnbits__dialog-card">
|
||||
<strong>User Password</strong>
|
||||
<div class="row q-mt-md q-col-gutter-md">
|
||||
<div class="col-12">
|
||||
<q-input
|
||||
v-model="apiAcl.password"
|
||||
type="password"
|
||||
dense
|
||||
filled
|
||||
label="Password"
|
||||
hint="User password is required for this action."
|
||||
>
|
||||
</q-input>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
@click="runPasswordGuardedFunction()"
|
||||
:label="$t('ok')"
|
||||
color="primary"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
v-close-popup
|
||||
flat
|
||||
color="grey"
|
||||
class="q-ml-auto"
|
||||
v-text="$t('cancel')"
|
||||
></q-btn>
|
||||
</div>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<q-dialog v-model="apiAcl.showNewAclDialog" position="top">
|
||||
<q-card class="q-pa-md q-pt-md lnbits__dialog-card">
|
||||
<strong>New API Access Control List</strong>
|
||||
<div class="row q-mt-md q-col-gutter-md">
|
||||
<div class="col-12">
|
||||
<q-input v-model="apiAcl.newAclName" dense filled label="ACL Name">
|
||||
</q-input>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn @click="addApiACL()" label="Create" color="primary"></q-btn>
|
||||
<q-btn
|
||||
v-close-popup
|
||||
flat
|
||||
color="grey"
|
||||
class="q-ml-auto"
|
||||
v-text="$t('close')"
|
||||
></q-btn>
|
||||
</div>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
<q-dialog v-model="apiAcl.showNewTokenDialog" position="top">
|
||||
<q-card class="q-pa-md q-pt-md lnbits__dialog-card">
|
||||
<strong>New API Token</strong>
|
||||
<div class="row q-col-gutter-md q-mt-md">
|
||||
<div class="col-12">
|
||||
<q-input v-model="apiAcl.newTokenName" dense filled label="Token Name">
|
||||
</q-input>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<q-input
|
||||
v-model="apiAcl.newTokenExpiry"
|
||||
dense
|
||||
filled
|
||||
label="Expiration"
|
||||
hit="Expiration time in minutes (default xxx)"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<q-icon name="event" class="cursor-pointer">
|
||||
<q-popup-proxy
|
||||
cover
|
||||
transition-show="scale"
|
||||
transition-hide="scale"
|
||||
>
|
||||
<q-date v-model="apiAcl.newTokenExpiry" mask="YYYY-MM-DD HH:mm">
|
||||
<div class="row items-center justify-end">
|
||||
<q-btn v-close-popup label="Close" color="primary" flat />
|
||||
</div>
|
||||
</q-date>
|
||||
</q-popup-proxy>
|
||||
</q-icon>
|
||||
</template>
|
||||
|
||||
<template v-slot:append>
|
||||
<q-icon name="access_time" class="cursor-pointer">
|
||||
<q-popup-proxy
|
||||
cover
|
||||
transition-show="scale"
|
||||
transition-hide="scale"
|
||||
>
|
||||
<q-time
|
||||
v-model="apiAcl.newTokenExpiry"
|
||||
mask="YYYY-MM-DD HH:mm"
|
||||
format24h
|
||||
>
|
||||
<div class="row items-center justify-end">
|
||||
<q-btn v-close-popup label="Close" color="primary" flat />
|
||||
</div>
|
||||
</q-time>
|
||||
</q-popup-proxy>
|
||||
</q-icon>
|
||||
</template>
|
||||
</q-input>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn @click="generateApiToken()" label="Create" color="primary"></q-btn>
|
||||
<q-btn
|
||||
v-close-popup
|
||||
flat
|
||||
color="grey"
|
||||
class="q-ml-auto"
|
||||
v-text="$t('close')"
|
||||
></q-btn>
|
||||
</div>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
{% endblock %}
|
||||
@@ -1,991 +0,0 @@
|
||||
{% if not ajax %} {% extends "base.html" %} {% endif %}
|
||||
<!---->
|
||||
{% from "macros.jinja" import window_vars with context %}
|
||||
<!---->
|
||||
{% block scripts %} {{ window_vars(user, wallet, extensions, extension_data)
|
||||
}}{% endblock %} {% block page %}
|
||||
|
||||
<div class="row q-col-gutter-md q-mb-md">
|
||||
<div class="col-12">
|
||||
<q-card>
|
||||
<div>
|
||||
<div class="q-gutter-y-md">
|
||||
<q-tabs
|
||||
:model-value="tab"
|
||||
@update:model-value="handleTabChanged"
|
||||
active-color="primary"
|
||||
align="left"
|
||||
>
|
||||
<q-tab name="installed" :label="$t('installed')"></q-tab>
|
||||
<q-tab name="all" :label="$t('all')"></q-tab>
|
||||
<q-tab name="featured" :label="$t('featured')"></q-tab>
|
||||
<i
|
||||
v-if="!g.user.admin && tab != 'installed'"
|
||||
v-text="$t('only_admins_can_install')"
|
||||
></i>
|
||||
<q-space></q-space>
|
||||
<q-input
|
||||
:label="$t('search_extensions')"
|
||||
:dense="dense"
|
||||
class="float-right q-pr-xl"
|
||||
v-model="searchTerm"
|
||||
>
|
||||
<template v-slot:before>
|
||||
<q-icon name="search"> </q-icon>
|
||||
</template>
|
||||
<template v-slot:append>
|
||||
<q-icon
|
||||
v-if="searchTerm !== ''"
|
||||
name="close"
|
||||
@click="searchTerm = ''"
|
||||
class="cursor-pointer"
|
||||
>
|
||||
</q-icon>
|
||||
</template>
|
||||
</q-input>
|
||||
<q-badge
|
||||
v-if="g.user.admin && updatableExtensions?.length"
|
||||
@click="showUpdateAllDialog = true"
|
||||
color="primary"
|
||||
class="float-right q-pa-sm q-mr-md cursor-pointer"
|
||||
>
|
||||
<span
|
||||
v-text="$t('new_version') + ` (${updatableExtensions?.length})`"
|
||||
></span>
|
||||
</q-badge>
|
||||
<q-btn
|
||||
v-if="g.user.admin"
|
||||
flat
|
||||
round
|
||||
icon="settings"
|
||||
to="/admin#extensions"
|
||||
><q-tooltip v-text="$t('admin_settings')"></q-tooltip
|
||||
></q-btn>
|
||||
</q-tabs>
|
||||
</div>
|
||||
</div>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-col-gutter-md">
|
||||
<div
|
||||
v-if="filteredExtensions"
|
||||
class="col-12 col-sm-6 col-md-6 col-lg-4"
|
||||
v-for="extension in filteredExtensions"
|
||||
:key="extension.id + extension.hash"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<div class="row">
|
||||
<div class="col-3">
|
||||
<q-img
|
||||
@click="showExtensionDetails(extension.id, extension.details_link)"
|
||||
v-if="extension.icon"
|
||||
:src="extension.icon"
|
||||
spinner-color="white"
|
||||
style="cursor: pointer; max-width: 100%"
|
||||
>
|
||||
<q-tooltip>
|
||||
<span v-text="extension.shortDescription"></span>
|
||||
</q-tooltip>
|
||||
</q-img>
|
||||
<div v-else>
|
||||
<q-icon
|
||||
class="gt-sm"
|
||||
name="extension"
|
||||
color="primary"
|
||||
size="70px"
|
||||
></q-icon>
|
||||
<q-icon
|
||||
class="lt-md"
|
||||
name="extension"
|
||||
color="primary"
|
||||
size="35px"
|
||||
></q-icon>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-9 q-pl-md">
|
||||
<q-badge
|
||||
v-if="extension.isInstalled"
|
||||
@click="showManageExtension(extension)"
|
||||
class="float-right"
|
||||
color="transparent"
|
||||
style="cursor: pointer"
|
||||
>
|
||||
<span v-text="extension.installedRelease.version"></span>
|
||||
<q-tooltip
|
||||
><span v-text="extension.installedRelease.version"></span
|
||||
></q-tooltip>
|
||||
</q-badge>
|
||||
<div
|
||||
v-if="extension.name"
|
||||
class="text-h5"
|
||||
style="cursor: pointer"
|
||||
@click="showExtensionDetails(extension.id, extension.details_link)"
|
||||
v-text="extension.name"
|
||||
></div>
|
||||
<div>
|
||||
<lnbits-extension-rating :rating="0" />
|
||||
</div>
|
||||
<div style="justify-content: space-between; display: flex">
|
||||
<q-toggle
|
||||
size="xs"
|
||||
v-if="extension.isAvailable && extension.isInstalled && g.user.admin"
|
||||
:label="extension.isActive ? $t('activated'): $t('deactivated') "
|
||||
color="secondary"
|
||||
v-model="extension.isActive"
|
||||
@update:model-value="toggleExtension(extension)"
|
||||
><q-tooltip>
|
||||
|
||||
<span
|
||||
v-text="$t('activate_extension_details')"
|
||||
></span> </q-tooltip
|
||||
></q-toggle>
|
||||
<q-badge
|
||||
v-if="hasNewVersion(extension)"
|
||||
@click="showManageExtension(extension)"
|
||||
color="primary"
|
||||
class="float-right"
|
||||
style="cursor: pointer; margin: 5px 0"
|
||||
>
|
||||
<span v-text="$t('new_version')"></span>
|
||||
<q-tooltip
|
||||
><span v-text="extension.latestRelease.version"></span
|
||||
></q-tooltip>
|
||||
</q-badge>
|
||||
</div>
|
||||
<div>
|
||||
<p
|
||||
style="
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
"
|
||||
v-text="extension.shortDescription"
|
||||
></p>
|
||||
<q-tooltip
|
||||
><span v-text="extension.shortDescription"></span
|
||||
></q-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
id="dependencies"
|
||||
class="row q-pt-sm"
|
||||
v-if="extension.dependencies?.length"
|
||||
>
|
||||
<div class="col">
|
||||
<small v-text="$t('extension_depends_on')"></small>
|
||||
<q-badge
|
||||
v-for="dep in extension.dependencies"
|
||||
:key="dep"
|
||||
color="orange"
|
||||
>
|
||||
<small v-text="dep"></small>
|
||||
</q-badge>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
<q-separator></q-separator>
|
||||
<q-card-actions style="min-height: 52px">
|
||||
<div class="col-10">
|
||||
<div v-if="!extension.inProgress">
|
||||
<q-btn
|
||||
v-if="g.user.extensions.includes(extension.id) && extension.isActive && extension.isInstalled"
|
||||
flat
|
||||
color="primary"
|
||||
type="a"
|
||||
:href="extension.id + '/'"
|
||||
:label="$t('open')"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
v-if="g.user.extensions.includes(extension.id) && extension.isActive && extension.isInstalled"
|
||||
flat
|
||||
color="grey-5"
|
||||
@click="disableExtension(extension)"
|
||||
:label="$t('disable')"
|
||||
></q-btn>
|
||||
<q-badge
|
||||
v-if="extension.isAdminOnly && !g.user.admin"
|
||||
v-text="$t('admin_only')"
|
||||
>
|
||||
</q-badge>
|
||||
|
||||
<q-btn
|
||||
v-else-if="extension.isInstalled && extension.isActive && !g.user.extensions.includes(extension.id)"
|
||||
flat
|
||||
color="primary"
|
||||
@click="enableExtensionForUser(extension)"
|
||||
:label="$t(extension.isPaymentRequired ? 'pay_to_enable': 'enable')"
|
||||
>
|
||||
<q-tooltip>
|
||||
<span v-text="$t('enable_extension_details')">
|
||||
</span> </q-tooltip
|
||||
></q-btn>
|
||||
|
||||
<q-btn
|
||||
@click="showManageExtension(extension)"
|
||||
flat
|
||||
color="primary"
|
||||
v-if="g.user.admin"
|
||||
:label="$t('manage')"
|
||||
><q-tooltip
|
||||
><span v-text="$t('manage_extension_details')"></span
|
||||
></q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
<div v-else>
|
||||
<q-spinner-bars color="primary" size="2.55em"></q-spinner-bars>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-2">
|
||||
<div v-if="extension.details_link" class="float-right">
|
||||
<q-btn
|
||||
@click="showExtensionDetails(extension.id, extension.details_link)"
|
||||
flat
|
||||
:label="$t('more')"
|
||||
>
|
||||
<q-tooltip>
|
||||
<span v-text="extension.shortDescription"></span>
|
||||
</q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<q-dialog v-model="showUninstallDialog" position="top">
|
||||
<q-card class="q-pa-lg">
|
||||
<h6 class="q-my-md text-primary" v-text="$t('warning')"></h6>
|
||||
<p>
|
||||
<span v-text="$t('extension_uninstall_warning')"></span><br />
|
||||
<span v-text="$t('confirm_continue')"></span>
|
||||
</p>
|
||||
|
||||
<div class="row q-mt-lg">
|
||||
<q-checkbox
|
||||
v-model="uninstallAndDropDb"
|
||||
value="false"
|
||||
label="Cleanup database tables"
|
||||
>
|
||||
<q-tooltip class="bg-grey-8" anchor="bottom left" self="top left"
|
||||
><span v-text="$t('extension_db_drop_info')"></span>
|
||||
</q-tooltip>
|
||||
</q-checkbox>
|
||||
</div>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
@click="uninstallExtension()"
|
||||
v-text="$t('uninstall_confirm')"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
v-close-popup
|
||||
flat
|
||||
color="grey"
|
||||
class="q-ml-auto"
|
||||
v-text="$t('cancel')"
|
||||
></q-btn>
|
||||
</div>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<q-dialog v-model="showDropDbDialog" position="top">
|
||||
<q-card v-if="selectedExtension" class="q-pa-lg">
|
||||
<h6 class="q-my-md text-primary" v-text="$t('warning')"></h6>
|
||||
<p><span v-text="$t('extension_db_drop_warning')"></span><br /></p>
|
||||
<q-input
|
||||
v-model="dropDbExtensionId"
|
||||
:label="selectedExtension.id"
|
||||
></q-input>
|
||||
<br />
|
||||
<p v-text="$t('confirm_continue')"></p>
|
||||
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
:disable="dropDbExtensionId !== selectedExtension.id"
|
||||
outline
|
||||
color="red"
|
||||
@click="dropExtensionDb()"
|
||||
v-text="$t('confirm')"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
v-close-popup
|
||||
flat
|
||||
color="grey"
|
||||
class="q-ml-auto"
|
||||
v-text="$t('cancel')"
|
||||
></q-btn>
|
||||
</div>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<q-dialog v-model="showManageExtensionDialog" position="top">
|
||||
<q-card v-if="selectedRelease" class="q-pa-lg lnbits__dialog-card">
|
||||
<q-card-section>
|
||||
<div v-if="selectedRelease.paymentRequest">
|
||||
<a :href="'lightning:' + selectedRelease.paymentRequest">
|
||||
<q-responsive :ratio="1" class="q-mx-xl">
|
||||
<lnbits-qrcode
|
||||
:value="'lightning:' + selectedRelease.paymentRequest.toUpperCase()"
|
||||
></lnbits-qrcode>
|
||||
</q-responsive>
|
||||
</a>
|
||||
</div>
|
||||
<div v-else>
|
||||
<q-spinner-bars color="primary" size="2.55em"></q-spinner-bars>
|
||||
</div>
|
||||
</q-card-section>
|
||||
|
||||
<div class="row q-mt-lg">
|
||||
<div class="col">
|
||||
<q-btn
|
||||
v-if="selectedRelease.paymentRequest"
|
||||
outline
|
||||
color="grey"
|
||||
@click="copyText(selectedRelease.paymentRequest)"
|
||||
:label="$t('copy_invoice')"
|
||||
></q-btn>
|
||||
</div>
|
||||
<div class="col">
|
||||
<q-btn
|
||||
v-close-popup
|
||||
flat
|
||||
color="grey"
|
||||
class="float-right q-ml-lg"
|
||||
v-text="$t('close')"
|
||||
></q-btn>
|
||||
</div>
|
||||
</div>
|
||||
</q-card>
|
||||
<q-card v-else class="q-pa-lg lnbits__dialog-card">
|
||||
<q-tabs v-model="manageExtensionTab" active-color="primary" align="justify">
|
||||
<q-tab
|
||||
name="releases"
|
||||
:label="$t('releases')"
|
||||
@update="val => manageExtensionTab = val.name"
|
||||
></q-tab>
|
||||
|
||||
<q-tab
|
||||
v-if="selectedExtension && selectedExtension.isInstalled"
|
||||
name="sell"
|
||||
:label="$t('sell')"
|
||||
@update="val => manageExtensionTab = val.name"
|
||||
></q-tab>
|
||||
</q-tabs>
|
||||
|
||||
<div
|
||||
v-show="manageExtensionTab === 'releases'"
|
||||
class="col-12 col-md-5 q-gutter-y-md q-mt-md"
|
||||
v-if="selectedExtensionRepos"
|
||||
>
|
||||
<q-card
|
||||
flat
|
||||
bordered
|
||||
class="my-card"
|
||||
v-for="repoName of Object.keys(selectedExtensionRepos)"
|
||||
:key="repoName"
|
||||
>
|
||||
<q-expansion-item
|
||||
:key="repoName"
|
||||
group="repos"
|
||||
:caption="repoName"
|
||||
:content-inset-level="0.5"
|
||||
:default-opened="selectedExtensionRepos[repoName].isInstalled"
|
||||
>
|
||||
<template v-slot:header>
|
||||
<q-item-section avatar>
|
||||
<q-avatar
|
||||
:icon="selectedExtensionRepos[repoName].isInstalled ? 'download_done': 'download'"
|
||||
:text-color="selectedExtensionRepos[repoName].isInstalled ? 'green' : ''"
|
||||
/>
|
||||
</q-item-section>
|
||||
|
||||
<q-item-section>
|
||||
<div class="row">
|
||||
<div class="col-10">
|
||||
<span v-text="$t('repository')"></span>
|
||||
<br />
|
||||
<small v-text="repoName"></small>
|
||||
<q-tooltip
|
||||
><span v-text="selectedExtensionRepos[repoName].repo"></span
|
||||
></q-tooltip>
|
||||
</div>
|
||||
<div class="col-2"></div>
|
||||
</div>
|
||||
</q-item-section>
|
||||
</template>
|
||||
|
||||
<q-card-section class="q-pa-none">
|
||||
<q-separator></q-separator>
|
||||
|
||||
<q-list>
|
||||
<q-expansion-item
|
||||
v-for="release of selectedExtensionRepos[repoName].releases"
|
||||
:key="release.version"
|
||||
group="releases"
|
||||
@click="getGitHubReleaseDetails(release)"
|
||||
:icon="getReleaseIcon(release)"
|
||||
:label="release.description"
|
||||
:caption="release.version"
|
||||
:content-inset-level="0.5"
|
||||
:header-class="getReleaseIconColor(release)"
|
||||
>
|
||||
<div v-if="release.inProgress">
|
||||
<q-spinner-bars
|
||||
color="primary"
|
||||
size="2.55em"
|
||||
></q-spinner-bars>
|
||||
</div>
|
||||
<div v-else-if="release.error">
|
||||
<q-icon
|
||||
class="gt-sm"
|
||||
name="error"
|
||||
color="pink"
|
||||
size="70px"
|
||||
></q-icon>
|
||||
<span v-text="$t('release_details_error')"></span>
|
||||
</div>
|
||||
<q-card class="no-border" v-else>
|
||||
<q-card-section v-if="release.is_version_compatible">
|
||||
<span
|
||||
v-if="release.requiresPayment && !release.paid_sats"
|
||||
v-text="$t('extension_cost', {cost: release.cost_sats})"
|
||||
class="q-mb-lg"
|
||||
></span>
|
||||
<span
|
||||
v-if="release.requiresPayment && release.paid_sats"
|
||||
class="q-mb-lg"
|
||||
v-text="$t('extension_paid_sats', {paid_sats: release.paid_sats})"
|
||||
></span>
|
||||
<div
|
||||
v-if="!release.requiresPayment || (release.requiresPayment && release.paid_sats)"
|
||||
>
|
||||
<q-btn
|
||||
v-if="!release.isInstalled"
|
||||
@click="installExtension(release)"
|
||||
color="primary unelevated mt-lg pt-lg"
|
||||
:label="$t('install')"
|
||||
></q-btn>
|
||||
</div>
|
||||
|
||||
<div v-if="release.requiresPayment && !release.paid_sats">
|
||||
<div v-if="!release.payment_hash">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.number="release.paidAmount"
|
||||
type="number"
|
||||
:min="release.cost_sats"
|
||||
suffix="sat"
|
||||
class="q-mt-sm"
|
||||
>
|
||||
</q-input>
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
emit-value
|
||||
map-options
|
||||
v-model="release.wallet"
|
||||
:options="g.user.walletOptions"
|
||||
:label="$t('wallet_required')"
|
||||
class="q-mt-sm"
|
||||
>
|
||||
</q-select>
|
||||
<q-btn
|
||||
unelevated
|
||||
color="primary"
|
||||
@click="payAndInstall(release)"
|
||||
:disabled="!release.wallet"
|
||||
class="q-mt-sm"
|
||||
:label="$t('pay_from_wallet')"
|
||||
></q-btn>
|
||||
|
||||
<q-btn
|
||||
unelevated
|
||||
color="primary"
|
||||
@click="showInstallQRCode(release)"
|
||||
class="q-mt-sm float-right"
|
||||
:label="$t('show_qr')"
|
||||
></q-btn>
|
||||
</div>
|
||||
<div v-else>
|
||||
<br />
|
||||
<span
|
||||
class="q-mb-lg q-mt-lg"
|
||||
v-text="'There is a previous pending invoice for this release.'"
|
||||
></span>
|
||||
<br />
|
||||
<q-btn
|
||||
unelevated
|
||||
@click="installExtension(release)"
|
||||
color="primary"
|
||||
class="q-mt-sm"
|
||||
:label="$t('retry_install')"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
unelevated
|
||||
@click="clearHangingInvoice(release)"
|
||||
color="primary"
|
||||
class="q-mt-sm float-right"
|
||||
:label="$t('new_payment')"
|
||||
></q-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<q-btn
|
||||
v-if="release.isInstalled"
|
||||
@click="showUninstall()"
|
||||
:label="$t('uninstall')"
|
||||
flat
|
||||
color="red"
|
||||
></q-btn>
|
||||
</div>
|
||||
|
||||
<a
|
||||
v-if="release.html_url"
|
||||
class="text-secondary float-right"
|
||||
:href="release.html_url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style="color: inherit"
|
||||
v-text="$t('release_notes')"
|
||||
></a>
|
||||
</q-card-section>
|
||||
<q-card-section v-else>
|
||||
<span
|
||||
v-text="$t('extension_required_lnbits_version')"
|
||||
></span>
|
||||
<span class="q-mr-sm">:</span>
|
||||
<ul>
|
||||
<li v-if="release.min_lnbits_version">
|
||||
<span v-text="$t('min_version')"></span>
|
||||
<span class="q-mr-sm">:</span>
|
||||
<strong>
|
||||
<span v-text="release.min_lnbits_version"></span>
|
||||
</strong>
|
||||
</li>
|
||||
<li v-if="release.max_lnbits_version">
|
||||
<span v-text="$t('max_version')"></span>
|
||||
<span class="q-mr-sm">:</span>
|
||||
<strong>
|
||||
<span v-text="release.max_lnbits_version"></span>
|
||||
</strong>
|
||||
</li>
|
||||
</ul>
|
||||
</q-card-section>
|
||||
<q-card v-if="release.warning">
|
||||
<q-card-section>
|
||||
<div class="text-h6">
|
||||
<q-badge
|
||||
color="yellow"
|
||||
text-color="black"
|
||||
v-text="$t('warning')"
|
||||
>
|
||||
</q-badge>
|
||||
</div>
|
||||
<div class="text-subtitle2">
|
||||
<span v-text="release.warning"></span>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
<q-separator></q-separator> </q-card
|
||||
></q-expansion-item>
|
||||
</q-list>
|
||||
</q-card-section>
|
||||
</q-expansion-item>
|
||||
</q-card>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
v-if="selectedExtension?.isInstalled"
|
||||
@click="showUninstall()"
|
||||
flat
|
||||
color="red"
|
||||
v-text="$t('uninstall')"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
v-else-if="selectedExtension?.hasDatabaseTables"
|
||||
@click="showDropDb()"
|
||||
flat
|
||||
color="red"
|
||||
:label="$t('drop_db')"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
v-close-popup
|
||||
flat
|
||||
color="grey"
|
||||
class="q-ml-auto"
|
||||
v-text="$t('close')"
|
||||
></q-btn>
|
||||
</div>
|
||||
</div>
|
||||
<q-spinner-bars v-else color="primary" size="2.55em"></q-spinner-bars>
|
||||
<div
|
||||
v-if="selectedExtension"
|
||||
v-show="manageExtensionTab === 'sell'"
|
||||
class="col-12 col-md-5 q-gutter-y-md q-mt-md"
|
||||
>
|
||||
<q-toggle
|
||||
v-model="selectedExtension.payToEnable.required"
|
||||
:label="$t('sell_require')"
|
||||
color="secondary"
|
||||
style="max-height: 21px"
|
||||
></q-toggle>
|
||||
<q-select
|
||||
v-if="selectedExtension.payToEnable.required"
|
||||
filled
|
||||
dense
|
||||
emit-value
|
||||
map-options
|
||||
v-model="selectedExtension.payToEnable.wallet"
|
||||
:options="g.user.walletOptions"
|
||||
label="Wallet *"
|
||||
class="q-mt-md"
|
||||
></q-select>
|
||||
<q-input
|
||||
v-if="selectedExtension.payToEnable.required"
|
||||
filled
|
||||
dense
|
||||
v-model.number="selectedExtension.payToEnable.amount"
|
||||
:label="$t('amount_sats')"
|
||||
type="number"
|
||||
min="1"
|
||||
class="q-mt-md"
|
||||
>
|
||||
</q-input>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
@click="updatePayToInstallData(selectedExtension)"
|
||||
flat
|
||||
color="green"
|
||||
v-text="$t('update_payment')"
|
||||
></q-btn>
|
||||
|
||||
<q-btn
|
||||
v-close-popup
|
||||
flat
|
||||
color="grey"
|
||||
class="q-ml-auto"
|
||||
v-text="$t('close')"
|
||||
></q-btn>
|
||||
</div>
|
||||
</div>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<q-dialog v-model="showPayToEnableDialog" position="top">
|
||||
<q-card v-if="selectedExtension" class="q-pa-md">
|
||||
<q-card-section>
|
||||
<p>
|
||||
<span
|
||||
v-text="$t('sell_info', {name: selectedExtension.name, amount: selectedExtension.payToEnable.amount})"
|
||||
></span>
|
||||
</p>
|
||||
<p>
|
||||
<span v-text="$t('already_paid_question')"></span>
|
||||
<q-badge
|
||||
@click="enableExtension(selectedExtension)"
|
||||
color="primary"
|
||||
class="cursor-pointer"
|
||||
rounded
|
||||
>
|
||||
<strong> <span v-text="$t('recheck')"></span> </strong
|
||||
></q-badge>
|
||||
</p>
|
||||
</q-card-section>
|
||||
<q-card-section v-if="selectedExtension.payToEnable.showQRCode">
|
||||
<div class="row q-mt-lg">
|
||||
<div v-if="selectedExtension.payToEnable.paymentRequest" class="col">
|
||||
<a
|
||||
:href="'lightning:' + selectedExtension.payToEnable.paymentRequest"
|
||||
>
|
||||
<lnbits-qrcode
|
||||
:value="'lightning:' + selectedExtension.payToEnable.paymentRequest.toUpperCase()"
|
||||
></lnbits-qrcode>
|
||||
</a>
|
||||
</div>
|
||||
<div v-else class="col">
|
||||
<q-spinner-bars color="primary" size="2.55em"></q-spinner-bars>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-mt-lg">
|
||||
<div class="col">
|
||||
<q-btn
|
||||
v-if="selectedExtension.payToEnable.paymentRequest"
|
||||
outline
|
||||
color="grey"
|
||||
@click="copyText(selectedExtension.payToEnable.paymentRequest)"
|
||||
:label="$t('copy_invoice')"
|
||||
></q-btn>
|
||||
</div>
|
||||
<div class="col">
|
||||
<q-btn
|
||||
v-close-popup
|
||||
flat
|
||||
color="grey"
|
||||
class="float-right q-ml-lg"
|
||||
v-text="$t('close')"
|
||||
></q-btn>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-section v-else>
|
||||
<div class="row q-mt-lg">
|
||||
<div class="col">
|
||||
<div>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
type="number"
|
||||
v-model.number="selectedExtension.payToEnable.paidAmount"
|
||||
:min="selectedExtension.payToEnable.amount"
|
||||
suffix="sat"
|
||||
class="q-mt-sm"
|
||||
>
|
||||
</q-input>
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
v-model="selectedExtension.payToEnable.paymentWallet"
|
||||
emit-value
|
||||
map-options
|
||||
:options="g.user.walletOptions"
|
||||
:label="$t('wallet_required')"
|
||||
class="q-mt-sm"
|
||||
>
|
||||
</q-select>
|
||||
<q-separator class="q-mb-lg"></q-separator>
|
||||
<q-btn
|
||||
unelevated
|
||||
color="primary"
|
||||
class="q-mt-sm"
|
||||
@click="payAndEnable(selectedExtension)"
|
||||
:disabled="!selectedExtension.payToEnable.paymentWallet"
|
||||
:label="$t('pay_from_wallet')"
|
||||
></q-btn>
|
||||
|
||||
<q-btn
|
||||
unelevated
|
||||
@click="showEnableQRCode(selectedExtension)"
|
||||
color="primary"
|
||||
class="q-mt-sm float-right"
|
||||
:label="$t('show_qr')"
|
||||
></q-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<q-dialog v-model="showExtensionDetailsDialog" position="top">
|
||||
<q-card
|
||||
v-if="selectedExtensionDetails"
|
||||
class="q-pa-sm"
|
||||
style="width: 800px; max-width: 95vw"
|
||||
>
|
||||
<q-card-section>
|
||||
<div class="row">
|
||||
<div class="col-2 gt-md">
|
||||
<q-img
|
||||
:src="selectedExtensionDetails.icon"
|
||||
style="width: 100px"
|
||||
type="image"
|
||||
></q-img>
|
||||
</div>
|
||||
<div class="col-9 q-pl-md gt-xs">
|
||||
<h3 class="q-my-sm" v-text="selectedExtensionDetails.name"></h3>
|
||||
<h6
|
||||
class="q-my-sm"
|
||||
v-text="selectedExtensionDetails.short_description"
|
||||
></h6>
|
||||
</div>
|
||||
<div class="col-9 q-pl-md lt-sm">
|
||||
<h5 class="q-my-sm" v-text="selectedExtensionDetails.name"></h5>
|
||||
<p
|
||||
class="q-my-sm"
|
||||
v-text="selectedExtensionDetails.short_description"
|
||||
></p>
|
||||
</div>
|
||||
<div class="col-1">
|
||||
<q-btn
|
||||
v-close-popup
|
||||
flat
|
||||
color="grey"
|
||||
class="float-right q-ml-lg"
|
||||
v-text="$t('close')"
|
||||
></q-btn>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedExtensionDetails.images?.length" class="row q-my-lg">
|
||||
<div class="col q-pr-md">
|
||||
<q-carousel
|
||||
navigation-position="top"
|
||||
swipeable
|
||||
animated
|
||||
v-model="slide"
|
||||
v-model:fullscreen="fullscreen"
|
||||
thumbnails
|
||||
infinite
|
||||
:autoplay="autoplay"
|
||||
arrows
|
||||
transition-prev="slide-right"
|
||||
transition-next="slide-left"
|
||||
@mouseenter="autoplay = false"
|
||||
@mouseleave="autoplay = true"
|
||||
height="300px"
|
||||
>
|
||||
<template v-slot:control>
|
||||
<q-carousel-control position="bottom-right" :offset="[18, 18]">
|
||||
<q-btn
|
||||
push
|
||||
round
|
||||
dense
|
||||
color="white"
|
||||
text-color="primary"
|
||||
:icon="fullscreen ? 'fullscreen_exit' : 'fullscreen'"
|
||||
@click="fullscreen = !fullscreen"
|
||||
></q-btn>
|
||||
</q-carousel-control>
|
||||
</template>
|
||||
<q-carousel-slide
|
||||
v-for="(image, i) of selectedExtensionDetails.images"
|
||||
:img-src="image.uri"
|
||||
:key="i"
|
||||
:name="i"
|
||||
>
|
||||
<q-video
|
||||
v-if="image.link"
|
||||
class="absolute-full"
|
||||
:src="image.link"
|
||||
/>
|
||||
</q-carousel-slide>
|
||||
</q-carousel>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-sm-12 col-md-8 q-pr-sm">
|
||||
<div v-html="selectedExtensionDetails.description_md"></div>
|
||||
</div>
|
||||
<div class="col-sm-12 col-md-4 q-pl-sm">
|
||||
<lnbits-extension-rating
|
||||
:rating="0"
|
||||
size="2.5em"
|
||||
></lnbits-extension-rating>
|
||||
<div class="q-mt-md">
|
||||
<b>
|
||||
<span v-text="$t('contributors')"></span>
|
||||
</b>
|
||||
<small>
|
||||
<div v-for="contributor of selectedExtensionDetails.contributors">
|
||||
<a
|
||||
:href="contributor.uri"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style="color: var(--q-primary); text-decoration: none"
|
||||
>
|
||||
<span
|
||||
v-text="(contributor.name || contributor) + ' - ' + (contributor.role || 'dev')"
|
||||
></span>
|
||||
</a>
|
||||
</div>
|
||||
</small>
|
||||
</div>
|
||||
<div class="q-pt-md">
|
||||
<div>
|
||||
<q-btn
|
||||
size="sm"
|
||||
color="primary"
|
||||
label="Repo"
|
||||
type="a"
|
||||
:href="selectedExtensionDetails.repo"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="q-pr-xs"
|
||||
><q-tooltip>repository</q-tooltip></q-btn
|
||||
>
|
||||
</div>
|
||||
<div class="q-pt-lg">
|
||||
<div>
|
||||
<b>
|
||||
<span v-text="$t('license')"></span>
|
||||
</b>
|
||||
<q-badge
|
||||
color="primary"
|
||||
v-text="selectedExtensionDetails.license"
|
||||
></q-badge>
|
||||
</div>
|
||||
<br />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
<q-dialog v-model="showUpdateAllDialog" position="top">
|
||||
<q-card class="q-pa-md q-pt-md lnbits__dialog-card">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<h6 class="q-my-md" v-text="$t('update')"></h6>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="updatableExtensions?.length > 1" class="row">
|
||||
<div class="col-12">
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
@click="selectAllUpdatableExtensionss()"
|
||||
v-text="$t('select_all')"
|
||||
></q-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<q-virtual-scroll :items="updatableExtensions" style="max-height: 400px">
|
||||
<template v-slot="{ item, index }">
|
||||
<div class="row">
|
||||
<div class="q-col">
|
||||
<q-checkbox
|
||||
v-model="item.selectedForUpdate"
|
||||
:disable="item.isUpgraded"
|
||||
value="false"
|
||||
:label="item.name + ` (v${item.latestRelease?.version})`"
|
||||
>
|
||||
<q-spinner-bars
|
||||
v-if="item.inProgress"
|
||||
color="primary"
|
||||
size="1.5em"
|
||||
class="q-ml-md"
|
||||
></q-spinner-bars>
|
||||
</q-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</q-virtual-scroll>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
@click="updateSelectedExtensions()"
|
||||
outline
|
||||
color="grey"
|
||||
v-text="$t('update')"
|
||||
></q-btn>
|
||||
|
||||
<q-btn
|
||||
v-close-popup
|
||||
flat
|
||||
color="grey"
|
||||
class="q-ml-auto"
|
||||
v-text="$t('cancel')"
|
||||
></q-btn>
|
||||
</div>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
{% endblock %}
|
||||
@@ -1,150 +0,0 @@
|
||||
{% extends "public.html" %} {% block page_container %}
|
||||
<q-page-container>
|
||||
<q-page class="q-px-md q-py-lg" :class="{'q-px-lg': $q.screen.gt.xs}">
|
||||
{% block page %}
|
||||
<div class="row q-col-gutter-md justify-center main">
|
||||
<div class="col-10 col-md-8 col-lg-6 q-gutter-y-md">
|
||||
<q-card>
|
||||
<q-card-section class="grid">
|
||||
<div>
|
||||
<h6 class="q-my-none text-center">
|
||||
<strong v-text="$t('welcome_lnbits')"></strong>
|
||||
<p><span v-text="$t('setup_su_account')"></span></p>
|
||||
</h6>
|
||||
<br />
|
||||
<q-form class="q-gutter-md">
|
||||
<q-input
|
||||
filled
|
||||
v-model="loginData.username"
|
||||
:label="$t('username')"
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
v-model.trim="loginData.password"
|
||||
:type="loginData.isPwd ? 'password' : 'text'"
|
||||
autocomplete="off"
|
||||
:label="$t('password')"
|
||||
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password')]"
|
||||
><template v-slot:append>
|
||||
<q-icon
|
||||
:name="loginData.isPwd ? 'visibility_off' : 'visibility'"
|
||||
class="cursor-pointer"
|
||||
@click="loginData.isPwd = !loginData.isPwd"
|
||||
/> </template
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
v-model.trim="loginData.passwordRepeat"
|
||||
:type="loginData.isPwdRepeat ? 'password' : 'text'"
|
||||
autocomplete="off"
|
||||
:label="$t('password_repeat')"
|
||||
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password'), (val) => val === loginData.password || $t('invalid_password_repeat')]"
|
||||
><template v-slot:append>
|
||||
<q-icon
|
||||
:name="loginData.isPwdRepeat ? 'visibility_off' : 'visibility'"
|
||||
class="cursor-pointer"
|
||||
@click="loginData.isPwdRepeat = !loginData.isPwdRepeat"
|
||||
/> </template
|
||||
></q-input>
|
||||
<q-btn
|
||||
@click="setPassword()"
|
||||
unelevated
|
||||
color="primary"
|
||||
:label="$t('login')"
|
||||
:disable="checkPasswordsMatch || !loginData.username || !loginData.password || !loginData.passwordRepeat"
|
||||
></q-btn>
|
||||
</q-form>
|
||||
</div>
|
||||
<div class="hero-wrapper">
|
||||
<div class="hero q-mx-auto"></div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %} {% block scripts %}
|
||||
<style>
|
||||
main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
.grid {
|
||||
display: block;
|
||||
}
|
||||
.hero-wrapper {
|
||||
display: none;
|
||||
}
|
||||
.hero {
|
||||
display: block;
|
||||
height: 100%;
|
||||
max-width: 250px;
|
||||
background-image: url("{{ static_url_for('static', 'images/logos/lnbits.svg') }}");
|
||||
background-position: center;
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
@media (min-width: 992px) {
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-gap: 1rem;
|
||||
}
|
||||
.hero-wrapper {
|
||||
display: block;
|
||||
position: relative;
|
||||
height: 100%;
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
window.app = Vue.createApp({
|
||||
el: '#vue',
|
||||
mixins: [window.windowMixin],
|
||||
data: function () {
|
||||
return {
|
||||
loginData: {
|
||||
isPwd: true,
|
||||
isPwdRepeat: true,
|
||||
username: '',
|
||||
password: '',
|
||||
passwordRepeat: ''
|
||||
}
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.hasAdminUI = '{{ LNBITS_ADMIN_UI | tojson}}'
|
||||
},
|
||||
computed: {
|
||||
checkPasswordsMatch() {
|
||||
return this.loginData.password !== this.loginData.passwordRepeat
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async setPassword() {
|
||||
try {
|
||||
await LNbits.api.request(
|
||||
'PUT',
|
||||
'/api/v1/auth/first_install',
|
||||
null,
|
||||
{
|
||||
username: this.loginData.username,
|
||||
password: this.loginData.password,
|
||||
password_repeat: this.loginData.passwordRepeat
|
||||
}
|
||||
)
|
||||
|
||||
window.location.href = '/admin'
|
||||
} catch (e) {
|
||||
LNbits.utils.notifyApiError(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
{% endblock %}
|
||||
</q-page>
|
||||
</q-page-container>
|
||||
{% endblock %}
|
||||
@@ -1,708 +0,0 @@
|
||||
{% extends "public.html" %} {% block scripts %}
|
||||
<style>
|
||||
:root {
|
||||
--size: 100px;
|
||||
--gap: 25px;
|
||||
}
|
||||
|
||||
.btn-fixed-width {
|
||||
/* width: 45%; */
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--gap);
|
||||
margin: auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.marquee {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
gap: var(--gap);
|
||||
height: max-content;
|
||||
mask-image: linear-gradient(
|
||||
to right,
|
||||
hsl(0 0% 0% / 0),
|
||||
hsl(0 0% 0% / 1) 20%,
|
||||
hsl(0 0% 0% / 1) 80%,
|
||||
hsl(0 0% 0% / 0)
|
||||
);
|
||||
}
|
||||
|
||||
.marquee__group {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-around;
|
||||
gap: var(--gap);
|
||||
min-width: 100%;
|
||||
animation: scroll-x 60s linear infinite;
|
||||
}
|
||||
|
||||
.marquee:hover .marquee__group {
|
||||
animation-play-state: paused;
|
||||
}
|
||||
|
||||
.marquee__group div {
|
||||
width: var(--size);
|
||||
}
|
||||
|
||||
@keyframes scroll-x {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
}
|
||||
to {
|
||||
transform: translateX(calc(-100% - var(--gap)));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<script src="{{ static_url_for('static', 'js/index.js') }}"></script>
|
||||
{% endblock %} {% block page_container %}
|
||||
<q-page-container>
|
||||
<q-page
|
||||
class="q-px-md q-py-lg content-center"
|
||||
:class="{'q-px-lg': $q.screen.gt.xs}"
|
||||
>
|
||||
{% block page %}
|
||||
<div
|
||||
class="row justify-center items-center"
|
||||
style="min-height: calc(100vh / 1.618)"
|
||||
>
|
||||
<div
|
||||
class="full-width"
|
||||
:style="`max-width: ${'{{ LNBITS_CUSTOM_IMAGE }}' ? '850' : '600'}px`"
|
||||
>
|
||||
<div class="row q-mb-md">
|
||||
<div class="col-12">
|
||||
<div>
|
||||
<h5
|
||||
class="q-my-none"
|
||||
v-if="'{{LNBITS_SHOW_HOME_PAGE_ELEMENTS}}' == 'True'"
|
||||
>
|
||||
{{SITE_TITLE}}
|
||||
</h5>
|
||||
<template v-if="$q.screen.gt.sm">
|
||||
<h6
|
||||
class="q-my-sm"
|
||||
v-if="'{{LNBITS_SHOW_HOME_PAGE_ELEMENTS}}' == 'True'"
|
||||
>
|
||||
{{SITE_TAGLINE}}
|
||||
</h6>
|
||||
<p
|
||||
class="q-my-sm"
|
||||
v-if="'{{LNBITS_SHOW_HOME_PAGE_ELEMENTS}}' == 'True'"
|
||||
>
|
||||
{{SITE_DESCRIPTION}}
|
||||
</p>
|
||||
</template>
|
||||
<!-- <div
|
||||
class="gt-sm"
|
||||
v-html="formatDescription"
|
||||
v-if="'{{LNBITS_SHOW_HOME_PAGE_ELEMENTS}}' == 'True'"
|
||||
></div> -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<q-badge
|
||||
v-if="isAccessTokenExpired"
|
||||
class="q-mx-auto q-mb-md"
|
||||
color="primary"
|
||||
rounded
|
||||
>
|
||||
<div class="text-h6">
|
||||
<span v-text="$t('session_has_expired')"></span>
|
||||
</div>
|
||||
</q-badge>
|
||||
<q-card bordered class="full-width q-py-md">
|
||||
<div class="row">
|
||||
<div
|
||||
class="col-12"
|
||||
:class="{'col-sm-7' : '{{ LNBITS_CUSTOM_IMAGE }}', 'col-lg-6' : '{{ LNBITS_CUSTOM_IMAGE }}'}"
|
||||
>
|
||||
{% if lnurl and LNBITS_NEW_ACCOUNTS_ALLOWED and ("user-id-only"
|
||||
in LNBITS_AUTH_METHODS)%}
|
||||
<div class="full-height content-center">
|
||||
<q-card-section>
|
||||
<div class="text-body1">
|
||||
<span v-text="$t('claim_desc')"></span>
|
||||
</div>
|
||||
</q-card-section>
|
||||
<q-card-section>
|
||||
<q-btn
|
||||
unelevated
|
||||
color="primary"
|
||||
@click="processing"
|
||||
type="a"
|
||||
href="/lnurlwallet?lightning={{ lnurl }}"
|
||||
v-text="$t('press_to_claim')"
|
||||
class="full-width"
|
||||
></q-btn>
|
||||
</q-card-section>
|
||||
</div>
|
||||
{%else%}
|
||||
<username-password
|
||||
v-if="authMethod != 'user-id-only'"
|
||||
:allowed_new_users="allowedRegister"
|
||||
:auth-methods="LNBITS_AUTH_METHODS"
|
||||
:auth-action="authAction"
|
||||
v-model:user-name="username"
|
||||
v-model:password_1="password"
|
||||
v-model:password_2="passwordRepeat"
|
||||
v-model:reset-key="reset_key"
|
||||
@login="login"
|
||||
@register="register"
|
||||
@reset="reset"
|
||||
>
|
||||
<div
|
||||
class="text-center text-grey-6"
|
||||
v-if="authAction !== 'reset'"
|
||||
>
|
||||
<p
|
||||
v-if="authAction === 'login' && allowedRegister"
|
||||
class="q-mb-none"
|
||||
>
|
||||
Not registered? Create an
|
||||
<span
|
||||
class="text-secondary cursor-pointer"
|
||||
@click="showRegister('username-password')"
|
||||
>Account</span
|
||||
>
|
||||
</p>
|
||||
<p
|
||||
v-else-if="authAction === 'login' && !allowedRegister"
|
||||
class="q-mb-none"
|
||||
>
|
||||
<span v-text="$t('new_user_not_allowed')"></span>
|
||||
</p>
|
||||
<p v-else-if="authAction === 'register'" class="q-mb-none">
|
||||
<span v-text="$t('existing_account_question')"></span>
|
||||
|
||||
<span
|
||||
class="text-secondary cursor-pointer q-ml-sm"
|
||||
@click="showLogin('username-password')"
|
||||
v-text="$t('login')"
|
||||
></span>
|
||||
</p>
|
||||
</div>
|
||||
</username-password>
|
||||
{% if "user-id-only" in LNBITS_AUTH_METHODS %}
|
||||
<user-id-only
|
||||
:allowed_new_users="allowedRegister"
|
||||
v-model:usr="usr"
|
||||
v-model:wallet="walletName"
|
||||
:auth-action="authAction"
|
||||
:auth-method="authMethod"
|
||||
@show-login="showLogin"
|
||||
@show-register="showRegister"
|
||||
@login-usr="loginUsr"
|
||||
@create-wallet="createWallet"
|
||||
>
|
||||
</user-id-only>
|
||||
{%endif%} {% endif %}
|
||||
</div>
|
||||
<div
|
||||
class="col-sm-5 col-lg-6 gt-xs"
|
||||
v-if="'{{ LNBITS_CUSTOM_IMAGE }}'"
|
||||
>
|
||||
<div class="full-height flex flex-center q-pa-lg">
|
||||
<q-img
|
||||
:src="'{{ LNBITS_CUSTOM_IMAGE }}'"
|
||||
:ratio="1"
|
||||
width="250px"
|
||||
></q-img>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="'{{ LNBITS_DENOMINATION }}' == 'sats' && '{{ SITE_TITLE }}' == 'LNbits' && '{{ LNBITS_SHOW_HOME_PAGE_ELEMENTS }}' == 'True'"
|
||||
class="full-width q-mb-lg q-mt-sm"
|
||||
>
|
||||
<div class="flex flex-center q-gutter-md q-py-md">
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
type="a"
|
||||
href="https://github.com/lnbits/lnbits"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
:label="$t('view_github')"
|
||||
class=""
|
||||
></q-btn>
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
type="a"
|
||||
href="https://demo.lnbits.com/lnurlp/link/fH59GD"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
:label="$t('donate')"
|
||||
class=""
|
||||
></q-btn>
|
||||
</div>
|
||||
</div>
|
||||
{% if AD_SPACE_ENABLED and AD_SPACE %}
|
||||
<div class="q-pt-md full-width">
|
||||
<div class="row justify-center q-mb-xl">
|
||||
<div class="full-width text-center">
|
||||
<span class="text-uppercase text-grey">{{ AD_SPACE_TITLE }}</span>
|
||||
</div>
|
||||
<div class="flex flex-center columm">
|
||||
{% for ADS in AD_SPACE %} {% set AD = ADS.split(';') %}
|
||||
<div class="flex flex-center column q-pr-sm">
|
||||
<a href="{{ AD[0] }}">
|
||||
<img
|
||||
v-if="($q.dark.isActive)"
|
||||
src="{{ AD[1] }}"
|
||||
style="max-width: 420px"
|
||||
class="full-width"
|
||||
/>
|
||||
<img
|
||||
v-else
|
||||
src="{{ AD[2] }}"
|
||||
style="max-width: 420px"
|
||||
class="full-width"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div
|
||||
v-if="'{{ LNBITS_DENOMINATION }}' == 'sats' && '{{ SITE_TITLE }}' == 'LNbits' && '{{ LNBITS_SHOW_HOME_PAGE_ELEMENTS }}' == 'True'"
|
||||
class="full-width"
|
||||
>
|
||||
<div class="wrapper">
|
||||
<div class="marquee">
|
||||
<div class="marquee__group">
|
||||
<div>
|
||||
<a
|
||||
href="https://github.com/ElementsProject/lightning"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/cln.png') }}' : '{{ static_url_for('static', 'images/clnl.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://github.com/lightningnetwork/lnd"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/lnd.png') }}' : '{{ static_url_for('static', 'images/lnd.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://opennode.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/opennode.png') }}' : '{{ static_url_for('static', 'images/opennodel.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://lnpay.co/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/lnpay.png') }}' : '{{ static_url_for('static', 'images/lnpayl.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://github.com/rootzoll/raspiblitz"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/blitz.png') }}' : '{{ static_url_for('static', 'images/blitzl.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://start9.com/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/start9.png') }}' : '{{ static_url_for('static', 'images/start9l.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://getumbrel.com/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/umbrel.png') }}' : '{{ static_url_for('static', 'images/umbrell.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://mynodebtc.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/mynode.png') }}' : '{{ static_url_for('static', 'images/mynodel.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://github.com/shesek/spark-wallet"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/spark.png') }}' : '{{ static_url_for('static', 'images/sparkl.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://voltage.cloud"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/voltage.png') }}' : '{{ static_url_for('static', 'images/voltagel.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://breez.technology/sdk/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/breez.png') }}' : '{{ static_url_for('static', 'images/breezl.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://blockstream.com/lightning/greenlight/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/greenlight.png') }}' : '{{ static_url_for('static', 'images/greenlightl.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://getalby.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/alby.png') }}' : '{{ static_url_for('static', 'images/albyl.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://zbd.gg"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/zbd.png') }}' : '{{ static_url_for('static', 'images/zbdl.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://phoenix.acinq.co/server"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/phoenixd.png') }}' : '{{ static_url_for('static', 'images/phoenixdl.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://boltz.exchange/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/boltz.svg') }}' : '{{ static_url_for('static', 'images/boltz.svg') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://www.blink.sv/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/blink_logo.png') }}' : '{{ static_url_for('static', 'images/blink_logol.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<!-- # -->
|
||||
</div>
|
||||
<div class="marquee__group">
|
||||
<div>
|
||||
<a
|
||||
href="https://github.com/ElementsProject/lightning"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/cln.png') }}' : '{{ static_url_for('static', 'images/clnl.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://github.com/lightningnetwork/lnd"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/lnd.png') }}' : '{{ static_url_for('static', 'images/lnd.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://opennode.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/opennode.png') }}' : '{{ static_url_for('static', 'images/opennodel.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://lnpay.co/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/lnpay.png') }}' : '{{ static_url_for('static', 'images/lnpayl.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://github.com/rootzoll/raspiblitz"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/blitz.png') }}' : '{{ static_url_for('static', 'images/blitzl.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://start9.com/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/start9.png') }}' : '{{ static_url_for('static', 'images/start9l.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://getumbrel.com/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/umbrel.png') }}' : '{{ static_url_for('static', 'images/umbrell.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://mynodebtc.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/mynode.png') }}' : '{{ static_url_for('static', 'images/mynodel.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://github.com/shesek/spark-wallet"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/spark.png') }}' : '{{ static_url_for('static', 'images/sparkl.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://voltage.cloud"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/voltage.png') }}' : '{{ static_url_for('static', 'images/voltagel.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://breez.technology/sdk/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/breez.png') }}' : '{{ static_url_for('static', 'images/breezl.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://blockstream.com/lightning/greenlight/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/greenlight.png') }}' : '{{ static_url_for('static', 'images/greenlightl.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://getalby.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/alby.png') }}' : '{{ static_url_for('static', 'images/albyl.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://zbd.gg"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/zbd.png') }}' : '{{ static_url_for('static', 'images/zbdl.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://phoenix.acinq.co/server"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/phoenixd.png') }}' : '{{ static_url_for('static', 'images/phoenixdl.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://boltz.exchange/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/boltz.svg') }}' : '{{ static_url_for('static', 'images/boltz.svg') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://www.blink.sv/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/blink_logo.png') }}' : '{{ static_url_for('static', 'images/blink_logol.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<!-- # -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
</q-page>
|
||||
</q-page-container>
|
||||
{% endblock %}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 %}
|
||||
@@ -0,0 +1,3 @@
|
||||
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
|
||||
%} {% block scripts %} {{ window_vars(user) }} {% endblock %} {% block page %}{%
|
||||
endblock %}
|
||||
@@ -0,0 +1,3 @@
|
||||
{% extends "public.html" %} {% from "macros.jinja" import window_vars with
|
||||
context %} {% block scripts %} {{ window_vars() }} {% endblock %} {% block page
|
||||
%} {% endblock %}
|
||||
@@ -1,379 +0,0 @@
|
||||
<q-tab-panel name="channels">
|
||||
<q-dialog v-model="connectPeerDialog.show" position="top">
|
||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<q-form class="q-gutter-md">
|
||||
<q-input
|
||||
dense
|
||||
type="text"
|
||||
filled
|
||||
v-model="connectPeerDialog.data.uri"
|
||||
label="Node URI"
|
||||
hint="pubkey@host:port"
|
||||
></q-input>
|
||||
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
:label="$t('connect')"
|
||||
color="primary"
|
||||
@click="connectPeer"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
v-close-popup
|
||||
flat
|
||||
color="grey"
|
||||
class="q-ml-auto"
|
||||
:label="$t('cancel')"
|
||||
></q-btn>
|
||||
</div>
|
||||
</q-form>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<q-dialog v-model="setFeeDialog.show">
|
||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<label class="text-h6">Set Channel Fee</label>
|
||||
<p class="text-caption" v-text="setFeeDialog.channel_id"></p>
|
||||
<q-separator></q-separator>
|
||||
<q-form class="q-gutter-md">
|
||||
<q-input
|
||||
dense
|
||||
type="number"
|
||||
filled
|
||||
v-model.number="setFeeDialog.data.fee_ppm"
|
||||
label="Fee Rate PPM"
|
||||
></q-input>
|
||||
<q-input
|
||||
dense
|
||||
type="number"
|
||||
filled
|
||||
v-model.number="setFeeDialog.data.fee_base_msat"
|
||||
label="Fee Base msat"
|
||||
></q-input>
|
||||
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
:label="$t('set')"
|
||||
color="primary"
|
||||
@click="setChannelFee(setFeeDialog.channel_id)"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
v-close-popup
|
||||
flat
|
||||
color="grey"
|
||||
class="q-ml-auto"
|
||||
:label="$t('cancel')"
|
||||
></q-btn>
|
||||
</div>
|
||||
</q-form>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<q-dialog v-model="openChannelDialog.show">
|
||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<q-form class="q-gutter-md">
|
||||
<q-input
|
||||
dense
|
||||
type="text"
|
||||
filled
|
||||
v-model="openChannelDialog.data.peer_id"
|
||||
label="Peer ID"
|
||||
></q-input>
|
||||
<q-input
|
||||
dense
|
||||
type="number"
|
||||
filled
|
||||
v-model.number="openChannelDialog.data.funding_amount"
|
||||
label="Funding Amount"
|
||||
></q-input>
|
||||
<q-expansion-item icon="warning" label="Advanced">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<div class="column q-gutter-md">
|
||||
<q-input
|
||||
dense
|
||||
type="number"
|
||||
filled
|
||||
v-model.number="openChannelDialog.data.push_amount"
|
||||
label="Push Amount"
|
||||
hint="This gifts sats to the other side!"
|
||||
></q-input>
|
||||
|
||||
<q-input
|
||||
dense
|
||||
type="number"
|
||||
filled
|
||||
v-model.number="openChannelDialog.data.fee_rate"
|
||||
label="Fee Rate"
|
||||
></q-input>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
:label="$t('open')"
|
||||
color="primary"
|
||||
@click="openChannel"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
v-close-popup
|
||||
flat
|
||||
color="grey"
|
||||
class="q-ml-auto"
|
||||
:label="$t('cancel')"
|
||||
></q-btn>
|
||||
</div>
|
||||
</q-form>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<q-dialog v-model="closeChannelDialog.show" position="top">
|
||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<q-form class="q-gutter-md">
|
||||
<div>
|
||||
<q-checkbox v-model="closeChannelDialog.data.force" label="Force" />
|
||||
</div>
|
||||
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
:label="$t('close')"
|
||||
color="primary"
|
||||
@click="closeChannel"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
v-close-popup
|
||||
flat
|
||||
color="grey"
|
||||
class="q-ml-auto"
|
||||
:label="$t('cancel')"
|
||||
></q-btn>
|
||||
</div>
|
||||
</q-form>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<q-card-section class="q-pa-none">
|
||||
<div class="row q-col-gutter-lg">
|
||||
<div class="col-12 col-xl-6">
|
||||
<q-card class="full-height">
|
||||
<q-card-section class="q-gutter-y-sm">
|
||||
<div class="row items-center q-mt-none q-gutter-x-sm no-wrap">
|
||||
<div class="col-grow text-h6 q-my-none col-grow">Channels</div>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
clearable
|
||||
v-model="channels.filter"
|
||||
placeholder="Search..."
|
||||
class="col-auto"
|
||||
></q-input>
|
||||
<q-select
|
||||
dense
|
||||
size="sm"
|
||||
style="min-width: 200px"
|
||||
filled
|
||||
multiple
|
||||
clearable
|
||||
v-model="stateFilters"
|
||||
:options="this.states"
|
||||
class="col-auto"
|
||||
></q-select>
|
||||
<q-btn
|
||||
unelevated
|
||||
color="primary"
|
||||
size="md"
|
||||
class="col-auto"
|
||||
@click="showOpenChannelDialog()"
|
||||
>
|
||||
Open channel
|
||||
</q-btn>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-subtitle1 col-grow">Total</div>
|
||||
<lnbits-channel-balance
|
||||
:balance="this.totalBalance"
|
||||
></lnbits-channel-balance>
|
||||
</div>
|
||||
<q-separator></q-separator>
|
||||
<q-table
|
||||
dense
|
||||
flat
|
||||
:rows="this.filteredChannels"
|
||||
:filter="channels.filter"
|
||||
no-data-label="No channels opened"
|
||||
>
|
||||
<template v-slot:header="props">
|
||||
<q-tr :props="props" style="height: 0"> </q-tr>
|
||||
</template>
|
||||
<template v-slot:body="props">
|
||||
<q-tr :props="props">
|
||||
<div class="q-pb-sm">
|
||||
<div class="row items-center q-gutter-sm">
|
||||
<div class="text-subtitle1" v-text="props.row.name"></div>
|
||||
<div class="text-caption" v-if="props.row.peer_id">
|
||||
<span>Peer ID</span>
|
||||
<q-btn
|
||||
size="xs"
|
||||
flat
|
||||
dense
|
||||
icon="content_paste"
|
||||
@click="copyText(props.row.peer_id)"
|
||||
></q-btn>
|
||||
</div>
|
||||
<div class="text-caption col-grow">
|
||||
<span>Fees</span>
|
||||
<q-btn
|
||||
size="xs"
|
||||
flat
|
||||
dense
|
||||
icon="settings"
|
||||
@click="showSetFeeDialog(props.row.id)"
|
||||
></q-btn>
|
||||
<span v-if="props.row.fee_ppm">
|
||||
<span v-text="props.row.fee_ppm"></span> ppm,
|
||||
<span v-text="props.row.fee_base_msat"></span> msat
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-caption" v-if="props.row.id">
|
||||
<span>Channel ID</span>
|
||||
<q-btn
|
||||
size="xs"
|
||||
flat
|
||||
dense
|
||||
icon="content_paste"
|
||||
@click="copyText(props.row.id)"
|
||||
></q-btn>
|
||||
</div>
|
||||
<div class="text-caption" v-if="props.row.short_id">
|
||||
<span v-text="props.row.short_id"></span>
|
||||
<q-btn
|
||||
size="xs"
|
||||
flat
|
||||
dense
|
||||
icon="content_paste"
|
||||
@click="copyText(props.row.short_id)"
|
||||
></q-btn>
|
||||
</div>
|
||||
<q-badge
|
||||
rounded
|
||||
:color="states.find(s => s.value == props.row.state)?.color"
|
||||
v-text="states.find(s => s.value == props.row.state)?.label"
|
||||
>
|
||||
</q-badge>
|
||||
<q-btn
|
||||
:disable='props.row.state !== "active"'
|
||||
flat
|
||||
dense
|
||||
size="md"
|
||||
@click="showCloseChannelDialog(props.row)"
|
||||
icon="cancel"
|
||||
color="pink"
|
||||
></q-btn>
|
||||
</div>
|
||||
|
||||
<lnbits-channel-balance
|
||||
:balance="props.row.balance"
|
||||
:color="props.row.color"
|
||||
></lnbits-channel-balance>
|
||||
</div>
|
||||
</q-tr>
|
||||
</template>
|
||||
</q-table>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
<div class="col-12 col-xl-6">
|
||||
<q-card class="full-height">
|
||||
<q-card-section class="column q-gutter-y-sm">
|
||||
<div
|
||||
class="row items-center q-mt-none justify-between q-gutter-x-md no-wrap"
|
||||
>
|
||||
<div class="col-grow text-h6 q-my-none">Peers</div>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
clearable
|
||||
v-model="peers.filter"
|
||||
placeholder="Search..."
|
||||
class="col-auto"
|
||||
></q-input>
|
||||
<q-btn
|
||||
class="col-auto"
|
||||
color="primary"
|
||||
@click="connectPeerDialog.show = true"
|
||||
>
|
||||
Connect Peer
|
||||
</q-btn>
|
||||
</div>
|
||||
<q-separator></q-separator>
|
||||
<q-table
|
||||
dense
|
||||
flat
|
||||
:rows="peers.data"
|
||||
:filter="peers.filter"
|
||||
no-data-label="No transactions made yet"
|
||||
>
|
||||
<template v-slot:header="props">
|
||||
<q-tr :props="props" style="height: 0"> </q-tr>
|
||||
</template>
|
||||
<template v-slot:body="props">
|
||||
<q-tr :props="props">
|
||||
<div class="row no-wrap items-center q-gutter-sm">
|
||||
<div class="q-my-sm col-grow">
|
||||
<div
|
||||
class="text-subtitle1 text-bold"
|
||||
v-text="props.row.alias"
|
||||
></div>
|
||||
<div class="row items-center q-gutter-sm">
|
||||
<q-badge
|
||||
:style="`background-color: #${props.row.color}`"
|
||||
class="text-bold"
|
||||
v-text="'#'+props.row.color"
|
||||
>
|
||||
</q-badge>
|
||||
<div
|
||||
class="text-bold"
|
||||
v-text="shortenNodeId(props.row.id)"
|
||||
></div>
|
||||
<q-btn
|
||||
size="xs"
|
||||
flat
|
||||
dense
|
||||
icon="content_paste"
|
||||
@click="copyText(props.row.id)"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
size="xs"
|
||||
flat
|
||||
dense
|
||||
icon="qr_code"
|
||||
@click="showNodeInfoDialog(props.row)"
|
||||
></q-btn>
|
||||
</div>
|
||||
</div>
|
||||
<q-btn
|
||||
unelevated
|
||||
color="primary"
|
||||
@click="showOpenChannelDialog(props.row.id)"
|
||||
>
|
||||
Open channel
|
||||
</q-btn>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
size="md"
|
||||
@click="disconnectPeer(props.row.id)"
|
||||
icon="cancel"
|
||||
color="pink"
|
||||
></q-btn>
|
||||
</div>
|
||||
</q-tr>
|
||||
</template>
|
||||
</q-table>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-tab-panel>
|
||||
@@ -1,68 +0,0 @@
|
||||
<q-tab-panel name="dashboard">
|
||||
<q-card-section class="q-pa-none">
|
||||
<lnbits-node-info :info="this.info"></lnbits-node-info>
|
||||
<div class="row q-col-gutter-lg q-mt-sm">
|
||||
<div class="col-12 col-md-8 q-gutter-y-md">
|
||||
<div class="row q-col-gutter-md q-pb-lg">
|
||||
<div class="col-12 col-md-6 col-xl-4 q-gutter-y-md">
|
||||
<lnbits-stat
|
||||
:title="$t('total_capacity')"
|
||||
:msat="this.channel_stats.total_capacity"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-12 col-md-6 col-xl-4 q-gutter-y-md">
|
||||
<lnbits-stat title="Balance" :msat="this.info.balance_msat" />
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-6 col-xl-4 q-gutter-y-md">
|
||||
<lnbits-stat
|
||||
title="Fees collected"
|
||||
:msat="this.info.fees?.total_msat"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-6 col-xl-4 q-gutter-y-md">
|
||||
<lnbits-stat
|
||||
title="Onchain Balance"
|
||||
:btc="this.info.onchain_balance_sat / 100000000"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-6 col-xl-4 q-gutter-y-md">
|
||||
<lnbits-stat
|
||||
title="Onchain Confirmed"
|
||||
:btc="this.info.onchain_confirmed_sat / 100000000"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-12 col-md-6 col-xl-4 q-gutter-y-md">
|
||||
<lnbits-stat title="Peers" :amount="this.info.num_peers" />
|
||||
</div>
|
||||
<div class="col-12 col-md-6 col-xl-4 q-gutter-y-md">
|
||||
<lnbits-stat
|
||||
:title="$t('avg_channel_size')"
|
||||
:msat="this.channel_stats.avg_size"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-12 col-md-6 col-xl-4 q-gutter-y-md">
|
||||
<lnbits-stat
|
||||
:title="$t('biggest_channel_size')"
|
||||
:msat="this.channel_stats.biggest_size"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-12 col-md-6 col-xl-4 q-gutter-y-md">
|
||||
<lnbits-stat
|
||||
:title="$t('smallest_channel_size')"
|
||||
:msat="this.channel_stats.smallest_size"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column col-12 col-md-4 q-gutter-y-md">
|
||||
<lnbits-node-ranks :ranks="this.ranks"></lnbits-node-ranks>
|
||||
<lnbits-channel-stats
|
||||
:stats="this.channel_stats"
|
||||
></lnbits-channel-stats>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-tab-panel>
|
||||
@@ -1,314 +0,0 @@
|
||||
<q-tab-panel name="transactions">
|
||||
<q-card-section class="q-pa-none">
|
||||
<q-dialog v-model="transactionDetailsDialog.show" position="top">
|
||||
<q-card class="my-card">
|
||||
<q-card-section>
|
||||
<div class="text-center q-mb-lg">
|
||||
<div
|
||||
v-if="transactionDetailsDialog.data.isIn && transactionDetailsDialog.data.pending"
|
||||
>
|
||||
<q-icon
|
||||
size="18px"
|
||||
:name="'call_received'"
|
||||
:color="'green'"
|
||||
></q-icon>
|
||||
<span v-text="$t('payment_received')"></span>
|
||||
</div>
|
||||
<div class="row q-my-md">
|
||||
<div class="col-3"><b v-text="$t('payment_hash')"></b>:</div>
|
||||
<div class="col-9 text-wrap mono">
|
||||
<span
|
||||
v-text="transactionDetailsDialog.data.payment_hash"
|
||||
></span>
|
||||
<q-icon
|
||||
name="content_copy"
|
||||
@click="copyText(transactionDetailsDialog.data.payment_hash)"
|
||||
size="1em"
|
||||
color="grey"
|
||||
class="q-mb-xs cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="row"
|
||||
v-if="transactionDetailsDialog.data.preimage && !transactionDetailsDialog.data.pending"
|
||||
>
|
||||
<div class="col-3"><b v-text="$t('payment_proof')"></b>:</div>
|
||||
<div class="col-9 text-wrap mono">
|
||||
<span v-text="transactionDetailsDialog.data.preimage"></span>
|
||||
<q-icon
|
||||
name="content_copy"
|
||||
@click="copyText(transactionDetailsDialog.data.preimage)"
|
||||
size="1em"
|
||||
color="grey"
|
||||
class="q-mb-xs cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="transactionDetailsDialog.data.bolt11"
|
||||
class="text-center q-mb-lg"
|
||||
>
|
||||
<a :href="'lightning:' + transactionDetailsDialog.data.bolt11">
|
||||
<q-responsive :ratio="1" class="q-mx-xl">
|
||||
<qrcode-vue
|
||||
:value="'lightning:' + transactionDetailsDialog.data.bolt11.toUpperCase()"
|
||||
:options="{width: 340}"
|
||||
class="rounded-borders"
|
||||
></qrcode-vue>
|
||||
</q-responsive>
|
||||
</a>
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
@click="copyText(transactionDetailsDialog.data.bolt11)"
|
||||
:label="$t('copy_invoice')"
|
||||
class="q-mt-sm"
|
||||
></q-btn>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<div class="row q-col-gutter-md q-pb-lg"></div>
|
||||
|
||||
<div class="row q-col-gutter-lg">
|
||||
<div class="col-12 col-lg-6 q-gutter-y-md">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<div class="row items-center no-wrap q-mb-sm">
|
||||
<div class="col text-h6 q-my-none">Payments</div>
|
||||
<q-input
|
||||
v-if="payments.length > 10"
|
||||
filled
|
||||
dense
|
||||
clearable
|
||||
v-model="paymentsTable.filter"
|
||||
debounce="300"
|
||||
placeholder="Search by tag, memo, amount"
|
||||
class="q-mb-md"
|
||||
>
|
||||
</q-input>
|
||||
</div>
|
||||
<q-table
|
||||
dense
|
||||
flat
|
||||
:rows="paymentsTable.data"
|
||||
:columns="paymentsTable.columns"
|
||||
v-model:pagination="paymentsTable.pagination"
|
||||
row-key="payment_hash"
|
||||
no-data-label="No transactions made yet"
|
||||
:filter="paymentsTable.filter"
|
||||
@request="getPayments"
|
||||
>
|
||||
<template v-slot:body-cell-pending="props">
|
||||
<q-td auto-width class="text-center">
|
||||
<q-icon
|
||||
v-if="!props.row.pending"
|
||||
size="xs"
|
||||
name="call_made"
|
||||
color="green"
|
||||
@click="showTransactionDetailsDialog(props.row)"
|
||||
></q-icon>
|
||||
<q-icon
|
||||
v-else
|
||||
size="xs"
|
||||
name="settings_ethernet"
|
||||
color="grey"
|
||||
@click="showTransactionDetailsDialog(props.row)"
|
||||
>
|
||||
<q-tooltip>Pending</q-tooltip>
|
||||
</q-icon>
|
||||
<q-dialog
|
||||
v-model="props.row.expand"
|
||||
:props="props"
|
||||
position="top"
|
||||
>
|
||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<div class="text-center q-mb-lg">
|
||||
<div v-if="props.row.isIn && props.row.pending">
|
||||
<q-icon
|
||||
name="settings_ethernet"
|
||||
color="grey"
|
||||
></q-icon>
|
||||
<span v-text="$t('invoice_waiting')"></span>
|
||||
<lnbits-payment-details
|
||||
:payment="props.row"
|
||||
></lnbits-payment-details>
|
||||
<div
|
||||
v-if="props.row.bolt11"
|
||||
class="text-center q-mb-lg"
|
||||
>
|
||||
<a :href="'lightning:' + props.row.bolt11">
|
||||
<q-responsive :ratio="1" class="q-mx-xl">
|
||||
<qrcode-vue
|
||||
:value="'lightning:' + props.row.bolt11.toUpperCase()"
|
||||
:options="{width: 340}"
|
||||
class="rounded-borders"
|
||||
></qrcode-vue>
|
||||
</q-responsive>
|
||||
</a>
|
||||
</div>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
@click="copyText(props.row.bolt11)"
|
||||
:label="$t('copy_invoice')"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
v-close-popup
|
||||
flat
|
||||
color="grey"
|
||||
class="q-ml-auto"
|
||||
:label="$t('close')"
|
||||
></q-btn>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="props.row.isPaid && props.row.isIn">
|
||||
<q-icon
|
||||
size="18px"
|
||||
:name="'call_received'"
|
||||
:color="'green'"
|
||||
></q-icon>
|
||||
<span v-text="$t('payment_received')"></span>
|
||||
<lnbits-payment-details
|
||||
:payment="props.row"
|
||||
></lnbits-payment-details>
|
||||
</div>
|
||||
<div v-else-if="props.row.isPaid && props.row.isOut">
|
||||
<q-icon
|
||||
size="18px"
|
||||
:name="'call_made'"
|
||||
:color="'pink'"
|
||||
></q-icon>
|
||||
<span v-text="$t('payment_sent')"></span>
|
||||
<lnbits-payment-details
|
||||
:payment="props.row"
|
||||
></lnbits-payment-details>
|
||||
</div>
|
||||
<div v-else-if="props.row.isOut && props.row.pending">
|
||||
<q-icon
|
||||
name="settings_ethernet"
|
||||
color="grey"
|
||||
></q-icon>
|
||||
<span v-text="$t('outgoing_payment_pending')"></span>
|
||||
<lnbits-payment-details
|
||||
:payment="props.row"
|
||||
></lnbits-payment-details>
|
||||
</div>
|
||||
</div>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</q-td>
|
||||
</template>
|
||||
<template v-slot:body-cell-date="props">
|
||||
<q-td auto-width key="date" :props="props">
|
||||
<lnbits-date :ts="props.row.time"></lnbits-date>
|
||||
</q-td>
|
||||
</template>
|
||||
<template v-slot:body-cell-destination="props">
|
||||
<q-td auto-width key="destination">
|
||||
<div class="row items-center justify-between no-wrap">
|
||||
<q-badge
|
||||
:style="`background-color: #${props.row.destination?.color}`"
|
||||
class="text-bold"
|
||||
v-text="props.row.destination?.alias"
|
||||
></q-badge>
|
||||
<div>
|
||||
<q-btn
|
||||
size="xs"
|
||||
flat
|
||||
dense
|
||||
icon="content_paste"
|
||||
@click="copyText(info.id)"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
size="xs"
|
||||
flat
|
||||
dense
|
||||
icon="qr_code"
|
||||
@click="showNodeInfoDialog(props.row.destination)"
|
||||
></q-btn>
|
||||
</div>
|
||||
</div>
|
||||
</q-td>
|
||||
</template>
|
||||
</q-table>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
<div class="col-12 col-lg-6 q-gutter-y-md">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<div class="row items-center no-wrap q-mb-sm">
|
||||
<div class="col text-h6 q-my-none">Invoices</div>
|
||||
<q-input
|
||||
v-if="payments.length > 10"
|
||||
filled
|
||||
dense
|
||||
clearable
|
||||
v-model="paymentsTable.filter"
|
||||
debounce="300"
|
||||
placeholder="Search by tag, memo, amount"
|
||||
class="q-mb-md"
|
||||
>
|
||||
</q-input>
|
||||
</div>
|
||||
|
||||
<q-table
|
||||
dense
|
||||
flat
|
||||
:rows="invoiceTable.data"
|
||||
:columns="invoiceTable.columns"
|
||||
v-model:pagination="invoiceTable.pagination"
|
||||
no-data-label="No transactions made yet"
|
||||
:filter="invoiceTable.filter"
|
||||
@request="getInvoices"
|
||||
>
|
||||
<template v-slot:body-cell-pending="props">
|
||||
<q-td auto-width class="text-center">
|
||||
<q-icon
|
||||
v-if="!props.row.pending"
|
||||
size="xs"
|
||||
name="call_received"
|
||||
color="green"
|
||||
@click="showTransactionDetailsDialog(props.row)"
|
||||
></q-icon>
|
||||
<q-icon
|
||||
v-else
|
||||
size="xs"
|
||||
name="settings_ethernet"
|
||||
color="grey"
|
||||
@click="showTransactionDetailsDialog(props.row)"
|
||||
>
|
||||
<q-tooltip>Pending</q-tooltip>
|
||||
</q-icon>
|
||||
</q-td>
|
||||
</template>
|
||||
|
||||
<template v-slot:body-cell-paid_at="props">
|
||||
<q-td auto-width :props="props">
|
||||
<lnbits-date
|
||||
v-if="props.row.paid_at"
|
||||
:ts="props.row.paid_at"
|
||||
></lnbits-date>
|
||||
</q-td>
|
||||
</template>
|
||||
|
||||
<template v-slot:body-cell-expiry="props">
|
||||
<q-td auto-width :props="props">
|
||||
<lnbits-date
|
||||
v-if="props.row.expiry"
|
||||
:ts="props.row.expiry"
|
||||
></lnbits-date>
|
||||
</q-td>
|
||||
</template>
|
||||
</q-table>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-tab-panel>
|
||||
@@ -1,46 +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 %}
|
||||
|
||||
<q-dialog v-model="nodeInfoDialog.show" position="top">
|
||||
<lnbits-node-qrcode :info="nodeInfoDialog.data"></lnbits-node-qrcode>
|
||||
</q-dialog>
|
||||
|
||||
<div class="row q-col-gutter-md justify-center">
|
||||
<div class="col q-gutter-y-md">
|
||||
<q-card>
|
||||
<div class="q-pa-md">
|
||||
<div class="q-gutter-y-md">
|
||||
<q-tabs v-model="tab" active-color="primary" align="justify">
|
||||
<q-tab
|
||||
name="dashboard"
|
||||
:label="$t('dashboard')"
|
||||
@update="val => tab = val.name"
|
||||
></q-tab>
|
||||
<q-tab
|
||||
name="channels"
|
||||
:label="$t('channels')"
|
||||
@update="val => tab = val.name"
|
||||
></q-tab>
|
||||
<q-tab
|
||||
name="transactions"
|
||||
:label="$t('transactions')"
|
||||
@update="val => tab = val.name"
|
||||
></q-tab>
|
||||
</q-tabs>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<q-form name="settings_form" id="settings_form">
|
||||
<q-tab-panels v-model="tab" animated>
|
||||
{% include "node/_tab_dashboard.html" %} {% include
|
||||
"node/_tab_channels.html" %} {% include "node/_tab_transactions.html"
|
||||
%}
|
||||
</q-tab-panels>
|
||||
</q-form>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,133 +0,0 @@
|
||||
{% extends "public.html" %} {% from "macros.jinja" import window_vars with
|
||||
context %} {% block page %}
|
||||
|
||||
<div class="q-ma-lg-xl q-mx-auto q-ma-xl" style="max-width: 1048px">
|
||||
<lnbits-node-info :info="this.info"></lnbits-node-info>
|
||||
|
||||
<div class="row q-col-gutter-lg q-mt-sm">
|
||||
<div class="col-12 col-md-8 q-gutter-y-md">
|
||||
<div class="row q-col-gutter-md q-pb-lg">
|
||||
<div class="col-12 col-md-6 q-gutter-y-md">
|
||||
<lnbits-stat
|
||||
:title="$t('total_capacity')"
|
||||
:msat="this.channel_stats.total_capacity"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-6 q-gutter-y-md">
|
||||
<lnbits-stat title="Peers" :amount="this.info.num_peers" />
|
||||
</div>
|
||||
<div class="col-12 col-md-6 q-gutter-y-md">
|
||||
<lnbits-stat
|
||||
:title="$t('avg_channel_size')"
|
||||
:msat="this.channel_stats.avg_size"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-12 col-md-6 q-gutter-y-md">
|
||||
<lnbits-stat
|
||||
:title="$t('biggest_channel_size')"
|
||||
:msat="this.channel_stats.biggest_size"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-12 col-md-6 q-gutter-y-md">
|
||||
<lnbits-stat
|
||||
:title="$t('smallest_channel_size')"
|
||||
:msat="this.channel_stats.smallest_size"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-12 col-md-6 q-gutter-y-md">
|
||||
<lnbits-stat
|
||||
:title="$t('smallest_channel_size')"
|
||||
:msat="this.channel_stats.smallest_size"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column col-12 col-md-4 q-gutter-y-md">
|
||||
<lnbits-node-ranks :ranks="this.ranks"></lnbits-node-ranks>
|
||||
<lnbits-channel-stats :stats="this.channel_stats"></lnbits-channel-stats>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %} {% block scripts %} {{ window_vars(user) }}
|
||||
<script src="{{ static_url_for('static', 'js/node.js') }}"></script>
|
||||
<script>
|
||||
window.app = Vue.createApp({
|
||||
el: '#vue',
|
||||
config: {
|
||||
globalProperties: {
|
||||
LNbits,
|
||||
msg: 'hello'
|
||||
}
|
||||
},
|
||||
mixins: [window.windowMixin],
|
||||
data: function () {
|
||||
return {
|
||||
isSuperUser: false,
|
||||
wallet: {},
|
||||
tab: 'dashboard',
|
||||
payments: 1000,
|
||||
info: {},
|
||||
channel_stats: {},
|
||||
channels: [],
|
||||
activeBalance: {},
|
||||
ranks: {},
|
||||
|
||||
peers: [],
|
||||
|
||||
connectPeerDialog: {
|
||||
show: false,
|
||||
data: {}
|
||||
},
|
||||
|
||||
openChannelDialog: {
|
||||
show: false,
|
||||
data: {}
|
||||
},
|
||||
|
||||
closeChannelDialog: {
|
||||
show: false,
|
||||
data: {}
|
||||
},
|
||||
|
||||
nodeInfoDialog: {
|
||||
show: false,
|
||||
data: {}
|
||||
},
|
||||
|
||||
states: [
|
||||
{label: 'Active', value: 'active', color: 'green'},
|
||||
{label: 'Pending', value: 'pending', color: 'orange'},
|
||||
{label: 'Inactive', value: 'inactive', color: 'grey'},
|
||||
{label: 'Closed', value: 'closed', color: 'red'}
|
||||
]
|
||||
}
|
||||
},
|
||||
created: function () {
|
||||
this.getInfo()
|
||||
this.get1MLStats()
|
||||
},
|
||||
methods: {
|
||||
formatMsat: function (msat) {
|
||||
return LNbits.utils.formatMsat(msat)
|
||||
},
|
||||
api: function (method, url, data) {
|
||||
return LNbits.api.request(method, '/node/public/api/v1' + url, {}, data)
|
||||
},
|
||||
getInfo: function () {
|
||||
this.api('GET', '/info', {}).then(response => {
|
||||
this.info = response.data
|
||||
this.channel_stats = response.data.channel_stats
|
||||
})
|
||||
},
|
||||
get1MLStats: function () {
|
||||
this.api('GET', '/rank', {}).then(response => {
|
||||
this.ranks = response.data
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
@@ -1,390 +0,0 @@
|
||||
{% if not ajax %} {% extends "base.html" %} {% endif %}
|
||||
<!---->
|
||||
{% from "macros.jinja" import window_vars with context %}
|
||||
<!---->
|
||||
{% block scripts %} {{ window_vars(user) }} {% endblock %} {% block page %}
|
||||
<div class="row q-col-gutter-md q-mb-md">
|
||||
<div class="col-12">
|
||||
<q-card>
|
||||
<div class="q-pa-sm q-pl-lg">
|
||||
<div class="row items-center justify-between q-gutter-xs">
|
||||
<div class="col"></div>
|
||||
<div class="float-left">
|
||||
<q-checkbox
|
||||
dense
|
||||
@click="saveChartsPreferences"
|
||||
v-model="chartData.showPaymentStatus"
|
||||
:label="$t('payments_status_chart')"
|
||||
>
|
||||
</q-checkbox>
|
||||
</div>
|
||||
<q-separator vertical class="q-ma-sm"></q-separator>
|
||||
<div class="float-left">
|
||||
<q-checkbox
|
||||
dense
|
||||
@click="saveChartsPreferences"
|
||||
v-model="chartData.showPaymentTags"
|
||||
:label="$t('payments_tag_chart')"
|
||||
>
|
||||
</q-checkbox>
|
||||
</div>
|
||||
<q-separator vertical class="q-ma-sm"></q-separator>
|
||||
<div class="float-left">
|
||||
<q-checkbox
|
||||
dense
|
||||
@click="saveChartsPreferences"
|
||||
v-model="chartData.showBalance"
|
||||
:label="$t('payments_balance_chart')"
|
||||
>
|
||||
</q-checkbox>
|
||||
</div>
|
||||
<q-separator vertical class="q-ma-sm"></q-separator>
|
||||
<div class="float-left">
|
||||
<q-checkbox
|
||||
dense
|
||||
@click="saveChartsPreferences"
|
||||
v-model="chartData.showWalletsSize"
|
||||
:label="$t('payments_wallets_chart')"
|
||||
>
|
||||
</q-checkbox>
|
||||
</div>
|
||||
|
||||
<q-separator vertical class="q-ma-sm"></q-separator>
|
||||
<div class="float-left">
|
||||
<q-checkbox
|
||||
dense
|
||||
@click="saveChartsPreferences"
|
||||
v-model="chartData.showBalanceInOut"
|
||||
:label="$t('payments_balance_in_out_chart')"
|
||||
>
|
||||
</q-checkbox>
|
||||
</div>
|
||||
<q-separator vertical class="q-ma-sm"></q-separator>
|
||||
<div class="float-left">
|
||||
<q-checkbox
|
||||
dense
|
||||
@click="saveChartsPreferences"
|
||||
v-model="chartData.showPaymentCountInOut"
|
||||
:label="$t('payments_count_in_out_chart')"
|
||||
>
|
||||
</q-checkbox>
|
||||
</div>
|
||||
<q-separator vertical class="q-ma-sm"></q-separator>
|
||||
<q-btn icon="event" outline flat>
|
||||
<q-popup-proxy
|
||||
cover
|
||||
transition-show="scale"
|
||||
transition-hide="scale"
|
||||
>
|
||||
<q-date v-model="searchDate" mask="YYYY-MM-DD" range />
|
||||
<div class="row">
|
||||
<div class="col-6">
|
||||
<q-btn
|
||||
label="Search"
|
||||
@click="searchByDate()"
|
||||
color="primary"
|
||||
flat
|
||||
class="float-left"
|
||||
v-close-popup
|
||||
/>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<q-btn
|
||||
v-close-popup
|
||||
@click="clearDateSeach()"
|
||||
label="Clear"
|
||||
class="float-right"
|
||||
color="grey"
|
||||
flat
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</q-popup-proxy>
|
||||
<q-badge
|
||||
v-if="searchDate?.to || searchDate?.from"
|
||||
class="q-mt-lg q-mr-md"
|
||||
color="primary"
|
||||
rounded
|
||||
floating
|
||||
style="border-radius: 6px"
|
||||
/>
|
||||
</q-btn>
|
||||
<q-separator vertical class="q-ma-sm"></q-separator>
|
||||
<div>
|
||||
<q-btn
|
||||
v-if="g.user.admin"
|
||||
flat
|
||||
round
|
||||
icon="settings"
|
||||
to="/admin#server"
|
||||
>
|
||||
<q-tooltip v-text="$t('admin_settings')"></q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
<div v-show="!showDetails">
|
||||
<div class="row q-col-gutter-md justify-center q-mb-md">
|
||||
<div
|
||||
v-show="chartData.showPaymentStatus"
|
||||
class="col-lg-3 col-md-6 col-sm-12 text-center"
|
||||
>
|
||||
<q-card class="q-pt-sm">
|
||||
<strong v-text="$t('payment_chart_status')"></strong>
|
||||
<div style="height: 300px" class="q-pa-sm">
|
||||
<canvas v-if="chartsReady" ref="paymentsStatusChart"></canvas>
|
||||
</div>
|
||||
</q-card>
|
||||
</div>
|
||||
<div
|
||||
v-show="chartData.showPaymentStatus"
|
||||
class="col-lg-3 col-md-6 col-sm-12 text-center"
|
||||
>
|
||||
<q-card class="q-pt-sm">
|
||||
<strong v-text="$t('payment_chart_tags')"></strong>
|
||||
<div style="height: 300px" class="q-pa-sm">
|
||||
<canvas v-if="chartsReady" ref="paymentsTagsChart"></canvas>
|
||||
</div>
|
||||
</q-card>
|
||||
</div>
|
||||
<div
|
||||
v-show="chartData.showBalance"
|
||||
class="col-lg-6 col-md-12 col-sm-12 text-center"
|
||||
>
|
||||
<q-card class="q-pt-sm">
|
||||
<strong
|
||||
v-text="$t('lnbits_balance', {balance: (lnbitsBalance || 0).toLocaleString()})"
|
||||
></strong>
|
||||
<div style="height: 300px" class="q-pa-sm">
|
||||
<canvas v-if="chartsReady" ref="paymentsDailyChart"></canvas>
|
||||
</div>
|
||||
</q-card>
|
||||
</div>
|
||||
<div
|
||||
v-show="chartData.showWalletsSize"
|
||||
class="col-lg-6 col-md-12 col-sm-12 text-center"
|
||||
>
|
||||
<q-card class="q-pt-sm">
|
||||
<strong v-text="$t('payment_chart_tx_per_wallet')"></strong>
|
||||
<div style="height: 300px" class="q-pa-sm">
|
||||
<canvas v-if="chartsReady" ref="paymentsWalletsChart"></canvas>
|
||||
</div>
|
||||
</q-card>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-show="chartData.showBalanceInOut"
|
||||
class="col-lg-6 col-md-12 col-sm-12 text-center"
|
||||
>
|
||||
<q-card class="q-pt-sm">
|
||||
<strong v-text="$t('payments_balance_in_out')"></strong>
|
||||
<div style="height: 300px" class="q-pa-sm">
|
||||
<canvas v-if="chartsReady" ref="paymentsBalanceInOutChart"></canvas>
|
||||
</div>
|
||||
</q-card>
|
||||
</div>
|
||||
<div
|
||||
v-show="chartData.showPaymentCountInOut"
|
||||
class="col-lg-6 col-md-12 col-sm-12 text-center"
|
||||
>
|
||||
<q-card class="q-pt-sm">
|
||||
<strong v-text="$t('payments_count_in_out')"></strong>
|
||||
<div style="height: 300px" class="q-pa-sm">
|
||||
<canvas v-if="chartsReady" ref="paymentsCountInOutChart"></canvas>
|
||||
</div>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row q-col-gutter-md justify-center">
|
||||
<div class="col">
|
||||
<q-card class="q-pa-md">
|
||||
<q-table
|
||||
row-key="payment_hash"
|
||||
:rows="payments"
|
||||
:columns="paymentsTable.columns"
|
||||
v-model:pagination="paymentsTable.pagination"
|
||||
:filter="paymentsTable.search"
|
||||
@request="fetchPayments"
|
||||
>
|
||||
<template v-slot:header="props">
|
||||
<q-tr :props="props">
|
||||
<q-th v-for="col in props.cols" :key="col.name" :props="props">
|
||||
<q-input
|
||||
v-if="['wallet_id', 'payment_hash', 'memo'].includes(col.name)"
|
||||
v-model="searchData[col.name]"
|
||||
@keydown.enter="searchPaymentsBy()"
|
||||
@update:model-value="searchPaymentsBy()"
|
||||
dense
|
||||
type="text"
|
||||
filled
|
||||
clearable
|
||||
:label="col.label"
|
||||
>
|
||||
<template v-slot:append>
|
||||
<q-icon
|
||||
name="search"
|
||||
@click="searchPaymentsBy()"
|
||||
class="cursor-pointer"
|
||||
/>
|
||||
</template>
|
||||
</q-input>
|
||||
<q-select
|
||||
v-else-if="['status'].includes(col.name)"
|
||||
v-model="searchData[col.name]"
|
||||
:options="searchOptions[col.name]"
|
||||
@update:model-value="searchPaymentsBy()"
|
||||
:label="col.label"
|
||||
clearable
|
||||
style="width: 100px"
|
||||
></q-select>
|
||||
<q-select
|
||||
v-else-if="['tag'].includes(col.name)"
|
||||
v-model="searchData[col.name]"
|
||||
:options="searchOptions[col.name]"
|
||||
@update:model-value="searchPaymentsBy()"
|
||||
:label="col.label"
|
||||
clearable
|
||||
style="width: 100px"
|
||||
></q-select>
|
||||
<span v-else v-text="col.label"></span>
|
||||
</q-th>
|
||||
</q-tr>
|
||||
</template>
|
||||
<template v-slot:body="props">
|
||||
<q-tr auto-width :props="props">
|
||||
<q-td v-for="col in props.cols" :key="col.name" :props="props">
|
||||
<div v-if="col.name == 'status'">
|
||||
<q-tooltip
|
||||
><span v-text="$t('payment_details')"></span
|
||||
></q-tooltip>
|
||||
<q-icon
|
||||
@click="showDetailsToggle(props.row)"
|
||||
v-if="props.row.status === 'success'"
|
||||
size="14px"
|
||||
:name="props.row.outgoing ? 'call_made' : 'call_received'"
|
||||
:color="props.row.outgoing ? 'pink' : 'green'"
|
||||
class="cursor-pointer"
|
||||
></q-icon>
|
||||
<q-icon
|
||||
v-else-if="props.row.status === 'pending'"
|
||||
@click="showDetailsToggle(props.row)"
|
||||
name="downloading"
|
||||
:style="props.row.outgoing ? 'transform: rotate(225deg)' : 'transform: scaleX(-1) rotate(315deg)'"
|
||||
color="grey"
|
||||
class="cursor-pointer"
|
||||
></q-icon>
|
||||
<q-icon
|
||||
v-else
|
||||
@click="showDetailsToggle(props.row)"
|
||||
name="warning"
|
||||
color="yellow"
|
||||
class="cursor-pointer"
|
||||
></q-icon>
|
||||
</div>
|
||||
<div v-else-if="col.name == 'created_at'">
|
||||
<div>
|
||||
<q-tooltip anchor="top middle">
|
||||
<span v-text="formatDate(props.row.created_at)"></span>
|
||||
</q-tooltip>
|
||||
<span v-text="props.row.timeFrom"> </span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="['wallet_id', 'payment_hash', 'memo'].includes(col.name)"
|
||||
>
|
||||
<q-btn
|
||||
v-if="props.row[col.name]"
|
||||
icon="content_copy"
|
||||
size="sm"
|
||||
flat
|
||||
class="cursor-pointer q-mr-xs"
|
||||
@click="copyText(props.row[col.name])"
|
||||
>
|
||||
<q-tooltip anchor="top middle">Copy</q-tooltip>
|
||||
</q-btn>
|
||||
<span v-text="shortify(props.row[col.name], col.max_length)">
|
||||
</span>
|
||||
<q-tooltip>
|
||||
<span v-text="props.row[col.name]"></span>
|
||||
</q-tooltip>
|
||||
</div>
|
||||
<span
|
||||
v-else
|
||||
v-text="props.row[col.name]"
|
||||
class="cursor-pointer"
|
||||
></span>
|
||||
</q-td>
|
||||
</q-tr>
|
||||
</template>
|
||||
</q-table>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-show="showDetails">
|
||||
<q-card>
|
||||
<q-card-section class="flex">
|
||||
<div>
|
||||
<q-btn
|
||||
flat
|
||||
round
|
||||
icon="arrow_back"
|
||||
class="q-mr-md"
|
||||
@click="showDetailsToggle(null)"
|
||||
></q-btn>
|
||||
</div>
|
||||
<div class="self-center text-h6 text-weight-bolder text-grey-5">
|
||||
<span v-text="$t('payment_details_back')"></span>
|
||||
</div>
|
||||
</q-card-section>
|
||||
<q-separator></q-separator>
|
||||
<q-card-section class="text-h6">
|
||||
<q-item>
|
||||
<q-item-section avatar class="">
|
||||
<q-icon color="primary" name="receipt" size="44px"></q-icon>
|
||||
</q-item-section>
|
||||
|
||||
<q-item-section>
|
||||
<q-item-label>
|
||||
<div class="text-h6">
|
||||
<span v-text="$t('payment_details')"></span>
|
||||
</div>
|
||||
</q-item-label>
|
||||
<q-item-label caption v-text="$t('payment_details_desc')">
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-card-section>
|
||||
<q-card-section>
|
||||
<q-list separator>
|
||||
<q-item v-for="(value, key) in paymentDetails" :key="key">
|
||||
<q-item-section>
|
||||
<q-item-label v-text="key"></q-item-label>
|
||||
<q-item-label
|
||||
caption
|
||||
v-text="value"
|
||||
style="word-wrap: break-word"
|
||||
></q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section side>
|
||||
<q-btn
|
||||
v-show="value"
|
||||
icon="content_copy"
|
||||
flat
|
||||
class="cursor-pointer q-ml-sm"
|
||||
@click="copyText(value)"
|
||||
>
|
||||
<q-tooltip>Copy</q-tooltip>
|
||||
</q-btn>
|
||||
</q-item-section>
|
||||
<!-- <q-separator></q-separator> -->
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,44 +0,0 @@
|
||||
<q-dialog v-model="createWalletDialog.show" position="top">
|
||||
<q-card class="q-pa-md q-pt-md lnbits__dialog-card">
|
||||
<strong>Create Wallet</strong>
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="row q-mt-lg">
|
||||
<div class="col">
|
||||
<q-input
|
||||
v-model="createWalletDialog.data.name"
|
||||
:label='$t("name_your_wallet")'
|
||||
filled
|
||||
dense
|
||||
class="q-mb-md"
|
||||
>
|
||||
</q-input>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-mt-lg">
|
||||
<div class="col">
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
v-model="createWalletDialog.data.currency"
|
||||
:options="{{ currencies | safe }}"
|
||||
></q-select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
v-close-popup
|
||||
@click="createWallet()"
|
||||
unelevated
|
||||
color="primary"
|
||||
type="submit"
|
||||
>Create</q-btn
|
||||
>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
|
||||
>Cancel</q-btn
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
@@ -1,187 +0,0 @@
|
||||
<div class="row q-mb-lg">
|
||||
<div class="col">
|
||||
<q-btn
|
||||
icon="arrow_back_ios"
|
||||
@click="backToUsersPage()"
|
||||
:label="$t('back')"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
v-if="activeUser.data.id"
|
||||
@click="updateUser()"
|
||||
color="primary"
|
||||
:label="$t('update_account')"
|
||||
class="q-ml-md"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
v-else
|
||||
@click="createUser()"
|
||||
:label="$t('create_account')"
|
||||
color="primary"
|
||||
class="float-right"
|
||||
></q-btn>
|
||||
</div>
|
||||
</div>
|
||||
<q-card v-if="activeUser.show" class="q-pa-md">
|
||||
<q-card-section>
|
||||
<div class="text-h6">
|
||||
<span v-if="activeUser.data.id" v-text="$t('update_account')"></span>
|
||||
<span v-else v-text="$t('create_account')"></span>
|
||||
</div>
|
||||
</q-card-section>
|
||||
<q-card-section>
|
||||
<q-input
|
||||
v-if="activeUser.data.id"
|
||||
v-model="activeUser.data.id"
|
||||
:label="$t('user_id')"
|
||||
filled
|
||||
dense
|
||||
readonly
|
||||
:type="activeUser.data.showUserId ? 'text': 'password'"
|
||||
class="q-mb-md"
|
||||
><q-btn
|
||||
@click="activeUser.data.showUserId = !activeUser.data.showUserId"
|
||||
dense
|
||||
flat
|
||||
:icon="activeUser.data.showUserId ? 'visibility_off' : 'visibility'"
|
||||
color="grey"
|
||||
></q-btn>
|
||||
</q-input>
|
||||
<q-input
|
||||
v-model="activeUser.data.username"
|
||||
:label="$t('username')"
|
||||
filled
|
||||
dense
|
||||
class="q-mb-md"
|
||||
>
|
||||
</q-input>
|
||||
<q-toggle
|
||||
size="xs"
|
||||
v-if="!activeUser.data.id"
|
||||
color="secondary"
|
||||
:label="$t('set_password')"
|
||||
v-model="activeUser.setPassword"
|
||||
>
|
||||
<q-tooltip>Toggle Admin</q-tooltip>
|
||||
</q-toggle>
|
||||
|
||||
<q-input
|
||||
v-if="activeUser.setPassword"
|
||||
v-model="activeUser.data.password"
|
||||
:type="activeUser.data.showPassword ? 'text': 'password'"
|
||||
autocomplete="off"
|
||||
:label="$t('password')"
|
||||
filled
|
||||
dense
|
||||
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password')]"
|
||||
>
|
||||
<q-btn
|
||||
@click="activeUser.data.showPassword = !activeUser.data.showPassword"
|
||||
dense
|
||||
flat
|
||||
:icon="activeUser.data.showPassword ? 'visibility_off' : 'visibility'"
|
||||
color="grey"
|
||||
></q-btn>
|
||||
</q-input>
|
||||
<q-input
|
||||
v-if="activeUser.setPassword"
|
||||
v-model="activeUser.data.password_repeat"
|
||||
:type="activeUser.data.showPassword ? 'text': 'password'"
|
||||
type="password"
|
||||
autocomplete="off"
|
||||
:label="$t('password_repeat')"
|
||||
filled
|
||||
dense
|
||||
class="q-mb-md"
|
||||
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password')]"
|
||||
>
|
||||
<q-btn
|
||||
@click="activeUser.data.showPassword = !activeUser.data.showPassword"
|
||||
dense
|
||||
flat
|
||||
:icon="activeUser.data.showPassword ? 'visibility_off' : 'visibility'"
|
||||
color="grey"
|
||||
></q-btn>
|
||||
</q-input>
|
||||
|
||||
<q-input
|
||||
v-model="activeUser.data.pubkey"
|
||||
:label="$t('pubkey')"
|
||||
filled
|
||||
dense
|
||||
class="q-mb-md"
|
||||
>
|
||||
</q-input>
|
||||
<q-input
|
||||
v-model="activeUser.data.email"
|
||||
:label="$t('email')"
|
||||
filled
|
||||
dense
|
||||
class="q-mb-md"
|
||||
>
|
||||
</q-input>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-section v-if="activeUser.data.extra">
|
||||
<q-input
|
||||
v-model="activeUser.data.extra.first_name"
|
||||
:label="$t('first_name')"
|
||||
filled
|
||||
dense
|
||||
class="q-mb-md"
|
||||
>
|
||||
</q-input>
|
||||
<q-input
|
||||
v-model="activeUser.data.extra.last_name"
|
||||
:label="$t('last_name')"
|
||||
filled
|
||||
dense
|
||||
class="q-mb-md"
|
||||
>
|
||||
</q-input>
|
||||
<q-input
|
||||
v-model="activeUser.data.extra.provider"
|
||||
:label="$t('auth_provider')"
|
||||
filled
|
||||
dense
|
||||
class="q-mb-md"
|
||||
>
|
||||
</q-input>
|
||||
<q-input
|
||||
v-model="activeUser.data.extra.picture"
|
||||
:label="$t('picture')"
|
||||
filled
|
||||
dense
|
||||
class="q-mb-md"
|
||||
>
|
||||
</q-input>
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
v-model="activeUser.data.extensions"
|
||||
multiple
|
||||
label="User extensions"
|
||||
:options="g.extensions"
|
||||
></q-select>
|
||||
</q-card-section>
|
||||
<q-card-section v-if="activeUser.data.id">
|
||||
<q-btn
|
||||
@click="resetPassword(activeUser.data.id)"
|
||||
:disable="activeUser.data.is_super_user"
|
||||
:label="$t('reset_password')"
|
||||
icon="refresh"
|
||||
color="primary"
|
||||
>
|
||||
<q-tooltip>Generate and copy password reset url</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
@click="deleteUser(activeUser.data.id)"
|
||||
:disable="activeUser.data.is_super_user"
|
||||
:label="$t('delete')"
|
||||
icon="delete"
|
||||
color="negative"
|
||||
class="float-right"
|
||||
>
|
||||
<q-tooltip>Delete User</q-tooltip></q-btn
|
||||
>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
@@ -1,167 +0,0 @@
|
||||
<div v-if="paymentPage.show">
|
||||
<div class="row q-mb-lg">
|
||||
<div class="col">
|
||||
<q-btn
|
||||
icon="arrow_back_ios"
|
||||
@click="paymentPage.show = false"
|
||||
:label="$t('back')"
|
||||
></q-btn>
|
||||
</div>
|
||||
</div>
|
||||
<q-card class="q-pa-md">
|
||||
<q-card-section>
|
||||
<payment-list :wallet="paymentsWallet" />
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
<div v-else-if="activeWallet.show">
|
||||
<div class="row q-col-gutter-md q-mb-md">
|
||||
<div class="col-12">
|
||||
<q-card>
|
||||
<div class="q-pa-sm">
|
||||
<div class="row">
|
||||
<div class="q-pa-xs">
|
||||
<q-btn
|
||||
icon="arrow_back_ios"
|
||||
@click="backToUsersPage()"
|
||||
:label="$t('back')"
|
||||
></q-btn>
|
||||
</div>
|
||||
<div class="q-pa-xs">
|
||||
<q-btn
|
||||
@click="createWalletDialog.show = true"
|
||||
:label="$t('create_new_wallet')"
|
||||
color="primary"
|
||||
></q-btn>
|
||||
</div>
|
||||
<div class="q-pa-xs">
|
||||
<q-btn
|
||||
@click="deleteAllUserWallets(activeWallet.userId)"
|
||||
:label="$t('delete_all_wallets')"
|
||||
icon="delete"
|
||||
color="negative"
|
||||
></q-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
<q-card class="q-pa-md">
|
||||
<h2 class="text-h6 q-mb-md">Wallets</h2>
|
||||
<q-table :rows="wallets" :columns="walletTable.columns">
|
||||
<template v-slot:header="props">
|
||||
<q-tr :props="props">
|
||||
<q-th auto-width v-if="g.user.super_user"></q-th>
|
||||
<q-th auto-width></q-th>
|
||||
<q-th
|
||||
auto-width
|
||||
v-for="col in props.cols"
|
||||
v-text="col.label"
|
||||
:key="col.name"
|
||||
:props="props"
|
||||
></q-th>
|
||||
</q-tr>
|
||||
</template>
|
||||
<template v-slot:body="props">
|
||||
<q-tr :props="props">
|
||||
<q-td auto-width v-if="g.user.super_user">
|
||||
<lnbits-update-balance
|
||||
:wallet_id="props.row.id"
|
||||
@credit-value="handleBalanceUpdate"
|
||||
class="q-mr-md"
|
||||
></lnbits-update-balance>
|
||||
</q-td>
|
||||
<q-td auto-width>
|
||||
<q-btn
|
||||
round
|
||||
icon="menu"
|
||||
size="sm"
|
||||
color="secondary"
|
||||
@click="showPayments(props.row.id)"
|
||||
>
|
||||
<q-tooltip>Show Payments</q-tooltip>
|
||||
</q-btn>
|
||||
|
||||
<q-btn
|
||||
round
|
||||
v-if="!props.row.deleted"
|
||||
icon="vpn_key"
|
||||
size="sm"
|
||||
color="primary"
|
||||
class="q-ml-xs"
|
||||
@click="copyText(props.row.adminkey)"
|
||||
>
|
||||
<q-tooltip>Copy Admin Key</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
round
|
||||
v-if="!props.row.deleted"
|
||||
icon="vpn_key"
|
||||
size="sm"
|
||||
color="secondary"
|
||||
class="q-ml-xs"
|
||||
@click="copyText(props.row.inkey)"
|
||||
>
|
||||
<q-tooltip>Copy Invoice Key</q-tooltip>
|
||||
</q-btn>
|
||||
|
||||
<q-btn
|
||||
round
|
||||
icon="delete"
|
||||
size="sm"
|
||||
color="negative"
|
||||
class="q-ml-xs"
|
||||
@click="deleteUserWallet(props.row.user, props.row.id, props.row.deleted)"
|
||||
>
|
||||
<q-tooltip>Delete Wallet</q-tooltip>
|
||||
</q-btn>
|
||||
</q-td>
|
||||
<q-td auto-width>
|
||||
<q-btn
|
||||
icon="link"
|
||||
size="sm"
|
||||
flat
|
||||
class="cursor-pointer q-mr-xs"
|
||||
@click="copyWalletLink(props.row.id)"
|
||||
>
|
||||
<q-tooltip>Copy Wallet Link</q-tooltip>
|
||||
</q-btn>
|
||||
|
||||
<span v-text="props.row.name"></span>
|
||||
<q-btn
|
||||
round
|
||||
v-if="props.row.deleted"
|
||||
icon="toggle_off"
|
||||
size="sm"
|
||||
color="secondary"
|
||||
class="q-ml-xs"
|
||||
@click="undeleteUserWallet(props.row.user, props.row.id)"
|
||||
>
|
||||
<q-tooltip>Undelete Wallet</q-tooltip>
|
||||
</q-btn>
|
||||
</q-td>
|
||||
<q-td auto-width>
|
||||
<q-btn
|
||||
icon="content_copy"
|
||||
size="sm"
|
||||
flat
|
||||
class="cursor-pointer q-mr-xs"
|
||||
@click="copyText(props.row.id)"
|
||||
>
|
||||
<q-tooltip>Copy Wallet ID</q-tooltip>
|
||||
</q-btn>
|
||||
|
||||
<span
|
||||
v-text="props.row.id"
|
||||
:class="props.row.deleted ? 'text-strike' : ''"
|
||||
></span>
|
||||
</q-td>
|
||||
|
||||
<q-td auto-width v-text="props.row.currency"></q-td>
|
||||
<q-td auto-width v-text="formatSat(props.row.balance_msat)"></q-td>
|
||||
</q-tr>
|
||||
</template>
|
||||
</q-table>
|
||||
</q-card>
|
||||
</div>
|
||||
@@ -1,186 +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 justify-center">
|
||||
<div class="col">
|
||||
{% include "users/_manageWallet.html" %}
|
||||
<div v-if="activeUser.show" class="row">
|
||||
<div class="col-12 col-md-6">{%include "users/_manageUser.html" %}</div>
|
||||
</div>
|
||||
<div v-else-if="activeWallet.show">
|
||||
{%include "users/_createWalletDialog.html" %}
|
||||
</div>
|
||||
<div v-else>
|
||||
<div class="row q-col-gutter-md q-mb-md">
|
||||
<div class="col-12">
|
||||
<q-card>
|
||||
<div class="q-pa-sm">
|
||||
<div class="row items-center justify-between q-gutter-xs">
|
||||
<div class="col">
|
||||
<q-btn
|
||||
@click="showAccountPage()"
|
||||
:label="$t('create_account')"
|
||||
color="primary"
|
||||
>
|
||||
</q-btn>
|
||||
</div>
|
||||
<div>
|
||||
<q-btn
|
||||
v-if="g.user.admin"
|
||||
flat
|
||||
round
|
||||
icon="settings"
|
||||
to="/admin#users"
|
||||
>
|
||||
<q-tooltip v-text="$t('admin_settings')"></q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<q-card class="q-pa-md">
|
||||
<q-table
|
||||
row-key="id"
|
||||
:rows="users"
|
||||
:columns="usersTable.columns"
|
||||
v-model:pagination="usersTable.pagination"
|
||||
:no-data-label="$t('no_users')"
|
||||
:filter="usersTable.search"
|
||||
:loading="usersTable.loading"
|
||||
@request="fetchUsers"
|
||||
>
|
||||
<template v-slot:header="props">
|
||||
<q-tr :props="props">
|
||||
<q-th auto-width></q-th>
|
||||
<q-th v-for="col in props.cols" :key="col.name" :props="props">
|
||||
<q-input
|
||||
v-if="['user', 'username', 'email', 'pubkey', 'wallet_id'].includes(col.name)"
|
||||
v-model="searchData[col.name]"
|
||||
@keydown.enter="searchUserBy(col.name)"
|
||||
dense
|
||||
type="text"
|
||||
filled
|
||||
:label="col.label"
|
||||
>
|
||||
<template v-slot:append>
|
||||
<q-icon
|
||||
name="search"
|
||||
@click="searchUserBy(col.name)"
|
||||
class="cursor-pointer"
|
||||
/>
|
||||
</template>
|
||||
</q-input>
|
||||
|
||||
<span v-else v-text="col.label"></span>
|
||||
</q-th>
|
||||
</q-tr>
|
||||
</template>
|
||||
<template v-slot:body="props">
|
||||
<q-tr auto-width :props="props">
|
||||
<q-td>
|
||||
<q-btn
|
||||
@click="showAccountPage(props.row.id)"
|
||||
round
|
||||
icon="edit"
|
||||
size="sm"
|
||||
color="secondary"
|
||||
class="q-ml-xs"
|
||||
>
|
||||
<q-tooltip>
|
||||
<span v-text="$t('update_account')"></span>
|
||||
</q-tooltip>
|
||||
</q-btn>
|
||||
</q-td>
|
||||
<q-td>
|
||||
<q-toggle
|
||||
size="xs"
|
||||
v-if="!props.row.is_super_user"
|
||||
color="secondary"
|
||||
v-model="props.row.is_admin"
|
||||
@update:model-value="toggleAdmin(props.row.id)"
|
||||
>
|
||||
<q-tooltip>Toggle Admin</q-tooltip>
|
||||
</q-toggle>
|
||||
<q-btn
|
||||
round
|
||||
v-if="props.row.is_super_user"
|
||||
icon="verified"
|
||||
size="sm"
|
||||
color="secondary"
|
||||
class="q-ml-xs"
|
||||
>
|
||||
<q-tooltip>Super User</q-tooltip>
|
||||
</q-btn>
|
||||
</q-td>
|
||||
|
||||
<q-td>
|
||||
<q-btn
|
||||
icon="list"
|
||||
size="sm"
|
||||
color="secondary"
|
||||
:label="props.row.wallet_count"
|
||||
@click="fetchWallets(props.row.id)"
|
||||
>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
v-if="(users.length == 1) && searchData.wallet_id"
|
||||
round
|
||||
icon="menu"
|
||||
size="sm"
|
||||
color="secondary"
|
||||
class="q-ml-sm"
|
||||
@click="showWalletPayments(searchData.wallet_id)"
|
||||
>
|
||||
<q-tooltip>Show Payments</q-tooltip>
|
||||
</q-btn>
|
||||
</q-td>
|
||||
|
||||
<q-td>
|
||||
<q-btn
|
||||
icon="content_copy"
|
||||
size="sm"
|
||||
flat
|
||||
class="cursor-pointer q-mr-xs"
|
||||
@click="copyText(props.row.id)"
|
||||
>
|
||||
<q-tooltip>Copy User ID</q-tooltip>
|
||||
</q-btn>
|
||||
<span v-text="shortify(props.row.id)"></span>
|
||||
</q-td>
|
||||
<q-td v-text="props.row.username"></q-td>
|
||||
|
||||
<q-td v-text="props.row.email"></q-td>
|
||||
|
||||
<q-td>
|
||||
<q-btn
|
||||
v-if="props.row.pubkey"
|
||||
icon="content_copy"
|
||||
size="sm"
|
||||
flat
|
||||
class="cursor-pointer q-mr-xs"
|
||||
@click="copyText(props.row.pubkey)"
|
||||
>
|
||||
<q-tooltip>Copy Public Key</q-tooltip>
|
||||
</q-btn>
|
||||
<span v-text="shortify(props.row.pubkey)"></span>
|
||||
</q-td>
|
||||
<q-td v-text="formatSat(props.row.balance_msat)"></q-td>
|
||||
|
||||
<q-td v-text="props.row.transaction_count"></q-td>
|
||||
|
||||
<q-td v-text="formatDate(props.row.last_payment)"></q-td>
|
||||
</q-tr>
|
||||
</template>
|
||||
</q-table>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user