Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b66396857b | ||
|
|
9c85e1156c | ||
|
|
3b0024bcf2 | ||
|
|
4c4f0922a4 | ||
|
|
8e63756c61 | ||
|
|
0eb74c86ba | ||
|
|
e65ec56276 | ||
|
|
90c54f2c30 | ||
|
|
6a27b91fcb | ||
|
|
4dcf26bcb3 | ||
|
|
d5ae1e3d6a | ||
|
|
6f0d911a08 | ||
|
|
3e91200d3f | ||
|
|
f989eba2c9 | ||
|
|
11d57e9d52 | ||
|
|
62fa22098e | ||
|
|
4417d53562 | ||
|
|
b380108a4f | ||
|
|
87a42087de | ||
|
|
bee857c2af | ||
|
|
31782040c6 | ||
|
|
1195dd3eb3 | ||
|
|
a61d74deff | ||
|
|
d1b89bc68a | ||
|
|
5f8faf237c | ||
|
|
5b62f1c586 | ||
|
|
7306df3445 | ||
|
|
10fb35b47f | ||
|
|
0ffba99eaf | ||
|
|
18ebe010bc | ||
|
|
4817a62a66 | ||
|
|
5473d9e940 | ||
|
|
1ab68e80b3 | ||
|
|
3e142fab0f | ||
|
|
460ceeba1d | ||
|
|
14e678f661 | ||
|
|
a2469abebd | ||
|
|
3b01af242c | ||
|
|
4d1c4f6348 | ||
|
|
fed2d41139 | ||
|
|
c8147bd83f | ||
|
|
29e779d09d | ||
|
|
3a134672e9 | ||
|
|
6b52c42b2b | ||
|
|
811292529b | ||
|
|
5262f56aaf | ||
|
|
ddd27fe426 | ||
|
|
c15beaa014 | ||
|
|
1c38b129c0 | ||
|
|
a411de1d11 | ||
|
|
e64810d96a | ||
|
|
d64e2f42f5 | ||
|
|
e522a53340 | ||
|
|
c02e0618af | ||
|
|
1226d2e6d8 | ||
|
|
613c86f5e2 | ||
|
|
018d2fe800 | ||
|
|
033c5f30aa | ||
|
|
647f53821b | ||
|
|
3bac759cab | ||
|
|
dbf687c47a | ||
|
|
c8661ffcf3 | ||
|
|
00c3b69c7a | ||
|
|
6457f7b5e6 | ||
|
|
4ec6a055d9 | ||
|
|
484e1ae78a | ||
|
|
ba6408b944 | ||
|
|
dc7ce1e43c | ||
|
|
371dacf8b2 | ||
|
|
ebaad7fe49 | ||
|
|
ba0e431199 | ||
|
|
b2384c10cc | ||
|
|
80529dee4b | ||
|
|
c2a11fa0bb | ||
|
|
f286a91253 | ||
|
|
a937e09f85 | ||
|
|
219838244b | ||
|
|
54e0d1aa15 | ||
|
|
829e1b8f05 | ||
|
|
4a14cb9f41 | ||
|
|
cb746056e0 | ||
|
|
c1b3938d5d | ||
|
|
6e5fbb92e8 | ||
|
|
9f847ffcad | ||
|
|
b51daedb50 | ||
|
|
4dfce8f0fa | ||
|
|
bcdf1f401f | ||
|
|
a3816a90b6 | ||
|
|
33f4c6fde7 | ||
|
|
36445732d2 | ||
|
|
c5192146af | ||
|
|
542273a8d9 | ||
|
|
52608c4ffc | ||
|
|
5c5dd98af2 | ||
|
|
2971b571bc | ||
|
|
69703b0a05 | ||
|
|
97cc3c5adc | ||
|
|
b13c470d54 | ||
|
|
d79a55b4c6 | ||
|
|
5b16f54857 | ||
|
|
eb73daffe9 | ||
|
|
c536df0dae | ||
|
|
50561a8696 | ||
|
|
7a182244b9 | ||
|
|
1646b087cf | ||
|
|
bd1db0c919 | ||
|
|
193c751670 | ||
|
|
a8082f798f | ||
|
|
d3d1010360 | ||
|
|
3260acbcc7 | ||
|
|
b8922a7cd7 | ||
|
|
1ee2d53069 | ||
|
|
6acbf823ae | ||
|
|
4c16675b3b | ||
|
|
f526a93b6c | ||
|
|
fee40d7321 | ||
|
|
f19d59d429 | ||
|
|
a97f298586 | ||
|
|
0b3d5bbe0e | ||
|
|
c29e7a1759 | ||
|
|
bda054415a | ||
|
|
1b2db34e08 | ||
|
|
28e4612559 | ||
|
|
301079d4e1 | ||
|
|
6360f23495 | ||
|
|
fb98576431 | ||
|
|
8f0c1f6a80 | ||
|
|
576e20d0cd | ||
|
|
6773a0f533 | ||
|
|
2c53445b5e |
@@ -0,0 +1,14 @@
|
||||
# Top-most EditorConfig file
|
||||
root = true
|
||||
|
||||
# Unix-style newlines with a newline ending every file, utf-8 charset
|
||||
[*]
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
charset = utf-8
|
||||
|
||||
[/lnbits/static/vendor/*]
|
||||
end_of_line = unset
|
||||
insert_final_newline = unset
|
||||
trim_trailing_whitespace = unset
|
||||
@@ -1,13 +1,107 @@
|
||||
#For more information on .env files, their content and format: https://pypi.org/project/python-dotenv/
|
||||
|
||||
######################################
|
||||
########## Funding Source ############
|
||||
######################################
|
||||
|
||||
# which fundingsources are allowed in the admin ui
|
||||
LNBITS_ALLOWED_FUNDING_SOURCES="VoidWallet, FakeWallet, CoreLightningWallet, CoreLightningRestWallet, LndRestWallet, EclairWallet, LndWallet, LnTipsWallet, LNPayWallet, LNbitsWallet, AlbyWallet, OpenNodeWallet"
|
||||
|
||||
LNBITS_BACKEND_WALLET_CLASS=VoidWallet
|
||||
# VoidWallet is just a fallback that works without any actual Lightning capabilities,
|
||||
# just so you can see the UI before dealing with this file.
|
||||
|
||||
# Invoice expiry for LND, CLN, Eclair, LNbits funding sources
|
||||
LIGHTNING_INVOICE_EXPIRY=3600
|
||||
|
||||
# Set one of these blocks depending on the wallet kind you chose above:
|
||||
|
||||
# ClicheWallet
|
||||
CLICHE_ENDPOINT=ws://127.0.0.1:12000
|
||||
|
||||
# SparkWallet
|
||||
SPARK_URL=http://localhost:9737/rpc
|
||||
SPARK_TOKEN=myaccesstoken
|
||||
|
||||
# CoreLightningWallet
|
||||
CORELIGHTNING_RPC="/home/bob/.lightning/bitcoin/lightning-rpc"
|
||||
|
||||
# CoreLightningRestWallet
|
||||
CORELIGHTNING_REST_URL=http://127.0.0.1:8185/
|
||||
CORELIGHTNING_REST_MACAROON="/path/to/clnrest/access.macaroon" # or BASE64/HEXSTRING
|
||||
CORELIGHTNING_REST_CERT="/path/to/clnrest/tls.cert"
|
||||
|
||||
# LnbitsWallet
|
||||
LNBITS_ENDPOINT=https://legend.lnbits.com
|
||||
LNBITS_KEY=LNBITS_ADMIN_KEY
|
||||
|
||||
# LndWallet
|
||||
LND_GRPC_ENDPOINT=127.0.0.1
|
||||
LND_GRPC_PORT=10009
|
||||
LND_GRPC_CERT="/home/bob/.lnd/tls.cert"
|
||||
LND_GRPC_MACAROON="/home/bob/.lnd/data/chain/bitcoin/mainnet/admin.macaroon" # or HEXSTRING
|
||||
# To use an AES-encrypted macaroon, set
|
||||
# LND_GRPC_MACAROON="eNcRyPtEdMaCaRoOn"
|
||||
|
||||
# LndRestWallet
|
||||
LND_REST_ENDPOINT=https://127.0.0.1:8080/
|
||||
LND_REST_CERT="/home/bob/.lnd/tls.cert"
|
||||
LND_REST_MACAROON="/home/bob/.lnd/data/chain/bitcoin/mainnet/admin.macaroon" # or HEXSTRING
|
||||
# To use an AES-encrypted macaroon, set
|
||||
# LND_REST_MACAROON_ENCRYPTED="eNcRyPtEdMaCaRoOn"
|
||||
|
||||
# LNPayWallet
|
||||
LNPAY_API_ENDPOINT=https://api.lnpay.co/v1/
|
||||
# Secret API Key under developers tab
|
||||
LNPAY_API_KEY=LNPAY_API_KEY
|
||||
# Wallet Admin in Wallet Access Keys
|
||||
LNPAY_WALLET_KEY=LNPAY_ADMIN_KEY
|
||||
|
||||
# AlbyWallet
|
||||
ALBY_API_ENDPOINT=https://api.getalby.com/
|
||||
ALBY_ACCESS_TOKEN=ALBY_ACCESS_TOKEN
|
||||
|
||||
# OpenNodeWallet
|
||||
OPENNODE_API_ENDPOINT=https://api.opennode.com/
|
||||
OPENNODE_KEY=OPENNODE_ADMIN_KEY
|
||||
|
||||
# FakeWallet
|
||||
FAKE_WALLET_SECRET="ToTheMoon1"
|
||||
LNBITS_DENOMINATION=sats
|
||||
|
||||
# EclairWallet
|
||||
ECLAIR_URL=http://127.0.0.1:8283
|
||||
ECLAIR_PASS=eclairpw
|
||||
|
||||
# LnTipsWallet
|
||||
# Enter /api in LightningTipBot to get your key
|
||||
LNTIPS_API_KEY=LNTIPS_ADMIN_KEY
|
||||
LNTIPS_API_ENDPOINT=https://ln.tips
|
||||
|
||||
######################################
|
||||
########### Admin Settings ###########
|
||||
######################################
|
||||
|
||||
# Enable Admin GUI, available for the first user in LNBITS_ADMIN_USERS if available
|
||||
# Warning: Enabling this will make LNbits ignore this configuration file. Your settings will
|
||||
# be stored in your database and you will be able to change them only through the Admin UI.
|
||||
# Disable this to make LNbits use this config file again.
|
||||
LNBITS_ADMIN_UI=false
|
||||
|
||||
# Change theme
|
||||
LNBITS_SITE_TITLE="LNbits"
|
||||
LNBITS_SITE_TAGLINE="free and open-source lightning wallet"
|
||||
LNBITS_SITE_DESCRIPTION="Some description about your service, will display if title is not 'LNbits'"
|
||||
# Choose from bitcoin, mint, flamingo, freedom, salvador, autumn, monochrome, classic, cyber
|
||||
LNBITS_THEME_OPTIONS="classic, bitcoin, flamingo, freedom, mint, autumn, monochrome, salvador, cyber"
|
||||
# LNBITS_CUSTOM_LOGO="https://lnbits.com/assets/images/logo/logo.svg"
|
||||
|
||||
HOST=127.0.0.1
|
||||
PORT=5000
|
||||
|
||||
# uvicorn variable, uncomment to allow https behind a proxy
|
||||
# FORWARDED_ALLOW_IPS="*"
|
||||
|
||||
DEBUG=false
|
||||
|
||||
# Server security, rate limiting ips, blocked ips, allowed ips
|
||||
LNBITS_RATE_LIMIT_NO="200"
|
||||
LNBITS_RATE_LIMIT_UNIT="minute"
|
||||
@@ -15,16 +109,24 @@ LNBITS_ALLOWED_IPS=""
|
||||
LNBITS_BLOCKED_IPS=""
|
||||
|
||||
# Allow users and admins by user IDs (comma separated list)
|
||||
# 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, admin"
|
||||
|
||||
# Enable Admin GUI, available for the first user in LNBITS_ADMIN_USERS if available
|
||||
# Warning: Enabling this will make LNbits ignore this configuration file. Your settings will
|
||||
# be stored in your database and you will be able to change them only through the Admin UI.
|
||||
# Disable this to make LNbits use this config file again.
|
||||
LNBITS_ADMIN_UI=false
|
||||
# Disable account creation for new users
|
||||
# LNBITS_ALLOW_NEW_ACCOUNTS=false
|
||||
|
||||
# Enable Node Management without activating the LNBITS Admin GUI
|
||||
# by setting the following variables to true.
|
||||
LNBITS_NODE_UI=false
|
||||
LNBITS_PUBLIC_NODE_UI=false
|
||||
# Enabling the transactions tab can cause crashes on large Core Lightning nodes.
|
||||
LNBITS_NODE_UI_TRANSACTIONS=false
|
||||
|
||||
LNBITS_DEFAULT_WALLET_NAME="LNbits wallet"
|
||||
|
||||
@@ -40,6 +142,10 @@ LNBITS_HIDE_API=false
|
||||
# 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"
|
||||
@@ -61,80 +167,16 @@ LNBITS_RESERVE_FEE_PERCENT=1.0
|
||||
# Limit fiat currencies allowed to see in UI
|
||||
# LNBITS_ALLOWED_CURRENCIES="EUR, USD"
|
||||
|
||||
# Change theme
|
||||
LNBITS_SITE_TITLE="LNbits"
|
||||
LNBITS_SITE_TAGLINE="free and open-source lightning wallet"
|
||||
LNBITS_SITE_DESCRIPTION="Some description about your service, will display if title is not 'LNbits'"
|
||||
# Choose from bitcoin, mint, flamingo, freedom, salvador, autumn, monochrome, classic, cyber
|
||||
LNBITS_THEME_OPTIONS="classic, bitcoin, flamingo, freedom, mint, autumn, monochrome, salvador, cyber"
|
||||
# LNBITS_CUSTOM_LOGO="https://lnbits.com/assets/images/logo/logo.svg"
|
||||
######################################
|
||||
###### Logging and Development #######
|
||||
######################################
|
||||
|
||||
# which fundingsources are allowed in the admin ui
|
||||
LNBITS_ALLOWED_FUNDING_SOURCES="VoidWallet, FakeWallet, CoreLightningWallet, CoreLightningRestWallet, LndRestWallet, EclairWallet, LndWallet, LnTipsWallet, LNPayWallet, LNbitsWallet, OpenNodeWallet"
|
||||
DEBUG=false
|
||||
BUNDLE_ASSETS=true
|
||||
|
||||
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.
|
||||
# logging into LNBITS_DATA_FOLDER/logs/
|
||||
ENABLE_LOG_TO_FILE=true
|
||||
|
||||
# Invoice expiry for LND, CLN, Eclair, LNbits funding sources
|
||||
LIGHTNING_INVOICE_EXPIRY=3600
|
||||
|
||||
# Set one of these blocks depending on the wallet kind you chose above:
|
||||
|
||||
# ClicheWallet
|
||||
CLICHE_ENDPOINT=ws://127.0.0.1:12000
|
||||
|
||||
# SparkWallet
|
||||
SPARK_URL=http://localhost:9737/rpc
|
||||
SPARK_TOKEN=myaccesstoken
|
||||
|
||||
# CoreLightningWallet
|
||||
CORELIGHTNING_RPC="/home/bob/.lightning/bitcoin/lightning-rpc"
|
||||
|
||||
# LnbitsWallet
|
||||
LNBITS_ENDPOINT=https://legend.lnbits.com
|
||||
LNBITS_KEY=LNBITS_ADMIN_KEY
|
||||
|
||||
# LndWallet
|
||||
LND_GRPC_ENDPOINT=127.0.0.1
|
||||
LND_GRPC_PORT=10009
|
||||
LND_GRPC_CERT="/home/bob/.config/Zap/lnd/bitcoin/mainnet/wallet-1/data/chain/bitcoin/mainnet/tls.cert"
|
||||
LND_GRPC_MACAROON="/home/bob/.config/Zap/lnd/bitcoin/mainnet/wallet-1/data/chain/bitcoin/mainnet/admin.macaroon or HEXSTRING"
|
||||
# To use an AES-encrypted macaroon, set
|
||||
# LND_GRPC_MACAROON="eNcRyPtEdMaCaRoOn"
|
||||
|
||||
# LndRestWallet
|
||||
LND_REST_ENDPOINT=https://127.0.0.1:8080/
|
||||
LND_REST_CERT="/home/bob/.config/Zap/lnd/bitcoin/mainnet/wallet-1/data/chain/bitcoin/mainnet/tls.cert"
|
||||
LND_REST_MACAROON="/home/bob/.config/Zap/lnd/bitcoin/mainnet/wallet-1/data/chain/bitcoin/mainnet/admin.macaroon or HEXSTRING"
|
||||
# To use an AES-encrypted macaroon, set
|
||||
# LND_REST_MACAROON_ENCRYPTED="eNcRyPtEdMaCaRoOn"
|
||||
|
||||
# LNPayWallet
|
||||
LNPAY_API_ENDPOINT=https://api.lnpay.co/v1/
|
||||
# Secret API Key under developers tab
|
||||
LNPAY_API_KEY=LNPAY_API_KEY
|
||||
# Wallet Admin in Wallet Access Keys
|
||||
LNPAY_WALLET_KEY=LNPAY_ADMIN_KEY
|
||||
|
||||
# OpenNodeWallet
|
||||
OPENNODE_API_ENDPOINT=https://api.opennode.com/
|
||||
OPENNODE_KEY=OPENNODE_ADMIN_KEY
|
||||
|
||||
# FakeWallet
|
||||
FAKE_WALLET_SECRET="ToTheMoon1"
|
||||
LNBITS_DENOMINATION=sats
|
||||
|
||||
# EclairWallet
|
||||
ECLAIR_URL=http://127.0.0.1:8283
|
||||
ECLAIR_PASS=eclairpw
|
||||
|
||||
# LnTipsWallet
|
||||
# Enter /api in LightningTipBot to get your key
|
||||
LNTIPS_API_KEY=LNTIPS_ADMIN_KEY
|
||||
LNTIPS_API_ENDPOINT=https://ln.tips
|
||||
|
||||
# Cashu Mint
|
||||
# Use a long-enough random (!) private key.
|
||||
# Once set, you cannot change this key as for now.
|
||||
CASHU_PRIVATE_KEY="SuperSecretPrivateKey"
|
||||
# https://loguru.readthedocs.io/en/stable/api/logger.html#file
|
||||
LOG_ROTATION="100 MB"
|
||||
LOG_RETENTION="3 months"
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
name: LNbits CI
|
||||
on: [push, pull_request]
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
pull_request:
|
||||
|
||||
|
||||
jobs:
|
||||
|
||||
|
||||
@@ -2,9 +2,9 @@ name: codeql
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, ]
|
||||
branches: [main, dev]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
branches: [main, dev]
|
||||
schedule:
|
||||
- cron: '0 12 * * 5'
|
||||
|
||||
|
||||
@@ -23,5 +23,5 @@ jobs:
|
||||
prettier:
|
||||
uses: ./.github/workflows/make.yml
|
||||
with:
|
||||
make: prettier
|
||||
make: checkprettier
|
||||
npm: true
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
name: LNbits CI / nix
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
paths:
|
||||
- 'flake.nix'
|
||||
- 'flake.lock'
|
||||
- 'pyproject.toml'
|
||||
- 'poetry.lock'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'flake.nix'
|
||||
- 'flake.lock'
|
||||
- 'pyproject.toml'
|
||||
- 'poetry.lock'
|
||||
|
||||
jobs:
|
||||
nix:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: cachix/install-nix-action@v23
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
- run: nix build
|
||||
@@ -6,9 +6,8 @@ env:
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v[0-9]+.[0-9]+.[0-9]+"
|
||||
- "[0-9]+.[0-9]+.[0-9]+"
|
||||
- "[0-9]+.[0-9]+.[0-9]+.[0-9]+"
|
||||
- "[0-9]+.[0-9]+.[0-9]+-*"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
@@ -23,7 +23,6 @@ tests/data/*.sqlite3
|
||||
*.pyc
|
||||
*.env
|
||||
.env
|
||||
.super_user
|
||||
.pre-commit-config.yaml
|
||||
|
||||
data
|
||||
|
||||
@@ -30,6 +30,9 @@ checkprettier:
|
||||
checkblack:
|
||||
poetry run black --check .
|
||||
|
||||
checkeditorconfig:
|
||||
editorconfig-checker
|
||||
|
||||
dev:
|
||||
poetry run lnbits --reload
|
||||
|
||||
@@ -48,18 +51,17 @@ test-real-wallet:
|
||||
poetry run pytest
|
||||
|
||||
test-migration:
|
||||
rm -rf ./migration-data
|
||||
mkdir -p ./migration-data
|
||||
unzip tests/data/mock_data.zip -d ./migration-data
|
||||
LNBITS_ADMIN_UI=True \
|
||||
make test
|
||||
HOST=0.0.0.0 \
|
||||
PORT=5002 \
|
||||
LNBITS_DATA_FOLDER="./migration-data" \
|
||||
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
|
||||
HOST=0.0.0.0 \
|
||||
PORT=5002 \
|
||||
LNBITS_DATABASE_URL="postgres://lnbits:lnbits@localhost:5432/migration" \
|
||||
timeout 5s poetry run lnbits --host 0.0.0.0 --port 5002 || code=$?; if [[ $code -ne 124 && $code -ne 0 ]]; then exit $code; fi
|
||||
LNBITS_DATA_FOLDER="./migration-data" \
|
||||
LNBITS_DATA_FOLDER="./tests/data" \
|
||||
LNBITS_DATABASE_URL="postgres://lnbits:lnbits@localhost:5432/migration" \
|
||||
poetry run python tools/conv.py
|
||||
|
||||
@@ -95,8 +97,8 @@ bundle:
|
||||
npm run vendor_bundle_js
|
||||
npm run vendor_minify_js
|
||||
# increment serviceworker version
|
||||
sed -i -e "s/CACHE_VERSION =.*/CACHE_VERSION = $$(awk '/CACHE_VERSION =/ { print 1+$$4 }' lnbits/core/static/js/service-worker.js)/" \
|
||||
lnbits/core/static/js/service-worker.js
|
||||
sed -i -e "s/CACHE_VERSION =.*/CACHE_VERSION = $$(awk '/CACHE_VERSION =/ { print 1+$$4 }' lnbits/static/js/service-worker.js)/" \
|
||||
lnbits/static/js/service-worker.js
|
||||
|
||||
install-pre-commit-hook:
|
||||
@echo "Installing pre-commit hook to git"
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
# LNbits
|
||||
# LNbits BETA
|
||||
|
||||
[![license-badge]](LICENSE)
|
||||
[![docs-badge]][docs]
|
||||
|
||||

|
||||
|
||||
# LNbits v0.10 BETA, free and open-source Lightning wallet accounts system
|
||||
# Free and Open-Source Lightning Wallet Accounts System
|
||||
|
||||
(Join us on [https://t.me/lnbits](https://t.me/lnbits))
|
||||
|
||||
@@ -21,9 +21,17 @@ LNbits is a Python server that sits on top of any funding source. It can be used
|
||||
- Fallback wallet for the LNURL scheme
|
||||
- Instant wallet for LN demonstrations
|
||||
|
||||
LNbits can run on top of any Lightning funding source. It supports LND, CLN, Eclair, Spark, LNpay, OpenNode, LightningTipBot, and with more being added regularly.
|
||||
LNbits can run on top of any Lightning funding source. It currently supports the following, but more and more are added regularly:
|
||||
- LND (REST and gRPC)
|
||||
- Core Lightning aka CLN (gRPC, REST and Spark)
|
||||
- Eclair
|
||||
- LNPay
|
||||
- LNbits
|
||||
- OpenNode
|
||||
- Alby
|
||||
- LightningTipBot
|
||||
|
||||
See [LNbits Wiki](https://github.com/lnbits/lnbits/wiki/) for more detailed documentation.
|
||||
See [LNbits manual](https://docs.lnbits.org/guide/wallets.html) for more detailed documentation about each funding source.
|
||||
|
||||
Checkout the LNbits [YouTube](https://www.youtube.com/playlist?list=PLPj3KCksGbSYG0ciIQUWJru1dWstPHshe) video series.
|
||||
|
||||
@@ -52,7 +60,7 @@ LNURL has a fallback scheme, so if scanned by a regular QR code reader it can de
|
||||

|
||||
|
||||
Using **lnbits.com/?lightning="LNURL-withdraw"** will trigger a withdraw that builds an LNbits wallet.
|
||||
Example use would be an ATM, which utilises LNURL, if the user scans the QR with a regular QR code scanner app, they will still be able to access the funds.
|
||||
Example use would be an ATM, which utilizes LNURL, if the user scans the QR with a regular QR code scanner app, they will still be able to access the funds.
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
docs.lnbits.org
|
||||
docs.lnbits.org
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
title: "LNbits docs"
|
||||
remote_theme: pmarsceill/just-the-docs
|
||||
color_scheme: dark
|
||||
logo: "/logos/lnbits-full--inverse.png"
|
||||
logo: "/logos/lnbits-full-inverse.png"
|
||||
search_enabled: true
|
||||
url: https://docs.lnbits.org
|
||||
aux_links:
|
||||
|
||||
@@ -9,4 +9,4 @@ nav_order: 3
|
||||
API reference
|
||||
=============
|
||||
|
||||
[Swagger Docs](https://docs.lnbits.org/devs/swagger.html)
|
||||
[Swagger Docs](https://legend.lnbits.com/docs)
|
||||
|
||||
@@ -6,13 +6,13 @@ nav_order: 2
|
||||
---
|
||||
|
||||
|
||||
Making extensions
|
||||
Extension set up
|
||||
=================
|
||||
|
||||
Start off by cloning the [example extension](https://github.com/lnbits/example) into your `lnbits/extensions` folder and renaming it to `mysuperplugin`:
|
||||
Start off by creating a fork of the [example extension](https://github.com/lnbits/example) into own GitHub repository and rename the repository to `mysuperplugin`:
|
||||
```sh
|
||||
cd lnbits/extensions
|
||||
git clone https://github.com/lnbits/example.git --depth=1 mysuperplugin # Let's not use dashes or anything; it doesn't like those.
|
||||
cd [my-working-folder]
|
||||
git clone https://github.com/[my-user-name]/mysuperplugin.git --depth=1 # Let's not use dashes or anything; it doesn't like those.
|
||||
cd mysuperplugin
|
||||
rm -rf .git/
|
||||
find . -type f -print0 | xargs -0 sed -i 's/example/mysuperplugin/g' # Change all occurrences of 'example' to your plugin name 'mysuperplugin'.
|
||||
@@ -20,7 +20,18 @@ mv templates/example templates/mysuperplugin # Rename templates folder.
|
||||
```
|
||||
- if you are on macOS and having difficulty with 'sed', consider `brew install gnu-sed` and use 'gsed', without -0 option after xargs.
|
||||
|
||||
Going over the example extension's structure:
|
||||
1. Edit `manifest.json` and change the organisation name to your GitHub username.
|
||||
1. Push your changes to GitHub.
|
||||
1. In GitHub create a new release for your extension repo. Tag the release with `0.0.1`
|
||||
1. Copy the URL of the extension's raw `manifest.json` URL `https://raw.githubusercontent.com/[my-user-name]/mysuperplugin/master/manifest.json`
|
||||
1. If you are using the LMNbits Admin UI, go to the Admin UI > Server > Extension Sources, click "Add", paste the URL, then click "Save"
|
||||
1. If you are configuring LNbits via environment variables, add the URL to the .env file's `LNBITS_EXTENSIONS_MANIFESTS` variable. Restart the LNbits python process
|
||||
1. You will now see your extension in the LNbits > Extensions list. Click "Enable" to enable it.
|
||||
1. ...
|
||||
1. Profit!!!
|
||||
|
||||
Extension structure explained
|
||||
-----------------------------
|
||||
* views_api.py: This is where your public API would go. It will be exposed at "$DOMAIN/$PLUGIN/$ROUTE". For example: https://lnbits.com/mysuperplugin/api/v1/tools.
|
||||
* views.py: The `/` path will show up as your plugin's home page in lnbits' UI. Other pages you can define yourself. The `templates` folder should explain itself in relation to this.
|
||||
* migrations.py: Create database tables for your plugin. They'll be created automatically when you start lnbits.
|
||||
|
||||
@@ -9,7 +9,7 @@ nav_order: 2
|
||||
Websockets
|
||||
=================
|
||||
|
||||
`websockets` are a great way to add a two way instant data channel between server and client.
|
||||
`websockets` are a great way to add a two way instant data channel between server and client.
|
||||
|
||||
LNbits has a useful in built websocket tool. With a websocket client connect to (obv change `somespecificid`) `wss://legend.lnbits.com/api/v1/ws/somespecificid` (you can use an online websocket tester). Now make a get to `https://legend.lnbits.com/api/v1/ws/somespecificid/somedata`. You can send data to that websocket by using `from lnbits.core.services import websocketUpdater` and the function `websocketUpdater("somespecificid", "somdata")`.
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ nav_order: 4
|
||||
Admin UI
|
||||
========
|
||||
The LNbits Admin UI lets you change LNbits settings via the LNbits frontend.
|
||||
It is disabled by default and the first time you set the environment variable LNBITS_ADMIN_UI=true
|
||||
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.
|
||||
|
||||
@@ -32,15 +32,18 @@ There is also the possibility of posting the super user via webhook to another s
|
||||
|
||||
Admin Users
|
||||
===========
|
||||
environment variable: LNBITS_ADMIN_USERS, comma-separated list of user ids
|
||||
Admin Users can change settings in the admin ui as well, with the exception of funding source settings, because they require e server restart and could potentially make the server inaccessable. Also they have access to all the extension defined in LNBITS_ADMIN_EXTENSIONS.
|
||||
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 inaccessable. Also they have access to all the extension defined in `LNBITS_ADMIN_EXTENSIONS`.
|
||||
|
||||
|
||||
Allowed Users
|
||||
=============
|
||||
environment variable: LNBITS_ALLOWED_USERS, comma-separated list of user ids
|
||||
environment variable: `LNBITS_ALLOWED_USERS`, comma-separated list of user ids
|
||||
By defining this users, LNbits will no longer be usable by the public, only defined users and admins can then access the LNbits frontend.
|
||||
|
||||
Setting this environment variable also disables account creation.
|
||||
Account creation can be also disabled by setting `LNBITS_ALLOW_NEW_ACCOUNTS=false`
|
||||
|
||||
|
||||
How to activate
|
||||
=============
|
||||
@@ -52,12 +55,12 @@ $ sudo nano .env
|
||||
-> set: `LNBITS_ADMIN_UI=true`
|
||||
|
||||
Now start LNbits once in the terminal window
|
||||
```
|
||||
$ poetry run lnbits
|
||||
```
|
||||
$ poetry run lnbits
|
||||
```
|
||||
You can now `cat` the Super User ID:
|
||||
```
|
||||
$ cat .super_user
|
||||
$ cat data/.super_user
|
||||
123de4bfdddddbbeb48c8bc8382fe123
|
||||
```
|
||||
You can access your super user account at `/wallet?usr=super_user_id`. You just have to append it to your normal LNbits web domain.
|
||||
|
||||
@@ -15,7 +15,7 @@ An `Extension Manifest` is a link to a `JSON` file which contains information ab
|
||||
Multiple repositories can be configured. For more information check the [Manifest File](https://github.com/lnbits/lnbits/blob/main/docs/guide/extension-install.md#manifest-file) section.
|
||||
|
||||
|
||||
**LNbits** administrators should configure their instances to use repositories that they trust (like the [lnbits-extensions](https://github.com/lnbits/lnbits-extensions/) one).
|
||||
**LNbits** administrators should configure their instances to use repositories that they trust (like the [lnbits-extensions](https://github.com/lnbits/lnbits-extensions/) one).
|
||||
> **Warning**
|
||||
> Extensions can have bugs or malicious code, be careful what you install!!
|
||||
|
||||
@@ -40,9 +40,9 @@ Click the `Manage` button in order to install a particular release of the extens
|
||||
Select the version to be installed (usually the last one) and click `Install`. One can also check the `Release Notes` first.
|
||||
|
||||
> **Note**:
|
||||
>
|
||||
>
|
||||
> For Github repository: the order of the releases is the one in the GitHub releases page
|
||||
>
|
||||
>
|
||||
> For Explicit Release: the order of the releases is the one in the "extensions" object
|
||||
|
||||
|
||||
@@ -109,12 +109,12 @@ It goes under the `extensions` object and it is of the form:
|
||||
| htmlUrl | string | optional | Link to the extension home page. |
|
||||
| infoNotification | string | optional | Users that have this release installed will see a info message for their extension. For example if the extension support will be terminated soon. |
|
||||
| criticalNotification | string | optional | Reserved for urgent notifications. The admin user will receive a message each time it visits the `Install` page. One example is if the extension has a critical bug. |
|
||||
| dependencies | list | optional | A list of extension IDs. It signals that those extensions must be installed BEFORE the this one can be installed.
|
||||
| dependencies | list | optional | A list of extension IDs. It signals that those extensions must be installed BEFORE the this one can be installed.
|
||||
|
||||
</details>
|
||||
|
||||
This mode has the advantage of strictly specifying what releases of an extension can be installed.
|
||||
|
||||
|
||||
### GitHub Repository
|
||||
It goes under the `repos` object and it is of the form:
|
||||
|
||||
|
||||
@@ -19,24 +19,24 @@ allow-self-payment=1
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
<details><summary>Funding source only available via tor (e.g. Start9 or Umbrel)</summary>
|
||||
<p>If you want your setup to stay behind tor then only apps, pos and wallets that have tor activated can communicate with your wallets. Most likely you will have trouble when people try to redeem your voucher through onion or when importing your lnbits wallets into a wallet-app that doesnt support tor. If you plan to let LNbits wallets interact with plain internet shops and services you should consider <a href="https://github.com/TrezorHannes/Dual-LND-Hybrid-VPS">setting up hybrid mode for your node</a>.</p>
|
||||
</details>
|
||||
|
||||
|
||||
<details><summary>Funding source is in a cloud</summary>
|
||||
<p>This means that you might not have access to some files which would allow certain administrative functions. E.g. on <a href="https://voltage.cloud/">Voltage</a> lnd.conf can not be edited. Payments from your node to LNbits wallets can therefore not be configurated in this case atm so you will need to take an extra wallet to send from funding source->wallet x->LNbits wallet (only) for the initial funding of the wallet.</p>
|
||||
</details>
|
||||
|
||||
|
||||
<details><summary>LNbits via clearnet domain</summary>
|
||||
<p><a href="https://github.com/TrezorHannes/Dual-LND-Hybrid-VPS">Step by step guide how to convert your Tor only node</a> into a clearnet node to make apps like LNbits accessible via https.</p>
|
||||
</details>
|
||||
|
||||
|
||||
<details><summary>Which funding sources can I use for LNbits?</summary>
|
||||
<p>There are several ways to run a LNbits instance funded from different sources. It is important to choose a source that has a good liquidity and good peers connected. If you use LNbits for public services your users´ payments can then flow happily in both directions. If you would like to fund your LNbits wallet via btc please see section Troubleshooting.</p>
|
||||
<p>The <a href="http://docs.lnbits.org/guide/wallets.html">LNbits manual</a> shows you which sources can be used and how to configure each: CLN, LND, LNPay, Cliche, OpenNode as well as bots.</p>
|
||||
<p>The <a href="https://docs.lnbits.org/guide/wallets.html">LNbits manual</a> shows you which sources can be used and how to configure each.</p>
|
||||
</details>
|
||||
|
||||
|
||||
<!--Later to be added
|
||||
<details><summary>Advanced setup options</summary>
|
||||
<p>more text coming soon...</p>
|
||||
@@ -47,7 +47,7 @@ allow-self-payment=1
|
||||
<p>When you run your LNbits in clearnet basically everyone can generate a wallet on it. Since the funds of your node are bound to these wallets you might want to prevent that. There are two ways to do so:</p>
|
||||
<ul>
|
||||
<li>Configure allowed users & extensions <a href="https://github.com/lnbits/lnbits/blob/main/.env.example">in the .env file</a></li>
|
||||
<li>Configure allowed users & extensions <a href="https://github.com/lnbits/usermanager">via the Usermanager-Extension</a>. You can find <a href="http://docs.lnbits.org/guide/admin_ui.html">more info about the superuser and the admin extension here</a></li>
|
||||
<li>Configure allowed users & extensions <a href="https://github.com/lnbits/usermanager">via the Usermanager-Extension</a>. You can find <a href="https://docs.lnbits.org/guide/admin_ui.html">more info about the superuser and the admin extension here</a></li>
|
||||
</ul>
|
||||
<p>Please note that all entries in the .env file will not be the taken into account once you activated the admin extension.</p>
|
||||
</details>
|
||||
@@ -61,7 +61,7 @@ allow-self-payment=1
|
||||
<details><summary>LNbits is running via Tor only, you can't open it on a public domain like lnbits.yourdomain.com</summary>
|
||||
<ul>
|
||||
<li>Given that you want your setup to stay like this open your LNbits wallet using the .onion URI and create it again. In this way the QR is generated to be accessible via this .onion URI so via tor only. Do not generate that QR from a .local URI, because it will not be reachable via internet - only from within your home-LAN.</li>
|
||||
<li>Open your LN wallet app that you used to scan that QR and this time by using tor (see wallet app settings).
|
||||
<li>Open your LN wallet app that you used to scan that QR and this time by using tor (see wallet app settings).
|
||||
If the app doesn't offer tor, you can use Orbot (Android) instead. See section Installation->Clearnet for detailed instructions.</li>
|
||||
</ul>
|
||||
</details>
|
||||
@@ -70,7 +70,7 @@ allow-self-payment=1
|
||||
<details><summary>LNbits is running via Tor only, you want to offer public LN services via https</summary>
|
||||
<ul>
|
||||
<li>For this we need to partially open LNbits to a clearnet (domain/IP) through a https SSL certificate. Follow the instructions from <a href="https://docs.lnbits.org/guide/installation.html#reverse-proxy-with-automatic-https-using-caddy">this LNbits caddy installation instruction</a>.
|
||||
You need to have a domain and to be able to configure a CNAME for your DNS record as well as generate a subdomain dedicated to your LNbits instance like eg. lnbits.yourdomain.com.
|
||||
You need to have a domain and to be able to configure a CNAME for your DNS record as well as generate a subdomain dedicated to your LNbits instance like eg. lnbits.yourdomain.com.
|
||||
You also need access to your internet router to open the https port (usually 443) and forward it your LNbits IP within your LAN (usually 80). The ports might depend on your node implementation if those ports do not work please ask for them in a help group of your node supplier.</li>
|
||||
<li>You can also follow the Apache installation option, explained in the <a href="https://docs.lnbits.org/guide/installation.html#running-behind-an-apache2-reverse-proxy-over-https">LNbits installation manual</a>.</li>
|
||||
<li>If you run LNbits from a bundle node (myNode, Start9, Umbrel,Raspiblitz etc), you can follow <a href="https://github.com/TrezorHannes/vps-lnbits">this extensive guide</a> with many options to switch your Tor only LNbits into a clearnet LNbits. For Citadel there is a HTTPS Option in your manual to activate https for LNbits in the newest version.</li>
|
||||
@@ -89,9 +89,9 @@ allow-self-payment=1
|
||||
</li>
|
||||
<li>
|
||||
<details><summary>Wallet on your own funding source/node</summary>
|
||||
<p>Always save a copy of your wallet-URL, Export2phone-QR or LNDhub for your own wallets in a safe place.
|
||||
You can find all LNbits users and wallet-IDs in your LNbits user manager extension or in your sqlite database.
|
||||
To edit or read the LNbits database, go to the LNbits /data folder and look for the file called sqlite.db.
|
||||
<p>Always save a copy of your wallet-URL, Export2phone-QR or LNDhub for your own wallets in a safe place.
|
||||
You can find all LNbits users and wallet-IDs in your LNbits user manager extension or in your sqlite database.
|
||||
To edit or read the LNbits database, go to the LNbits /data folder and look for the file called sqlite.db.
|
||||
You can open and edit it with excel or with a dedicated SQL-Editor like <a href="https://sqlitebrowser.org/">SQLite browser</a>.</p>
|
||||
</details>
|
||||
</li>
|
||||
@@ -100,7 +100,7 @@ allow-self-payment=1
|
||||
|
||||
<details><summary>Configure a comment that people see when paying to my LNURLp QR</summary>
|
||||
<p>When you create a LNURL-p, by default the comment box is not filled. That means comments are not allowed to be attached to payments.<p>
|
||||
<p>In order to allow comments, add the characters lenght of the box, from 1 to 250. Once you put a number there,
|
||||
<p>In order to allow comments, add the characters lenght of the box, from 1 to 250. Once you put a number there,
|
||||
the comment box will be displayed in the payment process. You can also edit a LNURL-p already created and add that number.</p>
|
||||
|
||||

|
||||
@@ -113,16 +113,16 @@ allow-self-payment=1
|
||||
<li>
|
||||
<details><summary>A - Via an external swap service</summary>
|
||||
<p>If the user do not have full access of your LNbits, is just an external user, can use swap services like <a href="https://boltz.exchange/">Boltz</a>, <a href="https://fixedfloat.com/">FixedFloat</a>, <a href="https://swap.diamondhands.technology/">DiamondHands</a> or <a href="https://zigzag.io/">ZigZag</a>.</p>
|
||||
<p>This is useful if you provide only LNURL/LN invoices from your LNbits instance, but a payer only has onchain sats so
|
||||
<p>This is useful if you provide only LNURL/LN invoices from your LNbits instance, but a payer only has onchain sats so
|
||||
they will have to the swap those sats first on their side.</p>
|
||||
<p>The procedure is simple: user sends onchain btc to the swap service and provides the LNURL / LN invoice from LNbits as destination of the swap.</p>
|
||||
</details>
|
||||
</li>
|
||||
<li>
|
||||
<details><summary>B - Using the Onchain LNbits extension</summary>
|
||||
<p>Keep in mind that this is a separate wallet, not the LN btc one that is represented by LNbits as "your wallet" upon your LN funding source.
|
||||
This onchain wallet can be used also to swap LN btc to (e.g. your hardwarewallet) by using the LNbits Boltz or Deezy extension.
|
||||
If you run a webshop that is linked to your LNbits for LN payments, it is very handy to regularly drain all the sats from LN into onchain.
|
||||
<p>Keep in mind that this is a separate wallet, not the LN btc one that is represented by LNbits as "your wallet" upon your LN funding source.
|
||||
This onchain wallet can be used also to swap LN btc to (e.g. your hardwarewallet) by using the LNbits Boltz or Deezy extension.
|
||||
If you run a webshop that is linked to your LNbits for LN payments, it is very handy to regularly drain all the sats from LN into onchain.
|
||||
This leads to more space in your LN channels to be able to receive new fresh sats.</p>
|
||||
<p>Procedure:</p>
|
||||
<ul>
|
||||
@@ -130,25 +130,25 @@ allow-self-payment=1
|
||||
<li>Go to wallet information and copy the xpub</li>
|
||||
<li>Go to LNbits - Onchain extension and create a new watch-only wallet with that xpub</li>
|
||||
<li>Go to LNbits - Tipjar extension and create a new Tipjar. Select also the onchain option besides the LN wallet.</li>
|
||||
<li>Optional - Go to LNbits - SatsPay extension and create a new charge for onchain btc.
|
||||
<li>Optional - Go to LNbits - SatsPay extension and create a new charge for onchain btc.
|
||||
You can choose between onchain and LN or both. It will then create an invoice that can be shared.</li>
|
||||
<li>Optional - If you use your LNbits linked to a Wordpress + Woocommerce page, once you create/link a watch-only wallet to your LN btc shop wallet,
|
||||
<li>Optional - If you use your LNbits linked to a Wordpress + Woocommerce page, once you create/link a watch-only wallet to your LN btc shop wallet,
|
||||
the customer will have both options to pay on the same screen.</li>
|
||||
</ul>
|
||||
</details>
|
||||
</li>
|
||||
</ul>
|
||||
</details>
|
||||
|
||||
|
||||
<details><summary>Where can I see payment details?</summary>
|
||||
<p>When you receive a payment in LNbits, the transaction log will display only a resumed type of the transaction.
|
||||
<p>When you receive a payment in LNbits, the transaction log will display only a resumed type of the transaction.
|
||||
|
||||

|
||||
|
||||
<p>In your transaction overview you will find a little green arrow for received and a red arrow for sended funds.<p>
|
||||
<p>If you click on those arrows, a details popup shows attached messages as well as the sender´s name if given.</p>
|
||||
</details>
|
||||
|
||||
|
||||
<details><summary>Can I configure a name to the payments i make?</summary>
|
||||
<p>In LNbits this is currently not possible to do - but to receive. This is only possible if the sender's LN wallet supports <a href="https://github.com/lnurl/luds">LUD-18</a> (nameDesc) like e.g. <a href="https://darthcoin.substack.com/p/obw-open-bitcoin-wallet">Open Bitcion Wallet - OBW</a> does. You will then see an alias/pseudonym in the details section of your LNbits transactions (click the arrows). Note that you can give any name there and it might not be related to the real sender´s name(!) if your receive such.</p>
|
||||

|
||||
@@ -171,7 +171,7 @@ allow-self-payment=1
|
||||
<details><summary>ATM - deposit and withdraw in your shop or at your meetup</summary>
|
||||
<p>This is a do-it-yourself project consisting of a mini-computer (Raspberry Pi Zero), a coin acceptor, a display, a 3D printed case, and a Bitcoin Lightning wallet as a funding source. It exchanges fiat coins for valuable Bitcoin Lightning ⚡ Satoshis. The user can pick up the Satoshis via QR code (LNURL-withdraw) on a mobile phone wallet. You can get the components as individual parts and build the case yourself e.g. from <a href="https://www.Fulmo.org">Fulmo</a> who also made a <a href="https://blog.fulmo.org/the-lightningatm-pocket-edition/">guide</a> on it. The shop offers payments in Bitcoin and Lightning ⚡. The code can be found on <a href="https://github.com/21isenough/LightningATM">the ATM github project page></a>.</p>
|
||||
</details>
|
||||
|
||||
|
||||
<details><summary>POS Terminal - an offline terminal for merchants</summary>
|
||||
<p>The LNpos is a self-sufficient point of sale terminal which allows offline onchain payments and an offline Lightning ATM for withdrawals. Free and open source software, free from intermediaries, with integrated battery, WLAN, DIY. You can get the 3D print as well as the whole kit from the LNbits shop from 👇 Resources. It allows
|
||||
<li>LNPoS Online interactive Lightning payments</li>
|
||||
@@ -183,21 +183,21 @@ allow-self-payment=1
|
||||
</p>
|
||||
</p>
|
||||
</details>
|
||||
|
||||
|
||||
<details><summary>Hardware Wallet- build your own, stack harder</summary>
|
||||
<p>The hardwarewallet is a very cheap solution for builders. The projects´ <a hrel="https://github.com/lnbits/hardware-wallet">code and installation instructions for the LNbits hardware wallet can be found on github</a></p>
|
||||
<p>
|
||||
<img width="546" alt="Bildschirmfoto 2023-01-20 um 18 08 37" src="https://user-images.githubusercontent.com/63317640/213760948-38fd77b0-9247-4505-9433-f5af1b223527.png">
|
||||
</p>
|
||||
</details>
|
||||
|
||||
|
||||
<details><summary>Bitcoin Switch - turn things on with bitcoin</summary>
|
||||
<p>Candy dispenser, vending machines (online), grabbing machines, jukeboxes, bandits and <a href="https://github.com/cryptoteun/awesome-lnbits">all sorts of other things have already been build with LNbits´ tools</a>. Further info see 👇 Resources.</p>
|
||||
<p>
|
||||
<img width="549" alt="Bildschirmfoto 2023-01-20 um 18 11 55" src="https://user-images.githubusercontent.com/63317640/213761646-d25d4745-e50d-4389-98e5-f83237a8cf6b.png">
|
||||
</p>
|
||||
</details>
|
||||
|
||||
|
||||
<details><summary>Vending machine (offline)</summary>
|
||||
<p>This code works similar to the LNpos. Note that the <a href=" https://www.youtube.com/watch?v=Fg0UuuzsYXc&t=762s">setup-video for the vending machine</a> misses the new way of installing it via the new LNURLdevices extension. The <a href="https://github.com/arcbtc/LNURLVend">vending machine project code resides on github</a>.</p>
|
||||
<p>
|
||||
@@ -262,7 +262,7 @@ allow-self-payment=1
|
||||
</ul>
|
||||
|
||||
## Developing for LNbits
|
||||
<ul>
|
||||
<li><a href="http://docs.lnbits.org/devs/development.html">Making Estension / how to use Websockets / API reference</a></li>
|
||||
<ul>
|
||||
<li><a href="https://docs.lnbits.org/devs/development.html">Making extensions / How to use Websockets / API reference</a></li>
|
||||
<li><a href="https://t.me/lnbits">Telegram LNbits Support Group</a></li></ul>
|
||||
</ul>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
## Defining a route with path parameters
|
||||
**old:**
|
||||
```python
|
||||
# with <>
|
||||
# with <>
|
||||
@offlineshop_ext.route("/lnurl/<item_id>", methods=["GET"])
|
||||
```
|
||||
|
||||
@@ -35,8 +35,8 @@ async def extensions(user: User = Depends(check_user_exists)):
|
||||
```python
|
||||
return (
|
||||
{
|
||||
"id": wallet.wallet.id,
|
||||
"name": wallet.wallet.name,
|
||||
"id": wallet.wallet.id,
|
||||
"name": wallet.wallet.name,
|
||||
"balance": wallet.wallet.balance_msat
|
||||
},
|
||||
HTTPStatus.OK,
|
||||
@@ -47,8 +47,8 @@ FastAPI returns `HTTPStatus.OK` by default id no Exception is raised
|
||||
**new:**
|
||||
```python
|
||||
return {
|
||||
"id": wallet.wallet.id,
|
||||
"name": wallet.wallet.name,
|
||||
"id": wallet.wallet.id,
|
||||
"name": wallet.wallet.name,
|
||||
"balance": wallet.wallet.balance_msat
|
||||
}
|
||||
```
|
||||
@@ -119,4 +119,4 @@ Instead of hitting the database over and over again, we can store a short lived
|
||||
Example:
|
||||
* Get transactions for a wallet ID
|
||||
* User data for a user id
|
||||
* Wallet data for a Admin / Invoice key
|
||||
* Wallet data for a Admin / Invoice key
|
||||
|
||||
@@ -17,6 +17,7 @@ Mininum poetry version has is ^1.2, but it is recommended to use latest poetry.
|
||||
```sh
|
||||
git clone https://github.com/lnbits/lnbits.git
|
||||
cd lnbits
|
||||
git checkout main
|
||||
|
||||
# for making sure python 3.9 is installed, skip if installed. To check your installed version: python3 --version
|
||||
sudo apt update
|
||||
@@ -60,14 +61,13 @@ poetry install --only main
|
||||
|
||||
## Option 2: Nix
|
||||
|
||||
> note: currently not supported while we make some architectural changes on the path to leave beta
|
||||
|
||||
```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
|
||||
|
||||
# Clone and build LNbits
|
||||
git clone https://github.com/lnbits/lnbits.git
|
||||
cd lnbits
|
||||
# Modern debian distros usually include Nix, however you can install with:
|
||||
# 'sh <(curl -L https://nixos.org/nix/install) --daemon', or use setup here https://nixos.org/download.html#nix-verify-installation
|
||||
|
||||
nix build .#lnbits
|
||||
mkdir data
|
||||
|
||||
@@ -76,8 +76,11 @@ mkdir data
|
||||
#### Running the server
|
||||
|
||||
```sh
|
||||
# .env variables are currently passed when running
|
||||
LNBITS_DATA_FOLDER=data LNBITS_BACKEND_WALLET_CLASS=LNbitsWallet LNBITS_ENDPOINT=https://legend.lnbits.com LNBITS_KEY=7b1a78d6c78f48b09a202f2dcb2d22eb ./result/bin/lnbits --port 9000
|
||||
# .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
|
||||
|
||||
# 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
|
||||
```
|
||||
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ nav_order: 3
|
||||
Backend wallets
|
||||
===============
|
||||
|
||||
LNbits can run on top of many lightning-network funding sources. Currently there is support for CoreLightning, LND, LNbits, LNPay and OpenNode, with more being added regularly.
|
||||
LNbits can run on top of many lightning-network funding sources with more being added regularly.
|
||||
|
||||
A backend wallet can be configured using the following LNbits environment variables:
|
||||
|
||||
@@ -18,6 +18,13 @@ A backend wallet can be configured using the following LNbits environment variab
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: **CoreLightningWallet**
|
||||
- `CORELIGHTNING_RPC`: /file/path/lightning-rpc
|
||||
|
||||
### CoreLightning REST
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: **CoreLightningRestWallet**
|
||||
- `CORELIGHTNING_REST_URL`: http://127.0.0.1:8185/
|
||||
- `CORELIGHTNING_REST_MACAROON`: /file/path/admin.macaroon or Base64/Hex
|
||||
- `CORELIGHTNING_REST_CERT`: /home/lightning/clnrest/tls.cert
|
||||
|
||||
### Spark (Core Lightning)
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: **SparkWallet**
|
||||
@@ -29,7 +36,7 @@ A backend wallet can be configured using the following LNbits environment variab
|
||||
- `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 Bech64/Hex
|
||||
- `LND_REST_MACAROON`: /file/path/admin.macaroon or Base64/Hex
|
||||
|
||||
or
|
||||
|
||||
@@ -41,7 +48,7 @@ or
|
||||
- `LND_GRPC_ENDPOINT`: ip_address
|
||||
- `LND_GRPC_PORT`: port
|
||||
- `LND_GRPC_CERT`: /file/path/tls.cert
|
||||
- `LND_GRPC_MACAROON`: /file/path/admin.macaroon or Bech64/Hex
|
||||
- `LND_GRPC_MACAROON`: /file/path/admin.macaroon or Base64/Hex
|
||||
|
||||
You can also use an AES-encrypted macaroon (more info) instead by using
|
||||
|
||||
@@ -64,7 +71,6 @@ For the invoice listener to work you have a publicly accessible URL in your LNbi
|
||||
- `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 setting is necessary.
|
||||
@@ -73,6 +79,13 @@ For the invoice to work you must have a publicly accessible URL in your LNbits.
|
||||
- `OPENNODE_API_ENDPOINT`: https://api.opennode.com/
|
||||
- `OPENNODE_KEY`: opennodeAdminApiKey
|
||||
|
||||
### Alby
|
||||
|
||||
For the invoice to work you must have a publicly accessible URL in your LNbits. No manual webhook setting is necessary. You can generate an alby access token here: https://getalby.com/developer/access_tokens/new
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: **AlbyWallet**
|
||||
- `ALBY_API_ENDPOINT`: https://api.getalby.com/
|
||||
- `ALBY_ACCESS_TOKEN`: AlbyAccessToken
|
||||
|
||||
### Cliche Wallet
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ LNbits, free and open-source lightning-network wallet/accounts system
|
||||
LNbits is a very simple Python application that sits on top of any funding source, and can be used as:
|
||||
|
||||
* Accounts system to mitigate the risk of exposing applications to your full balance, via unique API keys for each wallet
|
||||
* Extendable platform for exploring lightning-network functionality via LNbits extension framework
|
||||
* Extendable platform for exploring lightning-network functionality via LNbits extension framework
|
||||
* Part of a development stack via LNbits API
|
||||
* Fallback wallet for the LNURL scheme
|
||||
* Instant wallet for LN demonstrations
|
||||
|
||||
|
Before Width: | Height: | Size: 69 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 101.041 30.0001"><defs><path id="a" d="M33.2619 148.1667h154.2143v68.7917H33.2619z"/></defs><g fill="#1f2234" aria-label="LNbits" font-family="sans-serif" font-size=".3095" font-weight="400" letter-spacing=".0031" style="line-height:1.25;white-space:pre;shape-inside:url(#a)" transform="matrix(72.4607 0 0 72.4607 -2399.2814 -10741.3589)"><g transform="matrix(.00244 0 0 .00244 33.0708 148.1594)"><circle cx="101.2976" cy="116.4167" r="84.6667" fill="#673ab7" fill-rule="evenodd"/><path fill="#eee" d="M79.1105 71.9667v49.0613h13.3803v40.141l31.2208-53.5213h-17.8404l17.8404-35.681z"/></g><g fill="#eee" font-family="roboto"><path d="M33.6755 148.518h.0962v.0364h-.1416v-.22h.0454zM33.9796 148.5544h-.0453l-.0883-.1448v.1448h-.0453v-.22h.0453l.0884.145v-.145h.0452z" font-weight="700" style="-inkscape-font-specification:'roboto Bold'"/><path d="M34.1625 148.4744q0 .0375-.0172.0603-.0172.0227-.0462.0227-.031 0-.048-.0219l-.0014.019h-.0256v-.2322h.028v.0866q.0168-.021.0466-.021.0298 0 .0467.0225.017.0225.017.0617zm-.028-.003q0-.0286-.011-.044-.011-.0157-.0317-.0157-.0277 0-.0398.0257v.0707q.0129.0257.04.0257.0202 0 .0314-.0156.0112-.0156.0112-.0469zM34.231 148.5544h-.028v-.1635h.028zm-.0302-.2069q0-.007.0041-.0115.0042-.005.0124-.005.0082 0 .0124.005.0042.005.0042.0115 0 .007-.0042.0113-.0042.005-.0124.005-.0082 0-.0124-.005-.004-.005-.004-.0113zM34.3167 148.3513v.0396h.0305v.0216h-.0305v.1014q0 .01.004.0148.0042.005.014.005.0048 0 .0133-.002v.0227q-.011.003-.0215.003-.0187 0-.0283-.0113-.0095-.0113-.0095-.0322v-.1014h-.0297v-.0216h.0297v-.0396zM34.478 148.511q0-.0113-.0086-.0175-.0085-.006-.0298-.0109-.0211-.005-.0337-.0109-.0124-.006-.0184-.015t-.006-.0209q0-.02.017-.034.017-.0139.0435-.0139.0278 0 .045.0144.0174.0144.0174.0367h-.028q0-.0115-.0099-.0198-.0097-.008-.0245-.008-.0152 0-.0238.007-.0086.007-.0086.0174 0 .0101.008.0153.008.005.0288.01.021.005.034.0112.013.007.0192.0157.0063.009.0063.0222 0 .022-.0175.0352-.0175.0131-.0455.0131-.0197 0-.0348-.007-.015-.007-.0237-.0193-.0085-.0125-.0085-.027h.028q.0007.014.0111.0223.0106.008.0279.008.0158 0 .0253-.006.0097-.006.0097-.0172z" style="-inkscape-font-specification:roboto"/></g></g></svg>
|
||||
|
Before Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 15 KiB |
@@ -1,19 +1,4 @@
|
||||
<svg width="900" height="900" viewBox="0 0 900 900" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g filter="url(#filter0_i_10_29)">
|
||||
<circle cx="450" cy="450" r="450" fill="#1E1E1E"/>
|
||||
</g>
|
||||
<path d="M387.802 476.423L281.613 730.887L584.54 389.763H459.418L619.113 168.387H407.558L305.485 476.423H387.802Z" fill="#FF1FE1"/>
|
||||
<defs>
|
||||
<filter id="filter0_i_10_29" x="-0.1" y="-0.1" width="900.1" height="900.1" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feMorphology radius="0.6" operator="dilate" in="SourceAlpha" result="effect1_innerShadow_10_29"/>
|
||||
<feOffset dx="-1" dy="-0.4"/>
|
||||
<feGaussianBlur stdDeviation="0.35"/>
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 0.121569 0 0 0 0 0.882353 0 0 0 1 0"/>
|
||||
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_10_29"/>
|
||||
</filter>
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 290 B |
@@ -1,12 +1,15 @@
|
||||
{
|
||||
"nodes": {
|
||||
"flake-utils": {
|
||||
"inputs": {
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1676283394,
|
||||
"narHash": "sha256-XX2f9c3iySLCw54rJ/CZs+ZK6IQy7GXNY4nSOyu2QG4=",
|
||||
"lastModified": 1689068808,
|
||||
"narHash": "sha256-6ixXo3wt24N/melDWjq70UuHQLxGV8jZvooRanIHXw0=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "3db36a8b464d0c4532ba1c7dda728f4576d6d073",
|
||||
"rev": "919d646de7be200f3bf08cb76ae1f09402b6f9b4",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -15,6 +18,27 @@
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nix-github-actions": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"poetry2nix",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1688870561,
|
||||
"narHash": "sha256-4UYkifnPEw1nAzqqPOTL2MvWtm3sNGw1UTYTalkTcGY=",
|
||||
"owner": "nix-community",
|
||||
"repo": "nix-github-actions",
|
||||
"rev": "165b1650b753316aa7f1787f3005a8d2da0f5301",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-community",
|
||||
"repo": "nix-github-actions",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1678470307,
|
||||
@@ -34,16 +58,17 @@
|
||||
"poetry2nix": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils",
|
||||
"nix-github-actions": "nix-github-actions",
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1678513806,
|
||||
"narHash": "sha256-bEto1lp9bIQ+DlJsXQyRxN5l6B/oy3Skb7DueYRJJBo=",
|
||||
"lastModified": 1695386222,
|
||||
"narHash": "sha256-5lgnhCCGW0NH5+m5iTED8u6NSSM/dbH9LBPvX0x0XXg=",
|
||||
"owner": "nix-community",
|
||||
"repo": "poetry2nix",
|
||||
"rev": "bf239d53fafb53cf439a72b3a50e86dd6a3984a5",
|
||||
"rev": "093383b3d7fdd36846a7d84e128ca11865800538",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -57,6 +82,21 @@
|
||||
"nixpkgs": "nixpkgs",
|
||||
"poetry2nix": "poetry2nix"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
|
||||
@@ -28,13 +28,30 @@
|
||||
});
|
||||
overlays = {
|
||||
default = final: prev: {
|
||||
${projectName} = self.packages.${final.hostPlatform.system}.${projectName};
|
||||
${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;
|
||||
overrides = pkgs.poetry2nix.overrides.withDefaults (final: prev: {
|
||||
ruff = prev.ruff.override { preferWheel = true; };
|
||||
wallycore = prev.wallycore.override { preferWheel = true; };
|
||||
fastapi = prev.fastapi.overridePythonAttrs (old: {
|
||||
postPatch = ''
|
||||
substituteInPlace pyproject.toml \
|
||||
--replace '"Framework :: Pydantic",' "" \
|
||||
--replace '"Framework :: Pydantic :: 1",' ""
|
||||
'';
|
||||
});
|
||||
bolt11 = prev.bolt11.overrideAttrs (old: {
|
||||
nativeBuildInputs = (old.nativeBuildInputs or [ ]) ++ [
|
||||
prev.poetry
|
||||
];
|
||||
});
|
||||
});
|
||||
};
|
||||
});
|
||||
nixosModules = {
|
||||
|
||||
@@ -9,6 +9,7 @@ import sys
|
||||
import traceback
|
||||
from hashlib import sha256
|
||||
from http import HTTPStatus
|
||||
from pathlib import Path
|
||||
from typing import Callable, List
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
@@ -33,15 +34,17 @@ from lnbits.utils.cache import cache
|
||||
from lnbits.wallets import get_wallet_class, set_wallet_class
|
||||
|
||||
from .commands import db_versions, load_disabled_extension_list, migrate_databases
|
||||
from .core import (
|
||||
add_installed_extension,
|
||||
core_app,
|
||||
core_app_extra,
|
||||
update_installed_extension_state,
|
||||
from .core import init_core_routers
|
||||
from .core.db import core_app_extra
|
||||
from .core.services import check_admin_settings, check_webpush_settings
|
||||
from .core.views.api import add_installed_extension
|
||||
from .core.views.generic import update_installed_extension_state
|
||||
from .extension_manager import (
|
||||
Extension,
|
||||
InstallableExtension,
|
||||
get_valid_extensions,
|
||||
version_parse,
|
||||
)
|
||||
from .core.services import check_admin_settings
|
||||
from .core.views.generic import core_html_routes
|
||||
from .extension_manager import Extension, InstallableExtension, get_valid_extensions
|
||||
from .helpers import template_renderer
|
||||
from .middleware import (
|
||||
CustomGZipMiddleware,
|
||||
@@ -74,12 +77,14 @@ def create_app() -> FastAPI:
|
||||
},
|
||||
)
|
||||
|
||||
app.mount("/static", StaticFiles(packages=[("lnbits", "static")]), name="static")
|
||||
app.mount(
|
||||
"/core/static",
|
||||
StaticFiles(packages=[("lnbits.core", "static")]),
|
||||
name="core_static",
|
||||
)
|
||||
# Allow registering new extensions routes without direct access to the `app` object
|
||||
setattr(core_app_extra, "register_new_ext_routes", register_new_ext_routes(app))
|
||||
setattr(core_app_extra, "register_new_ratelimiter", register_new_ratelimiter(app))
|
||||
|
||||
# register static files
|
||||
static_path = Path("lnbits", "static")
|
||||
static = StaticFiles(directory=static_path)
|
||||
app.mount("/static", static, name="static")
|
||||
|
||||
g().base_url = f"http://{settings.host}:{settings.port}"
|
||||
|
||||
@@ -95,16 +100,18 @@ def create_app() -> FastAPI:
|
||||
app.add_middleware(InstalledExtensionMiddleware)
|
||||
app.add_middleware(ExtensionsRedirectMiddleware)
|
||||
|
||||
register_custom_extensions_path()
|
||||
|
||||
# adds security middleware
|
||||
add_ip_block_middleware(app)
|
||||
add_ratelimit_middleware(app)
|
||||
|
||||
register_startup(app)
|
||||
register_routes(app)
|
||||
register_async_tasks(app)
|
||||
register_exception_handlers(app)
|
||||
register_shutdown(app)
|
||||
|
||||
# Allow registering new extensions routes without direct access to the `app` object
|
||||
setattr(core_app_extra, "register_new_ext_routes", register_new_ext_routes(app))
|
||||
setattr(core_app_extra, "register_new_ratelimiter", register_new_ratelimiter(app))
|
||||
|
||||
return app
|
||||
|
||||
|
||||
@@ -155,7 +162,7 @@ async def check_funding_source() -> None:
|
||||
|
||||
signal.signal(signal.SIGINT, original_sigint_handler)
|
||||
|
||||
logger.info(
|
||||
logger.success(
|
||||
f"✔️ Backend {WALLET.__class__.__name__} connected "
|
||||
f"and with a balance of {balance} msat."
|
||||
)
|
||||
@@ -213,7 +220,11 @@ async def build_all_installed_extensions_list() -> List[InstallableExtension]:
|
||||
continue
|
||||
|
||||
ext_releases = await InstallableExtension.get_extension_releases(ext_id)
|
||||
release = ext_releases[0] if len(ext_releases) else None
|
||||
ext_releases = sorted(
|
||||
ext_releases, key=lambda r: version_parse(r.version), reverse=True
|
||||
)
|
||||
|
||||
release = next((e for e in ext_releases if e.is_version_compatible), None)
|
||||
|
||||
if release:
|
||||
ext_info = InstallableExtension(
|
||||
@@ -228,9 +239,7 @@ def check_installed_extension_files(ext: InstallableExtension) -> bool:
|
||||
if ext.has_installed_version:
|
||||
return True
|
||||
|
||||
zip_files = glob.glob(
|
||||
os.path.join(settings.lnbits_data_folder, "extensions", "*.zip")
|
||||
)
|
||||
zip_files = glob.glob(os.path.join(settings.lnbits_data_folder, "zips", "*.zip"))
|
||||
|
||||
if f"./{str(ext.zip_path)}" not in zip_files:
|
||||
ext.download_archive()
|
||||
@@ -257,17 +266,32 @@ async def restore_installed_extension(app: FastAPI, ext: InstallableExtension):
|
||||
|
||||
def register_routes(app: FastAPI) -> None:
|
||||
"""Register FastAPI routes / LNbits extensions."""
|
||||
app.include_router(core_app)
|
||||
app.include_router(core_html_routes)
|
||||
init_core_routers(app)
|
||||
|
||||
for ext in get_valid_extensions():
|
||||
try:
|
||||
register_ext_routes(app, ext)
|
||||
except Exception as e:
|
||||
logger.error(str(e))
|
||||
raise ImportError(
|
||||
f"Please make sure that the extension `{ext.code}` follows conventions."
|
||||
)
|
||||
logger.error(f"Could not load extension `{ext.code}`: {str(e)}")
|
||||
|
||||
|
||||
def register_custom_extensions_path():
|
||||
if settings.has_default_extension_path:
|
||||
return
|
||||
default_ext_path = os.path.join("lnbits", "extensions")
|
||||
if os.path.isdir(default_ext_path) and len(os.listdir(default_ext_path)) != 0:
|
||||
logger.warning(
|
||||
"You are using a custom extensions path, "
|
||||
+ "but the default extensions directory is not empty. "
|
||||
+ f"Please clean-up the '{default_ext_path}' directory."
|
||||
)
|
||||
logger.warning(
|
||||
f"You can move the existing '{default_ext_path}' directory to: "
|
||||
+ f" '{settings.lnbits_extensions_path}/extensions'"
|
||||
)
|
||||
|
||||
sys.path.append(str(Path(settings.lnbits_extensions_path, "extensions")))
|
||||
sys.path.append(str(Path(settings.lnbits_extensions_path, "upgrades")))
|
||||
|
||||
|
||||
def register_new_ext_routes(app: FastAPI) -> Callable:
|
||||
@@ -306,7 +330,10 @@ def register_ext_routes(app: FastAPI, ext: Extension) -> None:
|
||||
if hasattr(ext_module, f"{ext.code}_static_files"):
|
||||
ext_statics = getattr(ext_module, f"{ext.code}_static_files")
|
||||
for s in ext_statics:
|
||||
app.mount(s["path"], s["app"], s["name"])
|
||||
static_dir = Path(
|
||||
settings.lnbits_extensions_path, "extensions", *s["path"].split("/")
|
||||
)
|
||||
app.mount(s["path"], StaticFiles(directory=static_dir), s["name"])
|
||||
|
||||
if hasattr(ext_module, f"{ext.code}_redirect_paths"):
|
||||
ext_redirects = getattr(ext_module, f"{ext.code}_redirect_paths")
|
||||
@@ -332,20 +359,16 @@ def register_startup(app: FastAPI):
|
||||
|
||||
# setup admin settings
|
||||
await check_admin_settings()
|
||||
await check_webpush_settings()
|
||||
|
||||
log_server_info()
|
||||
|
||||
# adds security middleware
|
||||
add_ratelimit_middleware(app)
|
||||
add_ip_block_middleware(app)
|
||||
|
||||
# initialize WALLET
|
||||
try:
|
||||
set_wallet_class()
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error initializing {settings.lnbits_backend_wallet_class}: "
|
||||
f"{str(e)}"
|
||||
f"Error initializing {settings.lnbits_backend_wallet_class}: {e}"
|
||||
)
|
||||
set_void_wallet_class()
|
||||
|
||||
@@ -401,9 +424,10 @@ def log_server_info():
|
||||
logger.info(f"Site title: {settings.lnbits_site_title}")
|
||||
logger.info(f"Funding source: {settings.lnbits_backend_wallet_class}")
|
||||
logger.info(f"Data folder: {settings.lnbits_data_folder}")
|
||||
logger.info(f"Git version: {settings.lnbits_commit}")
|
||||
logger.info(f"Database: {get_db_vendor_name()}")
|
||||
logger.info(f"Service fee: {settings.lnbits_service_fee}")
|
||||
logger.info(f"Service fee max: {settings.lnbits_service_fee_max}")
|
||||
logger.info(f"Service fee wallet: {settings.lnbits_service_fee_wallet}")
|
||||
|
||||
|
||||
def get_db_vendor_name():
|
||||
@@ -510,6 +534,23 @@ def configure_logger() -> None:
|
||||
log_level: str = "DEBUG" if settings.debug else "INFO"
|
||||
formatter = Formatter()
|
||||
logger.add(sys.stdout, level=log_level, format=formatter.format)
|
||||
|
||||
if settings.enable_log_to_file:
|
||||
logger.add(
|
||||
Path(settings.lnbits_data_folder, "logs", "lnbits.log"),
|
||||
rotation=settings.log_rotation,
|
||||
retention=settings.log_retention,
|
||||
level="INFO",
|
||||
format=formatter.format,
|
||||
)
|
||||
logger.add(
|
||||
Path(settings.lnbits_data_folder, "logs", "debug.log"),
|
||||
rotation=settings.log_rotation,
|
||||
retention=settings.log_retention,
|
||||
level="DEBUG",
|
||||
format=formatter.format,
|
||||
)
|
||||
|
||||
logging.getLogger("uvicorn").handlers = [InterceptHandler()]
|
||||
logging.getLogger("uvicorn.access").handlers = [InterceptHandler()]
|
||||
logging.getLogger("uvicorn.error").handlers = [InterceptHandler()]
|
||||
|
||||
@@ -1,365 +1,7 @@
|
||||
import hashlib
|
||||
import re
|
||||
import time
|
||||
from decimal import Decimal
|
||||
from typing import List, NamedTuple, Optional
|
||||
|
||||
import bitstring
|
||||
import secp256k1
|
||||
from bech32 import CHARSET, bech32_decode, bech32_encode
|
||||
from ecdsa import SECP256k1, VerifyingKey
|
||||
from ecdsa.util import sigdecode_string
|
||||
|
||||
|
||||
class Route(NamedTuple):
|
||||
pubkey: str
|
||||
short_channel_id: str
|
||||
base_fee_msat: int
|
||||
ppm_fee: int
|
||||
cltv: int
|
||||
|
||||
|
||||
class Invoice:
|
||||
payment_hash: str
|
||||
amount_msat: int = 0
|
||||
description: Optional[str] = None
|
||||
description_hash: Optional[str] = None
|
||||
payee: Optional[str] = None
|
||||
date: int
|
||||
expiry: int = 3600
|
||||
secret: Optional[str] = None
|
||||
route_hints: List[Route] = []
|
||||
min_final_cltv_expiry: int = 18
|
||||
|
||||
|
||||
def decode(pr: str) -> Invoice:
|
||||
"""bolt11 decoder,
|
||||
based on https://github.com/rustyrussell/lightning-payencode/blob/master/lnaddr.py
|
||||
"""
|
||||
|
||||
hrp, decoded_data = bech32_decode(pr)
|
||||
if hrp is None or decoded_data is None:
|
||||
raise ValueError("Bad bech32 checksum")
|
||||
if not hrp.startswith("ln"):
|
||||
raise ValueError("Does not start with ln")
|
||||
|
||||
bitarray = _u5_to_bitarray(decoded_data)
|
||||
|
||||
# final signature 65 bytes, split it off.
|
||||
if len(bitarray) < 65 * 8:
|
||||
raise ValueError("Too short to contain signature")
|
||||
|
||||
# extract the signature
|
||||
signature = bitarray[-65 * 8 :].tobytes()
|
||||
|
||||
# the tagged fields as a bitstream
|
||||
data = bitstring.ConstBitStream(bitarray[: -65 * 8])
|
||||
|
||||
# build the invoice object
|
||||
invoice = Invoice()
|
||||
|
||||
# decode the amount from the hrp
|
||||
m = re.search(r"[^\d]+", hrp[2:])
|
||||
if m:
|
||||
amountstr = hrp[2 + m.end() :]
|
||||
if amountstr != "":
|
||||
invoice.amount_msat = _unshorten_amount(amountstr)
|
||||
|
||||
# pull out date
|
||||
date_bin = data.read(35)
|
||||
invoice.date = date_bin.uint # type: ignore
|
||||
|
||||
while data.pos != data.len:
|
||||
tag, tagdata, data = _pull_tagged(data)
|
||||
data_length = len(tagdata or []) / 5
|
||||
|
||||
if tag == "d":
|
||||
invoice.description = _trim_to_bytes(tagdata).decode()
|
||||
elif tag == "h" and data_length == 52:
|
||||
invoice.description_hash = _trim_to_bytes(tagdata).hex()
|
||||
elif tag == "p" and data_length == 52:
|
||||
invoice.payment_hash = _trim_to_bytes(tagdata).hex()
|
||||
elif tag == "x":
|
||||
invoice.expiry = tagdata.uint # type: ignore
|
||||
elif tag == "n":
|
||||
invoice.payee = _trim_to_bytes(tagdata).hex()
|
||||
# this won't work in most cases, we must extract the payee
|
||||
# from the signature
|
||||
elif tag == "s":
|
||||
invoice.secret = _trim_to_bytes(tagdata).hex()
|
||||
elif tag == "r":
|
||||
s = bitstring.ConstBitStream(tagdata)
|
||||
while s.pos + 264 + 64 + 32 + 32 + 16 < s.len:
|
||||
route = Route(
|
||||
pubkey=s.read(264).tobytes().hex(), # type: ignore
|
||||
short_channel_id=_readable_scid(s.read(64).intbe), # type: ignore
|
||||
base_fee_msat=s.read(32).intbe, # type: ignore
|
||||
ppm_fee=s.read(32).intbe, # type: ignore
|
||||
cltv=s.read(16).intbe, # type: ignore
|
||||
)
|
||||
invoice.route_hints.append(route)
|
||||
|
||||
# BOLT #11:
|
||||
# A reader MUST check that the `signature` is valid (see the `n` tagged
|
||||
# field specified below).
|
||||
# A reader MUST use the `n` field to validate the signature instead of
|
||||
# performing signature recovery if a valid `n` field is provided.
|
||||
message = bytearray([ord(c) for c in hrp]) + data.tobytes()
|
||||
sig = signature[0:64]
|
||||
if invoice.payee:
|
||||
key = VerifyingKey.from_string(bytes.fromhex(invoice.payee), curve=SECP256k1)
|
||||
key.verify(sig, message, hashlib.sha256, sigdecode=sigdecode_string)
|
||||
else:
|
||||
keys = VerifyingKey.from_public_key_recovery(
|
||||
sig, message, SECP256k1, hashlib.sha256
|
||||
)
|
||||
signaling_byte = signature[64]
|
||||
key = keys[int(signaling_byte)]
|
||||
invoice.payee = key.to_string("compressed").hex()
|
||||
|
||||
return invoice
|
||||
|
||||
|
||||
def encode(options):
|
||||
"""Convert options into LnAddr and pass it to the encoder"""
|
||||
addr = LnAddr()
|
||||
addr.currency = options["currency"]
|
||||
addr.fallback = options["fallback"] if options["fallback"] else None
|
||||
if options["amount"]:
|
||||
addr.amount = options["amount"]
|
||||
if options["timestamp"]:
|
||||
addr.date = int(options["timestamp"])
|
||||
|
||||
addr.paymenthash = bytes.fromhex(options["paymenthash"])
|
||||
|
||||
if options["description"]:
|
||||
addr.tags.append(("d", options["description"]))
|
||||
if options["description_hash"]:
|
||||
addr.tags.append(("h", options["description_hash"]))
|
||||
if options["expires"]:
|
||||
addr.tags.append(("x", options["expires"]))
|
||||
|
||||
if options["fallback"]:
|
||||
addr.tags.append(("f", options["fallback"]))
|
||||
if options["route"]:
|
||||
for r in options["route"]:
|
||||
splits = r.split("/")
|
||||
route = []
|
||||
while len(splits) >= 5:
|
||||
route.append(
|
||||
(
|
||||
bytes.fromhex(splits[0]),
|
||||
bytes.fromhex(splits[1]),
|
||||
int(splits[2]),
|
||||
int(splits[3]),
|
||||
int(splits[4]),
|
||||
)
|
||||
)
|
||||
splits = splits[5:]
|
||||
assert len(splits) == 0
|
||||
addr.tags.append(("r", route))
|
||||
return lnencode(addr, options["privkey"])
|
||||
|
||||
|
||||
def lnencode(addr, privkey):
|
||||
if addr.amount:
|
||||
amount = Decimal(str(addr.amount))
|
||||
# We can only send down to millisatoshi.
|
||||
if amount * 10**12 % 10:
|
||||
raise ValueError(f"Cannot encode {addr.amount}: too many decimal places")
|
||||
|
||||
amount = addr.currency + shorten_amount(amount)
|
||||
else:
|
||||
amount = addr.currency if addr.currency else ""
|
||||
|
||||
hrp = f"ln{amount}0n"
|
||||
|
||||
# Start with the timestamp
|
||||
data = bitstring.pack("uint:35", addr.date)
|
||||
|
||||
# Payment hash
|
||||
data += tagged_bytes("p", addr.paymenthash)
|
||||
tags_set = set()
|
||||
|
||||
for k, v in addr.tags:
|
||||
# BOLT #11:
|
||||
#
|
||||
# A writer MUST NOT include more than one `d`, `h`, `n` or `x` fields,
|
||||
if k in ("d", "h", "n", "x"):
|
||||
if k in tags_set:
|
||||
raise ValueError(f"Duplicate '{k}' tag")
|
||||
|
||||
if k == "r":
|
||||
route = bitstring.BitArray()
|
||||
for step in v:
|
||||
pubkey, channel, feebase, feerate, cltv = step
|
||||
route.append(
|
||||
bitstring.BitArray(pubkey)
|
||||
+ bitstring.BitArray(channel)
|
||||
+ bitstring.pack("intbe:32", feebase)
|
||||
+ bitstring.pack("intbe:32", feerate)
|
||||
+ bitstring.pack("intbe:16", cltv)
|
||||
)
|
||||
data += tagged("r", route)
|
||||
elif k == "f":
|
||||
# NOTE: there was an error fallback here that's now removed
|
||||
continue
|
||||
elif k == "d":
|
||||
data += tagged_bytes("d", v.encode())
|
||||
elif k == "x":
|
||||
# Get minimal length by trimming leading 5 bits at a time.
|
||||
expirybits = bitstring.pack("intbe:64", v)[4:64]
|
||||
while expirybits.startswith("0b00000"):
|
||||
expirybits = expirybits[5:]
|
||||
data += tagged("x", expirybits)
|
||||
elif k == "h":
|
||||
data += tagged_bytes("h", v)
|
||||
elif k == "n":
|
||||
data += tagged_bytes("n", v)
|
||||
else:
|
||||
# FIXME: Support unknown tags?
|
||||
raise ValueError(f"Unknown tag {k}")
|
||||
|
||||
tags_set.add(k)
|
||||
|
||||
# BOLT #11:
|
||||
#
|
||||
# A writer MUST include either a `d` or `h` field, and MUST NOT include
|
||||
# both.
|
||||
if "d" in tags_set and "h" in tags_set:
|
||||
raise ValueError("Cannot include both 'd' and 'h'")
|
||||
if "d" not in tags_set and "h" not in tags_set:
|
||||
raise ValueError("Must include either 'd' or 'h'")
|
||||
|
||||
# We actually sign the hrp, then data (padded to 8 bits with zeroes).
|
||||
privkey = secp256k1.PrivateKey(bytes.fromhex(privkey))
|
||||
sig = privkey.ecdsa_sign_recoverable(
|
||||
bytearray([ord(c) for c in hrp]) + data.tobytes()
|
||||
)
|
||||
# This doesn't actually serialize, but returns a pair of values :(
|
||||
sig, recid = privkey.ecdsa_recoverable_serialize(sig)
|
||||
data += bytes(sig) + bytes([recid])
|
||||
|
||||
return bech32_encode(hrp, bitarray_to_u5(data))
|
||||
|
||||
|
||||
class LnAddr:
|
||||
def __init__(
|
||||
self,
|
||||
paymenthash=None,
|
||||
amount=None,
|
||||
currency="bc",
|
||||
tags=None,
|
||||
date=None,
|
||||
fallback=None,
|
||||
):
|
||||
self.date = int(time.time()) if not date else int(date)
|
||||
self.tags = [] if not tags else tags
|
||||
self.unknown_tags = []
|
||||
self.paymenthash = paymenthash
|
||||
self.signature = None
|
||||
self.pubkey = None
|
||||
self.fallback = fallback
|
||||
self.currency = currency
|
||||
self.amount = amount
|
||||
|
||||
def __str__(self):
|
||||
assert self.pubkey, "LnAddr, pubkey must be set"
|
||||
pubkey = bytes.hex(self.pubkey.serialize())
|
||||
tags = ", ".join([f"{k}={v}" for k, v in self.tags])
|
||||
return f"LnAddr[{pubkey}, amount={self.amount}{self.currency} tags=[{tags}]]"
|
||||
|
||||
|
||||
def shorten_amount(amount):
|
||||
"""Given an amount in bitcoin, shorten it"""
|
||||
# Convert to pico initially
|
||||
amount = int(amount * 10**12)
|
||||
units = ["p", "n", "u", "m", ""]
|
||||
unit = ""
|
||||
for unit in units:
|
||||
if amount % 1000 == 0:
|
||||
amount //= 1000
|
||||
else:
|
||||
break
|
||||
return str(amount) + unit
|
||||
|
||||
|
||||
def _unshorten_amount(amount: str) -> int:
|
||||
"""Given a shortened amount, return millisatoshis"""
|
||||
# BOLT #11:
|
||||
# The following `multiplier` letters are defined:
|
||||
#
|
||||
# * `m` (milli): multiply by 0.001
|
||||
# * `u` (micro): multiply by 0.000001
|
||||
# * `n` (nano): multiply by 0.000000001
|
||||
# * `p` (pico): multiply by 0.000000000001
|
||||
units = {"p": 10**12, "n": 10**9, "u": 10**6, "m": 10**3}
|
||||
unit = str(amount)[-1]
|
||||
|
||||
# BOLT #11:
|
||||
# A reader SHOULD fail if `amount` contains a non-digit, or is followed by
|
||||
# anything except a `multiplier` in the table above.
|
||||
if not re.fullmatch(r"\d+[pnum]?", str(amount)):
|
||||
raise ValueError(f"Invalid amount '{amount}'")
|
||||
|
||||
if unit in units:
|
||||
return int(int(amount[:-1]) * 100_000_000_000 / units[unit])
|
||||
else:
|
||||
return int(amount) * 100_000_000_000
|
||||
|
||||
|
||||
def _pull_tagged(stream):
|
||||
tag = stream.read(5).uint
|
||||
length = stream.read(5).uint * 32 + stream.read(5).uint
|
||||
return (CHARSET[tag], stream.read(length * 5), stream)
|
||||
|
||||
|
||||
# Tagged field containing BitArray
|
||||
def tagged(char, bits):
|
||||
# Tagged fields need to be zero-padded to 5 bits.
|
||||
while bits.len % 5 != 0:
|
||||
bits.append("0b0")
|
||||
return (
|
||||
bitstring.pack(
|
||||
"uint:5, uint:5, uint:5",
|
||||
CHARSET.find(char),
|
||||
(bits.len / 5) / 32,
|
||||
(bits.len / 5) % 32,
|
||||
)
|
||||
+ bits
|
||||
)
|
||||
|
||||
|
||||
def tagged_bytes(char, bits):
|
||||
return tagged(char, bitstring.BitArray(bits))
|
||||
|
||||
|
||||
def _trim_to_bytes(barr):
|
||||
# Adds a byte if necessary.
|
||||
b = barr.tobytes()
|
||||
if barr.len % 8 != 0:
|
||||
return b[:-1]
|
||||
return b
|
||||
|
||||
|
||||
def _readable_scid(short_channel_id: int) -> str:
|
||||
blockheight = (short_channel_id >> 40) & 0xFFFFFF
|
||||
transactionindex = (short_channel_id >> 16) & 0xFFFFFF
|
||||
outputindex = short_channel_id & 0xFFFF
|
||||
return f"{blockheight}x{transactionindex}x{outputindex}"
|
||||
|
||||
|
||||
def _u5_to_bitarray(arr: List[int]) -> bitstring.BitArray:
|
||||
ret = bitstring.BitArray()
|
||||
for a in arr:
|
||||
ret += bitstring.pack("uint:5", a)
|
||||
return ret
|
||||
|
||||
|
||||
def bitarray_to_u5(barr):
|
||||
assert barr.len % 5 == 0
|
||||
ret = []
|
||||
s = bitstring.ConstBitStream(barr)
|
||||
while s.pos != s.len:
|
||||
ret.append(s.read(5).uint) # type: ignore
|
||||
return ret
|
||||
from bolt11 import (
|
||||
Bolt11 as Invoice, # noqa: F401
|
||||
)
|
||||
from bolt11 import (
|
||||
decode, # noqa: F401
|
||||
encode, # noqa: F401
|
||||
)
|
||||
|
||||
@@ -1,45 +1,109 @@
|
||||
import asyncio
|
||||
from functools import wraps
|
||||
from pathlib import Path
|
||||
from typing import Optional, Tuple
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import click
|
||||
import httpx
|
||||
from fastapi.exceptions import HTTPException
|
||||
from loguru import logger
|
||||
from packaging import version
|
||||
|
||||
from lnbits.core.models import User
|
||||
from lnbits.core.services import check_admin_settings
|
||||
from lnbits.core.views.api import api_install_extension, api_uninstall_extension
|
||||
from lnbits.settings import settings
|
||||
|
||||
from .core import db as core_db
|
||||
from .core import migrations as core_migrations
|
||||
from .core.crud import get_dbversions, get_inactive_extensions
|
||||
from .core.crud import (
|
||||
get_dbversions,
|
||||
get_inactive_extensions,
|
||||
get_installed_extension,
|
||||
get_installed_extensions,
|
||||
)
|
||||
from .core.helpers import migrate_extension_database, run_migration
|
||||
from .db import COCKROACH, POSTGRES, SQLITE
|
||||
from .extension_manager import get_valid_extensions
|
||||
from .extension_manager import (
|
||||
CreateExtension,
|
||||
ExtensionRelease,
|
||||
InstallableExtension,
|
||||
get_valid_extensions,
|
||||
)
|
||||
|
||||
|
||||
def coro(f):
|
||||
@wraps(f)
|
||||
def wrapper(*args, **kwargs):
|
||||
return asyncio.run(f(*args, **kwargs))
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
@click.group()
|
||||
def command_group():
|
||||
def lnbits_cli():
|
||||
"""
|
||||
Python CLI for LNbits
|
||||
"""
|
||||
|
||||
|
||||
@click.command("superuser")
|
||||
@lnbits_cli.group()
|
||||
def db():
|
||||
"""
|
||||
Database related commands
|
||||
"""
|
||||
|
||||
|
||||
@lnbits_cli.group()
|
||||
def extensions():
|
||||
"""
|
||||
Extensions related commands
|
||||
"""
|
||||
|
||||
|
||||
def get_super_user() -> Optional[str]:
|
||||
"""Get the superuser"""
|
||||
superuser_file = Path(settings.lnbits_data_folder, ".super_user")
|
||||
if not superuser_file.exists() or not superuser_file.is_file():
|
||||
raise ValueError(
|
||||
"Superuser id not found. Please check that the file "
|
||||
+ f"'{superuser_file.absolute()}' exists and has read permissions."
|
||||
)
|
||||
with open(superuser_file, "r") as file:
|
||||
return file.readline()
|
||||
|
||||
|
||||
@lnbits_cli.command("superuser")
|
||||
def superuser():
|
||||
"""Prints the superuser"""
|
||||
with open(".super_user", "r") as file:
|
||||
print(f"http://{settings.host}:{settings.port}/wallet?usr={file.readline()}")
|
||||
try:
|
||||
click.echo(get_super_user())
|
||||
except ValueError as e:
|
||||
click.echo(str(e))
|
||||
|
||||
|
||||
@click.command("delete-settings")
|
||||
def delete_settings():
|
||||
@lnbits_cli.command("superuser-url")
|
||||
def superuser_url():
|
||||
"""Prints the superuser"""
|
||||
try:
|
||||
click.echo(
|
||||
f"http://{settings.host}:{settings.port}/wallet?usr={get_super_user()}"
|
||||
)
|
||||
except ValueError as e:
|
||||
click.echo(str(e))
|
||||
|
||||
|
||||
@lnbits_cli.command("delete-settings")
|
||||
@coro
|
||||
async def delete_settings():
|
||||
"""Deletes the settings"""
|
||||
|
||||
async def wrap():
|
||||
async with core_db.connect() as conn:
|
||||
await conn.execute("DELETE from settings")
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.run_until_complete(wrap())
|
||||
async with core_db.connect() as conn:
|
||||
await conn.execute("DELETE from settings")
|
||||
|
||||
|
||||
@click.command("database-migrate")
|
||||
@db.command("migrate")
|
||||
def database_migrate():
|
||||
"""Migrate databases"""
|
||||
loop = asyncio.get_event_loop()
|
||||
@@ -70,16 +134,19 @@ async def migrate_databases():
|
||||
|
||||
current_versions = await get_dbversions(conn)
|
||||
core_version = current_versions.get("core", 0)
|
||||
await run_migration(conn, core_migrations, core_version)
|
||||
await run_migration(conn, core_migrations, "core", core_version)
|
||||
|
||||
for ext in get_valid_extensions():
|
||||
current_version = current_versions.get(ext.code, 0)
|
||||
await migrate_extension_database(ext, current_version)
|
||||
try:
|
||||
await migrate_extension_database(ext, current_version)
|
||||
except Exception as e:
|
||||
logger.exception(f"Error migrating extension {ext.code}: {e}")
|
||||
|
||||
logger.info("✔️ All migrations done.")
|
||||
|
||||
|
||||
@click.command("database-versions")
|
||||
@db.command("versions")
|
||||
def database_versions():
|
||||
"""Show current database versions"""
|
||||
loop = asyncio.get_event_loop()
|
||||
@@ -98,14 +165,395 @@ async def load_disabled_extension_list() -> None:
|
||||
settings.lnbits_deactivated_extensions += inactive_extensions
|
||||
|
||||
|
||||
@extensions.command("list")
|
||||
@coro
|
||||
async def extensions_list():
|
||||
"""Show currently installed extensions"""
|
||||
click.echo("Installed extensions:")
|
||||
|
||||
from lnbits.app import build_all_installed_extensions_list
|
||||
|
||||
for ext in await build_all_installed_extensions_list():
|
||||
assert ext.installed_release, f"Extension {ext.id} has no installed_release"
|
||||
click.echo(f" - {ext.id} ({ext.installed_release.version})")
|
||||
|
||||
|
||||
@extensions.command("update")
|
||||
@click.argument("extension", required=False)
|
||||
@click.option("-a", "--all", is_flag=True, help="Update all extensions.")
|
||||
@click.option(
|
||||
"-i", "--repo-index", help="Select the index of the repository to be used."
|
||||
)
|
||||
@click.option(
|
||||
"-s",
|
||||
"--source-repo",
|
||||
help="""
|
||||
Provide the repository URL to be used for updating.
|
||||
The URL must be one present in `LNBITS_EXTENSIONS_MANIFESTS`
|
||||
or configured via the Admin UI. This option is required only
|
||||
if an extension is present in more than one repository.
|
||||
""",
|
||||
)
|
||||
@click.option(
|
||||
"-u",
|
||||
"--url",
|
||||
help="Use this option to update a running server. Eg: 'http://localhost:5000'.",
|
||||
)
|
||||
@click.option(
|
||||
"-d",
|
||||
"--admin-user",
|
||||
help="Admin user ID (must have permissions to install extensions).",
|
||||
)
|
||||
@coro
|
||||
async def extensions_update(
|
||||
extension: Optional[str] = None,
|
||||
all: Optional[bool] = False,
|
||||
repo_index: Optional[str] = None,
|
||||
source_repo: Optional[str] = None,
|
||||
url: Optional[str] = None,
|
||||
admin_user: Optional[str] = None,
|
||||
):
|
||||
"""
|
||||
Update extension to the latest version.
|
||||
If an extension is not present it will be instaled.
|
||||
"""
|
||||
if not extension and not all:
|
||||
click.echo("Extension ID is required.")
|
||||
click.echo("Or specify the '--all' flag to update all extensions")
|
||||
return
|
||||
if extension and all:
|
||||
click.echo("Only one of extension ID or the '--all' flag must be specified")
|
||||
return
|
||||
if url and not _is_url(url):
|
||||
click.echo(f"Invalid '--url' option value: {url}")
|
||||
return
|
||||
|
||||
if not await _can_run_operation(url):
|
||||
return
|
||||
|
||||
if extension:
|
||||
await update_extension(extension, repo_index, source_repo, url, admin_user)
|
||||
return
|
||||
|
||||
click.echo("Updating all extensions...")
|
||||
installed_extensions = await get_installed_extensions()
|
||||
updated_extensions = []
|
||||
for e in installed_extensions:
|
||||
try:
|
||||
click.echo(f"""{"="*50} {e.id} {"="*(50-len(e.id))} """)
|
||||
success, msg = await update_extension(
|
||||
e.id, repo_index, source_repo, url, admin_user
|
||||
)
|
||||
if version:
|
||||
updated_extensions.append(
|
||||
{"id": e.id, "success": success, "message": msg}
|
||||
)
|
||||
except Exception as ex:
|
||||
click.echo(f"Failed to install extension '{e.id}': {ex}")
|
||||
|
||||
if len(updated_extensions) == 0:
|
||||
click.echo("No extension was updated.")
|
||||
return
|
||||
|
||||
for u in sorted(updated_extensions, key=lambda d: str(d["id"])):
|
||||
status = "updated to " if u["success"] else "not updated "
|
||||
click.echo(
|
||||
f"""'{u["id"]}' {" "*(20-len(str(u["id"])))}"""
|
||||
+ f""" - {status}: '{u["message"]}'"""
|
||||
)
|
||||
|
||||
|
||||
@extensions.command("install")
|
||||
@click.argument("extension")
|
||||
@click.option(
|
||||
"-i", "--repo-index", help="Select the index of the repository to be used."
|
||||
)
|
||||
@click.option(
|
||||
"-s",
|
||||
"--source-repo",
|
||||
help="""
|
||||
Provide the repository URL to be used for updating.
|
||||
The URL must be one present in `LNBITS_EXTENSIONS_MANIFESTS`
|
||||
or configured via the Admin UI. This option is required only
|
||||
if an extension is present in more than one repository.
|
||||
""",
|
||||
)
|
||||
@click.option(
|
||||
"-u",
|
||||
"--url",
|
||||
help="Use this option to update a running server. Eg: 'http://localhost:5000'.",
|
||||
)
|
||||
@click.option(
|
||||
"-d",
|
||||
"--admin-user",
|
||||
help="Admin user ID (must have permissions to install extensions).",
|
||||
)
|
||||
@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,
|
||||
):
|
||||
"""Install a extension"""
|
||||
click.echo(f"Installing {extension}... {repo_index}")
|
||||
if url and not _is_url(url):
|
||||
click.echo(f"Invalid '--url' option value: {url}")
|
||||
return
|
||||
|
||||
if not await _can_run_operation(url):
|
||||
return
|
||||
await install_extension(extension, repo_index, source_repo, url, admin_user)
|
||||
|
||||
|
||||
@extensions.command("uninstall")
|
||||
@click.argument("extension")
|
||||
@click.option(
|
||||
"-u",
|
||||
"--url",
|
||||
help="Use this option to update a running server. Eg: 'http://localhost:5000'.",
|
||||
)
|
||||
@click.option(
|
||||
"-d",
|
||||
"--admin-user",
|
||||
help="Admin user ID (must have permissions to install extensions).",
|
||||
)
|
||||
@coro
|
||||
async def extensions_uninstall(
|
||||
extension: str, url: Optional[str] = None, admin_user: Optional[str] = None
|
||||
):
|
||||
"""Uninstall a extension"""
|
||||
click.echo(f"Uninstalling '{extension}'...")
|
||||
|
||||
if url and not _is_url(url):
|
||||
click.echo(f"Invalid '--url' option value: {url}")
|
||||
return
|
||||
|
||||
if not await _can_run_operation(url):
|
||||
return
|
||||
try:
|
||||
await _call_uninstall_extension(extension, url, admin_user)
|
||||
click.echo(f"Extension '{extension}' uninstalled.")
|
||||
except HTTPException as ex:
|
||||
click.echo(f"Failed to uninstall '{extension}' Error: '{ex.detail}'.")
|
||||
return False, ex.detail
|
||||
except Exception as ex:
|
||||
click.echo(f"Failed to uninstall '{extension}': {str(ex)}.")
|
||||
return False, str(ex)
|
||||
|
||||
|
||||
def main():
|
||||
"""main function"""
|
||||
command_group.add_command(superuser)
|
||||
command_group.add_command(delete_settings)
|
||||
command_group.add_command(database_migrate)
|
||||
command_group.add_command(database_versions)
|
||||
command_group()
|
||||
lnbits_cli()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
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,
|
||||
) -> Tuple[bool, str]:
|
||||
try:
|
||||
release = await _select_release(extension, repo_index, source_repo)
|
||||
if not release:
|
||||
return False, "No release selected"
|
||||
|
||||
data = CreateExtension(
|
||||
ext_id=extension, archive=release.archive, source_repo=release.source_repo
|
||||
)
|
||||
await _call_install_extension(data, url, admin_user)
|
||||
click.echo(f"Extension '{extension}' ({release.version}) installed.")
|
||||
return True, release.version
|
||||
except HTTPException as ex:
|
||||
click.echo(f"Failed to install '{extension}' Error: '{ex.detail}'.")
|
||||
return False, ex.detail
|
||||
except Exception as ex:
|
||||
click.echo(f"Failed to install '{extension}': {str(ex)}.")
|
||||
return False, str(ex)
|
||||
|
||||
|
||||
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,
|
||||
) -> Tuple[bool, str]:
|
||||
try:
|
||||
click.echo(f"Updating '{extension}' extension.")
|
||||
installed_ext = await get_installed_extension(extension)
|
||||
if not installed_ext:
|
||||
click.echo(
|
||||
f"Extension '{extension}' is not installed. Preparing to install..."
|
||||
)
|
||||
return await install_extension(extension, repo_index, source_repo, url)
|
||||
|
||||
click.echo(f"Current '{extension}' version: {installed_ext.installed_version}.")
|
||||
|
||||
assert (
|
||||
installed_ext.installed_release
|
||||
), "Cannot find previously installed release. Please uninstall first."
|
||||
|
||||
release = await _select_release(extension, repo_index, source_repo)
|
||||
if not release:
|
||||
return False, "No release selected."
|
||||
if (
|
||||
release.version == installed_ext.installed_version
|
||||
and release.source_repo == installed_ext.installed_release.source_repo
|
||||
):
|
||||
click.echo(f"Extension '{extension}' already up to date.")
|
||||
return False, "Already up to date"
|
||||
|
||||
click.echo(f"Updating '{extension}' extension to version: {release.version }")
|
||||
|
||||
data = CreateExtension(
|
||||
ext_id=extension, archive=release.archive, source_repo=release.source_repo
|
||||
)
|
||||
|
||||
await _call_install_extension(data, url, admin_user)
|
||||
click.echo(f"Extension '{extension}' updated.")
|
||||
return True, release.version
|
||||
except HTTPException as ex:
|
||||
click.echo(f"Failed to update '{extension}' Error: '{ex.detail}.")
|
||||
return False, ex.detail
|
||||
except Exception as ex:
|
||||
click.echo(f"Failed to update '{extension}': {str(ex)}.")
|
||||
return False, str(ex)
|
||||
|
||||
|
||||
async def _select_release(
|
||||
extension: str,
|
||||
repo_index: Optional[str] = None,
|
||||
source_repo: Optional[str] = None,
|
||||
) -> Optional[ExtensionRelease]:
|
||||
all_releases = await InstallableExtension.get_extension_releases(extension)
|
||||
if len(all_releases) == 0:
|
||||
click.echo(f"No repository found for extension '{extension}'.")
|
||||
return None
|
||||
|
||||
latest_repo_releases = _get_latest_release_per_repo(all_releases)
|
||||
|
||||
if source_repo:
|
||||
if source_repo not in latest_repo_releases:
|
||||
click.echo(f"Repository not found: '{source_repo}'")
|
||||
return None
|
||||
return latest_repo_releases[source_repo]
|
||||
|
||||
if len(latest_repo_releases) == 1:
|
||||
return latest_repo_releases[list(latest_repo_releases.keys())[0]]
|
||||
|
||||
repos = list(latest_repo_releases.keys())
|
||||
repos.sort()
|
||||
if not repo_index:
|
||||
click.echo("Multiple repos found. Please select one using:")
|
||||
click.echo(" --repo-index option for index of the repo, or")
|
||||
click.echo(" --source-repo option for the manifest URL")
|
||||
click.echo("")
|
||||
click.echo("Repositories: ")
|
||||
|
||||
for index, repo in enumerate(repos):
|
||||
release = latest_repo_releases[repo]
|
||||
click.echo(f" [{index}] {repo} --> {release.version}")
|
||||
click.echo("")
|
||||
return None
|
||||
|
||||
if not repo_index.isnumeric() or not 0 <= int(repo_index) < len(repos):
|
||||
click.echo(f"--repo-index must be between '0' and '{len(repos) - 1}'")
|
||||
return None
|
||||
|
||||
return latest_repo_releases[repos[int(repo_index)]]
|
||||
|
||||
|
||||
def _get_latest_release_per_repo(all_releases):
|
||||
latest_repo_releases = {}
|
||||
for release in all_releases:
|
||||
try:
|
||||
if not release.is_version_compatible:
|
||||
continue
|
||||
# do not remove, parsing also validates
|
||||
release_version = version.parse(release.version)
|
||||
if release.source_repo not in latest_repo_releases:
|
||||
latest_repo_releases[release.source_repo] = release
|
||||
continue
|
||||
if release_version > version.parse(
|
||||
latest_repo_releases[release.source_repo].version
|
||||
):
|
||||
latest_repo_releases[release.source_repo] = release
|
||||
except version.InvalidVersion as ex:
|
||||
logger.warning(f"Invalid version {release.name}: {ex}")
|
||||
return latest_repo_releases
|
||||
|
||||
|
||||
async def _call_install_extension(
|
||||
data: CreateExtension, url: Optional[str], user_id: Optional[str] = None
|
||||
):
|
||||
if url:
|
||||
user_id = user_id or get_super_user()
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.post(
|
||||
f"{url}/api/v1/extension?usr={user_id}", json=data.dict(), timeout=40
|
||||
)
|
||||
resp.raise_for_status()
|
||||
else:
|
||||
await api_install_extension(data, User(id="mock_id"))
|
||||
|
||||
|
||||
async def _call_uninstall_extension(
|
||||
extension: str, url: Optional[str], user_id: Optional[str] = None
|
||||
):
|
||||
if url:
|
||||
user_id = user_id or get_super_user()
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.delete(
|
||||
f"{url}/api/v1/extension/{extension}?usr={user_id}", timeout=40
|
||||
)
|
||||
resp.raise_for_status()
|
||||
else:
|
||||
await api_uninstall_extension(extension, User(id="mock_id"))
|
||||
|
||||
|
||||
async def _can_run_operation(url) -> bool:
|
||||
await check_admin_settings()
|
||||
if await _is_lnbits_started(url):
|
||||
if not url:
|
||||
click.echo("LNbits server is started. Please either:")
|
||||
click.echo(
|
||||
f" - use the '--url' option. Eg: --url=http://{settings.host}:{settings.port}"
|
||||
)
|
||||
click.echo(
|
||||
f" - stop the server running at 'http://{settings.host}:{settings.port}'"
|
||||
)
|
||||
|
||||
return False
|
||||
elif url:
|
||||
click.echo(
|
||||
"The option '--url' has been provided,"
|
||||
+ f" but no server found runnint at '{url}'"
|
||||
)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def _is_lnbits_started(url: Optional[str]):
|
||||
try:
|
||||
url = url or f"http://{settings.host}:{settings.port}/api/v1/health"
|
||||
async with httpx.AsyncClient() as client:
|
||||
await client.get(url)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _is_url(url):
|
||||
try:
|
||||
result = urlparse(url)
|
||||
return all([result.scheme, result.netloc])
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
@@ -1,15 +1,24 @@
|
||||
from fastapi.routing import APIRouter
|
||||
from fastapi import APIRouter
|
||||
|
||||
from lnbits.core.models import CoreAppExtra
|
||||
from lnbits.db import Database
|
||||
from .db import core_app_extra, db
|
||||
from .views.admin_api import admin_router
|
||||
from .views.api import api_router
|
||||
|
||||
db = Database("database")
|
||||
# this compat is needed for usermanager extension
|
||||
from .views.generic import generic_router, update_user_extension
|
||||
from .views.node_api import node_router, public_node_router, super_node_router
|
||||
from .views.public_api import public_router
|
||||
|
||||
core_app: APIRouter = APIRouter(tags=["Core"])
|
||||
# backwards compatibility for extensions
|
||||
core_app = APIRouter(tags=["Core"])
|
||||
|
||||
core_app_extra: CoreAppExtra = CoreAppExtra()
|
||||
|
||||
from .views.admin_api import *
|
||||
from .views.api import *
|
||||
from .views.generic import *
|
||||
from .views.public_api import *
|
||||
def init_core_routers(app):
|
||||
app.include_router(core_app)
|
||||
app.include_router(generic_router)
|
||||
app.include_router(public_router)
|
||||
app.include_router(api_router)
|
||||
app.include_router(node_router)
|
||||
app.include_router(super_node_router)
|
||||
app.include_router(public_node_router)
|
||||
app.include_router(admin_router)
|
||||
|
||||
@@ -1,19 +1,33 @@
|
||||
import datetime
|
||||
import json
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any, Dict, List, Literal, Optional
|
||||
from urllib.parse import urlparse
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import shortuuid
|
||||
|
||||
from lnbits import bolt11
|
||||
from lnbits.core.db import db
|
||||
from lnbits.core.models import WalletType
|
||||
from lnbits.db import Connection, Database, Filters, Page
|
||||
from lnbits.db import DB_TYPE, SQLITE, Connection, Database, Filters, Page
|
||||
from lnbits.extension_manager import InstallableExtension
|
||||
from lnbits.settings import AdminSettings, EditableSettings, SuperSettings, settings
|
||||
from lnbits.settings import (
|
||||
AdminSettings,
|
||||
EditableSettings,
|
||||
SuperSettings,
|
||||
WebPushSettings,
|
||||
settings,
|
||||
)
|
||||
|
||||
from . import db
|
||||
from .models import BalanceCheck, Payment, PaymentFilters, TinyURL, User, Wallet
|
||||
from .models import (
|
||||
BalanceCheck,
|
||||
Payment,
|
||||
PaymentFilters,
|
||||
PaymentHistoryPoint,
|
||||
TinyURL,
|
||||
User,
|
||||
Wallet,
|
||||
WebPushSubscription,
|
||||
)
|
||||
|
||||
# accounts
|
||||
# --------
|
||||
@@ -62,7 +76,7 @@ async def get_user(user_id: str, conn: Optional[Connection] = None) -> Optional[
|
||||
SELECT balance FROM balances WHERE wallet = wallets.id
|
||||
), 0) AS balance_msat
|
||||
FROM wallets
|
||||
WHERE "user" = ?
|
||||
WHERE "user" = ? and wallets.deleted = false
|
||||
""",
|
||||
(user_id,),
|
||||
)
|
||||
@@ -167,13 +181,15 @@ async def drop_extension_db(*, ext_id: str, conn: Optional[Connection] = None) -
|
||||
)
|
||||
|
||||
|
||||
async def get_installed_extension(ext_id: str, conn: Optional[Connection] = None):
|
||||
async def get_installed_extension(
|
||||
ext_id: str, conn: Optional[Connection] = None
|
||||
) -> Optional[InstallableExtension]:
|
||||
row = await (conn or db).fetchone(
|
||||
"SELECT * FROM installed_extensions WHERE id = ?",
|
||||
(ext_id,),
|
||||
)
|
||||
|
||||
return dict(row) if row else None
|
||||
return InstallableExtension.from_row(row) if row else None
|
||||
|
||||
|
||||
async def get_installed_extensions(
|
||||
@@ -268,11 +284,8 @@ async def delete_wallet(
|
||||
) -> None:
|
||||
await (conn or db).execute(
|
||||
"""
|
||||
UPDATE wallets AS w
|
||||
SET
|
||||
"user" = 'del:' || w."user",
|
||||
adminkey = 'del:' || w.adminkey,
|
||||
inkey = 'del:' || w.inkey
|
||||
UPDATE wallets
|
||||
SET deleted = true
|
||||
WHERE id = ? AND "user" = ?
|
||||
""",
|
||||
(wallet_id, user_id),
|
||||
@@ -523,22 +536,17 @@ async def create_payment(
|
||||
memo: str,
|
||||
fee: int = 0,
|
||||
preimage: Optional[str] = None,
|
||||
expiry: Optional[datetime.datetime] = None,
|
||||
pending: bool = True,
|
||||
extra: Optional[Dict] = None,
|
||||
webhook: Optional[str] = None,
|
||||
conn: Optional[Connection] = None,
|
||||
) -> Payment:
|
||||
# todo: add this when tests are fixed
|
||||
previous_payment = await get_wallet_payment(wallet_id, payment_hash, conn=conn)
|
||||
# we don't allow the creation of the same invoice twice
|
||||
# note: this can be removed if the db uniquess constarints are set appropriately
|
||||
previous_payment = await get_standalone_payment(checking_id, conn=conn)
|
||||
assert previous_payment is None, "Payment already exists"
|
||||
|
||||
try:
|
||||
invoice = bolt11.decode(payment_request)
|
||||
expiration_date = datetime.datetime.fromtimestamp(invoice.date + invoice.expiry)
|
||||
except Exception:
|
||||
# assume maximum bolt11 expiry of 31 days to be on the safe side
|
||||
expiration_date = datetime.datetime.now() + datetime.timedelta(days=31)
|
||||
|
||||
await (conn or db).execute(
|
||||
"""
|
||||
INSERT INTO apipayments
|
||||
@@ -558,11 +566,11 @@ async def create_payment(
|
||||
fee,
|
||||
(
|
||||
json.dumps(extra)
|
||||
if extra and extra != {} and type(extra) is dict
|
||||
if extra and extra != {} and isinstance(extra, dict)
|
||||
else None
|
||||
),
|
||||
webhook,
|
||||
db.datetime_to_timestamp(expiration_date),
|
||||
db.datetime_to_timestamp(expiry) if expiry else None,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -643,10 +651,77 @@ async def update_payment_extra(
|
||||
)
|
||||
|
||||
|
||||
async def delete_payment(checking_id: str, conn: Optional[Connection] = None) -> None:
|
||||
await (conn or db).execute(
|
||||
"DELETE FROM apipayments WHERE checking_id = ?", (checking_id,)
|
||||
async def update_pending_payments(wallet_id: str):
|
||||
pending_payments = await get_payments(
|
||||
wallet_id=wallet_id,
|
||||
pending=True,
|
||||
exclude_uncheckable=True,
|
||||
)
|
||||
for payment in pending_payments:
|
||||
await payment.check_status()
|
||||
|
||||
|
||||
DateTrunc = Literal["hour", "day", "month"]
|
||||
sqlite_formats = {
|
||||
"hour": "%Y-%m-%d %H:00:00",
|
||||
"day": "%Y-%m-%d 00:00:00",
|
||||
"month": "%Y-%m-01 00:00:00",
|
||||
}
|
||||
|
||||
|
||||
async def get_payments_history(
|
||||
wallet_id: Optional[str] = None,
|
||||
group: DateTrunc = "day",
|
||||
filters: Optional[Filters] = None,
|
||||
) -> List[PaymentHistoryPoint]:
|
||||
if not filters:
|
||||
filters = Filters()
|
||||
where = ["(pending = False OR amount < 0)"]
|
||||
values = []
|
||||
if wallet_id:
|
||||
where.append("wallet = ?")
|
||||
values.append(wallet_id)
|
||||
|
||||
if DB_TYPE == SQLITE and group in sqlite_formats:
|
||||
date_trunc = f"strftime('{sqlite_formats[group]}', time, 'unixepoch')"
|
||||
elif group in ("day", "hour", "month"):
|
||||
date_trunc = f"date_trunc('{group}', time)"
|
||||
else:
|
||||
raise ValueError(f"Invalid group value: {group}")
|
||||
|
||||
transactions = await db.fetchall(
|
||||
f"""
|
||||
SELECT {date_trunc} date,
|
||||
SUM(CASE WHEN amount > 0 THEN amount ELSE 0 END) income,
|
||||
SUM(CASE WHEN amount < 0 THEN abs(amount) + abs(fee) ELSE 0 END) spending
|
||||
FROM apipayments
|
||||
{filters.where(where)}
|
||||
GROUP BY date
|
||||
ORDER BY date DESC
|
||||
""",
|
||||
filters.values(values),
|
||||
)
|
||||
if wallet_id:
|
||||
wallet = await get_wallet(wallet_id)
|
||||
if wallet:
|
||||
balance = wallet.balance_msat
|
||||
else:
|
||||
raise ValueError("Unknown wallet")
|
||||
else:
|
||||
balance = await get_total_balance()
|
||||
|
||||
# since we dont know the balance at the starting point,
|
||||
# we take the current balance and walk backwards
|
||||
results: list[PaymentHistoryPoint] = []
|
||||
for row in transactions:
|
||||
results.insert(
|
||||
0,
|
||||
PaymentHistoryPoint(
|
||||
balance=balance, date=row[0], income=row[1], spending=row[2]
|
||||
),
|
||||
)
|
||||
balance -= row.income - row.spending
|
||||
return results
|
||||
|
||||
|
||||
async def delete_wallet_payment(
|
||||
@@ -661,6 +736,10 @@ async def delete_wallet_payment(
|
||||
async def check_internal(
|
||||
payment_hash: str, conn: Optional[Connection] = None
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Returns the checking_id of the internal payment if it exists,
|
||||
otherwise None
|
||||
"""
|
||||
row = await (conn or db).fetchone(
|
||||
"""
|
||||
SELECT checking_id FROM apipayments
|
||||
@@ -787,12 +866,17 @@ async def get_admin_settings(is_super_user: bool = False) -> Optional[AdminSetti
|
||||
return admin_settings
|
||||
|
||||
|
||||
async def delete_admin_settings():
|
||||
async def delete_admin_settings() -> None:
|
||||
await db.execute("DELETE FROM settings")
|
||||
|
||||
|
||||
async def update_admin_settings(data: EditableSettings):
|
||||
await db.execute("UPDATE settings SET editable_settings = ?", (json.dumps(data),))
|
||||
async def update_admin_settings(data: EditableSettings) -> None:
|
||||
row = await db.fetchone("SELECT editable_settings FROM settings")
|
||||
editable_settings = json.loads(row["editable_settings"]) if row else {}
|
||||
editable_settings.update(data.dict(exclude_unset=True))
|
||||
await db.execute(
|
||||
"UPDATE settings SET editable_settings = ?", (json.dumps(editable_settings),)
|
||||
)
|
||||
|
||||
|
||||
async def update_super_user(super_user: str) -> SuperSettings:
|
||||
@@ -875,3 +959,82 @@ async def delete_tinyurl(tinyurl_id: str):
|
||||
"DELETE FROM tiny_url WHERE id = ?",
|
||||
(tinyurl_id,),
|
||||
)
|
||||
|
||||
|
||||
# push_notification
|
||||
# -----------------
|
||||
|
||||
|
||||
async def get_webpush_settings() -> Optional[WebPushSettings]:
|
||||
row = await db.fetchone("SELECT * FROM webpush_settings")
|
||||
if not row:
|
||||
return None
|
||||
vapid_keypair = json.loads(row["vapid_keypair"])
|
||||
return WebPushSettings(**vapid_keypair)
|
||||
|
||||
|
||||
async def create_webpush_settings(webpush_settings: dict):
|
||||
await db.execute(
|
||||
"INSERT INTO webpush_settings (vapid_keypair) VALUES (?)",
|
||||
(json.dumps(webpush_settings),),
|
||||
)
|
||||
return await get_webpush_settings()
|
||||
|
||||
|
||||
async def get_webpush_subscription(
|
||||
endpoint: str, user: str
|
||||
) -> Optional[WebPushSubscription]:
|
||||
row = await db.fetchone(
|
||||
"SELECT * FROM webpush_subscriptions WHERE endpoint = ? AND user = ?",
|
||||
(
|
||||
endpoint,
|
||||
user,
|
||||
),
|
||||
)
|
||||
return WebPushSubscription(**dict(row)) if row else None
|
||||
|
||||
|
||||
async def get_webpush_subscriptions_for_user(
|
||||
user: str,
|
||||
) -> List[WebPushSubscription]:
|
||||
rows = await db.fetchall(
|
||||
"SELECT * FROM webpush_subscriptions WHERE user = ?",
|
||||
(user,),
|
||||
)
|
||||
return [WebPushSubscription(**dict(row)) for row in rows]
|
||||
|
||||
|
||||
async def create_webpush_subscription(
|
||||
endpoint: str, user: str, data: str, host: str
|
||||
) -> WebPushSubscription:
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO webpush_subscriptions (endpoint, user, data, host)
|
||||
VALUES (?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
endpoint,
|
||||
user,
|
||||
data,
|
||||
host,
|
||||
),
|
||||
)
|
||||
subscription = await get_webpush_subscription(endpoint, user)
|
||||
assert subscription, "Newly created webpush subscription couldn't be retrieved"
|
||||
return subscription
|
||||
|
||||
|
||||
async def delete_webpush_subscription(endpoint: str, user: str) -> None:
|
||||
await db.execute(
|
||||
"DELETE FROM webpush_subscriptions WHERE endpoint = ? AND user = ?",
|
||||
(
|
||||
endpoint,
|
||||
user,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def delete_webpush_subscriptions(endpoint: str) -> None:
|
||||
await db.execute(
|
||||
"DELETE FROM webpush_subscriptions WHERE endpoint = ?", (endpoint,)
|
||||
)
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
from lnbits.core.models import CoreAppExtra
|
||||
from lnbits.db import Database
|
||||
|
||||
db = Database("database")
|
||||
core_app_extra: CoreAppExtra = CoreAppExtra()
|
||||
@@ -6,11 +6,11 @@ from uuid import UUID
|
||||
import httpx
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.core.db import db as core_db
|
||||
from lnbits.db import Connection
|
||||
from lnbits.extension_manager import Extension
|
||||
from lnbits.settings import settings
|
||||
|
||||
from . import db as core_db
|
||||
from .crud import update_migration_version
|
||||
|
||||
|
||||
@@ -25,12 +25,13 @@ async def migrate_extension_database(ext: Extension, current_version):
|
||||
)
|
||||
|
||||
async with ext_db.connect() as ext_conn:
|
||||
await run_migration(ext_conn, ext_migrations, current_version)
|
||||
await run_migration(ext_conn, ext_migrations, ext.code, current_version)
|
||||
|
||||
|
||||
async def run_migration(db: Connection, migrations_module: Any, current_version: int):
|
||||
async def run_migration(
|
||||
db: Connection, migrations_module: Any, db_name: str, current_version: int
|
||||
):
|
||||
matcher = re.compile(r"^m(\d\d\d)_")
|
||||
db_name = migrations_module.__name__.split(".")[-2]
|
||||
for key, migrate in migrations_module.__dict__.items():
|
||||
match = matcher.match(key)
|
||||
if match:
|
||||
|
||||
@@ -327,3 +327,69 @@ async def m012_add_currency_to_wallet(db):
|
||||
ALTER TABLE wallets ADD COLUMN currency TEXT
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
async def m013_add_deleted_to_wallets(db):
|
||||
"""
|
||||
Adds deleted column to wallets.
|
||||
"""
|
||||
try:
|
||||
await db.execute(
|
||||
"ALTER TABLE wallets ADD COLUMN deleted BOOLEAN NOT NULL DEFAULT false"
|
||||
)
|
||||
except OperationalError:
|
||||
pass
|
||||
|
||||
|
||||
async def m014_set_deleted_wallets(db):
|
||||
"""
|
||||
Sets deleted column to wallets.
|
||||
"""
|
||||
try:
|
||||
rows = await (
|
||||
await db.execute(
|
||||
"""
|
||||
SELECT *
|
||||
FROM wallets
|
||||
WHERE user LIKE 'del:%'
|
||||
AND adminkey LIKE 'del:%'
|
||||
AND inkey LIKE 'del:%'
|
||||
"""
|
||||
)
|
||||
).fetchall()
|
||||
|
||||
for row in rows:
|
||||
try:
|
||||
user = row[2].split(":")[1]
|
||||
adminkey = row[3].split(":")[1]
|
||||
inkey = row[4].split(":")[1]
|
||||
await db.execute(
|
||||
"""
|
||||
UPDATE wallets SET user = ?, adminkey = ?, inkey = ?, deleted = true
|
||||
WHERE id = ?
|
||||
""",
|
||||
(user, adminkey, inkey, row[0]),
|
||||
)
|
||||
except Exception:
|
||||
continue
|
||||
except OperationalError:
|
||||
# this is necessary now because it may be the case that this migration will
|
||||
# run twice in some environments.
|
||||
# catching errors like this won't be necessary in anymore now that we
|
||||
# keep track of db versions so no migration ever runs twice.
|
||||
pass
|
||||
|
||||
|
||||
async def m015_create_push_notification_subscriptions_table(db):
|
||||
await db.execute(
|
||||
f"""
|
||||
CREATE TABLE IF NOT EXISTS webpush_subscriptions (
|
||||
endpoint TEXT NOT NULL,
|
||||
"user" TEXT NOT NULL,
|
||||
data TEXT NOT NULL,
|
||||
host TEXT NOT NULL,
|
||||
timestamp TIMESTAMP NOT NULL DEFAULT {db.timestamp_now},
|
||||
PRIMARY KEY (endpoint, "user")
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
@@ -29,6 +29,7 @@ class Wallet(BaseModel):
|
||||
inkey: str
|
||||
currency: Optional[str]
|
||||
balance_msat: int
|
||||
deleted: bool
|
||||
|
||||
@property
|
||||
def balance(self) -> int:
|
||||
@@ -234,9 +235,9 @@ class Payment(FromRowModel):
|
||||
return status
|
||||
|
||||
async def delete(self, conn: Optional[Connection] = None) -> None:
|
||||
from .crud import delete_payment
|
||||
from .crud import delete_wallet_payment
|
||||
|
||||
await delete_payment(self.checking_id, conn=conn)
|
||||
await delete_wallet_payment(self.checking_id, self.wallet_id, conn=conn)
|
||||
|
||||
|
||||
class PaymentFilters(FilterModel):
|
||||
@@ -257,6 +258,13 @@ class PaymentFilters(FilterModel):
|
||||
webhook_status: Optional[int]
|
||||
|
||||
|
||||
class PaymentHistoryPoint(BaseModel):
|
||||
date: datetime.datetime
|
||||
income: int
|
||||
spending: int
|
||||
balance: int
|
||||
|
||||
|
||||
class BalanceCheck(BaseModel):
|
||||
wallet: str
|
||||
service: str
|
||||
@@ -267,8 +275,12 @@ class BalanceCheck(BaseModel):
|
||||
return cls(wallet=row["wallet"], service=row["service"], url=row["url"])
|
||||
|
||||
|
||||
def _do_nothing(*_):
|
||||
pass
|
||||
|
||||
|
||||
class CoreAppExtra:
|
||||
register_new_ext_routes: Callable
|
||||
register_new_ext_routes: Callable = _do_nothing
|
||||
register_new_ratelimiter: Callable
|
||||
|
||||
|
||||
@@ -329,3 +341,19 @@ class CreateTopup(BaseModel):
|
||||
|
||||
class CreateLnurlAuth(BaseModel):
|
||||
callback: str
|
||||
|
||||
|
||||
class CreateWallet(BaseModel):
|
||||
name: Optional[str] = None
|
||||
|
||||
|
||||
class CreateWebPushSubscription(BaseModel):
|
||||
subscription: str
|
||||
|
||||
|
||||
class WebPushSubscription(BaseModel):
|
||||
endpoint: str
|
||||
user: str
|
||||
data: str
|
||||
host: str
|
||||
timestamp: str
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
import asyncio
|
||||
import datetime
|
||||
import json
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Tuple, TypedDict
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import httpx
|
||||
from bolt11 import Bolt11
|
||||
from bolt11 import decode as bolt11_decode
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from fastapi import Depends, WebSocket
|
||||
from lnurl import LnurlErrorResponse
|
||||
from lnurl import decode as decode_lnurl
|
||||
from loguru import logger
|
||||
from py_vapid import Vapid
|
||||
from py_vapid.utils import b64urlencode
|
||||
|
||||
from lnbits import bolt11
|
||||
from lnbits.core.db import db
|
||||
from lnbits.db import Connection
|
||||
from lnbits.decorators import WalletTypeInfo, require_admin_key
|
||||
from lnbits.helpers import url_for
|
||||
@@ -25,7 +32,6 @@ from lnbits.utils.exchange_rates import fiat_amount_as_satoshis, satoshis_amount
|
||||
from lnbits.wallets import FAKE_WALLET, get_wallet_class, set_wallet_class
|
||||
from lnbits.wallets.base import PaymentResponse, PaymentStatus
|
||||
|
||||
from . import db
|
||||
from .crud import (
|
||||
check_internal,
|
||||
check_internal_pending,
|
||||
@@ -40,6 +46,7 @@ from .crud import (
|
||||
get_total_balance,
|
||||
get_wallet,
|
||||
get_wallet_payment,
|
||||
update_admin_settings,
|
||||
update_payment_details,
|
||||
update_payment_status,
|
||||
update_super_user,
|
||||
@@ -87,7 +94,9 @@ async def calculate_fiat_amounts(
|
||||
extra["wallet_fiat_amount"] = round(fiat_amount, ndigits=3)
|
||||
extra["wallet_fiat_rate"] = amount_sat / fiat_amount
|
||||
|
||||
logger.debug(f"Calculated fiat amounts for {wallet}: {extra=}")
|
||||
logger.debug(
|
||||
f"Calculated fiat amounts {wallet.id=} {amount=} {currency=}: {extra=}"
|
||||
)
|
||||
|
||||
return amount_sat, extra
|
||||
|
||||
@@ -109,6 +118,9 @@ async def create_invoice(
|
||||
if not amount > 0:
|
||||
raise InvoiceFailure("Amountless invoices not supported.")
|
||||
|
||||
if await get_wallet(wallet_id, conn=conn) is None:
|
||||
raise InvoiceFailure("Wallet does not exist.")
|
||||
|
||||
invoice_memo = None if description_hash else memo
|
||||
|
||||
# use the fake wallet if the invoice is for internal use only
|
||||
@@ -128,7 +140,7 @@ async def create_invoice(
|
||||
if not ok or not payment_request or not checking_id:
|
||||
raise InvoiceFailure(error_message or "unexpected backend error.")
|
||||
|
||||
invoice = bolt11.decode(payment_request)
|
||||
invoice = bolt11_decode(payment_request)
|
||||
|
||||
amount_msat = 1000 * amount_sat
|
||||
await create_payment(
|
||||
@@ -137,6 +149,7 @@ async def create_invoice(
|
||||
payment_request=payment_request,
|
||||
payment_hash=invoice.payment_hash,
|
||||
amount=amount_msat,
|
||||
expiry=get_bolt11_expiry(invoice),
|
||||
memo=memo,
|
||||
extra=extra,
|
||||
webhook=webhook,
|
||||
@@ -165,8 +178,13 @@ async def pay_invoice(
|
||||
If the payment is still in flight, we hope that some other process
|
||||
will regularly check for the payment.
|
||||
"""
|
||||
invoice = bolt11.decode(payment_request)
|
||||
fee_reserve_msat = fee_reserve(invoice.amount_msat)
|
||||
invoice = bolt11_decode(payment_request)
|
||||
|
||||
if not invoice.amount_msat or not invoice.amount_msat > 0:
|
||||
raise ValueError("Amountless invoices not supported.")
|
||||
if max_sat and invoice.amount_msat > max_sat * 1000:
|
||||
raise ValueError("Amount in invoice is too high.")
|
||||
|
||||
async with db.reuse_conn(conn) if conn else db.connect() as conn:
|
||||
temp_id = invoice.payment_hash
|
||||
internal_id = f"internal_{invoice.payment_hash}"
|
||||
@@ -177,7 +195,7 @@ async def pay_invoice(
|
||||
raise ValueError("Amount in invoice is too high.")
|
||||
|
||||
_, extra = await calculate_fiat_amounts(
|
||||
invoice.amount_msat, wallet_id, extra=extra, conn=conn
|
||||
invoice.amount_msat / 1000, wallet_id, extra=extra, conn=conn
|
||||
)
|
||||
|
||||
# put all parameters that don't change here
|
||||
@@ -187,6 +205,7 @@ async def pay_invoice(
|
||||
payment_hash: str
|
||||
amount: int
|
||||
memo: str
|
||||
expiry: Optional[datetime.datetime]
|
||||
extra: Optional[Dict]
|
||||
|
||||
payment_kwargs: PaymentKwargs = PaymentKwargs(
|
||||
@@ -194,6 +213,7 @@ async def pay_invoice(
|
||||
payment_request=payment_request,
|
||||
payment_hash=invoice.payment_hash,
|
||||
amount=-invoice.amount_msat,
|
||||
expiry=get_bolt11_expiry(invoice),
|
||||
memo=description or invoice.description or "",
|
||||
extra=extra,
|
||||
)
|
||||
@@ -207,6 +227,9 @@ async def pay_invoice(
|
||||
# (pending only)
|
||||
internal_checking_id = await check_internal(invoice.payment_hash, conn=conn)
|
||||
if internal_checking_id:
|
||||
fee_reserve_total_msat = fee_reserve_total(
|
||||
invoice.amount_msat, internal=True
|
||||
)
|
||||
# perform additional checks on the internal payment
|
||||
# the payment hash is not enough to make sure that this is the same invoice
|
||||
internal_invoice = await get_standalone_payment(
|
||||
@@ -223,19 +246,22 @@ async def pay_invoice(
|
||||
# create a new payment from this wallet
|
||||
new_payment = await create_payment(
|
||||
checking_id=internal_id,
|
||||
fee=0,
|
||||
fee=0 + abs(fee_reserve_total_msat),
|
||||
pending=False,
|
||||
conn=conn,
|
||||
**payment_kwargs,
|
||||
)
|
||||
else:
|
||||
fee_reserve_total_msat = fee_reserve_total(
|
||||
invoice.amount_msat, internal=False
|
||||
)
|
||||
logger.debug(f"creating temporary payment with id {temp_id}")
|
||||
# create a temporary payment here so we can check if
|
||||
# the balance is enough in the next step
|
||||
try:
|
||||
new_payment = await create_payment(
|
||||
checking_id=temp_id,
|
||||
fee=-fee_reserve_msat,
|
||||
fee=-abs(fee_reserve_total_msat),
|
||||
conn=conn,
|
||||
**payment_kwargs,
|
||||
)
|
||||
@@ -249,14 +275,18 @@ async def pay_invoice(
|
||||
assert wallet, "Wallet for balancecheck could not be fetched"
|
||||
if wallet.balance_msat < 0:
|
||||
logger.debug("balance is too low, deleting temporary payment")
|
||||
if not internal_checking_id and wallet.balance_msat > -fee_reserve_msat:
|
||||
if (
|
||||
not internal_checking_id
|
||||
and wallet.balance_msat > -fee_reserve_total_msat
|
||||
):
|
||||
raise PaymentFailure(
|
||||
f"You must reserve at least ({round(fee_reserve_msat/1000)} sat) to"
|
||||
" cover potential routing fees."
|
||||
f"You must reserve at least ({round(fee_reserve_total_msat/1000)}"
|
||||
" sat) to cover potential routing fees."
|
||||
)
|
||||
raise PermissionError("Insufficient balance.")
|
||||
|
||||
if internal_checking_id:
|
||||
service_fee_msat = service_fee(invoice.amount_msat, internal=True)
|
||||
logger.debug(f"marking temporary payment as not pending {internal_checking_id}")
|
||||
# mark the invoice from the other side as not pending anymore
|
||||
# so the other side only has access to his new money when we are sure
|
||||
@@ -273,6 +303,8 @@ async def pay_invoice(
|
||||
logger.debug(f"enqueuing internal invoice {internal_checking_id}")
|
||||
await internal_invoice_queue.put(internal_checking_id)
|
||||
else:
|
||||
fee_reserve_msat = fee_reserve(invoice.amount_msat, internal=False)
|
||||
service_fee_msat = service_fee(invoice.amount_msat, internal=False)
|
||||
logger.debug(f"backend: sending payment {temp_id}")
|
||||
# actually pay the external invoice
|
||||
WALLET = get_wallet_class()
|
||||
@@ -294,7 +326,10 @@ async def pay_invoice(
|
||||
await update_payment_details(
|
||||
checking_id=temp_id,
|
||||
pending=payment.ok is not True,
|
||||
fee=payment.fee_msat,
|
||||
fee=-(
|
||||
abs(payment.fee_msat if payment.fee_msat else 0)
|
||||
+ abs(service_fee_msat)
|
||||
),
|
||||
preimage=payment.preimage,
|
||||
new_checking_id=payment.checking_id,
|
||||
conn=conn,
|
||||
@@ -322,6 +357,18 @@ async def pay_invoice(
|
||||
f" database: {temp_id}"
|
||||
)
|
||||
|
||||
# credit service fee wallet
|
||||
if settings.lnbits_service_fee_wallet and service_fee_msat:
|
||||
new_payment = await create_payment(
|
||||
wallet_id=settings.lnbits_service_fee_wallet,
|
||||
fee=0,
|
||||
amount=abs(service_fee_msat),
|
||||
memo="service fee",
|
||||
checking_id="service_fee" + temp_id,
|
||||
payment_request=payment_request,
|
||||
payment_hash=invoice.payment_hash,
|
||||
pending=False,
|
||||
)
|
||||
return invoice.payment_hash
|
||||
|
||||
|
||||
@@ -475,12 +522,31 @@ async def check_transaction_status(
|
||||
|
||||
# WARN: this same value must be used for balance check and passed to
|
||||
# WALLET.pay_invoice(), it may cause a vulnerability if the values differ
|
||||
def fee_reserve(amount_msat: int) -> int:
|
||||
def fee_reserve(amount_msat: int, internal: bool = False) -> int:
|
||||
reserve_min = settings.lnbits_reserve_fee_min
|
||||
reserve_percent = settings.lnbits_reserve_fee_percent
|
||||
return max(int(reserve_min), int(amount_msat * reserve_percent / 100.0))
|
||||
|
||||
|
||||
def service_fee(amount_msat: int, internal: bool = False) -> int:
|
||||
service_fee_percent = settings.lnbits_service_fee
|
||||
fee_max = settings.lnbits_service_fee_max * 1000
|
||||
if settings.lnbits_service_fee_wallet:
|
||||
if internal and settings.lnbits_service_fee_ignore_internal:
|
||||
return 0
|
||||
fee_percentage = int(amount_msat / 100 * service_fee_percent)
|
||||
if fee_max > 0 and fee_percentage > fee_max:
|
||||
return fee_max
|
||||
else:
|
||||
return fee_percentage
|
||||
else:
|
||||
return 0
|
||||
|
||||
|
||||
def fee_reserve_total(amount_msat: int, internal: bool = False) -> int:
|
||||
return fee_reserve(amount_msat, internal) + service_fee(amount_msat, internal)
|
||||
|
||||
|
||||
async def send_payment_notification(wallet: Wallet, payment: Payment):
|
||||
await websocketUpdater(
|
||||
wallet.id,
|
||||
@@ -520,7 +586,7 @@ async def check_admin_settings():
|
||||
# create new settings if table is empty
|
||||
logger.warning("Settings DB empty. Inserting default settings.")
|
||||
settings_db = await init_admin_settings(settings.super_user)
|
||||
logger.warning("Initialized settings from enviroment variables.")
|
||||
logger.warning("Initialized settings from environment variables.")
|
||||
|
||||
if settings.super_user and settings.super_user != settings_db.super_user:
|
||||
# .env super_user overwrites DB super_user
|
||||
@@ -528,11 +594,8 @@ async def check_admin_settings():
|
||||
|
||||
update_cached_settings(settings_db.dict())
|
||||
|
||||
admin_url = f'{settings.lnbits_baseurl}wallet?usr=<ID from ".super_user" file>'
|
||||
logger.success(f"✔️ Access super user account at: {admin_url}")
|
||||
|
||||
# saving it to .super_user file
|
||||
with open(".super_user", "w") as file:
|
||||
# saving superuser to {data_dir}/.super_user file
|
||||
with open(Path(settings.lnbits_data_folder) / ".super_user", "w") as file:
|
||||
file.write(settings.super_user)
|
||||
|
||||
# callback for saas
|
||||
@@ -543,6 +606,34 @@ async def check_admin_settings():
|
||||
):
|
||||
send_admin_user_to_saas()
|
||||
|
||||
logger.success(
|
||||
"✔️ Admin UI is enabled. run `poetry run lnbits-cli superuser` "
|
||||
"to get the superuser."
|
||||
)
|
||||
|
||||
|
||||
async def check_webpush_settings():
|
||||
if not settings.lnbits_webpush_privkey:
|
||||
vapid = Vapid()
|
||||
vapid.generate_keys()
|
||||
privkey = vapid.private_pem()
|
||||
assert vapid.public_key, "VAPID public key does not exist"
|
||||
pubkey = b64urlencode(
|
||||
vapid.public_key.public_bytes(
|
||||
serialization.Encoding.X962,
|
||||
serialization.PublicFormat.UncompressedPoint,
|
||||
)
|
||||
)
|
||||
push_settings = {
|
||||
"lnbits_webpush_privkey": privkey.decode(),
|
||||
"lnbits_webpush_pubkey": pubkey,
|
||||
}
|
||||
update_cached_settings(push_settings)
|
||||
await update_admin_settings(EditableSettings(**push_settings))
|
||||
|
||||
logger.info("Initialized webpush settings with generated VAPID key pair.")
|
||||
logger.info(f"Pubkey: {settings.lnbits_webpush_pubkey}")
|
||||
|
||||
|
||||
def update_cached_settings(sets_dict: dict):
|
||||
for key, value in sets_dict.items():
|
||||
@@ -609,3 +700,11 @@ async def get_balance_delta() -> Tuple[int, int, int]:
|
||||
if error_message:
|
||||
raise Exception(error_message)
|
||||
return node_balance - total_balance, node_balance, total_balance
|
||||
|
||||
|
||||
def get_bolt11_expiry(invoice: Bolt11) -> datetime.datetime:
|
||||
if invoice.expiry:
|
||||
return datetime.datetime.fromtimestamp(invoice.date + invoice.expiry)
|
||||
else:
|
||||
# assume maximum bolt11 expiry of 31 days to be on the safe side
|
||||
return datetime.datetime.now() + datetime.timedelta(days=31)
|
||||
|
||||
@@ -4,19 +4,27 @@ from typing import Dict
|
||||
import httpx
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.core.crud import (
|
||||
get_balance_notify,
|
||||
get_wallet,
|
||||
get_webpush_subscriptions_for_user,
|
||||
)
|
||||
from lnbits.core.db import db
|
||||
from lnbits.core.models import Payment
|
||||
from lnbits.core.services import (
|
||||
get_balance_delta,
|
||||
send_payment_notification,
|
||||
switch_to_voidwallet,
|
||||
)
|
||||
from lnbits.settings import get_wallet_class, settings
|
||||
from lnbits.tasks import (
|
||||
SseListenersDict,
|
||||
create_permanent_task,
|
||||
create_task,
|
||||
register_invoice_listener,
|
||||
send_push_notification,
|
||||
)
|
||||
|
||||
from . import db
|
||||
from .crud import get_balance_notify, get_wallet
|
||||
from .models import Payment
|
||||
from .services import get_balance_delta, send_payment_notification, switch_to_voidwallet
|
||||
|
||||
api_invoice_listeners: Dict[str, asyncio.Queue] = SseListenersDict(
|
||||
"api_invoice_listeners"
|
||||
)
|
||||
@@ -119,6 +127,8 @@ async def wait_for_paid_invoices(invoice_paid_queue: asyncio.Queue):
|
||||
except (httpx.ConnectError, httpx.RequestError):
|
||||
pass
|
||||
|
||||
await send_payment_push_notification(payment)
|
||||
|
||||
|
||||
async def dispatch_api_invoice_listeners(payment: Payment):
|
||||
"""
|
||||
@@ -159,3 +169,24 @@ async def mark_webhook_sent(payment: Payment, status: int) -> None:
|
||||
""",
|
||||
(status, payment.payment_hash),
|
||||
)
|
||||
|
||||
|
||||
async def send_payment_push_notification(payment: Payment):
|
||||
wallet = await get_wallet(payment.wallet_id)
|
||||
|
||||
if wallet:
|
||||
subscriptions = await get_webpush_subscriptions_for_user(wallet.user)
|
||||
|
||||
amount = int(payment.amount / 1000)
|
||||
|
||||
title = f"LNbits: {wallet.name}"
|
||||
body = f"You just received {amount} sat{'s'[:amount^1]}!"
|
||||
|
||||
if payment.memo:
|
||||
body += f"\r\n{payment.memo}"
|
||||
|
||||
for subscription in subscriptions:
|
||||
url = (
|
||||
f"https://{subscription.host}/wallet?usr={wallet.user}&wal={wallet.id}"
|
||||
)
|
||||
await send_push_notification(subscription, title, body, url)
|
||||
|
||||
@@ -25,94 +25,129 @@
|
||||
</ul>
|
||||
<br />
|
||||
</div>
|
||||
<div class="col">
|
||||
{% if LNBITS_NODE_UI_AVAILABLE %}
|
||||
<p>Node Management</p>
|
||||
<q-toggle
|
||||
label="Node UI"
|
||||
v-model="formData.lnbits_node_ui"
|
||||
></q-toggle>
|
||||
<q-toggle
|
||||
v-if="formData.lnbits_node_ui"
|
||||
label="Public node UI"
|
||||
v-model="formData.lnbits_public_node_ui"
|
||||
></q-toggle>
|
||||
<br />
|
||||
<q-toggle
|
||||
v-if="formData.lnbits_node_ui"
|
||||
label="Transactions Tab (Disable on large CLN nodes)"
|
||||
v-model="formData.lnbits_node_ui_transactions"
|
||||
></q-toggle>
|
||||
{% else %}
|
||||
<p>Node Management not supported by active funding source</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 col-md-4">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<p>Active Funding<small> (Requires server restart)</small></p>
|
||||
<q-select
|
||||
:disable="!isSuperUser"
|
||||
filled
|
||||
v-model="formData.lnbits_backend_wallet_class"
|
||||
hint="Select the active funding wallet"
|
||||
:options="settings.lnbits_allowed_funding_sources"
|
||||
></q-select>
|
||||
</div>
|
||||
</div>
|
||||
<p>Invoice Expiry</p>
|
||||
<q-input
|
||||
filled
|
||||
v-model.number="formData.lightning_invoice_expiry"
|
||||
type="number"
|
||||
label="Invoice expiry (seconds)"
|
||||
mask="#######"
|
||||
>
|
||||
</q-input>
|
||||
</div>
|
||||
<div class="col-12 col-md-8">
|
||||
<p>Fee reserve</p>
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 col-md-4">
|
||||
<p>Invoice Expiry</p>
|
||||
<div class="col-6">
|
||||
<q-input
|
||||
filled
|
||||
v-model.number="formData.lightning_invoice_expiry"
|
||||
type="number"
|
||||
label="Invoice expiry (seconds)"
|
||||
mask="#######"
|
||||
filled
|
||||
v-model="formData.lnbits_reserve_fee_min"
|
||||
label="Reserve fee in msats"
|
||||
>
|
||||
</q-input>
|
||||
</div>
|
||||
<div class="col-12 col-md-8">
|
||||
<p>Fee reserve</p>
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-6">
|
||||
<q-input
|
||||
type="number"
|
||||
filled
|
||||
v-model="formData.lnbits_reserve_fee_min"
|
||||
label="Reserve fee in msats"
|
||||
>
|
||||
</q-input>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<q-input
|
||||
type="number"
|
||||
filled
|
||||
name="lnbits_reserve_fee_percent"
|
||||
v-model="formData.lnbits_reserve_fee_percent"
|
||||
label="Reserve fee in percent"
|
||||
step="0.1"
|
||||
></q-input>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<q-input
|
||||
type="number"
|
||||
filled
|
||||
name="lnbits_reserve_fee_percent"
|
||||
v-model="formData.lnbits_reserve_fee_percent"
|
||||
label="Reserve fee in percent"
|
||||
step="0.1"
|
||||
></q-input>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isSuperUser">
|
||||
<p class="q-my-md">
|
||||
Funding Sources<small> (Requires server restart)</small>
|
||||
</p>
|
||||
<q-list
|
||||
v-for="(fund, idx) in settings.lnbits_allowed_funding_sources"
|
||||
:key="idx"
|
||||
>
|
||||
<q-expansion-item
|
||||
expand-separator
|
||||
icon="payments"
|
||||
:label="fund"
|
||||
v-if="funding_sources.get(fund)"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section
|
||||
v-for="([key, prop], i) in Object.entries(funding_sources.get(fund))"
|
||||
:key="i"
|
||||
<h6 class="q-mt-xl q-mb-md">Service Fees</h6>
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 col-md-6">
|
||||
<p>Service Fee</p>
|
||||
<q-input
|
||||
filled
|
||||
type="number"
|
||||
v-model.number="formData.lnbits_service_fee"
|
||||
label="Service fee (%)"
|
||||
step="0.1"
|
||||
hint="Fee charged per tx (%)"
|
||||
></q-input>
|
||||
<br />
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<p>Service fee max</p>
|
||||
<q-input
|
||||
filled
|
||||
type="number"
|
||||
v-model.number="formData.lnbits_service_fee_max"
|
||||
label="Service fee max (sats)"
|
||||
hint="Max service fee to charge in (sats)"
|
||||
></q-input>
|
||||
<br />
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<p>Fee Wallet</p>
|
||||
<q-input
|
||||
filled
|
||||
v-model="formData.lnbits_service_fee_wallet"
|
||||
label="Fee wallet (wallet ID)"
|
||||
hint="Wallet ID to send funds to"
|
||||
></q-input>
|
||||
<br />
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<p>Disable Service Fee for Internal Payments</p>
|
||||
<q-item tag="label" v-ripple>
|
||||
<q-item-section>
|
||||
<q-item-label>Disable Fee</q-item-label>
|
||||
<q-item-label caption
|
||||
>Disable Service Fee for Internal Lightning
|
||||
Payments</q-item-label
|
||||
>
|
||||
<q-input
|
||||
dense
|
||||
filled
|
||||
type="text"
|
||||
v-model="formData[key]"
|
||||
:label="prop.label"
|
||||
class="q-pr-md"
|
||||
:hint="prop.hint"
|
||||
></q-input>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
</q-list>
|
||||
</q-item-section>
|
||||
<q-item-section avatar>
|
||||
<q-toggle
|
||||
size="md"
|
||||
v-model="formData.lnbits_service_fee_ignore_internal"
|
||||
checked-icon="check"
|
||||
color="green"
|
||||
unchecked-icon="clear"
|
||||
/>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<br />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isSuperUser">
|
||||
<lnbits-funding-sources
|
||||
:form-data="formData"
|
||||
:allowed-funding-sources="settings.lnbits_allowed_funding_sources"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
</template>
|
||||
</q-banner>
|
||||
<q-banner v-if="!updateAvailable" class="bg-green text-white">
|
||||
<q-icon size="28px" name="checknark"></q-icon>
|
||||
<q-icon size="28px" name="checkmark"></q-icon>
|
||||
<span v-text="$t('latest_update', {version: statusData.version})"></span>
|
||||
</q-banner>
|
||||
<q-card>
|
||||
|
||||
@@ -19,41 +19,6 @@
|
||||
<br />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 col-md-6">
|
||||
<p>Service Fee</p>
|
||||
<q-input
|
||||
filled
|
||||
type="number"
|
||||
v-model.number="formData.lnbits_service_fee"
|
||||
label="Service fee (%)"
|
||||
step="0.1"
|
||||
hint="Fee charged per tx (%)"
|
||||
></q-input>
|
||||
<br />
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<p>Miscellaneous</p>
|
||||
<q-item tag="label" v-ripple>
|
||||
<q-item-section>
|
||||
<q-item-label>Hide API</q-item-label>
|
||||
<q-item-label caption
|
||||
>Hides wallet api, extensions can choose to honor</q-item-label
|
||||
>
|
||||
</q-item-section>
|
||||
<q-item-section avatar>
|
||||
<q-toggle
|
||||
size="md"
|
||||
v-model="formData.lnbits_hide_api"
|
||||
checked-icon="check"
|
||||
color="green"
|
||||
unchecked-icon="clear"
|
||||
/>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<br />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 col-md-6">
|
||||
<p>Allowed currencies</p>
|
||||
@@ -75,7 +40,7 @@
|
||||
clearable
|
||||
hint="Default currency for accounting"
|
||||
label="Currency"
|
||||
:options="formData.lnbits_allowed_currencies.length ? formData.lnbits_allowed_currencies : {{ currencies }}"
|
||||
:options="formData.lnbits_allowed_currencies?.length ? formData.lnbits_allowed_currencies : {{ currencies }}"
|
||||
></q-select>
|
||||
<br />
|
||||
</div>
|
||||
@@ -93,6 +58,27 @@
|
||||
></q-select>
|
||||
<br />
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<p>Miscellaneous</p>
|
||||
<q-item tag="label" v-ripple>
|
||||
<q-item-section>
|
||||
<q-item-label>Hide API</q-item-label>
|
||||
<q-item-label caption
|
||||
>Hides wallet api, extensions can choose to honor</q-item-label
|
||||
>
|
||||
</q-item-section>
|
||||
<q-item-section avatar>
|
||||
<q-toggle
|
||||
size="md"
|
||||
v-model="formData.lnbits_hide_api"
|
||||
checked-icon="check"
|
||||
color="green"
|
||||
unchecked-icon="clear"
|
||||
/>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<br />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
@@ -31,12 +31,12 @@
|
||||
v-model="formData.lnbits_site_description"
|
||||
filled
|
||||
type="textarea"
|
||||
hint="Use plain text or raw HTML"
|
||||
hint="Use plain text, Markdown, or raw HTML"
|
||||
/>
|
||||
</div>
|
||||
<br />
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 col-md-6">
|
||||
<div class="col-12 col-md-4">
|
||||
<p>Default Wallet Name</p>
|
||||
<q-input
|
||||
filled
|
||||
@@ -46,7 +46,7 @@
|
||||
></q-input>
|
||||
<br />
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<div class="col-12 col-md-4">
|
||||
<p>Denomination</p>
|
||||
<q-input
|
||||
filled
|
||||
@@ -57,6 +57,17 @@
|
||||
></q-input>
|
||||
<br />
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<p>QR code logo</p>
|
||||
<q-input
|
||||
filled
|
||||
type="text"
|
||||
v-model="formData.lnbits_qr_logo"
|
||||
label="https://example.com/image.svg"
|
||||
hint="URL to logo image in QR code"
|
||||
></q-input>
|
||||
<br />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 col-md-6">
|
||||
|
||||
@@ -164,522 +164,5 @@
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
{% endblock %} {% block scripts %} {{ window_vars(user) }}
|
||||
<script>
|
||||
new Vue({
|
||||
el: '#vue',
|
||||
mixins: [windowMixin],
|
||||
data: function () {
|
||||
return {
|
||||
settings: {},
|
||||
logs: [],
|
||||
serverlogEnabled: false,
|
||||
lnbits_theme_options: [
|
||||
'classic',
|
||||
'bitcoin',
|
||||
'flamingo',
|
||||
'cyber',
|
||||
'freedom',
|
||||
'mint',
|
||||
'autumn',
|
||||
'monochrome',
|
||||
'salvador'
|
||||
],
|
||||
auditData: {},
|
||||
statusData: {},
|
||||
statusDataTable: {
|
||||
columns: [
|
||||
{
|
||||
name: 'date',
|
||||
align: 'left',
|
||||
label: this.$t('date'),
|
||||
field: 'date'
|
||||
},
|
||||
{
|
||||
name: 'message',
|
||||
align: 'left',
|
||||
label: this.$t('memo'),
|
||||
field: 'message'
|
||||
}
|
||||
]
|
||||
},
|
||||
formData: {},
|
||||
formAddAdmin: '',
|
||||
formAddUser: '',
|
||||
formAddExtensionsManifest: '',
|
||||
formAllowedIPs: '',
|
||||
formBlockedIPs: '',
|
||||
isSuperUser: false,
|
||||
wallet: {},
|
||||
cancel: {},
|
||||
topUpDialog: {
|
||||
show: false
|
||||
},
|
||||
tab: 'funding',
|
||||
needsRestart: false,
|
||||
funding_sources: new Map([
|
||||
['VoidWallet', null],
|
||||
[
|
||||
'FakeWallet',
|
||||
{
|
||||
fake_wallet_secret: {
|
||||
value: null,
|
||||
label: 'Secret'
|
||||
}
|
||||
}
|
||||
],
|
||||
[
|
||||
'CoreLightningWallet',
|
||||
{
|
||||
corelightning_rpc: {
|
||||
value: null,
|
||||
label: 'Endpoint'
|
||||
}
|
||||
}
|
||||
],
|
||||
[
|
||||
'CoreLightningRestWallet',
|
||||
{
|
||||
corelightning_rest_url: {
|
||||
value: null,
|
||||
label: 'Endpoint'
|
||||
},
|
||||
corelightning_rest_cert: {
|
||||
value: null,
|
||||
label: 'Certificate'
|
||||
},
|
||||
corelightning_rest_macaroon: {
|
||||
value: null,
|
||||
label: 'Macaroon'
|
||||
}
|
||||
}
|
||||
],
|
||||
[
|
||||
'LndRestWallet',
|
||||
{
|
||||
lnd_rest_endpoint: {
|
||||
value: null,
|
||||
label: 'Endpoint'
|
||||
},
|
||||
lnd_rest_cert: {
|
||||
value: null,
|
||||
label: 'Certificate'
|
||||
},
|
||||
lnd_rest_macaroon: {
|
||||
value: null,
|
||||
label: 'Macaroon'
|
||||
},
|
||||
lnd_rest_macaroon_encrypted: {
|
||||
value: null,
|
||||
label: 'Encrypted Macaroon'
|
||||
}
|
||||
}
|
||||
],
|
||||
[
|
||||
'LndWallet',
|
||||
{
|
||||
lnd_grpc_endpoint: {
|
||||
value: null,
|
||||
label: 'Endpoint'
|
||||
},
|
||||
lnd_grpc_cert: {
|
||||
value: null,
|
||||
label: 'Certificate'
|
||||
},
|
||||
lnd_grpc_port: {
|
||||
value: null,
|
||||
label: 'Port'
|
||||
},
|
||||
lnd_grpc_admin_macaroon: {
|
||||
value: null,
|
||||
label: 'Admin Macaroon'
|
||||
},
|
||||
lnd_grpc_macaroon_encrypted: {
|
||||
value: null,
|
||||
label: 'Encrypted Macaroon'
|
||||
}
|
||||
}
|
||||
],
|
||||
[
|
||||
'LnTipsWallet',
|
||||
{
|
||||
lntips_api_endpoint: {
|
||||
value: null,
|
||||
label: 'Endpoint'
|
||||
},
|
||||
lntips_api_key: {
|
||||
value: null,
|
||||
label: 'API Key'
|
||||
}
|
||||
}
|
||||
],
|
||||
[
|
||||
'LNPayWallet',
|
||||
{
|
||||
lnpay_api_endpoint: {
|
||||
value: null,
|
||||
label: 'Endpoint'
|
||||
},
|
||||
lnpay_api_key: {
|
||||
value: null,
|
||||
label: 'API Key'
|
||||
},
|
||||
lnpay_wallet_key: {
|
||||
value: null,
|
||||
label: 'Wallet Key'
|
||||
}
|
||||
}
|
||||
],
|
||||
[
|
||||
'EclairWallet',
|
||||
{
|
||||
eclair_url: {
|
||||
value: null,
|
||||
label: 'URL'
|
||||
},
|
||||
eclair_pass: {
|
||||
value: null,
|
||||
label: 'Password'
|
||||
}
|
||||
}
|
||||
],
|
||||
[
|
||||
'LNbitsWallet',
|
||||
{
|
||||
lnbits_endpoint: {
|
||||
value: null,
|
||||
label: 'Endpoint'
|
||||
},
|
||||
lnbits_key: {
|
||||
value: null,
|
||||
label: 'Admin Key'
|
||||
}
|
||||
}
|
||||
],
|
||||
[
|
||||
'OpenNodeWallet',
|
||||
{
|
||||
opennode_api_endpoint: {
|
||||
value: null,
|
||||
label: 'Endpoint'
|
||||
},
|
||||
opennode_key: {
|
||||
value: null,
|
||||
label: 'Key'
|
||||
}
|
||||
}
|
||||
],
|
||||
[
|
||||
'ClicheWallet',
|
||||
{
|
||||
cliche_endpoint: {
|
||||
value: null,
|
||||
label: 'Endpoint'
|
||||
}
|
||||
}
|
||||
],
|
||||
[
|
||||
'SparkWallet',
|
||||
{
|
||||
spark_url: {
|
||||
value: null,
|
||||
label: 'Endpoint'
|
||||
},
|
||||
spark_token: {
|
||||
value: null,
|
||||
label: 'Token'
|
||||
}
|
||||
}
|
||||
]
|
||||
])
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.getSettings()
|
||||
this.getAudit()
|
||||
this.balance = +'{{ balance|safe }}'
|
||||
},
|
||||
computed: {
|
||||
lnbitsVersion() {
|
||||
return LNBITS_VERSION
|
||||
},
|
||||
checkChanges() {
|
||||
return !_.isEqual(this.settings, this.formData)
|
||||
},
|
||||
updateAvailable() {
|
||||
return LNBITS_VERSION !== this.statusData.version
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
addAdminUser() {
|
||||
let addUser = this.formAddAdmin
|
||||
let admin_users = this.formData.lnbits_admin_users
|
||||
if (addUser && addUser.length && !admin_users.includes(addUser)) {
|
||||
//admin_users = [...admin_users, addUser]
|
||||
this.formData.lnbits_admin_users = [...admin_users, addUser]
|
||||
this.formAddAdmin = ''
|
||||
}
|
||||
},
|
||||
removeAdminUser(user) {
|
||||
let admin_users = this.formData.lnbits_admin_users
|
||||
this.formData.lnbits_admin_users = admin_users.filter(u => u !== user)
|
||||
},
|
||||
addAllowedUser() {
|
||||
let addUser = this.formAddUser
|
||||
let allowed_users = this.formData.lnbits_allowed_users
|
||||
if (addUser && addUser.length && !allowed_users.includes(addUser)) {
|
||||
this.formData.lnbits_allowed_users = [...allowed_users, addUser]
|
||||
this.formAddUser = ''
|
||||
}
|
||||
},
|
||||
removeAllowedUser(user) {
|
||||
let allowed_users = this.formData.lnbits_allowed_users
|
||||
this.formData.lnbits_allowed_users = allowed_users.filter(
|
||||
u => u !== user
|
||||
)
|
||||
},
|
||||
addExtensionsManifest() {
|
||||
const addManifest = this.formAddExtensionsManifest.trim()
|
||||
const manifests = this.formData.lnbits_extensions_manifests
|
||||
if (
|
||||
addManifest &&
|
||||
addManifest.length &&
|
||||
!manifests.includes(addManifest)
|
||||
) {
|
||||
this.formData.lnbits_extensions_manifests = [
|
||||
...manifests,
|
||||
addManifest
|
||||
]
|
||||
this.formAddExtensionsManifest = ''
|
||||
}
|
||||
},
|
||||
removeExtensionsManifest(manifest) {
|
||||
const manifests = this.formData.lnbits_extensions_manifests
|
||||
this.formData.lnbits_extensions_manifests = manifests.filter(
|
||||
m => m !== manifest
|
||||
)
|
||||
},
|
||||
async toggleServerLog() {
|
||||
this.serverlogEnabled = !this.serverlogEnabled
|
||||
if (this.serverlogEnabled) {
|
||||
const wsProto = location.protocol !== 'http:' ? 'wss://' : 'ws://'
|
||||
const digestHex = await LNbits.utils.digestMessage(this.g.user.id)
|
||||
const localUrl =
|
||||
wsProto +
|
||||
document.domain +
|
||||
':' +
|
||||
location.port +
|
||||
'/api/v1/ws/' +
|
||||
digestHex
|
||||
this.ws = new WebSocket(localUrl)
|
||||
this.ws.addEventListener('message', async ({data}) => {
|
||||
this.logs.push(data.toString())
|
||||
const scrollArea = this.$refs.logScroll
|
||||
if (scrollArea) {
|
||||
const scrollTarget = scrollArea.getScrollTarget()
|
||||
const duration = 0
|
||||
scrollArea.setScrollPosition(scrollTarget.scrollHeight, duration)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
this.ws.close()
|
||||
}
|
||||
},
|
||||
addAllowedIPs() {
|
||||
const allowedIPs = this.formAllowedIPs.trim()
|
||||
const allowed_ips = this.formData.lnbits_allowed_ips
|
||||
if (
|
||||
allowedIPs &&
|
||||
allowedIPs.length &&
|
||||
!allowed_ips.includes(allowedIPs)
|
||||
) {
|
||||
this.formData.lnbits_allowed_ips = [...allowed_ips, allowedIPs]
|
||||
this.formAllowedIPs = ''
|
||||
}
|
||||
},
|
||||
removeAllowedIPs(allowed_ip) {
|
||||
const allowed_ips = this.formData.lnbits_allowed_ips
|
||||
this.formData.lnbits_allowed_ips = allowed_ips.filter(
|
||||
a => a !== allowed_ip
|
||||
)
|
||||
},
|
||||
addBlockedIPs() {
|
||||
const blockedIPs = this.formBlockedIPs.trim()
|
||||
const blocked_ips = this.formData.lnbits_blocked_ips
|
||||
if (
|
||||
blockedIPs &&
|
||||
blockedIPs.length &&
|
||||
!blocked_ips.includes(blockedIPs)
|
||||
) {
|
||||
this.formData.lnbits_blocked_ips = [...blocked_ips, blockedIPs]
|
||||
this.formBlockedIPs = ''
|
||||
}
|
||||
},
|
||||
removeBlockedIPs(blocked_ip) {
|
||||
const blocked_ips = this.formData.lnbits_blocked_ips
|
||||
this.formData.lnbits_blocked_ips = blocked_ips.filter(
|
||||
b => b !== blocked_ip
|
||||
)
|
||||
},
|
||||
restartServer() {
|
||||
LNbits.api
|
||||
.request('GET', '/admin/api/v1/restart/?usr=' + this.g.user.id)
|
||||
.then(response => {
|
||||
this.$q.notify({
|
||||
type: 'positive',
|
||||
message: 'Success! Restarted Server',
|
||||
icon: null
|
||||
})
|
||||
this.needsRestart = false
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
topupWallet() {
|
||||
LNbits.api
|
||||
.request(
|
||||
'PUT',
|
||||
'/admin/api/v1/topup/?usr=' + this.g.user.id,
|
||||
this.g.user.wallets[0].adminkey,
|
||||
this.wallet
|
||||
)
|
||||
.then(response => {
|
||||
this.$q.notify({
|
||||
type: 'positive',
|
||||
message:
|
||||
'Success! Added ' +
|
||||
this.wallet.amount +
|
||||
' to ' +
|
||||
this.wallet.id,
|
||||
icon: null
|
||||
})
|
||||
this.wallet = {}
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
updateFundingData() {
|
||||
this.settings.lnbits_allowed_funding_sources.map(f => {
|
||||
let opts = this.funding_sources.get(f)
|
||||
if (!opts) return
|
||||
Object.keys(opts).forEach(e => {
|
||||
opts[e].value = this.settings[e]
|
||||
})
|
||||
})
|
||||
},
|
||||
formatDate(date) {
|
||||
return moment(date * 1000).fromNow()
|
||||
},
|
||||
getNotifications() {
|
||||
if (this.settings.lnbits_notifications) {
|
||||
axios
|
||||
.get(this.settings.lnbits_status_manifest)
|
||||
.then(response => {
|
||||
this.statusData = response.data
|
||||
})
|
||||
.catch(error => {
|
||||
this.formData.lnbits_notifications = false
|
||||
error.response.data = {}
|
||||
error.response.data.message = 'Could not fetch status manifest.'
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
}
|
||||
},
|
||||
getAudit() {
|
||||
LNbits.api
|
||||
.request(
|
||||
'GET',
|
||||
'/admin/api/v1/audit/?usr=' + this.g.user.id,
|
||||
this.g.user.wallets[0].adminkey
|
||||
)
|
||||
.then(response => {
|
||||
this.auditData = response.data
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
getSettings() {
|
||||
LNbits.api
|
||||
.request(
|
||||
'GET',
|
||||
'/admin/api/v1/settings/?usr=' + this.g.user.id,
|
||||
this.g.user.wallets[0].adminkey
|
||||
)
|
||||
.then(response => {
|
||||
this.isSuperUser = response.data.is_super_user || false
|
||||
this.settings = response.data
|
||||
this.formData = {...this.settings}
|
||||
this.updateFundingData()
|
||||
this.getNotifications()
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
updateSettings() {
|
||||
let data = _.omit(this.formData, [
|
||||
'is_super_user',
|
||||
'lnbits_allowed_funding_sources'
|
||||
])
|
||||
LNbits.api
|
||||
.request(
|
||||
'PUT',
|
||||
'/admin/api/v1/settings/?usr=' + this.g.user.id,
|
||||
this.g.user.wallets[0].adminkey,
|
||||
data
|
||||
)
|
||||
.then(response => {
|
||||
this.needsRestart =
|
||||
this.settings.lnbits_backend_wallet_class !==
|
||||
this.formData.lnbits_backend_wallet_class ||
|
||||
this.settings.lnbits_killswitch !==
|
||||
this.formData.lnbits_killswitch
|
||||
this.settings = this.formData
|
||||
this.formData = _.clone(this.settings)
|
||||
this.updateFundingData()
|
||||
this.$q.notify({
|
||||
type: 'positive',
|
||||
message: `Success! Settings changed! ${
|
||||
this.needsRestart ? 'Restart required!' : ''
|
||||
}`,
|
||||
icon: null
|
||||
})
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
deleteSettings() {
|
||||
LNbits.utils
|
||||
.confirmDialog(
|
||||
'Are you sure you want to restore settings to default?'
|
||||
)
|
||||
.onOk(() => {
|
||||
LNbits.api
|
||||
.request(
|
||||
'DELETE',
|
||||
'/admin/api/v1/settings/?usr=' + this.g.user.id
|
||||
)
|
||||
.then(response => {
|
||||
this.$q.notify({
|
||||
type: 'positive',
|
||||
message:
|
||||
'Success! Restored settings to defaults, restart required!',
|
||||
icon: null
|
||||
})
|
||||
this.needsRestart = true
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
})
|
||||
},
|
||||
downloadBackup() {
|
||||
window.open('/admin/api/v1/backup/?usr=' + this.g.user.id, '_blank')
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
<script src="{{ static_url_for('static', 'js/admin.js') }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -165,7 +165,7 @@
|
||||
<q-toggle
|
||||
v-if="extension.isAvailable && extension.isInstalled && g.user.admin"
|
||||
:label="extension.isActive ? $t('activated'): $t('deactivated') "
|
||||
color="secodary"
|
||||
color="secondary"
|
||||
style="max-height: 21px"
|
||||
v-model="extension.isActive"
|
||||
@input="toggleExtension(extension)"
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
{% extends "public.html" %} {% block scripts %}
|
||||
<script src="/core/static/js/index.js"></script>
|
||||
<script src="{{ static_url_for('static', 'js/index.js') }}"></script>
|
||||
{% endblock %} {% block page %}
|
||||
<div class="row q-col-gutter-md justify-center">
|
||||
<div class="col-12 col-md-7 col-lg-6 q-gutter-y-md">
|
||||
{% if lnurl or LNBITS_NEW_ACCOUNTS_ALLOWED %}
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
{% if lnurl %}
|
||||
@@ -14,7 +15,7 @@
|
||||
href="{{ url_for('core.lnurlwallet') }}?lightning={{ lnurl }}"
|
||||
v-text="$t('press_to_claim')"
|
||||
></q-btn>
|
||||
{% else %}
|
||||
{% elif LNBITS_NEW_ACCOUNTS_ALLOWED %}
|
||||
<q-form @submit="createWallet" class="q-gutter-md">
|
||||
<q-input
|
||||
filled
|
||||
@@ -33,6 +34,7 @@
|
||||
{% endif %}
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
{% endif %}
|
||||
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
@@ -61,7 +63,9 @@
|
||||
></q-btn>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else>{{SITE_DESCRIPTION | safe}}</p>
|
||||
<div v-else>
|
||||
<div v-html="formatDescription"></div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
@@ -81,7 +85,7 @@
|
||||
<a href="https://github.com/ElementsProject/lightning">
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '/static/images/cln.png' : '/static/images/clnl.png'"
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/cln.png') }}' : '{{ static_url_for('static', 'images/clnl.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
@@ -89,7 +93,7 @@
|
||||
<a href="https://github.com/lightningnetwork/lnd">
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '/static/images/lnd.png' : '/static/images/lnd.png'"
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/lnd.png') }}' : '{{ static_url_for('static', 'images/lnd.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
@@ -100,7 +104,7 @@
|
||||
<a href="https://opennode.com">
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '/static/images/opennode.png' : '/static/images/opennodel.png'"
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/opennode.png') }}' : '{{ static_url_for('static', 'images/opennodel.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
@@ -108,7 +112,7 @@
|
||||
<a href="https://lnpay.co/">
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '/static/images/lnpay.png' : '/static/images/lnpayl.png'"
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/lnpay.png') }}' : '{{ static_url_for('static', 'images/lnpayl.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
@@ -119,7 +123,7 @@
|
||||
<a href="https://github.com/rootzoll/raspiblitz">
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '/static/images/blitz.png' : '/static/images/blitzl.png'"
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/blitz.png') }}' : '{{ static_url_for('static', 'images/blitzl.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
@@ -127,7 +131,7 @@
|
||||
<a href="https://start9.com/">
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '/static/images/start9.png' : '/static/images/start9l.png'"
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/start9.png') }}' : '{{ static_url_for('static', 'images/start9l.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
@@ -137,7 +141,7 @@
|
||||
<a href="https://getumbrel.com/">
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '/static/images/umbrel.png' : '/static/images/umbrell.png'"
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/umbrel.png') }}' : '{{ static_url_for('static', 'images/umbrell.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
@@ -145,7 +149,7 @@
|
||||
<a href="https://mynodebtc.com">
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '/static/images/mynode.png' : '/static/images/mynodel.png'"
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/mynode.png') }}' : '{{ static_url_for('static', 'images/mynodel.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
@@ -155,7 +159,7 @@
|
||||
<a href="https://github.com/shesek/spark-wallet">
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '/static/images/spark.png' : '/static/images/sparkl.png'"
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/spark.png') }}' : '{{ static_url_for('static', 'images/sparkl.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
@@ -163,7 +167,7 @@
|
||||
<a href="https://voltage.cloud">
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '/static/images/voltage.png' : '/static/images/voltagel.png'"
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/voltage.png') }}' : '{{ static_url_for('static', 'images/voltagel.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
{% from "macros.jinja" import window_vars with context %}
|
||||
<!---->
|
||||
{% block scripts %} {{ window_vars(user, wallet) }}
|
||||
<script src="/core/static/js/wallet.js"></script>
|
||||
<script src="{{ static_url_for('static', 'js/wallet.js') }}"></script>
|
||||
{% endblock %}
|
||||
<!---->
|
||||
{% block title %} {{ wallet.name }} - {{ SITE_TITLE }} {% endblock %}
|
||||
@@ -240,11 +240,9 @@
|
||||
>
|
||||
<a :href="'lightning:' + props.row.bolt11">
|
||||
<q-responsive :ratio="1" class="q-mx-xl">
|
||||
<qrcode
|
||||
<lnbits-qrcode
|
||||
:value="'lightning:' + props.row.bolt11.toUpperCase()"
|
||||
:options="{width: 340}"
|
||||
class="rounded-borders"
|
||||
></qrcode>
|
||||
></lnbits-qrcode>
|
||||
</q-responsive>
|
||||
</a>
|
||||
</div>
|
||||
@@ -330,12 +328,11 @@
|
||||
<q-card>
|
||||
<q-card-section class="text-center">
|
||||
<a href="lightning:{{wallet.lnurlwithdraw_full}}">
|
||||
<qrcode
|
||||
value="lightning:{{wallet.lnurlwithdraw_full}}"
|
||||
:options="{width:240}"
|
||||
></qrcode>
|
||||
<lnbits-qrcode
|
||||
:value="lightning:{{wallet.lnurlwithdraw_full}}"
|
||||
></lnbits-qrcode>
|
||||
</a>
|
||||
<p v-text="$('drain_funds_desc')"></p>
|
||||
<p v-text="$t('drain_funds_desc')"></p>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
@@ -352,7 +349,6 @@
|
||||
<p v-text="$t('export_to_phone_desc')"></p>
|
||||
<qrcode
|
||||
:value="'{{request.base_url}}' +'wallet?usr={{user.id}}&wal={{wallet.id}}'"
|
||||
:options="{width:240}"
|
||||
></qrcode>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
@@ -428,7 +424,7 @@
|
||||
<q-btn
|
||||
unelevated
|
||||
color="red-10"
|
||||
@click="deleteWallet('{{ wallet.id }}', '{{ user.id }}')"
|
||||
@click="deleteWallet()"
|
||||
:label="$t('delete_wallet')"
|
||||
></q-btn>
|
||||
</q-card-section>
|
||||
@@ -481,7 +477,7 @@
|
||||
filled
|
||||
dense
|
||||
v-model.number="receive.data.amount"
|
||||
:label="$('amount')+' ({{LNBITS_DENOMINATION}}) *'"
|
||||
:label="$t('amount')+' ({{LNBITS_DENOMINATION}}) *'"
|
||||
mask="#.##"
|
||||
fill-mask="0"
|
||||
reverse-fill-mask
|
||||
@@ -552,11 +548,9 @@
|
||||
<div class="text-center q-mb-lg">
|
||||
<a :href="'lightning:' + receive.paymentReq">
|
||||
<q-responsive :ratio="1" class="q-mx-xl">
|
||||
<qrcode
|
||||
<lnbits-qrcode
|
||||
:value="'lightning:' + receive.paymentReq.toUpperCase()"
|
||||
:options="{width: 340}"
|
||||
class="rounded-borders"
|
||||
></qrcode>
|
||||
></lnbits-qrcode>
|
||||
</q-responsive>
|
||||
</a>
|
||||
</div>
|
||||
@@ -827,6 +821,19 @@
|
||||
<q-dialog v-model="paymentsChart.show">
|
||||
<q-card class="q-pa-sm" style="width: 800px; max-width: unset">
|
||||
<q-card-section>
|
||||
<div class="row q-gutter-sm justify-between">
|
||||
<div class="text-h6">Payments Chart</div>
|
||||
<q-select
|
||||
label="Group"
|
||||
filled
|
||||
dense
|
||||
v-model="paymentsChart.group"
|
||||
style="min-width: 120px"
|
||||
:options="paymentsChart.groupOptions"
|
||||
>
|
||||
</q-select>
|
||||
</div>
|
||||
|
||||
<canvas ref="canvas" width="600" height="400"></canvas>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
<q-tab-panel name="channels">
|
||||
<q-dialog v-model="connectPeerDialog.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="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="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">
|
||||
<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>
|
||||
{% raw %}
|
||||
<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
|
||||
:data="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 col-grow">
|
||||
{{props.row.name}}
|
||||
</div>
|
||||
<div class="text-caption" v-if="props.row.short_id">
|
||||
{{ props.row.short_id }}
|
||||
<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"
|
||||
>
|
||||
{{ 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>
|
||||
|
||||
{% endraw %}
|
||||
</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">
|
||||
{% raw %}
|
||||
<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
|
||||
:data="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">
|
||||
{{ props.row.alias }}
|
||||
</div>
|
||||
<div class="row items-center q-gutter-sm">
|
||||
<q-badge
|
||||
:style="`background-color: #${props.row.color}`"
|
||||
class="text-bold"
|
||||
>
|
||||
#{{ props.row.color }}
|
||||
</q-badge>
|
||||
<div class="text-bold">
|
||||
{{ 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>
|
||||
|
||||
{% endraw %}
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-tab-panel>
|
||||
@@ -0,0 +1,70 @@
|
||||
<q-tab-panel name="dashboard">
|
||||
<q-card-section class="q-pa-none">
|
||||
{% raw %}
|
||||
<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="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>
|
||||
{% endraw %}
|
||||
</q-card-section>
|
||||
</q-tab-panel>
|
||||
@@ -0,0 +1,316 @@
|
||||
<q-tab-panel name="transactions">
|
||||
<q-card-section class="q-pa-none">
|
||||
<q-dialog v-model="transactionDetailsDialog.show">
|
||||
<q-card class="my-card">
|
||||
<q-card-section>
|
||||
{% raw %}
|
||||
<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">
|
||||
{{ transactionDetailsDialog.data.payment_hash }}
|
||||
<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">
|
||||
{{ transactionDetailsDialog.data.preimage }}
|
||||
<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
|
||||
:value="'lightning:' + transactionDetailsDialog.data.bolt11.toUpperCase()"
|
||||
:options="{width: 340}"
|
||||
class="rounded-borders"
|
||||
></qrcode>
|
||||
</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>
|
||||
{% endraw %}
|
||||
</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
|
||||
:data="paymentsTable.data"
|
||||
:columns="paymentsTable.columns"
|
||||
:pagination.sync="paymentsTable.pagination"
|
||||
row-key="payment_hash"
|
||||
no-data-label="No transactions made yet"
|
||||
:filter="paymentsTable.filter"
|
||||
@request="getPayments"
|
||||
>
|
||||
{% raw %}
|
||||
<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">
|
||||
<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
|
||||
:value="'lightning:' + props.row.bolt11.toUpperCase()"
|
||||
:options="{width: 340}"
|
||||
class="rounded-borders"
|
||||
></qrcode>
|
||||
</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"
|
||||
>
|
||||
{{ 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>
|
||||
{% endraw %}
|
||||
</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
|
||||
:data="invoiceTable.data"
|
||||
:columns="invoiceTable.columns"
|
||||
:pagination.sync="invoiceTable.pagination"
|
||||
no-data-label="No transactions made yet"
|
||||
:filter="invoiceTable.filter"
|
||||
@request="getInvoices"
|
||||
>
|
||||
{% raw %}
|
||||
<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>
|
||||
|
||||
{% endraw %}
|
||||
</q-table>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-tab-panel>
|
||||
@@ -0,0 +1,377 @@
|
||||
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
|
||||
%} {% block page %}
|
||||
|
||||
<q-dialog v-model="nodeInfoDialog.show">
|
||||
<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 %} {% block scripts %} {{ window_vars(user) }}
|
||||
<script src="{{ static_url_for('static', 'js/node.js') }}"></script>
|
||||
<script>
|
||||
Vue.component(VueQrcode.name, VueQrcode)
|
||||
Vue.use(VueQrcodeReader)
|
||||
|
||||
new Vue({
|
||||
el: '#vue',
|
||||
config: {
|
||||
globalProperties: {
|
||||
LNbits,
|
||||
msg: 'hello'
|
||||
}
|
||||
},
|
||||
mixins: [windowMixin],
|
||||
data: function () {
|
||||
return {
|
||||
isSuperUser: false,
|
||||
wallet: {},
|
||||
tab: 'dashboard',
|
||||
payments: 1000,
|
||||
info: {},
|
||||
channel_stats: {},
|
||||
|
||||
channels: {
|
||||
data: [],
|
||||
filter: ''
|
||||
},
|
||||
|
||||
activeBalance: {},
|
||||
ranks: {},
|
||||
|
||||
peers: {
|
||||
data: [],
|
||||
filter: ''
|
||||
},
|
||||
|
||||
connectPeerDialog: {
|
||||
show: false,
|
||||
data: {}
|
||||
},
|
||||
|
||||
openChannelDialog: {
|
||||
show: false,
|
||||
data: {}
|
||||
},
|
||||
|
||||
closeChannelDialog: {
|
||||
show: false,
|
||||
data: {}
|
||||
},
|
||||
|
||||
nodeInfoDialog: {
|
||||
show: false,
|
||||
data: {}
|
||||
},
|
||||
|
||||
transactionDetailsDialog: {
|
||||
show: false,
|
||||
data: {}
|
||||
},
|
||||
|
||||
states: [
|
||||
{label: 'Active', value: 'active', color: 'green'},
|
||||
{label: 'Pending', value: 'pending', color: 'orange'},
|
||||
{label: 'Inactive', value: 'inactive', color: 'grey'},
|
||||
{label: 'Closed', value: 'closed', color: 'red'}
|
||||
],
|
||||
|
||||
stateFilters: [
|
||||
{label: 'Active', value: 'active'},
|
||||
{label: 'Pending', value: 'pending'}
|
||||
],
|
||||
|
||||
paymentsTable: {
|
||||
data: [],
|
||||
columns: [
|
||||
{
|
||||
name: 'pending',
|
||||
label: ''
|
||||
},
|
||||
{
|
||||
name: 'memo',
|
||||
align: 'left',
|
||||
label: this.$t('memo'),
|
||||
field: 'memo'
|
||||
},
|
||||
{
|
||||
name: 'date',
|
||||
align: 'left',
|
||||
label: this.$t('date'),
|
||||
field: 'date',
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
name: 'sat',
|
||||
align: 'right',
|
||||
label: this.$t('amount') + ' (' + LNBITS_DENOMINATION + ')',
|
||||
field: row => this.formatMsat(row.amount),
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
name: 'fee',
|
||||
align: 'right',
|
||||
label: this.$t('fee') + ' (m' + LNBITS_DENOMINATION + ')',
|
||||
field: 'fee'
|
||||
},
|
||||
{
|
||||
name: 'destination',
|
||||
align: 'right',
|
||||
label: 'Destination',
|
||||
field: 'destination'
|
||||
}
|
||||
],
|
||||
pagination: {
|
||||
rowsPerPage: 10,
|
||||
page: 1,
|
||||
rowsNumber: 10
|
||||
},
|
||||
filter: null
|
||||
},
|
||||
invoiceTable: {
|
||||
data: [],
|
||||
columns: [
|
||||
{
|
||||
name: 'pending',
|
||||
label: ''
|
||||
},
|
||||
{
|
||||
name: 'memo',
|
||||
align: 'left',
|
||||
label: this.$t('memo'),
|
||||
field: 'memo'
|
||||
},
|
||||
{
|
||||
name: 'paid_at',
|
||||
field: 'paid_at',
|
||||
align: 'right',
|
||||
label: 'Paid at',
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
name: 'expiry',
|
||||
label: this.$t('expiry'),
|
||||
field: 'expiry',
|
||||
align: 'right',
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
name: 'amount',
|
||||
label: this.$t('amount') + ' (' + LNBITS_DENOMINATION + ')',
|
||||
field: row => this.formatMsat(row.amount),
|
||||
sortable: true
|
||||
}
|
||||
],
|
||||
pagination: {
|
||||
rowsPerPage: 10,
|
||||
page: 1,
|
||||
rowsNumber: 10
|
||||
},
|
||||
filter: null
|
||||
}
|
||||
}
|
||||
},
|
||||
created: function () {
|
||||
this.getInfo()
|
||||
this.get1MLStats()
|
||||
},
|
||||
watch: {
|
||||
tab: function (val) {
|
||||
if (val === 'transactions' && !this.paymentsTable.data.length) {
|
||||
this.getPayments()
|
||||
this.getInvoices()
|
||||
} else if (val === 'channels' && !this.channels.data.length) {
|
||||
this.getChannels()
|
||||
this.getPeers()
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
checkChanges() {
|
||||
return !_.isEqual(this.settings, this.formData)
|
||||
},
|
||||
filteredChannels: function () {
|
||||
return this.stateFilters
|
||||
? this.channels.data.filter(channel => {
|
||||
return this.stateFilters.find(({value}) => value == channel.state)
|
||||
})
|
||||
: this.channels.data
|
||||
},
|
||||
totalBalance: function () {
|
||||
return this.filteredChannels.reduce(
|
||||
(balance, channel) => {
|
||||
balance.local_msat += channel.balance.local_msat
|
||||
balance.remote_msat += channel.balance.remote_msat
|
||||
balance.total_msat += channel.balance.total_msat
|
||||
return balance
|
||||
},
|
||||
{local_msat: 0, remote_msat: 0, total_msat: 0}
|
||||
)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
formatMsat: function (msat) {
|
||||
return LNbits.utils.formatMsat(msat)
|
||||
},
|
||||
api: function (method, url, options) {
|
||||
const params = new URLSearchParams(options?.query)
|
||||
params.set('usr', this.g.user.id)
|
||||
return LNbits.api
|
||||
.request(method, `/node/api/v1${url}?${params}`, {}, options?.data)
|
||||
.catch(error => {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
getChannels: function () {
|
||||
return this.api('GET', '/channels').then(response => {
|
||||
this.channels.data = response.data
|
||||
})
|
||||
},
|
||||
getInfo: function () {
|
||||
return this.api('GET', '/info').then(response => {
|
||||
this.info = response.data
|
||||
this.channel_stats = response.data.channel_stats
|
||||
})
|
||||
},
|
||||
get1MLStats: function () {
|
||||
return this.api('GET', '/rank').then(response => {
|
||||
this.ranks = response.data
|
||||
})
|
||||
},
|
||||
getPayments: function (props) {
|
||||
if (props) {
|
||||
this.paymentsTable.pagination = props.pagination
|
||||
}
|
||||
let pagination = this.paymentsTable.pagination
|
||||
const query = {
|
||||
limit: pagination.rowsPerPage,
|
||||
offset: (pagination.page - 1) * pagination.rowsPerPage ?? 0
|
||||
}
|
||||
return this.api('GET', '/payments', {query}).then(response => {
|
||||
this.paymentsTable.data = response.data.data
|
||||
this.paymentsTable.pagination.rowsNumber = response.data.total
|
||||
})
|
||||
},
|
||||
getInvoices: function (props) {
|
||||
if (props) {
|
||||
this.invoiceTable.pagination = props.pagination
|
||||
}
|
||||
let pagination = this.invoiceTable.pagination
|
||||
const query = {
|
||||
limit: pagination.rowsPerPage,
|
||||
offset: (pagination.page - 1) * pagination.rowsPerPage ?? 0
|
||||
}
|
||||
return this.api('GET', '/invoices', {query}).then(response => {
|
||||
this.invoiceTable.data = response.data.data
|
||||
this.invoiceTable.pagination.rowsNumber = response.data.total
|
||||
})
|
||||
},
|
||||
getPeers: function () {
|
||||
return this.api('GET', '/peers').then(response => {
|
||||
this.peers.data = response.data
|
||||
console.log('peers', this.peers)
|
||||
})
|
||||
},
|
||||
connectPeer: function () {
|
||||
console.log('peer', this.connectPeerDialog)
|
||||
this.api('POST', '/peers', {data: this.connectPeerDialog.data}).then(
|
||||
() => {
|
||||
this.connectPeerDialog.show = false
|
||||
this.getPeers()
|
||||
}
|
||||
)
|
||||
},
|
||||
disconnectPeer: function (id) {
|
||||
LNbits.utils
|
||||
.confirmDialog('Do you really wanna disconnect this peer?')
|
||||
.onOk(() => {
|
||||
this.api('DELETE', `/peers/${id}`).then(response => {
|
||||
this.$q.notify({
|
||||
message: 'Disconnected',
|
||||
icon: null
|
||||
})
|
||||
this.needsRestart = true
|
||||
this.getPeers()
|
||||
})
|
||||
})
|
||||
},
|
||||
openChannel: function () {
|
||||
this.api('POST', '/channels', {data: this.openChannelDialog.data})
|
||||
.then(response => {
|
||||
this.openChannelDialog.show = false
|
||||
this.getChannels()
|
||||
})
|
||||
.catch(error => {
|
||||
console.log(error)
|
||||
})
|
||||
},
|
||||
showCloseChannelDialog: function (channel) {
|
||||
this.closeChannelDialog.show = true
|
||||
this.closeChannelDialog.data = {
|
||||
force: false,
|
||||
short_id: channel.short_id,
|
||||
...channel.point
|
||||
}
|
||||
},
|
||||
closeChannel: function () {
|
||||
this.api('DELETE', '/channels', {
|
||||
query: this.closeChannelDialog.data
|
||||
}).then(response => {
|
||||
this.closeChannelDialog.show = false
|
||||
this.getChannels()
|
||||
})
|
||||
},
|
||||
showOpenChannelDialog: function (peer_id) {
|
||||
this.openChannelDialog.show = true
|
||||
this.openChannelDialog.data = {peer_id, funding_amount: 0}
|
||||
},
|
||||
showNodeInfoDialog: function (node) {
|
||||
this.nodeInfoDialog.show = true
|
||||
this.nodeInfoDialog.data = node
|
||||
},
|
||||
showTransactionDetailsDialog: function (details) {
|
||||
this.transactionDetailsDialog.show = true
|
||||
this.transactionDetailsDialog.data = details
|
||||
console.log('details', details)
|
||||
},
|
||||
exportCSV: function () {},
|
||||
shortenNodeId
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,136 @@
|
||||
{% 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="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>
|
||||
Vue.component(VueQrcode.name, VueQrcode)
|
||||
Vue.use(VueQrcodeReader)
|
||||
|
||||
new Vue({
|
||||
el: '#vue',
|
||||
config: {
|
||||
globalProperties: {
|
||||
LNbits,
|
||||
msg: 'hello'
|
||||
}
|
||||
},
|
||||
mixins: [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 %}
|
||||
@@ -6,7 +6,7 @@ from subprocess import Popen
|
||||
from typing import Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from fastapi import Depends
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi.responses import FileResponse
|
||||
from starlette.exceptions import HTTPException
|
||||
|
||||
@@ -19,13 +19,15 @@ from lnbits.core.services import (
|
||||
)
|
||||
from lnbits.decorators import check_admin, check_super_user
|
||||
from lnbits.server import server_restart
|
||||
from lnbits.settings import AdminSettings, EditableSettings, settings
|
||||
from lnbits.settings import AdminSettings, UpdateSettings, settings
|
||||
|
||||
from .. import core_app, core_app_extra
|
||||
from .. import core_app_extra
|
||||
from ..crud import delete_admin_settings, get_admin_settings, update_admin_settings
|
||||
|
||||
admin_router = APIRouter()
|
||||
|
||||
@core_app.get(
|
||||
|
||||
@admin_router.get(
|
||||
"/admin/api/v1/audit",
|
||||
name="Audit",
|
||||
description="show the current balance of the node and the LNbits database",
|
||||
@@ -46,7 +48,7 @@ async def api_auditor():
|
||||
)
|
||||
|
||||
|
||||
@core_app.get("/admin/api/v1/settings/")
|
||||
@admin_router.get("/admin/api/v1/settings/", response_model=Optional[AdminSettings])
|
||||
async def api_get_settings(
|
||||
user: User = Depends(check_admin),
|
||||
) -> Optional[AdminSettings]:
|
||||
@@ -54,13 +56,11 @@ async def api_get_settings(
|
||||
return admin_settings
|
||||
|
||||
|
||||
@core_app.put(
|
||||
@admin_router.put(
|
||||
"/admin/api/v1/settings/",
|
||||
status_code=HTTPStatus.OK,
|
||||
)
|
||||
async def api_update_settings(
|
||||
data: EditableSettings, user: User = Depends(check_admin)
|
||||
):
|
||||
async def api_update_settings(data: UpdateSettings, user: User = Depends(check_admin)):
|
||||
await update_admin_settings(data)
|
||||
admin_settings = await get_admin_settings(user.super_user)
|
||||
assert admin_settings, "Updated admin settings not found."
|
||||
@@ -69,7 +69,7 @@ async def api_update_settings(
|
||||
return {"status": "Success"}
|
||||
|
||||
|
||||
@core_app.delete(
|
||||
@admin_router.delete(
|
||||
"/admin/api/v1/settings/",
|
||||
status_code=HTTPStatus.OK,
|
||||
dependencies=[Depends(check_super_user)],
|
||||
@@ -79,7 +79,7 @@ async def api_delete_settings() -> None:
|
||||
server_restart.set()
|
||||
|
||||
|
||||
@core_app.get(
|
||||
@admin_router.get(
|
||||
"/admin/api/v1/restart/",
|
||||
status_code=HTTPStatus.OK,
|
||||
dependencies=[Depends(check_super_user)],
|
||||
@@ -89,7 +89,7 @@ async def api_restart_server() -> dict[str, str]:
|
||||
return {"status": "Success"}
|
||||
|
||||
|
||||
@core_app.put(
|
||||
@admin_router.put(
|
||||
"/admin/api/v1/topup/",
|
||||
name="Topup",
|
||||
status_code=HTTPStatus.OK,
|
||||
@@ -113,7 +113,7 @@ async def api_topup_balance(data: CreateTopup) -> dict[str, str]:
|
||||
return {"status": "Success"}
|
||||
|
||||
|
||||
@core_app.get(
|
||||
@admin_router.get(
|
||||
"/admin/api/v1/backup/",
|
||||
status_code=HTTPStatus.OK,
|
||||
dependencies=[Depends(check_super_user)],
|
||||
|
||||
@@ -1,29 +1,32 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import uuid
|
||||
from http import HTTPStatus
|
||||
from io import BytesIO
|
||||
from typing import Dict, List, Optional, Union
|
||||
from urllib.parse import ParseResult, parse_qs, urlencode, urlparse, urlunparse
|
||||
from urllib.parse import ParseResult, parse_qs, unquote, urlencode, urlparse, urlunparse
|
||||
|
||||
import httpx
|
||||
import pyqrcode
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
Body,
|
||||
Depends,
|
||||
Header,
|
||||
Request,
|
||||
Response,
|
||||
WebSocket,
|
||||
WebSocketDisconnect,
|
||||
)
|
||||
from fastapi.exceptions import HTTPException
|
||||
from fastapi.responses import JSONResponse
|
||||
from loguru import logger
|
||||
from sse_starlette.sse import EventSourceResponse
|
||||
from starlette.responses import RedirectResponse, StreamingResponse
|
||||
|
||||
from lnbits import bolt11, lnurl
|
||||
from lnbits.core.db import core_app_extra, db
|
||||
from lnbits.core.helpers import (
|
||||
migrate_extension_database,
|
||||
stop_extension_background_work,
|
||||
@@ -33,12 +36,17 @@ from lnbits.core.models import (
|
||||
CreateInvoice,
|
||||
CreateLnurl,
|
||||
CreateLnurlAuth,
|
||||
CreateWallet,
|
||||
CreateWebPushSubscription,
|
||||
DecodePayment,
|
||||
Payment,
|
||||
PaymentFilters,
|
||||
PaymentHistoryPoint,
|
||||
Query,
|
||||
User,
|
||||
Wallet,
|
||||
WalletType,
|
||||
WebPushSubscription,
|
||||
)
|
||||
from lnbits.db import Filters, Page
|
||||
from lnbits.decorators import (
|
||||
@@ -65,22 +73,30 @@ from lnbits.utils.exchange_rates import (
|
||||
satoshis_amount_as_fiat,
|
||||
)
|
||||
|
||||
from .. import core_app, core_app_extra, db
|
||||
from ..crud import (
|
||||
DateTrunc,
|
||||
add_installed_extension,
|
||||
create_account,
|
||||
create_tinyurl,
|
||||
create_wallet,
|
||||
create_webpush_subscription,
|
||||
delete_dbversion,
|
||||
delete_installed_extension,
|
||||
delete_tinyurl,
|
||||
delete_wallet,
|
||||
delete_webpush_subscription,
|
||||
drop_extension_db,
|
||||
get_dbversions,
|
||||
get_payments,
|
||||
get_payments_history,
|
||||
get_payments_paginated,
|
||||
get_standalone_payment,
|
||||
get_tinyurl,
|
||||
get_tinyurl_by_url,
|
||||
get_wallet_for_key,
|
||||
get_webpush_subscription,
|
||||
save_balance_check,
|
||||
update_pending_payments,
|
||||
update_wallet,
|
||||
)
|
||||
from ..services import (
|
||||
@@ -88,6 +104,7 @@ from ..services import (
|
||||
PaymentFailure,
|
||||
check_transaction_status,
|
||||
create_invoice,
|
||||
fee_reserve_total,
|
||||
pay_invoice,
|
||||
perform_lnurlauth,
|
||||
websocketManager,
|
||||
@@ -95,13 +112,15 @@ from ..services import (
|
||||
)
|
||||
from ..tasks import api_invoice_listeners
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
@core_app.get("/api/v1/health", status_code=HTTPStatus.OK)
|
||||
|
||||
@api_router.get("/api/v1/health", status_code=HTTPStatus.OK)
|
||||
async def health():
|
||||
return
|
||||
|
||||
|
||||
@core_app.get("/api/v1/wallet")
|
||||
@api_router.get("/api/v1/wallet")
|
||||
async def api_wallet(wallet: WalletTypeInfo = Depends(get_key_type)):
|
||||
if wallet.wallet_type == WalletType.admin:
|
||||
return {
|
||||
@@ -113,7 +132,7 @@ async def api_wallet(wallet: WalletTypeInfo = Depends(get_key_type)):
|
||||
return {"name": wallet.wallet.name, "balance": wallet.wallet.balance_msat}
|
||||
|
||||
|
||||
@core_app.put("/api/v1/wallet/{new_name}")
|
||||
@api_router.put("/api/v1/wallet/{new_name}")
|
||||
async def api_update_wallet_name(
|
||||
new_name: str, wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||
):
|
||||
@@ -125,7 +144,7 @@ async def api_update_wallet_name(
|
||||
}
|
||||
|
||||
|
||||
@core_app.patch("/api/v1/wallet", response_model=Wallet)
|
||||
@api_router.patch("/api/v1/wallet", response_model=Wallet)
|
||||
async def api_update_wallet(
|
||||
name: Optional[str] = Body(None),
|
||||
currency: Optional[str] = Body(None),
|
||||
@@ -134,7 +153,36 @@ async def api_update_wallet(
|
||||
return await update_wallet(wallet.wallet.id, name, currency)
|
||||
|
||||
|
||||
@core_app.get(
|
||||
@api_router.delete("/api/v1/wallet")
|
||||
async def api_delete_wallet(
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||
) -> None:
|
||||
await delete_wallet(
|
||||
user_id=wallet.wallet.user,
|
||||
wallet_id=wallet.wallet.id,
|
||||
)
|
||||
|
||||
|
||||
@api_router.post("/api/v1/wallet", response_model=Wallet)
|
||||
async def api_create_wallet(
|
||||
data: CreateWallet,
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||
) -> Wallet:
|
||||
return await create_wallet(user_id=wallet.wallet.user, wallet_name=data.name)
|
||||
|
||||
|
||||
@api_router.post("/api/v1/account", response_model=Wallet)
|
||||
async def api_create_account(data: CreateWallet) -> Wallet:
|
||||
if not settings.new_accounts_allowed:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail="Account creation is disabled.",
|
||||
)
|
||||
account = await create_account()
|
||||
return await create_wallet(user_id=account.id, wallet_name=data.name)
|
||||
|
||||
|
||||
@api_router.get(
|
||||
"/api/v1/payments",
|
||||
name="Payment List",
|
||||
summary="get list of payments",
|
||||
@@ -146,16 +194,7 @@ async def api_payments(
|
||||
wallet: WalletTypeInfo = Depends(get_key_type),
|
||||
filters: Filters = Depends(parse_filters(PaymentFilters)),
|
||||
):
|
||||
pending_payments = await get_payments(
|
||||
wallet_id=wallet.wallet.id,
|
||||
pending=True,
|
||||
exclude_uncheckable=True,
|
||||
filters=filters,
|
||||
)
|
||||
for payment in pending_payments:
|
||||
await check_transaction_status(
|
||||
wallet_id=payment.wallet_id, payment_hash=payment.payment_hash
|
||||
)
|
||||
await update_pending_payments(wallet.wallet.id)
|
||||
return await get_payments(
|
||||
wallet_id=wallet.wallet.id,
|
||||
pending=True,
|
||||
@@ -164,7 +203,22 @@ async def api_payments(
|
||||
)
|
||||
|
||||
|
||||
@core_app.get(
|
||||
@api_router.get(
|
||||
"/api/v1/payments/history",
|
||||
name="Get payments history",
|
||||
response_model=List[PaymentHistoryPoint],
|
||||
openapi_extra=generate_filter_params_openapi(PaymentFilters),
|
||||
)
|
||||
async def api_payments_history(
|
||||
wallet: WalletTypeInfo = Depends(get_key_type),
|
||||
group: DateTrunc = Query("day"),
|
||||
filters: Filters[PaymentFilters] = Depends(parse_filters(PaymentFilters)),
|
||||
):
|
||||
await update_pending_payments(wallet.wallet.id)
|
||||
return await get_payments_history(wallet.wallet.id, group, filters)
|
||||
|
||||
|
||||
@api_router.get(
|
||||
"/api/v1/payments/paginated",
|
||||
name="Payment List",
|
||||
summary="get paginated list of payments",
|
||||
@@ -176,16 +230,7 @@ async def api_payments_paginated(
|
||||
wallet: WalletTypeInfo = Depends(get_key_type),
|
||||
filters: Filters = Depends(parse_filters(PaymentFilters)),
|
||||
):
|
||||
pending = await get_payments_paginated(
|
||||
wallet_id=wallet.wallet.id,
|
||||
pending=True,
|
||||
exclude_uncheckable=True,
|
||||
filters=filters,
|
||||
)
|
||||
for payment in pending.data:
|
||||
await check_transaction_status(
|
||||
wallet_id=payment.wallet_id, payment_hash=payment.payment_hash
|
||||
)
|
||||
await update_pending_payments(wallet.wallet.id)
|
||||
page = await get_payments_paginated(
|
||||
wallet_id=wallet.wallet.id,
|
||||
pending=True,
|
||||
@@ -287,9 +332,13 @@ async def api_payments_create_invoice(data: CreateInvoice, wallet: Wallet):
|
||||
}
|
||||
|
||||
|
||||
async def api_payments_pay_invoice(bolt11: str, wallet: Wallet):
|
||||
async def api_payments_pay_invoice(
|
||||
bolt11: str, wallet: Wallet, extra: Optional[dict] = None
|
||||
):
|
||||
try:
|
||||
payment_hash = await pay_invoice(wallet_id=wallet.id, payment_request=bolt11)
|
||||
payment_hash = await pay_invoice(
|
||||
wallet_id=wallet.id, payment_request=bolt11, extra=extra
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e))
|
||||
except PermissionError as e:
|
||||
@@ -306,7 +355,7 @@ async def api_payments_pay_invoice(bolt11: str, wallet: Wallet):
|
||||
}
|
||||
|
||||
|
||||
@core_app.post(
|
||||
@api_router.post(
|
||||
"/api/v1/payments",
|
||||
summary="Create or pay an invoice",
|
||||
description="""
|
||||
@@ -330,7 +379,7 @@ async def api_payments_create(
|
||||
detail="BOLT11 string is invalid or not given",
|
||||
)
|
||||
return await api_payments_pay_invoice(
|
||||
invoiceData.bolt11, wallet.wallet
|
||||
invoiceData.bolt11, wallet.wallet, invoiceData.extra
|
||||
) # admin key
|
||||
elif not invoiceData.out:
|
||||
# invoice key
|
||||
@@ -342,7 +391,22 @@ async def api_payments_create(
|
||||
)
|
||||
|
||||
|
||||
@core_app.post("/api/v1/payments/lnurl")
|
||||
@api_router.get("/api/v1/payments/fee-reserve")
|
||||
async def api_payments_fee_reserve(invoice: str = Query("invoice")) -> JSONResponse:
|
||||
invoice_obj = bolt11.decode(invoice)
|
||||
if invoice_obj.amount_msat:
|
||||
response = {
|
||||
"fee_reserve": fee_reserve_total(invoice_obj.amount_msat),
|
||||
}
|
||||
return JSONResponse(response)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail="Invoice has no amount.",
|
||||
)
|
||||
|
||||
|
||||
@api_router.post("/api/v1/payments/lnurl")
|
||||
async def api_payments_pay_lnurl(
|
||||
data: CreateLnurl, wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||
):
|
||||
@@ -388,17 +452,6 @@ async def api_payments_pay_lnurl(
|
||||
),
|
||||
)
|
||||
|
||||
if invoice.description_hash != data.description_hash:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail=(
|
||||
(
|
||||
f"{domain} returned an invalid invoice. Expected description_hash"
|
||||
f" == {data.description_hash}, got {invoice.description_hash}."
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
extra = {}
|
||||
|
||||
if params.get("successAction"):
|
||||
@@ -445,11 +498,13 @@ async def subscribe_wallet_invoices(request: Request, wallet: Wallet):
|
||||
yield dict(data=payment.json(), event="payment-received")
|
||||
except asyncio.CancelledError:
|
||||
logger.debug(f"removing listener for wallet {uid}")
|
||||
except Exception as exc:
|
||||
logger.error(f"Error in sse: {exc}")
|
||||
finally:
|
||||
api_invoice_listeners.pop(uid)
|
||||
return
|
||||
|
||||
|
||||
@core_app.get("/api/v1/payments/sse")
|
||||
@api_router.get("/api/v1/payments/sse")
|
||||
async def api_payments_sse(
|
||||
request: Request, wallet: WalletTypeInfo = Depends(get_key_type)
|
||||
):
|
||||
@@ -461,12 +516,12 @@ async def api_payments_sse(
|
||||
|
||||
|
||||
# TODO: refactor this route into a public and admin one
|
||||
@core_app.get("/api/v1/payments/{payment_hash}")
|
||||
@api_router.get("/api/v1/payments/{payment_hash}")
|
||||
async def api_payment(payment_hash, X_Api_Key: Optional[str] = Header(None)):
|
||||
# We use X_Api_Key here because we want this call to work with and without keys
|
||||
# If a valid key is given, we also return the field "details", otherwise not
|
||||
wallet = await get_wallet_for_key(X_Api_Key) if isinstance(X_Api_Key, str) else None
|
||||
|
||||
wallet = wallet if wallet and not wallet.deleted else None
|
||||
# we have to specify the wallet id here, because postgres and sqlite return
|
||||
# internal payments in different order and get_standalone_payment otherwise
|
||||
# just fetches the first one, causing unpredictable results
|
||||
@@ -506,7 +561,7 @@ async def api_payment(payment_hash, X_Api_Key: Optional[str] = Header(None)):
|
||||
return {"paid": not payment.pending, "preimage": payment.preimage}
|
||||
|
||||
|
||||
@core_app.get("/api/v1/lnurlscan/{code}")
|
||||
@api_router.get("/api/v1/lnurlscan/{code}")
|
||||
async def api_lnurlscan(code: str, wallet: WalletTypeInfo = Depends(get_key_type)):
|
||||
try:
|
||||
url = lnurl.decode(code)
|
||||
@@ -621,33 +676,24 @@ async def api_lnurlscan(code: str, wallet: WalletTypeInfo = Depends(get_key_type
|
||||
return params
|
||||
|
||||
|
||||
@core_app.post("/api/v1/payments/decode", status_code=HTTPStatus.OK)
|
||||
async def api_payments_decode(data: DecodePayment, response: Response):
|
||||
@api_router.post("/api/v1/payments/decode", status_code=HTTPStatus.OK)
|
||||
async def api_payments_decode(data: DecodePayment) -> JSONResponse:
|
||||
payment_str = data.data
|
||||
try:
|
||||
if payment_str[:5] == "LNURL":
|
||||
url = lnurl.decode(payment_str)
|
||||
return {"domain": url}
|
||||
return JSONResponse({"domain": url})
|
||||
else:
|
||||
invoice = bolt11.decode(payment_str)
|
||||
return {
|
||||
"payment_hash": invoice.payment_hash,
|
||||
"amount_msat": invoice.amount_msat,
|
||||
"description": invoice.description,
|
||||
"description_hash": invoice.description_hash,
|
||||
"payee": invoice.payee,
|
||||
"date": invoice.date,
|
||||
"expiry": invoice.expiry,
|
||||
"secret": invoice.secret,
|
||||
"route_hints": invoice.route_hints,
|
||||
"min_final_cltv_expiry": invoice.min_final_cltv_expiry,
|
||||
}
|
||||
except Exception:
|
||||
response.status_code = HTTPStatus.BAD_REQUEST
|
||||
return {"message": "Failed to decode"}
|
||||
return JSONResponse(invoice.data)
|
||||
except Exception as exc:
|
||||
return JSONResponse(
|
||||
{"message": f"Failed to decode: {str(exc)}"},
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
@core_app.post("/api/v1/lnurlauth")
|
||||
@api_router.post("/api/v1/lnurlauth")
|
||||
async def api_perform_lnurlauth(
|
||||
data: CreateLnurlAuth, wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||
):
|
||||
@@ -659,7 +705,7 @@ async def api_perform_lnurlauth(
|
||||
return ""
|
||||
|
||||
|
||||
@core_app.get("/api/v1/currencies")
|
||||
@api_router.get("/api/v1/currencies")
|
||||
async def api_list_currencies_available():
|
||||
if len(settings.lnbits_allowed_currencies) > 0:
|
||||
return [
|
||||
@@ -670,7 +716,7 @@ async def api_list_currencies_available():
|
||||
return list(currencies.keys())
|
||||
|
||||
|
||||
@core_app.post("/api/v1/conversion")
|
||||
@api_router.post("/api/v1/conversion")
|
||||
async def api_fiat_as_sats(data: ConversionData):
|
||||
output = {}
|
||||
if data.from_ == "sat":
|
||||
@@ -688,7 +734,7 @@ async def api_fiat_as_sats(data: ConversionData):
|
||||
return output
|
||||
|
||||
|
||||
@core_app.get("/api/v1/qrcode/{data}", response_class=StreamingResponse)
|
||||
@api_router.get("/api/v1/qrcode/{data}", response_class=StreamingResponse)
|
||||
async def img(data):
|
||||
qr = pyqrcode.create(data)
|
||||
stream = BytesIO()
|
||||
@@ -709,7 +755,7 @@ async def img(data):
|
||||
)
|
||||
|
||||
|
||||
@core_app.websocket("/api/v1/ws/{item_id}")
|
||||
@api_router.websocket("/api/v1/ws/{item_id}")
|
||||
async def websocket_connect(websocket: WebSocket, item_id: str):
|
||||
await websocketManager.connect(websocket, item_id)
|
||||
try:
|
||||
@@ -719,7 +765,7 @@ async def websocket_connect(websocket: WebSocket, item_id: str):
|
||||
websocketManager.disconnect(websocket)
|
||||
|
||||
|
||||
@core_app.post("/api/v1/ws/{item_id}")
|
||||
@api_router.post("/api/v1/ws/{item_id}")
|
||||
async def websocket_update_post(item_id: str, data: str):
|
||||
try:
|
||||
await websocketUpdater(item_id, data)
|
||||
@@ -728,7 +774,7 @@ async def websocket_update_post(item_id: str, data: str):
|
||||
return {"sent": False, "data": data}
|
||||
|
||||
|
||||
@core_app.get("/api/v1/ws/{item_id}/{data}")
|
||||
@api_router.get("/api/v1/ws/{item_id}/{data}")
|
||||
async def websocket_update_get(item_id: str, data: str):
|
||||
try:
|
||||
await websocketUpdater(item_id, data)
|
||||
@@ -737,7 +783,7 @@ async def websocket_update_get(item_id: str, data: str):
|
||||
return {"sent": False, "data": data}
|
||||
|
||||
|
||||
@core_app.post("/api/v1/extension")
|
||||
@api_router.post("/api/v1/extension")
|
||||
async def api_install_extension(
|
||||
data: CreateExtension, user: User = Depends(check_admin)
|
||||
):
|
||||
@@ -796,7 +842,7 @@ async def api_install_extension(
|
||||
)
|
||||
|
||||
|
||||
@core_app.delete("/api/v1/extension/{ext_id}")
|
||||
@api_router.delete("/api/v1/extension/{ext_id}")
|
||||
async def api_uninstall_extension(ext_id: str, user: User = Depends(check_admin)):
|
||||
installable_extensions = await InstallableExtension.get_installable_extensions()
|
||||
|
||||
@@ -839,7 +885,7 @@ async def api_uninstall_extension(ext_id: str, user: User = Depends(check_admin)
|
||||
)
|
||||
|
||||
|
||||
@core_app.get(
|
||||
@api_router.get(
|
||||
"/api/v1/extension/{ext_id}/releases", dependencies=[Depends(check_admin)]
|
||||
)
|
||||
async def get_extension_releases(ext_id: str):
|
||||
@@ -856,7 +902,7 @@ async def get_extension_releases(ext_id: str):
|
||||
)
|
||||
|
||||
|
||||
@core_app.get(
|
||||
@api_router.get(
|
||||
"/api/v1/extension/release/{org}/{repo}/{tag_name}",
|
||||
dependencies=[Depends(check_admin)],
|
||||
)
|
||||
@@ -877,7 +923,7 @@ async def get_extension_release(org: str, repo: str, tag_name: str):
|
||||
)
|
||||
|
||||
|
||||
@core_app.delete(
|
||||
@api_router.delete(
|
||||
"/api/v1/extension/{ext_id}/db",
|
||||
dependencies=[Depends(check_admin)],
|
||||
)
|
||||
@@ -903,10 +949,7 @@ async def delete_extension_db(ext_id: str):
|
||||
)
|
||||
|
||||
|
||||
# TINYURL
|
||||
|
||||
|
||||
@core_app.post(
|
||||
@api_router.post(
|
||||
"/api/v1/tinyurl",
|
||||
name="Tinyurl",
|
||||
description="creates a tinyurl",
|
||||
@@ -927,7 +970,7 @@ async def api_create_tinyurl(
|
||||
)
|
||||
|
||||
|
||||
@core_app.get(
|
||||
@api_router.get(
|
||||
"/api/v1/tinyurl/{tinyurl_id}",
|
||||
name="Tinyurl",
|
||||
description="get a tinyurl by id",
|
||||
@@ -949,7 +992,7 @@ async def api_get_tinyurl(
|
||||
)
|
||||
|
||||
|
||||
@core_app.delete(
|
||||
@api_router.delete(
|
||||
"/api/v1/tinyurl/{tinyurl_id}",
|
||||
name="Tinyurl",
|
||||
description="delete a tinyurl by id",
|
||||
@@ -972,7 +1015,7 @@ async def api_delete_tinyurl(
|
||||
)
|
||||
|
||||
|
||||
@core_app.get(
|
||||
@api_router.get(
|
||||
"/t/{tinyurl_id}",
|
||||
name="Tinyurl",
|
||||
description="redirects a tinyurl by id",
|
||||
@@ -986,3 +1029,36 @@ async def api_tinyurl(tinyurl_id: str):
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="unable to find tinyurl"
|
||||
)
|
||||
|
||||
|
||||
@api_router.post("/api/v1/webpush", status_code=HTTPStatus.CREATED)
|
||||
async def api_create_webpush_subscription(
|
||||
request: Request,
|
||||
data: CreateWebPushSubscription,
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||
) -> WebPushSubscription:
|
||||
subscription = json.loads(data.subscription)
|
||||
endpoint = subscription["endpoint"]
|
||||
host = urlparse(str(request.url)).netloc
|
||||
|
||||
subscription = await get_webpush_subscription(endpoint, wallet.wallet.user)
|
||||
if subscription:
|
||||
return subscription
|
||||
else:
|
||||
return await create_webpush_subscription(
|
||||
endpoint,
|
||||
wallet.wallet.user,
|
||||
data.subscription,
|
||||
host,
|
||||
)
|
||||
|
||||
|
||||
@api_router.delete("/api/v1/webpush", status_code=HTTPStatus.OK)
|
||||
async def api_delete_webpush_subscription(
|
||||
request: Request,
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||
):
|
||||
endpoint = unquote(
|
||||
base64.b64decode(str(request.query_params.get("endpoint"))).decode("utf-8")
|
||||
)
|
||||
await delete_webpush_subscription(endpoint, wallet.wallet.user)
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import asyncio
|
||||
from http import HTTPStatus
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from fastapi import Depends, Query, Request, status
|
||||
from fastapi.exceptions import HTTPException
|
||||
from fastapi.responses import FileResponse, RedirectResponse
|
||||
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse
|
||||
from fastapi.routing import APIRouter
|
||||
from loguru import logger
|
||||
from pydantic.types import UUID4
|
||||
from starlette.responses import HTMLResponse, JSONResponse
|
||||
|
||||
from lnbits.core import db
|
||||
from lnbits.core.db import db
|
||||
from lnbits.core.helpers import to_valid_user_id
|
||||
from lnbits.core.models import User
|
||||
from lnbits.decorators import check_admin, check_user_exists
|
||||
@@ -23,7 +24,6 @@ from ...utils.exchange_rates import currencies
|
||||
from ..crud import (
|
||||
create_account,
|
||||
create_wallet,
|
||||
delete_wallet,
|
||||
get_balance_check,
|
||||
get_dbversions,
|
||||
get_inactive_extensions,
|
||||
@@ -35,24 +35,24 @@ from ..crud import (
|
||||
)
|
||||
from ..services import pay_invoice, redeem_lnurl_withdraw
|
||||
|
||||
core_html_routes: APIRouter = APIRouter(
|
||||
generic_router = APIRouter(
|
||||
tags=["Core NON-API Website Routes"], include_in_schema=False
|
||||
)
|
||||
|
||||
|
||||
@core_html_routes.get("/favicon.ico", response_class=FileResponse)
|
||||
@generic_router.get("/favicon.ico", response_class=FileResponse)
|
||||
async def favicon():
|
||||
return FileResponse("lnbits/core/static/favicon.ico")
|
||||
return FileResponse(Path("lnbits", "static", "favicon.ico"))
|
||||
|
||||
|
||||
@core_html_routes.get("/", response_class=HTMLResponse)
|
||||
@generic_router.get("/", response_class=HTMLResponse)
|
||||
async def home(request: Request, lightning: str = ""):
|
||||
return template_renderer().TemplateResponse(
|
||||
"core/index.html", {"request": request, "lnurl": lightning}
|
||||
)
|
||||
|
||||
|
||||
@core_html_routes.get("/robots.txt", response_class=HTMLResponse)
|
||||
@generic_router.get("/robots.txt", response_class=HTMLResponse)
|
||||
async def robots():
|
||||
data = """
|
||||
User-agent: *
|
||||
@@ -61,7 +61,7 @@ async def robots():
|
||||
return HTMLResponse(content=data, media_type="text/plain")
|
||||
|
||||
|
||||
@core_html_routes.get(
|
||||
@generic_router.get(
|
||||
"/extensions", name="install.extensions", response_class=HTMLResponse
|
||||
)
|
||||
async def extensions_install(
|
||||
@@ -158,80 +158,55 @@ async def extensions_install(
|
||||
raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e))
|
||||
|
||||
|
||||
@core_html_routes.get(
|
||||
@generic_router.get(
|
||||
"/wallet",
|
||||
response_class=HTMLResponse,
|
||||
description="""
|
||||
just **wallet_name**: create a new user, then create a new wallet
|
||||
for user with wallet_name
|
||||
just **user_id**: return the first user wallet or create one if none found
|
||||
(with default wallet_name)
|
||||
**user_id** and **wallet_name**: create a new wallet for user with wallet_name
|
||||
**user_id** and **wallet_id**: return that wallet if user is the owner
|
||||
nothing: create everything
|
||||
""",
|
||||
description="show wallet page",
|
||||
)
|
||||
async def wallet(
|
||||
request: Request = Query(None),
|
||||
nme: Optional[str] = Query(None),
|
||||
usr: Optional[UUID4] = Query(None),
|
||||
request: Request,
|
||||
usr: UUID4 = Query(...),
|
||||
wal: Optional[UUID4] = Query(None),
|
||||
):
|
||||
user_id = usr.hex if usr else None
|
||||
wallet_id = wal.hex if wal else None
|
||||
wallet_name = nme
|
||||
user_id = usr.hex
|
||||
user = await get_user(user_id)
|
||||
|
||||
if not user_id:
|
||||
new_user = await create_account()
|
||||
user = await get_user(new_user.id)
|
||||
assert user, "Newly created user has to exist."
|
||||
logger.info(f"Create user {user.id}")
|
||||
else:
|
||||
user = await get_user(user_id)
|
||||
if not user:
|
||||
return template_renderer().TemplateResponse(
|
||||
"error.html", {"request": request, "err": "User does not exist."}
|
||||
)
|
||||
if (
|
||||
len(settings.lnbits_allowed_users) > 0
|
||||
and user_id not in settings.lnbits_allowed_users
|
||||
and user_id not in settings.lnbits_admin_users
|
||||
and user_id != settings.super_user
|
||||
):
|
||||
return template_renderer().TemplateResponse(
|
||||
"error.html", {"request": request, "err": "User not authorized."}
|
||||
)
|
||||
if user_id == settings.super_user or user_id in settings.lnbits_admin_users:
|
||||
user.admin = True
|
||||
if user_id == settings.super_user:
|
||||
user.super_user = True
|
||||
|
||||
if not wallet_id:
|
||||
if user.wallets and not wallet_name:
|
||||
wallet = user.wallets[0]
|
||||
else:
|
||||
wallet = await create_wallet(user_id=user.id, wallet_name=wallet_name)
|
||||
logger.info(
|
||||
f"Created new wallet {wallet_name if wallet_name else '(no name)'} for"
|
||||
f" user {user.id}"
|
||||
)
|
||||
|
||||
return RedirectResponse(
|
||||
f"/wallet?usr={user.id}&wal={wallet.id}",
|
||||
status_code=status.HTTP_307_TEMPORARY_REDIRECT,
|
||||
if not user:
|
||||
return template_renderer().TemplateResponse(
|
||||
"error.html", {"request": request, "err": "User does not exist."}
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
"Access "
|
||||
f"{'user '+ user.id + ' ' if user else ''} "
|
||||
f"{'wallet ' + wallet_name if wallet_name else ''}"
|
||||
)
|
||||
if not wal:
|
||||
if len(user.wallets) == 0:
|
||||
wallet = await create_wallet(user_id=user.id)
|
||||
return RedirectResponse(url=f"/wallet?usr={user_id}&wal={wallet.id}")
|
||||
return RedirectResponse(url=f"/wallet?usr={user_id}&wal={user.wallets[0].id}")
|
||||
else:
|
||||
wallet_id = wal.hex
|
||||
|
||||
userwallet = user.get_wallet(wallet_id)
|
||||
if not userwallet:
|
||||
if not userwallet or userwallet.deleted:
|
||||
return template_renderer().TemplateResponse(
|
||||
"error.html", {"request": request, "err": "Wallet not found"}
|
||||
)
|
||||
|
||||
if (
|
||||
len(settings.lnbits_allowed_users) > 0
|
||||
and user_id not in settings.lnbits_allowed_users
|
||||
and user_id not in settings.lnbits_admin_users
|
||||
and user_id != settings.super_user
|
||||
):
|
||||
return template_renderer().TemplateResponse(
|
||||
"error.html", {"request": request, "err": "User not authorized."}
|
||||
)
|
||||
|
||||
if user_id == settings.super_user or user_id in settings.lnbits_admin_users:
|
||||
user.admin = True
|
||||
if user_id == settings.super_user:
|
||||
user.super_user = True
|
||||
|
||||
logger.debug(f"Access user {user.id} wallet {userwallet.name}")
|
||||
|
||||
return template_renderer().TemplateResponse(
|
||||
"core/wallet.html",
|
||||
{
|
||||
@@ -239,18 +214,27 @@ async def wallet(
|
||||
"user": user.dict(),
|
||||
"wallet": userwallet.dict(),
|
||||
"service_fee": settings.lnbits_service_fee,
|
||||
"service_fee_max": settings.lnbits_service_fee_max,
|
||||
"web_manifest": f"/manifest/{user.id}.webmanifest",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@core_html_routes.get("/withdraw", response_class=JSONResponse)
|
||||
@generic_router.get("/withdraw", response_class=JSONResponse)
|
||||
async def lnurl_full_withdraw(request: Request):
|
||||
user = await get_user(request.query_params.get("usr"))
|
||||
usr_param = request.query_params.get("usr")
|
||||
if not usr_param:
|
||||
return {"status": "ERROR", "reason": "usr parameter not provided."}
|
||||
|
||||
user = await get_user(usr_param)
|
||||
if not user:
|
||||
return {"status": "ERROR", "reason": "User does not exist."}
|
||||
|
||||
wallet = user.get_wallet(request.query_params.get("wal"))
|
||||
wal_param = request.query_params.get("wal")
|
||||
if not wal_param:
|
||||
return {"status": "ERROR", "reason": "wal parameter not provided."}
|
||||
|
||||
wallet = user.get_wallet(wal_param)
|
||||
if not wallet:
|
||||
return {"status": "ERROR", "reason": "Wallet does not exist."}
|
||||
|
||||
@@ -267,17 +251,27 @@ async def lnurl_full_withdraw(request: Request):
|
||||
}
|
||||
|
||||
|
||||
@core_html_routes.get("/withdraw/cb", response_class=JSONResponse)
|
||||
@generic_router.get("/withdraw/cb", response_class=JSONResponse)
|
||||
async def lnurl_full_withdraw_callback(request: Request):
|
||||
user = await get_user(request.query_params.get("usr"))
|
||||
usr_param = request.query_params.get("usr")
|
||||
if not usr_param:
|
||||
return {"status": "ERROR", "reason": "usr parameter not provided."}
|
||||
|
||||
user = await get_user(usr_param)
|
||||
if not user:
|
||||
return {"status": "ERROR", "reason": "User does not exist."}
|
||||
|
||||
wallet = user.get_wallet(request.query_params.get("wal"))
|
||||
wal_param = request.query_params.get("wal")
|
||||
if not wal_param:
|
||||
return {"status": "ERROR", "reason": "wal parameter not provided."}
|
||||
|
||||
wallet = user.get_wallet(wal_param)
|
||||
if not wallet:
|
||||
return {"status": "ERROR", "reason": "Wallet does not exist."}
|
||||
|
||||
pr = request.query_params.get("pr")
|
||||
if not pr:
|
||||
return {"status": "ERROR", "reason": "payment_request not provided."}
|
||||
|
||||
async def pay():
|
||||
try:
|
||||
@@ -294,40 +288,18 @@ async def lnurl_full_withdraw_callback(request: Request):
|
||||
return {"status": "OK"}
|
||||
|
||||
|
||||
@core_html_routes.get("/deletewallet", response_class=RedirectResponse)
|
||||
async def deletewallet(wal: str = Query(...), usr: str = Query(...)):
|
||||
user = await get_user(usr)
|
||||
if not user:
|
||||
raise HTTPException(HTTPStatus.FORBIDDEN, "User not found.")
|
||||
|
||||
user_wallet_ids = [u.id for u in user.wallets]
|
||||
|
||||
if wal not in user_wallet_ids:
|
||||
raise HTTPException(HTTPStatus.FORBIDDEN, "Not your wallet.")
|
||||
else:
|
||||
await delete_wallet(user_id=user.id, wallet_id=wal)
|
||||
user_wallet_ids.remove(wal)
|
||||
logger.debug("Deleted wallet {wal} of user {user.id}")
|
||||
|
||||
if user_wallet_ids:
|
||||
return RedirectResponse(
|
||||
url_for("/wallet", usr=user.id, wal=user_wallet_ids[0]),
|
||||
status_code=status.HTTP_307_TEMPORARY_REDIRECT,
|
||||
)
|
||||
|
||||
return RedirectResponse(
|
||||
url_for("/"), status_code=status.HTTP_307_TEMPORARY_REDIRECT
|
||||
)
|
||||
|
||||
|
||||
@core_html_routes.get("/withdraw/notify/{service}")
|
||||
@generic_router.get("/withdraw/notify/{service}")
|
||||
async def lnurl_balance_notify(request: Request, service: str):
|
||||
bc = await get_balance_check(request.query_params.get("wal"), service)
|
||||
wal_param = request.query_params.get("wal")
|
||||
if not wal_param:
|
||||
return {"status": "ERROR", "reason": "wal parameter not provided."}
|
||||
|
||||
bc = await get_balance_check(wal_param, service)
|
||||
if bc:
|
||||
await redeem_lnurl_withdraw(bc.wallet, bc.url)
|
||||
|
||||
|
||||
@core_html_routes.get(
|
||||
@generic_router.get(
|
||||
"/lnurlwallet", response_class=RedirectResponse, name="core.lnurlwallet"
|
||||
)
|
||||
async def lnurlwallet(request: Request):
|
||||
@@ -337,10 +309,14 @@ async def lnurlwallet(request: Request):
|
||||
assert user, "Newly created user not found."
|
||||
wallet = await create_wallet(user_id=user.id, conn=conn)
|
||||
|
||||
lightning_param = request.query_params.get("lightning")
|
||||
if not lightning_param:
|
||||
return {"status": "ERROR", "reason": "lightning parameter not provided."}
|
||||
|
||||
asyncio.create_task(
|
||||
redeem_lnurl_withdraw(
|
||||
wallet.id,
|
||||
request.query_params.get("lightning"),
|
||||
lightning_param,
|
||||
"LNbits initial funding: voucher redeem.",
|
||||
{"tag": "lnurlwallet"},
|
||||
5, # wait 5 seconds before sending the invoice to the service
|
||||
@@ -353,13 +329,15 @@ async def lnurlwallet(request: Request):
|
||||
)
|
||||
|
||||
|
||||
@core_html_routes.get("/service-worker.js", response_class=FileResponse)
|
||||
@generic_router.get("/service-worker.js", response_class=FileResponse)
|
||||
async def service_worker():
|
||||
return FileResponse("lnbits/core/static/js/service-worker.js")
|
||||
return FileResponse(Path("lnbits", "static", "js", "service-worker.js"))
|
||||
|
||||
|
||||
@core_html_routes.get("/manifest/{usr}.webmanifest")
|
||||
async def manifest(usr: str):
|
||||
@generic_router.get("/manifest/{usr}.webmanifest")
|
||||
async def manifest(request: Request, usr: str):
|
||||
host = urlparse(str(request.url)).netloc
|
||||
|
||||
user = await get_user(usr)
|
||||
if not user:
|
||||
raise HTTPException(status_code=HTTPStatus.NOT_FOUND)
|
||||
@@ -372,7 +350,7 @@ async def manifest(usr: str):
|
||||
"src": (
|
||||
settings.lnbits_custom_logo
|
||||
if settings.lnbits_custom_logo
|
||||
else "https://cdn.jsdelivr.net/gh/lnbits/lnbits@0.3.0/docs/logos/lnbits.png"
|
||||
else "https://cdn.jsdelivr.net/gh/lnbits/lnbits@main/docs/logos/lnbits.png"
|
||||
),
|
||||
"type": "image/png",
|
||||
"sizes": "900x900",
|
||||
@@ -393,10 +371,49 @@ async def manifest(usr: str):
|
||||
}
|
||||
for wallet in user.wallets
|
||||
],
|
||||
"url_handlers": [{"origin": f"https://{host}"}],
|
||||
}
|
||||
|
||||
|
||||
@core_html_routes.get("/admin", response_class=HTMLResponse)
|
||||
@generic_router.get("/node", response_class=HTMLResponse)
|
||||
async def node(request: Request, user: User = Depends(check_admin)):
|
||||
if not settings.lnbits_node_ui:
|
||||
raise HTTPException(status_code=HTTPStatus.SERVICE_UNAVAILABLE)
|
||||
|
||||
WALLET = get_wallet_class()
|
||||
_, balance = await WALLET.status()
|
||||
|
||||
return template_renderer().TemplateResponse(
|
||||
"node/index.html",
|
||||
{
|
||||
"request": request,
|
||||
"user": user.dict(),
|
||||
"settings": settings.dict(),
|
||||
"balance": balance,
|
||||
"wallets": user.wallets[0].dict(),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@generic_router.get("/node/public", response_class=HTMLResponse)
|
||||
async def node_public(request: Request):
|
||||
if not settings.lnbits_public_node_ui:
|
||||
raise HTTPException(status_code=HTTPStatus.SERVICE_UNAVAILABLE)
|
||||
|
||||
WALLET = get_wallet_class()
|
||||
_, balance = await WALLET.status()
|
||||
|
||||
return template_renderer().TemplateResponse(
|
||||
"node/public.html",
|
||||
{
|
||||
"request": request,
|
||||
"settings": settings.dict(),
|
||||
"balance": balance,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@generic_router.get("/admin", response_class=HTMLResponse)
|
||||
async def index(request: Request, user: User = Depends(check_admin)):
|
||||
if not settings.lnbits_admin_ui:
|
||||
raise HTTPException(status_code=HTTPStatus.NOT_FOUND)
|
||||
@@ -416,7 +433,7 @@ async def index(request: Request, user: User = Depends(check_admin)):
|
||||
)
|
||||
|
||||
|
||||
@core_html_routes.get("/uuidv4/{hex_value}")
|
||||
@generic_router.get("/uuidv4/{hex_value}")
|
||||
async def hex_to_uuid4(hex_value: str):
|
||||
try:
|
||||
user_id = to_valid_user_id(hex_value).hex
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
from http import HTTPStatus
|
||||
from typing import List, Optional
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from starlette.status import HTTP_503_SERVICE_UNAVAILABLE
|
||||
|
||||
from lnbits.decorators import check_admin, check_super_user, parse_filters
|
||||
from lnbits.nodes import get_node_class
|
||||
from lnbits.settings import settings
|
||||
|
||||
from ...db import Filters, Page
|
||||
from ...nodes.base import (
|
||||
ChannelPoint,
|
||||
Node,
|
||||
NodeChannel,
|
||||
NodeInfoResponse,
|
||||
NodeInvoice,
|
||||
NodeInvoiceFilters,
|
||||
NodePayment,
|
||||
NodePaymentsFilters,
|
||||
NodePeerInfo,
|
||||
PublicNodeInfo,
|
||||
)
|
||||
from ...utils.cache import cache
|
||||
|
||||
|
||||
def require_node():
|
||||
NODE = get_node_class()
|
||||
if not NODE:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_IMPLEMENTED,
|
||||
detail="Active backend does not implement Node API",
|
||||
)
|
||||
if not settings.lnbits_node_ui:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.SERVICE_UNAVAILABLE,
|
||||
detail="Not enabled",
|
||||
)
|
||||
return NODE
|
||||
|
||||
|
||||
def check_public():
|
||||
if not (settings.lnbits_node_ui and settings.lnbits_public_node_ui):
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.SERVICE_UNAVAILABLE,
|
||||
detail="Not enabled",
|
||||
)
|
||||
|
||||
|
||||
node_router = APIRouter(prefix="/node/api/v1", dependencies=[Depends(check_admin)])
|
||||
super_node_router = APIRouter(
|
||||
prefix="/node/api/v1", dependencies=[Depends(check_super_user)]
|
||||
)
|
||||
public_node_router = APIRouter(
|
||||
prefix="/node/public/api/v1", dependencies=[Depends(check_public)]
|
||||
)
|
||||
|
||||
|
||||
@node_router.get(
|
||||
"/ok",
|
||||
description="Check if node api can be enabled",
|
||||
status_code=200,
|
||||
dependencies=[Depends(require_node)],
|
||||
)
|
||||
async def api_get_ok():
|
||||
pass
|
||||
|
||||
|
||||
@public_node_router.get("/info", response_model=PublicNodeInfo)
|
||||
async def api_get_public_info(node: Node = Depends(require_node)) -> PublicNodeInfo:
|
||||
return await cache.save_result(node.get_public_info, key="node:public_info")
|
||||
|
||||
|
||||
@node_router.get("/info")
|
||||
async def api_get_info(
|
||||
node: Node = Depends(require_node),
|
||||
) -> Optional[NodeInfoResponse]:
|
||||
return await node.get_info()
|
||||
|
||||
|
||||
@node_router.get("/channels")
|
||||
async def api_get_channels(
|
||||
node: Node = Depends(require_node),
|
||||
) -> Optional[List[NodeChannel]]:
|
||||
return await node.get_channels()
|
||||
|
||||
|
||||
@super_node_router.post("/channels", response_model=ChannelPoint)
|
||||
async def api_create_channel(
|
||||
node: Node = Depends(require_node),
|
||||
peer_id: str = Body(),
|
||||
funding_amount: int = Body(),
|
||||
push_amount: Optional[int] = Body(None),
|
||||
fee_rate: Optional[int] = Body(None),
|
||||
):
|
||||
return await node.open_channel(peer_id, funding_amount, push_amount, fee_rate)
|
||||
|
||||
|
||||
@super_node_router.delete("/channels")
|
||||
async def api_delete_channel(
|
||||
short_id: Optional[str],
|
||||
funding_txid: Optional[str],
|
||||
output_index: Optional[int],
|
||||
force: bool = False,
|
||||
node: Node = Depends(require_node),
|
||||
) -> Optional[List[NodeChannel]]:
|
||||
return await node.close_channel(
|
||||
short_id,
|
||||
ChannelPoint(funding_txid=funding_txid, output_index=output_index)
|
||||
if funding_txid is not None and output_index is not None
|
||||
else None,
|
||||
force,
|
||||
)
|
||||
|
||||
|
||||
@node_router.get("/payments", response_model=Page[NodePayment])
|
||||
async def api_get_payments(
|
||||
node: Node = Depends(require_node),
|
||||
filters: Filters = Depends(parse_filters(NodePaymentsFilters)),
|
||||
) -> Optional[Page[NodePayment]]:
|
||||
if not settings.lnbits_node_ui_transactions:
|
||||
raise HTTPException(
|
||||
HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="You can enable node transactions in the Admin UI",
|
||||
)
|
||||
return await node.get_payments(filters)
|
||||
|
||||
|
||||
@node_router.get("/invoices", response_model=Page[NodeInvoice])
|
||||
async def api_get_invoices(
|
||||
node: Node = Depends(require_node),
|
||||
filters: Filters = Depends(parse_filters(NodeInvoiceFilters)),
|
||||
) -> Optional[Page[NodeInvoice]]:
|
||||
if not settings.lnbits_node_ui_transactions:
|
||||
raise HTTPException(
|
||||
HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="You can enable node transactions in the Admin UI",
|
||||
)
|
||||
return await node.get_invoices(filters)
|
||||
|
||||
|
||||
@node_router.get("/peers", response_model=List[NodePeerInfo])
|
||||
async def api_get_peers(node: Node = Depends(require_node)) -> List[NodePeerInfo]:
|
||||
return await node.get_peers()
|
||||
|
||||
|
||||
@super_node_router.post("/peers")
|
||||
async def api_connect_peer(
|
||||
uri: str = Body(embed=True), node: Node = Depends(require_node)
|
||||
):
|
||||
return await node.connect_peer(uri)
|
||||
|
||||
|
||||
@super_node_router.delete("/peers/{peer_id}")
|
||||
async def api_disconnect_peer(peer_id: str, node: Node = Depends(require_node)):
|
||||
return await node.disconnect_peer(peer_id)
|
||||
|
||||
|
||||
class NodeRank(BaseModel):
|
||||
capacity: Optional[int]
|
||||
channelcount: Optional[int]
|
||||
age: Optional[int]
|
||||
growth: Optional[int]
|
||||
availability: Optional[int]
|
||||
|
||||
|
||||
# Same for public and private api
|
||||
@node_router.get(
|
||||
"/rank",
|
||||
description="Retrieve node ranks from https://1ml.com",
|
||||
response_model=Optional[NodeRank],
|
||||
)
|
||||
@public_node_router.get(
|
||||
"/rank",
|
||||
description="Retrieve node ranks from https://1ml.com",
|
||||
response_model=Optional[NodeRank],
|
||||
)
|
||||
async def api_get_1ml_stats(node: Node = Depends(require_node)) -> Optional[NodeRank]:
|
||||
node_id = await node.get_id()
|
||||
async with httpx.AsyncClient() as client:
|
||||
r = await client.get(url=f"https://1ml.com/node/{node_id}/json", timeout=15)
|
||||
try:
|
||||
r.raise_for_status()
|
||||
return r.json()["noderank"]
|
||||
except httpx.HTTPStatusError:
|
||||
raise HTTPException(status_code=404, detail="Node not found on 1ml.com")
|
||||
@@ -1,18 +1,18 @@
|
||||
import asyncio
|
||||
import datetime
|
||||
from http import HTTPStatus
|
||||
|
||||
from fastapi import HTTPException
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from loguru import logger
|
||||
|
||||
from lnbits import bolt11
|
||||
|
||||
from .. import core_app
|
||||
from ..crud import get_standalone_payment
|
||||
from ..tasks import api_invoice_listeners
|
||||
|
||||
public_router = APIRouter()
|
||||
|
||||
@core_app.get("/public/v1/payment/{payment_hash}")
|
||||
|
||||
@public_router.get("/public/v1/payment/{payment_hash}")
|
||||
async def api_public_payment_longpolling(payment_hash):
|
||||
payment = await get_standalone_payment(payment_hash)
|
||||
|
||||
@@ -25,8 +25,7 @@ async def api_public_payment_longpolling(payment_hash):
|
||||
|
||||
try:
|
||||
invoice = bolt11.decode(payment.bolt11)
|
||||
expiration = datetime.datetime.fromtimestamp(invoice.date + invoice.expiry)
|
||||
if expiration < datetime.datetime.now():
|
||||
if invoice.has_expired():
|
||||
return {"status": "expired"}
|
||||
except Exception:
|
||||
raise HTTPException(
|
||||
|
||||
@@ -50,13 +50,10 @@ if settings.lnbits_database_url:
|
||||
|
||||
register_type(new_type((1184, 1114), "TIMESTAMP2INT", _parse_timestamp))
|
||||
else:
|
||||
if os.path.isdir(settings.lnbits_data_folder):
|
||||
DB_TYPE = SQLITE
|
||||
else:
|
||||
raise NotADirectoryError(
|
||||
f"LNBITS_DATA_FOLDER named {settings.lnbits_data_folder} was not created"
|
||||
f" - please 'mkdir {settings.lnbits_data_folder}' and try again"
|
||||
)
|
||||
if not os.path.isdir(settings.lnbits_data_folder):
|
||||
os.mkdir(settings.lnbits_data_folder)
|
||||
logger.info(f"Created {settings.lnbits_data_folder}")
|
||||
DB_TYPE = SQLITE
|
||||
|
||||
|
||||
def compat_timestamp_placeholder():
|
||||
|
||||
@@ -53,12 +53,12 @@ class KeyChecker(SecurityBase):
|
||||
# avoided here. Also, we should not return the wallet here - thats
|
||||
# silly. Possibly store it in a Redis DB
|
||||
wallet = await get_wallet_for_key(key_value, self._key_type)
|
||||
self.wallet = wallet # type: ignore
|
||||
if not wallet:
|
||||
if not wallet or wallet.deleted:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.UNAUTHORIZED,
|
||||
detail="Invalid key or expired key.",
|
||||
detail="Invalid key or wallet.",
|
||||
)
|
||||
self.wallet = wallet # type: ignore
|
||||
except KeyError:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST, detail="`X-API-KEY` header missing."
|
||||
|
||||
@@ -37,7 +37,7 @@ class ExplicitRelease(BaseModel):
|
||||
def is_version_compatible(self):
|
||||
if not self.min_lnbits_version:
|
||||
return True
|
||||
return version.parse(self.min_lnbits_version) <= version.parse(settings.version)
|
||||
return version_parse(self.min_lnbits_version) <= version_parse(settings.version)
|
||||
|
||||
|
||||
class GitHubRelease(BaseModel):
|
||||
@@ -75,7 +75,7 @@ class ExtensionConfig(BaseModel):
|
||||
def is_version_compatible(self):
|
||||
if not self.min_lnbits_version:
|
||||
return True
|
||||
return version.parse(self.min_lnbits_version) <= version.parse(settings.version)
|
||||
return version_parse(self.min_lnbits_version) <= version_parse(settings.version)
|
||||
|
||||
|
||||
def download_url(url, save_path):
|
||||
@@ -182,12 +182,19 @@ class Extension(NamedTuple):
|
||||
upgrade_hash: Optional[str] = ""
|
||||
|
||||
@property
|
||||
def module_name(self):
|
||||
return (
|
||||
f"lnbits.extensions.{self.code}"
|
||||
if self.upgrade_hash == ""
|
||||
else f"lnbits.upgrades.{self.code}-{self.upgrade_hash}.{self.code}"
|
||||
)
|
||||
def module_name(self) -> str:
|
||||
if self.is_upgrade_extension:
|
||||
if settings.has_default_extension_path:
|
||||
return f"lnbits.upgrades.{self.code}-{self.upgrade_hash}"
|
||||
return f"{self.code}-{self.upgrade_hash}"
|
||||
|
||||
if settings.has_default_extension_path:
|
||||
return f"lnbits.extensions.{self.code}"
|
||||
return self.code
|
||||
|
||||
@property
|
||||
def is_upgrade_extension(self) -> bool:
|
||||
return self.upgrade_hash != ""
|
||||
|
||||
@classmethod
|
||||
def from_installable_ext(cls, ext_info: "InstallableExtension") -> "Extension":
|
||||
@@ -205,7 +212,7 @@ class Extension(NamedTuple):
|
||||
|
||||
class ExtensionManager:
|
||||
def __init__(self) -> None:
|
||||
p = Path(settings.lnbits_path, "extensions")
|
||||
p = Path(settings.lnbits_extensions_path, "extensions")
|
||||
Path(p).mkdir(parents=True, exist_ok=True)
|
||||
self._extension_folders: List[Path] = [f for f in p.iterdir() if f.is_dir()]
|
||||
|
||||
@@ -330,21 +337,25 @@ class InstallableExtension(BaseModel):
|
||||
|
||||
@property
|
||||
def zip_path(self) -> Path:
|
||||
extensions_data_dir = Path(settings.lnbits_data_folder, "extensions")
|
||||
extensions_data_dir = Path(settings.lnbits_data_folder, "zips")
|
||||
Path(extensions_data_dir).mkdir(parents=True, exist_ok=True)
|
||||
return Path(extensions_data_dir, f"{self.id}.zip")
|
||||
|
||||
@property
|
||||
def ext_dir(self) -> Path:
|
||||
return Path(settings.lnbits_path, "extensions", self.id)
|
||||
return Path(settings.lnbits_extensions_path, "extensions", self.id)
|
||||
|
||||
@property
|
||||
def ext_upgrade_dir(self) -> Path:
|
||||
return Path("lnbits", "upgrades", f"{self.id}-{self.hash}")
|
||||
return Path(
|
||||
settings.lnbits_extensions_path, "upgrades", f"{self.id}-{self.hash}"
|
||||
)
|
||||
|
||||
@property
|
||||
def module_name(self) -> str:
|
||||
return f"lnbits.extensions.{self.id}"
|
||||
if settings.has_default_extension_path:
|
||||
return f"lnbits.extensions.{self.id}"
|
||||
return self.id
|
||||
|
||||
@property
|
||||
def module_installed(self) -> bool:
|
||||
@@ -389,21 +400,26 @@ class InstallableExtension(BaseModel):
|
||||
|
||||
def extract_archive(self):
|
||||
logger.info(f"Extracting extension {self.name} ({self.installed_version}).")
|
||||
Path("lnbits", "upgrades").mkdir(parents=True, exist_ok=True)
|
||||
shutil.rmtree(self.ext_upgrade_dir, True)
|
||||
with zipfile.ZipFile(self.zip_path, "r") as zip_ref:
|
||||
zip_ref.extractall(self.ext_upgrade_dir)
|
||||
generated_dir_name = os.listdir(self.ext_upgrade_dir)[0]
|
||||
os.rename(
|
||||
Path(self.ext_upgrade_dir, generated_dir_name),
|
||||
Path(self.ext_upgrade_dir, self.id),
|
||||
Path(settings.lnbits_extensions_path, "upgrades").mkdir(
|
||||
parents=True, exist_ok=True
|
||||
)
|
||||
|
||||
tmp_dir = Path(settings.lnbits_data_folder, "unzip-temp", self.hash)
|
||||
shutil.rmtree(tmp_dir, True)
|
||||
|
||||
with zipfile.ZipFile(self.zip_path, "r") as zip_ref:
|
||||
zip_ref.extractall(tmp_dir)
|
||||
generated_dir_name = os.listdir(tmp_dir)[0]
|
||||
shutil.rmtree(self.ext_upgrade_dir, True)
|
||||
shutil.copytree(
|
||||
Path(tmp_dir, generated_dir_name),
|
||||
Path(self.ext_upgrade_dir),
|
||||
)
|
||||
shutil.rmtree(tmp_dir, True)
|
||||
|
||||
# Pre-packed extensions can be upgraded
|
||||
# Mark the extension as installed so we know it is not the pre-packed version
|
||||
with open(
|
||||
Path(self.ext_upgrade_dir, self.id, "config.json"), "r+"
|
||||
) as json_file:
|
||||
with open(Path(self.ext_upgrade_dir, "config.json"), "r+") as json_file:
|
||||
config_json = json.load(json_file)
|
||||
|
||||
self.name = config_json.get("name")
|
||||
@@ -419,10 +435,7 @@ class InstallableExtension(BaseModel):
|
||||
)
|
||||
|
||||
shutil.rmtree(self.ext_dir, True)
|
||||
shutil.copytree(
|
||||
Path(self.ext_upgrade_dir, self.id),
|
||||
Path(settings.lnbits_path, "extensions", self.id),
|
||||
)
|
||||
shutil.copytree(Path(self.ext_upgrade_dir), Path(self.ext_dir))
|
||||
logger.success(f"Extension {self.name} ({self.installed_version}) installed.")
|
||||
|
||||
def nofiy_upgrade(self) -> None:
|
||||
@@ -457,7 +470,7 @@ class InstallableExtension(BaseModel):
|
||||
if not self.latest_release:
|
||||
self.latest_release = release
|
||||
return
|
||||
if version.parse(self.latest_release.version) < version.parse(release.version):
|
||||
if version_parse(self.latest_release.version) < version_parse(release.version):
|
||||
self.latest_release = release
|
||||
|
||||
@classmethod
|
||||
@@ -600,3 +613,14 @@ def get_valid_extensions() -> List[Extension]:
|
||||
return [
|
||||
extension for extension in ExtensionManager().extensions if extension.is_valid
|
||||
]
|
||||
|
||||
|
||||
def version_parse(v: str):
|
||||
"""
|
||||
Wrapper for version.parse() that does not throw if the version is invalid.
|
||||
Instead it return the lowest possible version ("0.0.0")
|
||||
"""
|
||||
try:
|
||||
return version.parse(v)
|
||||
except Exception:
|
||||
return version.parse("0.0.0")
|
||||
|
||||
@@ -4,9 +4,11 @@ from typing import Any, List, Optional, Type
|
||||
|
||||
import jinja2
|
||||
import shortuuid
|
||||
from pydantic import BaseModel
|
||||
from pydantic.schema import field_schema
|
||||
|
||||
from lnbits.jinja2_templating import Jinja2Templates
|
||||
from lnbits.nodes import get_node_class
|
||||
from lnbits.requestvars import g
|
||||
from lnbits.settings import settings
|
||||
|
||||
@@ -27,11 +29,20 @@ def url_for(endpoint: str, external: Optional[bool] = False, **params: Any) -> s
|
||||
return url
|
||||
|
||||
|
||||
def static_url_for(static: str, path: str) -> str:
|
||||
return f"/{static}/{path}?v={settings.server_startup_time}"
|
||||
|
||||
|
||||
def template_renderer(additional_folders: Optional[List] = None) -> Jinja2Templates:
|
||||
folders = ["lnbits/templates", "lnbits/core/templates"]
|
||||
if additional_folders:
|
||||
additional_folders += [
|
||||
Path(settings.lnbits_extensions_path, "extensions", f)
|
||||
for f in additional_folders
|
||||
]
|
||||
folders.extend(additional_folders)
|
||||
t = Jinja2Templates(loader=jinja2.FileSystemLoader(folders))
|
||||
t.env.globals["static_url_for"] = static_url_for
|
||||
|
||||
if settings.lnbits_ad_space_enabled:
|
||||
t.env.globals["AD_SPACE"] = settings.lnbits_ad_space.split(",")
|
||||
@@ -44,9 +55,17 @@ def template_renderer(additional_folders: Optional[List] = None) -> Jinja2Templa
|
||||
t.env.globals["SITE_TAGLINE"] = settings.lnbits_site_tagline
|
||||
t.env.globals["SITE_DESCRIPTION"] = settings.lnbits_site_description
|
||||
t.env.globals["LNBITS_THEME_OPTIONS"] = settings.lnbits_theme_options
|
||||
t.env.globals["COMMIT_VERSION"] = settings.lnbits_commit
|
||||
t.env.globals["LNBITS_QR_LOGO"] = settings.lnbits_qr_logo
|
||||
t.env.globals["LNBITS_VERSION"] = settings.version
|
||||
t.env.globals["LNBITS_NEW_ACCOUNTS_ALLOWED"] = settings.new_accounts_allowed
|
||||
t.env.globals["LNBITS_ADMIN_UI"] = settings.lnbits_admin_ui
|
||||
t.env.globals["LNBITS_SERVICE_FEE"] = settings.lnbits_service_fee
|
||||
t.env.globals["LNBITS_SERVICE_FEE_MAX"] = settings.lnbits_service_fee_max
|
||||
t.env.globals["LNBITS_SERVICE_FEE_WALLET"] = settings.lnbits_service_fee_wallet
|
||||
t.env.globals["LNBITS_NODE_UI"] = (
|
||||
settings.lnbits_node_ui and get_node_class() is not None
|
||||
)
|
||||
t.env.globals["LNBITS_NODE_UI_AVAILABLE"] = get_node_class() is not None
|
||||
t.env.globals["EXTENSIONS"] = [
|
||||
e
|
||||
for e in get_valid_extensions()
|
||||
@@ -56,8 +75,8 @@ def template_renderer(additional_folders: Optional[List] = None) -> Jinja2Templa
|
||||
t.env.globals["USE_CUSTOM_LOGO"] = settings.lnbits_custom_logo
|
||||
|
||||
if settings.bundle_assets:
|
||||
t.env.globals["INCLUDED_JS"] = ["/static/bundle.min.js"]
|
||||
t.env.globals["INCLUDED_CSS"] = ["/static/bundle.min.css"]
|
||||
t.env.globals["INCLUDED_JS"] = ["bundle.min.js"]
|
||||
t.env.globals["INCLUDED_CSS"] = ["bundle.min.css"]
|
||||
else:
|
||||
vendor_filepath = Path(settings.lnbits_path, "static", "vendor.json")
|
||||
with open(vendor_filepath) as vendor_file:
|
||||
@@ -65,6 +84,8 @@ def template_renderer(additional_folders: Optional[List] = None) -> Jinja2Templa
|
||||
t.env.globals["INCLUDED_JS"] = vendor_files["js"]
|
||||
t.env.globals["INCLUDED_CSS"] = vendor_files["css"]
|
||||
|
||||
t.env.globals["WEBPUSH_PUBKEY"] = settings.lnbits_webpush_pubkey
|
||||
|
||||
return t
|
||||
|
||||
|
||||
@@ -123,3 +144,25 @@ def generate_filter_params_openapi(model: Type[FilterModel], keep_optional=False
|
||||
return {
|
||||
"parameters": params,
|
||||
}
|
||||
|
||||
|
||||
def insert_query(table_name: str, model: BaseModel) -> str:
|
||||
"""
|
||||
Generate an insert query with placeholders for a given table and model
|
||||
:param table_name: Name of the table
|
||||
:param model: Pydantic model
|
||||
"""
|
||||
placeholders = ", ".join(["?"] * len(model.dict().keys()))
|
||||
fields = ", ".join(model.dict().keys())
|
||||
return f"INSERT INTO {table_name} ({fields}) VALUES ({placeholders})"
|
||||
|
||||
|
||||
def update_query(table_name: str, model: BaseModel, where: str = "WHERE id = ?") -> str:
|
||||
"""
|
||||
Generate an update query with placeholders for a given table and model
|
||||
:param table_name: Name of the table
|
||||
:param model: Pydantic model
|
||||
:param where: Where string, default to `WHERE id = ?`
|
||||
"""
|
||||
query = ", ".join([f"{field} = ?" for field in model.dict().keys()])
|
||||
return f"UPDATE {table_name} SET {query} {where}"
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
from typing import Union
|
||||
|
||||
from bech32 import bech32_decode, bech32_encode, convertbits
|
||||
from fastapi.datastructures import URL
|
||||
|
||||
|
||||
def decode(lnurl: str) -> str:
|
||||
@@ -10,8 +13,8 @@ def decode(lnurl: str) -> str:
|
||||
return bytes(bech32_data).decode()
|
||||
|
||||
|
||||
def encode(url: str) -> str:
|
||||
bech32_data = convertbits(url.encode(), 8, 5, True)
|
||||
def encode(url: Union[str, URL]) -> str:
|
||||
bech32_data = convertbits(str(url).encode(), 8, 5, True)
|
||||
assert bech32_data
|
||||
lnurl = bech32_encode("lnurl", bech32_data)
|
||||
return lnurl.upper()
|
||||
|
||||
@@ -2,7 +2,7 @@ from http import HTTPStatus
|
||||
from typing import Any, List, Tuple, Union
|
||||
from urllib.parse import parse_qs
|
||||
|
||||
from fastapi import FastAPI, Request, Response
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from slowapi import _rate_limit_exceeded_handler
|
||||
from slowapi.errors import RateLimitExceeded
|
||||
@@ -10,7 +10,7 @@ from slowapi.middleware import SlowAPIMiddleware
|
||||
from starlette.middleware.gzip import GZipMiddleware
|
||||
from starlette.types import ASGIApp, Receive, Scope, Send
|
||||
|
||||
from lnbits.core import core_app_extra
|
||||
from lnbits.core.db import core_app_extra
|
||||
from lnbits.helpers import template_renderer
|
||||
from lnbits.settings import settings
|
||||
|
||||
@@ -25,47 +25,46 @@ class InstalledExtensionMiddleware:
|
||||
self.app = app
|
||||
|
||||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||
if "path" not in scope:
|
||||
full_path = scope.get("path", "/")
|
||||
if full_path == "/":
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
path_elements = scope["path"].split("/")
|
||||
if len(path_elements) > 2:
|
||||
_, path_name, path_type, *rest = path_elements
|
||||
else:
|
||||
_, path_name = path_elements
|
||||
path_type = None
|
||||
rest = []
|
||||
|
||||
top_path, *rest = [p for p in full_path.split("/") if p]
|
||||
headers = scope.get("headers", [])
|
||||
|
||||
# block path for all users if the extension is disabled
|
||||
if path_name in settings.lnbits_deactivated_extensions:
|
||||
if top_path in settings.lnbits_deactivated_extensions:
|
||||
response = self._response_by_accepted_type(
|
||||
headers, f"Extension '{path_name}' disabled", HTTPStatus.NOT_FOUND
|
||||
headers, f"Extension '{top_path}' disabled", HTTPStatus.NOT_FOUND
|
||||
)
|
||||
await response(scope, receive, send)
|
||||
return
|
||||
|
||||
if not self._user_allowed_to_extension(path_name, scope):
|
||||
if not self._user_allowed_to_extension(top_path, scope):
|
||||
response = self._response_by_accepted_type(
|
||||
headers, "User not authorized.", HTTPStatus.FORBIDDEN
|
||||
)
|
||||
await response(scope, receive, send)
|
||||
return
|
||||
|
||||
# re-route API trafic if the extension has been upgraded
|
||||
if path_type == "api":
|
||||
upgraded_extensions = list(
|
||||
filter(
|
||||
lambda ext: ext.endswith(f"/{path_name}"),
|
||||
settings.lnbits_upgraded_extensions,
|
||||
)
|
||||
)
|
||||
if len(upgraded_extensions) != 0:
|
||||
upgrade_path = upgraded_extensions[0]
|
||||
tail = "/".join(rest)
|
||||
scope["path"] = f"/upgrades/{upgrade_path}/{path_type}/{tail}"
|
||||
# static resources do not require redirect
|
||||
if rest[0:1] == ["static"]:
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
upgrade_path = next(
|
||||
(
|
||||
e
|
||||
for e in settings.lnbits_upgraded_extensions
|
||||
if e.endswith(f"/{top_path}")
|
||||
),
|
||||
None,
|
||||
)
|
||||
# re-route all trafic if the extension has been upgraded
|
||||
if upgrade_path:
|
||||
tail = "/".join(rest)
|
||||
scope["path"] = f"/upgrades/{upgrade_path}/{tail}"
|
||||
|
||||
await self.app(scope, receive, send)
|
||||
|
||||
@@ -233,15 +232,6 @@ def add_ip_block_middleware(app: FastAPI):
|
||||
status_code=403, # Forbidden
|
||||
content={"detail": "IP is blocked"},
|
||||
)
|
||||
# this try: except: block is not needed on latest FastAPI
|
||||
# (await call_next(request) is enough)
|
||||
# https://stackoverflow.com/questions/71222144/runtimeerror-no-response-returned-in-fastapi-when-refresh-request
|
||||
# TODO: remove after https://github.com/lnbits/lnbits/pull/1609 is merged
|
||||
try:
|
||||
return await call_next(request)
|
||||
except RuntimeError as exc:
|
||||
if str(exc) == "No response returned." and await request.is_disconnected():
|
||||
return Response(status_code=HTTPStatus.NO_CONTENT)
|
||||
raise # bubble up different exceptions
|
||||
return await call_next(request)
|
||||
|
||||
app.middleware("http")(block_allow_ip_middleware)
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
from typing import Optional
|
||||
|
||||
from .base import Node
|
||||
|
||||
|
||||
def get_node_class() -> Optional[Node]:
|
||||
return NODE
|
||||
|
||||
|
||||
def set_node_class(node: Node):
|
||||
global NODE
|
||||
NODE = node
|
||||
|
||||
|
||||
NODE: Optional[Node] = None
|
||||
@@ -0,0 +1,236 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING, List, Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from lnbits.db import FilterModel, Filters, Page
|
||||
from lnbits.utils.cache import cache
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from lnbits.wallets.base import Wallet
|
||||
|
||||
|
||||
class NodePeerInfo(BaseModel):
|
||||
id: str
|
||||
alias: Optional[str] = None
|
||||
color: Optional[str] = None
|
||||
last_timestamp: Optional[int] = None
|
||||
addresses: Optional[list[str]] = None
|
||||
|
||||
|
||||
class ChannelState(Enum):
|
||||
ACTIVE = "active"
|
||||
PENDING = "pending"
|
||||
CLOSED = "closed"
|
||||
INACTIVE = "inactive"
|
||||
|
||||
|
||||
class ChannelBalance(BaseModel):
|
||||
local_msat: int
|
||||
remote_msat: int
|
||||
total_msat: int
|
||||
|
||||
|
||||
class ChannelPoint(BaseModel):
|
||||
funding_txid: str
|
||||
output_index: int
|
||||
|
||||
|
||||
class NodeChannel(BaseModel):
|
||||
short_id: Optional[str] = None
|
||||
point: Optional[ChannelPoint] = None
|
||||
peer_id: str
|
||||
balance: ChannelBalance
|
||||
state: ChannelState
|
||||
name: Optional[str]
|
||||
color: Optional[str]
|
||||
|
||||
|
||||
class ChannelStats(BaseModel):
|
||||
counts: dict[ChannelState, int]
|
||||
avg_size: int
|
||||
biggest_size: Optional[int]
|
||||
smallest_size: Optional[int]
|
||||
total_capacity: int
|
||||
|
||||
@classmethod
|
||||
def from_list(cls, channels: list[NodeChannel]):
|
||||
counts: dict[ChannelState, int] = {}
|
||||
for channel in channels:
|
||||
counts[channel.state] = counts.get(channel.state, 0) + 1
|
||||
|
||||
active_channel_sizes = [
|
||||
channel.balance.total_msat
|
||||
for channel in channels
|
||||
if channel.state == ChannelState.ACTIVE
|
||||
]
|
||||
|
||||
if len(active_channel_sizes) > 0:
|
||||
return cls(
|
||||
counts=counts,
|
||||
avg_size=int(sum(active_channel_sizes) / len(active_channel_sizes)),
|
||||
biggest_size=max(active_channel_sizes),
|
||||
smallest_size=min(active_channel_sizes),
|
||||
total_capacity=sum(active_channel_sizes),
|
||||
)
|
||||
else:
|
||||
return cls(
|
||||
counts=counts,
|
||||
avg_size=0,
|
||||
biggest_size=0,
|
||||
smallest_size=0,
|
||||
total_capacity=0,
|
||||
)
|
||||
|
||||
|
||||
class NodeFees(BaseModel):
|
||||
total_msat: int
|
||||
daily_msat: Optional[int] = None
|
||||
weekly_msat: Optional[int] = None
|
||||
monthly_msat: Optional[int] = None
|
||||
|
||||
|
||||
class PublicNodeInfo(BaseModel):
|
||||
id: str
|
||||
backend_name: str
|
||||
alias: str
|
||||
color: str
|
||||
num_peers: int
|
||||
blockheight: int
|
||||
channel_stats: ChannelStats
|
||||
addresses: list[str]
|
||||
|
||||
|
||||
class NodeInfoResponse(PublicNodeInfo):
|
||||
onchain_balance_sat: int
|
||||
onchain_confirmed_sat: int
|
||||
fees: NodeFees
|
||||
balance_msat: int
|
||||
|
||||
|
||||
class NodePayment(BaseModel):
|
||||
pending: bool
|
||||
amount: int
|
||||
fee: Optional[int] = None
|
||||
memo: Optional[str] = None
|
||||
time: int
|
||||
bolt11: Optional[str] = None
|
||||
preimage: Optional[str]
|
||||
payment_hash: str
|
||||
expiry: Optional[float] = None
|
||||
destination: Optional[NodePeerInfo] = None
|
||||
|
||||
|
||||
class NodeInvoice(BaseModel):
|
||||
pending: bool
|
||||
amount: int
|
||||
memo: Optional[str]
|
||||
bolt11: str
|
||||
preimage: Optional[str]
|
||||
payment_hash: str
|
||||
paid_at: Optional[int] = None
|
||||
expiry: Optional[int] = None
|
||||
|
||||
|
||||
class NodeInvoiceFilters(FilterModel):
|
||||
pass
|
||||
|
||||
|
||||
class NodePaymentsFilters(FilterModel):
|
||||
pass
|
||||
|
||||
|
||||
class Node(ABC):
|
||||
wallet: Wallet
|
||||
|
||||
def __init__(self, wallet: Wallet):
|
||||
self.wallet = wallet
|
||||
self.id: Optional[str] = None
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return self.__class__.__name__
|
||||
|
||||
async def get_id(self):
|
||||
if not self.id:
|
||||
self.id = await self._get_id()
|
||||
return self.id
|
||||
|
||||
@abstractmethod
|
||||
async def _get_id(self) -> str:
|
||||
pass
|
||||
|
||||
async def get_peers(self) -> list[NodePeerInfo]:
|
||||
peer_ids = await self.get_peer_ids()
|
||||
return [await self.get_peer_info(peer_id) for peer_id in peer_ids]
|
||||
|
||||
@abstractmethod
|
||||
async def get_peer_ids(self) -> list[str]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def connect_peer(self, uri: str):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def disconnect_peer(self, peer_id: str):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def _get_peer_info(self, peer_id: str) -> NodePeerInfo:
|
||||
pass
|
||||
|
||||
async def get_peer_info(self, peer_id: str) -> NodePeerInfo:
|
||||
key = f"node:peers:{peer_id}"
|
||||
info = cache.get(key)
|
||||
if not info:
|
||||
info = await self._get_peer_info(peer_id)
|
||||
if info.last_timestamp:
|
||||
cache.set(key, info)
|
||||
return info
|
||||
|
||||
@abstractmethod
|
||||
async def open_channel(
|
||||
self,
|
||||
peer_id: str,
|
||||
local_amount: int,
|
||||
push_amount: Optional[int] = None,
|
||||
fee_rate: Optional[int] = None,
|
||||
) -> ChannelPoint:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def close_channel(
|
||||
self,
|
||||
short_id: Optional[str] = None,
|
||||
point: Optional[ChannelPoint] = None,
|
||||
force: bool = False,
|
||||
):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def get_channels(self) -> List[NodeChannel]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def get_info(self) -> NodeInfoResponse:
|
||||
pass
|
||||
|
||||
async def get_public_info(self) -> PublicNodeInfo:
|
||||
info = await self.get_info()
|
||||
return PublicNodeInfo(**info.__dict__)
|
||||
|
||||
@abstractmethod
|
||||
async def get_payments(
|
||||
self, filters: Filters[NodePaymentsFilters]
|
||||
) -> Page[NodePayment]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def get_invoices(
|
||||
self, filters: Filters[NodeInvoiceFilters]
|
||||
) -> Page[NodeInvoice]:
|
||||
pass
|
||||
@@ -0,0 +1,328 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from http import HTTPStatus
|
||||
from typing import TYPE_CHECKING, List, Optional
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from lnbits.db import Filters, Page
|
||||
|
||||
from ..utils.cache import cache
|
||||
|
||||
try:
|
||||
from pyln.client import RpcError # type: ignore
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# override the false type
|
||||
class RpcError(RpcError): # type: ignore
|
||||
error: dict
|
||||
|
||||
except ImportError: # pragma: nocover
|
||||
LightningRpc = None
|
||||
|
||||
from lnbits.nodes.base import (
|
||||
ChannelBalance,
|
||||
ChannelPoint,
|
||||
ChannelState,
|
||||
ChannelStats,
|
||||
Node,
|
||||
NodeFees,
|
||||
NodeInvoice,
|
||||
NodeInvoiceFilters,
|
||||
NodePaymentsFilters,
|
||||
NodePeerInfo,
|
||||
)
|
||||
|
||||
from .base import NodeChannel, NodeInfoResponse, NodePayment
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from lnbits.wallets import CoreLightningWallet
|
||||
|
||||
|
||||
def catch_rpc_errors(f):
|
||||
async def wrapper(*args, **kwargs):
|
||||
try:
|
||||
return await f(*args, **kwargs)
|
||||
except RpcError as e:
|
||||
if e.error["code"] == -32602:
|
||||
raise HTTPException(status_code=400, detail=e.error["message"])
|
||||
else:
|
||||
raise HTTPException(status_code=500, detail=e.error["message"])
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
class CoreLightningNode(Node):
|
||||
wallet: CoreLightningWallet
|
||||
|
||||
async def ln_rpc(self, method, *args, **kwargs) -> dict:
|
||||
loop = asyncio.get_event_loop()
|
||||
fn = getattr(self.wallet.ln, method)
|
||||
return await loop.run_in_executor(None, lambda: fn(*args, **kwargs))
|
||||
|
||||
@catch_rpc_errors
|
||||
async def connect_peer(self, uri: str):
|
||||
# https://docs.corelightning.org/reference/lightning-connect
|
||||
try:
|
||||
await self.ln_rpc("connect", uri)
|
||||
except RpcError as e:
|
||||
if e.error["code"] == 400:
|
||||
raise HTTPException(HTTPStatus.BAD_REQUEST, detail=e.error["message"])
|
||||
else:
|
||||
raise
|
||||
|
||||
@catch_rpc_errors
|
||||
async def disconnect_peer(self, peer_id: str):
|
||||
try:
|
||||
await self.ln_rpc("disconnect", peer_id)
|
||||
except RpcError as e:
|
||||
if e.error["code"] == -1:
|
||||
raise HTTPException(
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
detail=e.error["message"],
|
||||
)
|
||||
else:
|
||||
raise
|
||||
|
||||
@catch_rpc_errors
|
||||
async def open_channel(
|
||||
self,
|
||||
peer_id: str,
|
||||
local_amount: int,
|
||||
push_amount: Optional[int] = None,
|
||||
fee_rate: Optional[int] = None,
|
||||
) -> ChannelPoint:
|
||||
try:
|
||||
result = await self.ln_rpc(
|
||||
"fundchannel",
|
||||
peer_id,
|
||||
amount=local_amount,
|
||||
push_msat=int(push_amount * 1000) if push_amount else None,
|
||||
feerate=fee_rate,
|
||||
)
|
||||
return ChannelPoint(
|
||||
funding_txid=result["txid"],
|
||||
output_index=result["outnum"],
|
||||
)
|
||||
except RpcError as e:
|
||||
message = e.error["message"]
|
||||
|
||||
if "amount: should be a satoshi amount" in message:
|
||||
raise HTTPException(
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
detail="The amount is not a valid satoshi amount.",
|
||||
)
|
||||
|
||||
if "Unknown peer" in message:
|
||||
raise HTTPException(
|
||||
HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
detail=(
|
||||
"We where able to connect to the peer but CLN "
|
||||
"can't find it when opening a channel."
|
||||
),
|
||||
)
|
||||
|
||||
if "Owning subdaemon openingd died" in message:
|
||||
# https://github.com/ElementsProject/lightning/issues/2798#issuecomment-511205719
|
||||
raise HTTPException(
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
detail=(
|
||||
"Likely the peer didn't like our channel opening "
|
||||
"proposal and disconnected from us."
|
||||
),
|
||||
)
|
||||
|
||||
if (
|
||||
"Number of pending channels exceed maximum" in message
|
||||
or "exceeds maximum chan size of 10 BTC" in message
|
||||
or "Could not afford" in message
|
||||
):
|
||||
raise HTTPException(HTTPStatus.BAD_REQUEST, detail=message)
|
||||
raise
|
||||
|
||||
@catch_rpc_errors
|
||||
async def close_channel(
|
||||
self,
|
||||
short_id: Optional[str] = None,
|
||||
point: Optional[ChannelPoint] = None,
|
||||
force: bool = False,
|
||||
):
|
||||
if not short_id:
|
||||
raise HTTPException(status_code=400, detail="Short id required")
|
||||
try:
|
||||
await self.ln_rpc("close", short_id)
|
||||
except RpcError as e:
|
||||
message = e.error["message"]
|
||||
if (
|
||||
"Short channel ID not active:" in message
|
||||
or "Short channel ID not found" in message
|
||||
):
|
||||
raise HTTPException(HTTPStatus.BAD_REQUEST, detail=message)
|
||||
else:
|
||||
raise
|
||||
|
||||
@catch_rpc_errors
|
||||
async def _get_id(self) -> str:
|
||||
info = await self.ln_rpc("getinfo")
|
||||
return info["id"]
|
||||
|
||||
@catch_rpc_errors
|
||||
async def get_peer_ids(self) -> List[str]:
|
||||
peers = await self.ln_rpc("listpeers")
|
||||
return [p["id"] for p in peers["peers"] if p["connected"]]
|
||||
|
||||
@catch_rpc_errors
|
||||
async def _get_peer_info(self, peer_id: str) -> NodePeerInfo:
|
||||
result = await self.ln_rpc("listnodes", peer_id)
|
||||
nodes = result["nodes"]
|
||||
if len(nodes) == 0:
|
||||
return NodePeerInfo(id=peer_id)
|
||||
node = nodes[0]
|
||||
if "last_timestamp" in node:
|
||||
return NodePeerInfo(
|
||||
id=node["nodeid"],
|
||||
alias=node["alias"],
|
||||
color=node["color"],
|
||||
last_timestamp=node["last_timestamp"],
|
||||
addresses=[
|
||||
address["address"] + ":" + str(address["port"])
|
||||
for address in node["addresses"]
|
||||
],
|
||||
)
|
||||
else:
|
||||
return NodePeerInfo(id=node["nodeid"])
|
||||
|
||||
@catch_rpc_errors
|
||||
async def get_channels(self) -> List[NodeChannel]:
|
||||
funds = await self.ln_rpc("listfunds")
|
||||
nodes = await self.ln_rpc("listnodes")
|
||||
nodes_by_id = {n["nodeid"]: n for n in nodes["nodes"]}
|
||||
|
||||
return [
|
||||
NodeChannel(
|
||||
short_id=ch.get("short_channel_id"),
|
||||
point=ChannelPoint(
|
||||
funding_txid=ch["funding_txid"],
|
||||
output_index=ch["funding_output"],
|
||||
),
|
||||
peer_id=ch["peer_id"],
|
||||
balance=ChannelBalance(
|
||||
local_msat=ch["our_amount_msat"],
|
||||
remote_msat=ch["amount_msat"] - ch["our_amount_msat"],
|
||||
total_msat=ch["amount_msat"],
|
||||
),
|
||||
name=nodes_by_id.get(ch["peer_id"], {}).get("alias"),
|
||||
color=nodes_by_id.get(ch["peer_id"], {}).get("color"),
|
||||
state=(
|
||||
ChannelState.ACTIVE
|
||||
if ch["state"] == "CHANNELD_NORMAL"
|
||||
else ChannelState.PENDING
|
||||
if ch["state"] in ("CHANNELD_AWAITING_LOCKIN", "OPENINGD")
|
||||
else ChannelState.CLOSED
|
||||
if ch["state"]
|
||||
in (
|
||||
"CHANNELD_CLOSING",
|
||||
"CLOSINGD_COMPLETE",
|
||||
"CLOSINGD_SIGEXCHANGE",
|
||||
"ONCHAIN",
|
||||
)
|
||||
else ChannelState.INACTIVE
|
||||
),
|
||||
)
|
||||
for ch in funds["channels"]
|
||||
]
|
||||
|
||||
@catch_rpc_errors
|
||||
async def get_info(self) -> NodeInfoResponse:
|
||||
info = await self.ln_rpc("getinfo")
|
||||
funds = await self.ln_rpc("listfunds")
|
||||
|
||||
channels = await self.get_channels()
|
||||
active_channels = [
|
||||
channel for channel in channels if channel.state == ChannelState.ACTIVE
|
||||
]
|
||||
return NodeInfoResponse(
|
||||
id=info["id"],
|
||||
backend_name="CLN",
|
||||
alias=info["alias"],
|
||||
color=info["color"],
|
||||
onchain_balance_sat=sum(
|
||||
output["amount_msat"] / 1000 for output in funds["outputs"]
|
||||
),
|
||||
onchain_confirmed_sat=sum(
|
||||
output["amount_msat"] / 1000
|
||||
for output in funds["outputs"]
|
||||
if output["status"] == "confirmed"
|
||||
),
|
||||
channel_stats=ChannelStats.from_list(channels),
|
||||
num_peers=info["num_peers"],
|
||||
blockheight=info["blockheight"],
|
||||
balance_msat=sum(channel.balance.local_msat for channel in active_channels),
|
||||
fees=NodeFees(total_msat=info["fees_collected_msat"]),
|
||||
addresses=[address["address"] for address in info["address"]],
|
||||
)
|
||||
|
||||
@catch_rpc_errors
|
||||
async def get_payments(
|
||||
self, filters: Filters[NodePaymentsFilters]
|
||||
) -> Page[NodePayment]:
|
||||
async def get_payments():
|
||||
result = await self.ln_rpc("listpays")
|
||||
return [
|
||||
NodePayment(
|
||||
bolt11=pay.get("bolt11"),
|
||||
amount=pay["amount_msat"],
|
||||
fee=int(pay["amount_msat"]) - int(pay["amount_sent_msat"]),
|
||||
memo=pay.get("description"),
|
||||
time=pay["created_at"],
|
||||
preimage=pay.get("preimage"),
|
||||
payment_hash=pay["payment_hash"],
|
||||
pending=pay["status"] != "complete",
|
||||
destination=await self.get_peer_info(pay["destination"]),
|
||||
)
|
||||
for pay in reversed(result["pays"])
|
||||
if pay["status"] != "failed"
|
||||
]
|
||||
|
||||
results = await cache.save_result(get_payments, key="node:payments")
|
||||
count = len(results)
|
||||
if filters.offset:
|
||||
results = results[filters.offset :]
|
||||
if filters.limit:
|
||||
results = results[: filters.limit]
|
||||
return Page(data=results, total=count)
|
||||
|
||||
@catch_rpc_errors
|
||||
async def get_invoices(
|
||||
self, filters: Filters[NodeInvoiceFilters]
|
||||
) -> Page[NodeInvoice]:
|
||||
result = await cache.save_result(
|
||||
lambda: self.ln_rpc("listinvoices"), key="node:invoices"
|
||||
)
|
||||
invoices = result["invoices"]
|
||||
invoices.reverse()
|
||||
count = len(invoices)
|
||||
if filters.offset:
|
||||
invoices = invoices[filters.offset :]
|
||||
if filters.limit:
|
||||
invoices = invoices[: filters.limit]
|
||||
return Page(
|
||||
data=[
|
||||
NodeInvoice(
|
||||
bolt11=invoice.get("bolt11") or invoice.get("bolt12"),
|
||||
amount=(
|
||||
invoice.get("amount_msat")
|
||||
or invoice.get("amount_received_msat")
|
||||
),
|
||||
preimage=invoice.get("payment_preimage"),
|
||||
memo=invoice.get("description"),
|
||||
paid_at=invoice.get("paid_at"),
|
||||
expiry=invoice["expires_at"],
|
||||
payment_hash=invoice["payment_hash"],
|
||||
pending=invoice["status"] != "paid",
|
||||
)
|
||||
for invoice in invoices
|
||||
],
|
||||
total=count,
|
||||
)
|
||||
@@ -0,0 +1,382 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
from http import HTTPStatus
|
||||
from typing import TYPE_CHECKING, List, Optional
|
||||
|
||||
from fastapi import HTTPException
|
||||
from httpx import HTTPStatusError
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.db import Filters, Page
|
||||
from lnbits.nodes import Node
|
||||
from lnbits.nodes.base import (
|
||||
ChannelBalance,
|
||||
ChannelPoint,
|
||||
ChannelState,
|
||||
ChannelStats,
|
||||
NodeChannel,
|
||||
NodeFees,
|
||||
NodeInfoResponse,
|
||||
NodeInvoice,
|
||||
NodeInvoiceFilters,
|
||||
NodePayment,
|
||||
NodePaymentsFilters,
|
||||
NodePeerInfo,
|
||||
PublicNodeInfo,
|
||||
)
|
||||
from lnbits.utils.cache import cache
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from lnbits.wallets import LndRestWallet
|
||||
|
||||
|
||||
def msat(raw: str) -> int:
|
||||
return int(raw) * 1000
|
||||
|
||||
|
||||
def _decode_bytes(data: str) -> str:
|
||||
return base64.b64decode(data).hex()
|
||||
|
||||
|
||||
def _parse_channel_point(raw: str) -> ChannelPoint:
|
||||
funding_tx, output_index = raw.split(":")
|
||||
return ChannelPoint(
|
||||
funding_txid=funding_tx,
|
||||
output_index=int(output_index),
|
||||
)
|
||||
|
||||
|
||||
class LndRestNode(Node):
|
||||
wallet: LndRestWallet
|
||||
|
||||
async def request(
|
||||
self, method: str, path: str, json: Optional[dict] = None, **kwargs
|
||||
):
|
||||
response = await self.wallet.client.request(
|
||||
method, f"{self.wallet.endpoint}{path}", json=json, **kwargs
|
||||
)
|
||||
try:
|
||||
response.raise_for_status()
|
||||
except HTTPStatusError as e:
|
||||
json = e.response.json()
|
||||
if json:
|
||||
error = json.get("error") or json
|
||||
raise HTTPException(e.response.status_code, detail=error.get("message"))
|
||||
return response.json()
|
||||
|
||||
def get(self, path: str, **kwargs):
|
||||
return self.request("GET", path, **kwargs)
|
||||
|
||||
async def _get_id(self) -> str:
|
||||
info = await self.get("/v1/getinfo")
|
||||
return info["identity_pubkey"]
|
||||
|
||||
async def get_peer_ids(self) -> list[str]:
|
||||
response = await self.get("/v1/peers")
|
||||
return [p["pub_key"] for p in response["peers"]]
|
||||
|
||||
async def connect_peer(self, uri: str):
|
||||
try:
|
||||
pubkey, host = uri.split("@")
|
||||
except ValueError:
|
||||
raise HTTPException(400, detail="Invalid peer URI")
|
||||
await self.request(
|
||||
"POST",
|
||||
"/v1/peers",
|
||||
json={
|
||||
"addr": {"pubkey": pubkey, "host": host},
|
||||
"perm": True,
|
||||
"timeout": 30,
|
||||
},
|
||||
)
|
||||
|
||||
async def disconnect_peer(self, peer_id: str):
|
||||
try:
|
||||
await self.request("DELETE", "/v1/peers/" + peer_id)
|
||||
except HTTPException as e:
|
||||
if "unable to disconnect" in e.detail:
|
||||
raise HTTPException(
|
||||
HTTPStatus.BAD_REQUEST, detail="Peer is not connected"
|
||||
)
|
||||
raise
|
||||
|
||||
async def _get_peer_info(self, peer_id: str) -> NodePeerInfo:
|
||||
try:
|
||||
response = await self.get("/v1/graph/node/" + peer_id)
|
||||
except HTTPException:
|
||||
return NodePeerInfo(id=peer_id)
|
||||
node = response["node"]
|
||||
return NodePeerInfo(
|
||||
id=peer_id,
|
||||
alias=node["alias"],
|
||||
color=node["color"].strip("#"),
|
||||
last_timestamp=node["last_update"],
|
||||
addresses=[a["addr"] for a in node["addresses"]],
|
||||
)
|
||||
|
||||
async def open_channel(
|
||||
self,
|
||||
peer_id: str,
|
||||
local_amount: int,
|
||||
push_amount: Optional[int] = None,
|
||||
fee_rate: Optional[int] = None,
|
||||
) -> ChannelPoint:
|
||||
response = await self.request(
|
||||
"POST",
|
||||
"/v1/channels",
|
||||
data=json.dumps(
|
||||
{
|
||||
# 'node_pubkey': base64.b64encode(peer_id.encode()).decode(),
|
||||
"node_pubkey_string": peer_id,
|
||||
"sat_per_vbyte": fee_rate,
|
||||
"local_funding_amount": local_amount,
|
||||
"push_sat": push_amount,
|
||||
}
|
||||
),
|
||||
)
|
||||
return ChannelPoint(
|
||||
# WHY IS THIS REVERSED?!
|
||||
funding_txid=bytes(
|
||||
reversed(base64.b64decode(response["funding_txid_bytes"]))
|
||||
).hex(),
|
||||
output_index=response["output_index"],
|
||||
)
|
||||
|
||||
async def _close_channel(
|
||||
self,
|
||||
point: ChannelPoint,
|
||||
force: bool = False,
|
||||
):
|
||||
async with self.wallet.client.stream(
|
||||
"DELETE",
|
||||
f"{self.wallet.endpoint}/v1/channels/{point.funding_txid}/{point.output_index}",
|
||||
params={"force": force},
|
||||
timeout=None,
|
||||
) as stream:
|
||||
async for chunk in stream.aiter_text():
|
||||
if chunk:
|
||||
chunk = json.loads(chunk)
|
||||
logger.info(f"LND Channel close update: {chunk['result']}")
|
||||
|
||||
async def close_channel(
|
||||
self,
|
||||
short_id: Optional[str] = None,
|
||||
point: Optional[ChannelPoint] = None,
|
||||
force: bool = False,
|
||||
):
|
||||
if not point:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST, detail="Channel point required"
|
||||
)
|
||||
|
||||
asyncio.create_task(self._close_channel(point, force))
|
||||
|
||||
async def get_channels(self) -> List[NodeChannel]:
|
||||
normal, pending, closed = await asyncio.gather(
|
||||
self.get("/v1/channels"),
|
||||
self.get("/v1/channels/pending"),
|
||||
self.get("/v1/channels/closed"),
|
||||
)
|
||||
|
||||
channels = []
|
||||
|
||||
async def parse_pending(raw_channels, state):
|
||||
for channel in raw_channels:
|
||||
channel = channel["channel"]
|
||||
info = await self.get_peer_info(channel["remote_node_pub"])
|
||||
channels.append(
|
||||
NodeChannel(
|
||||
peer_id=info.id,
|
||||
state=state,
|
||||
name=info.alias,
|
||||
color=info.color,
|
||||
point=_parse_channel_point(channel["channel_point"]),
|
||||
balance=ChannelBalance(
|
||||
local_msat=msat(channel["local_balance"]),
|
||||
remote_msat=msat(channel["remote_balance"]),
|
||||
total_msat=msat(channel["capacity"]),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
await parse_pending(pending["pending_open_channels"], ChannelState.PENDING)
|
||||
await parse_pending(
|
||||
pending["pending_force_closing_channels"], ChannelState.CLOSED
|
||||
)
|
||||
await parse_pending(pending["waiting_close_channels"], ChannelState.CLOSED)
|
||||
|
||||
for channel in closed["channels"]:
|
||||
info = await self.get_peer_info(channel["remote_pubkey"])
|
||||
channels.append(
|
||||
NodeChannel(
|
||||
peer_id=info.id,
|
||||
state=ChannelState.CLOSED,
|
||||
name=info.alias,
|
||||
color=info.color,
|
||||
point=_parse_channel_point(channel["channel_point"]),
|
||||
balance=ChannelBalance(
|
||||
local_msat=0,
|
||||
remote_msat=0,
|
||||
total_msat=msat(channel["capacity"]),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
for channel in normal["channels"]:
|
||||
info = await self.get_peer_info(channel["remote_pubkey"])
|
||||
channels.append(
|
||||
NodeChannel(
|
||||
short_id=channel["chan_id"],
|
||||
point=_parse_channel_point(channel["channel_point"]),
|
||||
peer_id=channel["remote_pubkey"],
|
||||
balance=ChannelBalance(
|
||||
local_msat=msat(channel["local_balance"]),
|
||||
remote_msat=msat(channel["remote_balance"]),
|
||||
total_msat=msat(channel["capacity"]),
|
||||
),
|
||||
state=ChannelState.ACTIVE
|
||||
if channel["active"]
|
||||
else ChannelState.INACTIVE,
|
||||
# name=channel['peer_alias'],
|
||||
name=info.alias,
|
||||
color=info.color,
|
||||
)
|
||||
)
|
||||
|
||||
return channels
|
||||
|
||||
async def get_public_info(self) -> PublicNodeInfo:
|
||||
info = await self.get("/v1/getinfo")
|
||||
channels = await self.get_channels()
|
||||
return PublicNodeInfo(
|
||||
backend_name="LND",
|
||||
id=info["identity_pubkey"],
|
||||
color=info["color"].lstrip("#"),
|
||||
alias=info["alias"],
|
||||
num_peers=info["num_peers"],
|
||||
blockheight=info["block_height"],
|
||||
addresses=info["uris"],
|
||||
channel_stats=ChannelStats.from_list(channels),
|
||||
)
|
||||
|
||||
async def get_info(self) -> NodeInfoResponse:
|
||||
public = await self.get_public_info()
|
||||
onchain = await self.get("/v1/balance/blockchain")
|
||||
fee_report = await self.get("/v1/fees")
|
||||
balance = await self.get("/v1/balance/channels")
|
||||
return NodeInfoResponse(
|
||||
**public.dict(),
|
||||
onchain_balance_sat=onchain["total_balance"],
|
||||
onchain_confirmed_sat=onchain["confirmed_balance"],
|
||||
balance_msat=balance["local_balance"]["msat"],
|
||||
fees=NodeFees(
|
||||
total_msat=0,
|
||||
daily_msat=fee_report["day_fee_sum"],
|
||||
weekly_msat=fee_report["week_fee_sum"],
|
||||
monthly_msat=fee_report["month_fee_sum"],
|
||||
),
|
||||
)
|
||||
|
||||
async def get_payments(
|
||||
self, filters: Filters[NodePaymentsFilters]
|
||||
) -> Page[NodePayment]:
|
||||
count_key = "node:payments_count"
|
||||
payments_count = cache.get(count_key)
|
||||
if not payments_count and filters.offset:
|
||||
# this forces fetching the payments count
|
||||
await self.get_payments(Filters(limit=1))
|
||||
payments_count = cache.get(count_key)
|
||||
|
||||
if filters.offset and payments_count:
|
||||
index_offset = max(payments_count + 1 - filters.offset, 0)
|
||||
else:
|
||||
index_offset = 0
|
||||
|
||||
response = await self.get(
|
||||
"/v1/payments",
|
||||
params={
|
||||
"index_offset": index_offset,
|
||||
"max_payments": filters.limit,
|
||||
"include_incomplete": True,
|
||||
"reversed": True,
|
||||
"count_total_payments": not index_offset,
|
||||
},
|
||||
)
|
||||
|
||||
if not filters.offset:
|
||||
payments_count = int(response["total_num_payments"])
|
||||
|
||||
cache.set(count_key, payments_count)
|
||||
|
||||
payments = [
|
||||
NodePayment(
|
||||
payment_hash=payment["payment_hash"],
|
||||
pending=payment["status"] == "IN_FLIGHT",
|
||||
amount=payment["value_msat"],
|
||||
fee=payment["fee_msat"],
|
||||
time=payment["creation_date"],
|
||||
destination=await self.get_peer_info(
|
||||
payment["htlcs"][0]["route"]["hops"][-1]["pub_key"]
|
||||
)
|
||||
if payment["htlcs"]
|
||||
else None,
|
||||
bolt11=payment["payment_request"],
|
||||
preimage=payment["payment_preimage"],
|
||||
)
|
||||
for payment in response["payments"]
|
||||
]
|
||||
|
||||
payments.sort(key=lambda p: p.time, reverse=True)
|
||||
|
||||
return Page(data=payments, total=payments_count or 0)
|
||||
|
||||
async def get_invoices(
|
||||
self, filters: Filters[NodeInvoiceFilters]
|
||||
) -> Page[NodeInvoice]:
|
||||
last_invoice_key = "node:last_invoice_index"
|
||||
last_invoice_index = cache.get(last_invoice_key)
|
||||
if not last_invoice_index and filters.offset:
|
||||
# this forces fetching the last invoice index so
|
||||
await self.get_invoices(Filters(limit=1))
|
||||
last_invoice_index = cache.get(last_invoice_key)
|
||||
|
||||
if filters.offset and last_invoice_index:
|
||||
index_offset = max(last_invoice_index + 1 - filters.offset, 0)
|
||||
else:
|
||||
index_offset = 0
|
||||
|
||||
response = await self.get(
|
||||
"/v1/invoices",
|
||||
params={
|
||||
"index_offset": index_offset,
|
||||
"num_max_invoices": filters.limit,
|
||||
"reversed": True,
|
||||
},
|
||||
)
|
||||
|
||||
if not filters.offset:
|
||||
last_invoice_index = int(response["last_index_offset"])
|
||||
|
||||
cache.set(last_invoice_key, last_invoice_index)
|
||||
|
||||
invoices = [
|
||||
NodeInvoice(
|
||||
payment_hash=_decode_bytes(invoice["r_hash"]),
|
||||
amount=invoice["value_msat"],
|
||||
memo=invoice["memo"],
|
||||
pending=invoice["state"] == "OPEN",
|
||||
paid_at=invoice["settle_date"],
|
||||
expiry=invoice["creation_date"] + invoice["expiry"],
|
||||
preimage=_decode_bytes(invoice["r_preimage"]),
|
||||
bolt11=invoice["payment_request"],
|
||||
)
|
||||
for invoice in reversed(response["invoices"])
|
||||
]
|
||||
|
||||
return Page(
|
||||
data=invoices,
|
||||
total=last_invoice_index or 0,
|
||||
)
|
||||
@@ -37,9 +37,12 @@ def main(
|
||||
|
||||
# create data dir if it does not exist
|
||||
Path(settings.lnbits_data_folder).mkdir(parents=True, exist_ok=True)
|
||||
Path(settings.lnbits_data_folder, "logs").mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# create extension dir if it does not exist
|
||||
Path(settings.lnbits_path, "extensions").mkdir(parents=True, exist_ok=True)
|
||||
# create `extensions`` dir if it does not exist
|
||||
Path(settings.lnbits_extensions_path, "extensions").mkdir(
|
||||
parents=True, exist_ok=True
|
||||
)
|
||||
|
||||
set_cli_settings(host=host, port=port, forwarded_allow_ips=forwarded_allow_ips)
|
||||
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import importlib.metadata
|
||||
import inspect
|
||||
import json
|
||||
import subprocess
|
||||
from os import path
|
||||
from sqlite3 import Row
|
||||
from time import time
|
||||
from typing import Any, List, Optional
|
||||
|
||||
import httpx
|
||||
from loguru import logger
|
||||
from pydantic import BaseSettings, Extra, Field, validator
|
||||
from pydantic import BaseModel, BaseSettings, Extra, Field, validator
|
||||
|
||||
|
||||
def list_parse_fallback(v: str):
|
||||
@@ -23,24 +25,22 @@ def list_parse_fallback(v: str):
|
||||
return []
|
||||
|
||||
|
||||
class LNbitsSettings(BaseSettings):
|
||||
class LNbitsSettings(BaseModel):
|
||||
@classmethod
|
||||
def validate(cls, val):
|
||||
def validate_list(cls, val):
|
||||
if isinstance(val, str):
|
||||
val = val.split(",") if val else []
|
||||
return val
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
env_file_encoding = "utf-8"
|
||||
case_sensitive = False
|
||||
json_loads = list_parse_fallback
|
||||
extra = Extra.ignore
|
||||
|
||||
|
||||
class UsersSettings(LNbitsSettings):
|
||||
lnbits_admin_users: List[str] = Field(default=[])
|
||||
lnbits_allowed_users: List[str] = Field(default=[])
|
||||
lnbits_allow_new_accounts: bool = Field(default=True)
|
||||
|
||||
@property
|
||||
def new_accounts_allowed(self) -> bool:
|
||||
return self.lnbits_allow_new_accounts and len(self.lnbits_allowed_users) == 0
|
||||
|
||||
|
||||
class ExtensionsSettings(LNbitsSettings):
|
||||
@@ -91,6 +91,7 @@ class ThemesSettings(LNbitsSettings):
|
||||
lnbits_ad_space_enabled: bool = Field(default=False)
|
||||
lnbits_allowed_currencies: List[str] = Field(default=[])
|
||||
lnbits_default_accounting_currency: Optional[str] = Field(default=None)
|
||||
lnbits_qr_logo: str = Field(default="/static/images/logos/lnbits.png")
|
||||
|
||||
|
||||
class OpsSettings(LNbitsSettings):
|
||||
@@ -98,6 +99,9 @@ class OpsSettings(LNbitsSettings):
|
||||
lnbits_reserve_fee_min: int = Field(default=2000)
|
||||
lnbits_reserve_fee_percent: float = Field(default=1.0)
|
||||
lnbits_service_fee: float = Field(default=0)
|
||||
lnbits_service_fee_ignore_internal: bool = Field(default=True)
|
||||
lnbits_service_fee_max: int = Field(default=0)
|
||||
lnbits_service_fee_wallet: str = Field(default=None)
|
||||
lnbits_hide_api: bool = Field(default=False)
|
||||
lnbits_denomination: str = Field(default="sats")
|
||||
|
||||
@@ -180,6 +184,11 @@ class LnPayFundingSource(LNbitsSettings):
|
||||
lnpay_admin_key: Optional[str] = Field(default=None)
|
||||
|
||||
|
||||
class AlbyFundingSource(LNbitsSettings):
|
||||
alby_api_endpoint: Optional[str] = Field(default="https://api.getalby.com/")
|
||||
alby_access_token: Optional[str] = Field(default=None)
|
||||
|
||||
|
||||
class OpenNodeFundingSource(LNbitsSettings):
|
||||
opennode_api_endpoint: Optional[str] = Field(default=None)
|
||||
opennode_key: Optional[str] = Field(default=None)
|
||||
@@ -199,14 +208,6 @@ class LnTipsFundingSource(LNbitsSettings):
|
||||
lntips_invoice_key: Optional[str] = Field(default=None)
|
||||
|
||||
|
||||
# todo: must be extracted
|
||||
class BoltzExtensionSettings(LNbitsSettings):
|
||||
boltz_network: str = Field(default="main")
|
||||
boltz_url: str = Field(default="https://boltz.exchange/api")
|
||||
boltz_mempool_space_url: str = Field(default="https://mempool.space")
|
||||
boltz_mempool_space_url_ws: str = Field(default="wss://mempool.space")
|
||||
|
||||
|
||||
class LightningSettings(LNbitsSettings):
|
||||
lightning_invoice_expiry: int = Field(default=3600)
|
||||
|
||||
@@ -221,6 +222,7 @@ class FundingSourcesSettings(
|
||||
LndRestFundingSource,
|
||||
LndGrpcFundingSource,
|
||||
LnPayFundingSource,
|
||||
AlbyFundingSource,
|
||||
OpenNodeFundingSource,
|
||||
SparkFundingSource,
|
||||
LnTipsFundingSource,
|
||||
@@ -228,6 +230,21 @@ class FundingSourcesSettings(
|
||||
lnbits_backend_wallet_class: str = Field(default="VoidWallet")
|
||||
|
||||
|
||||
class WebPushSettings(LNbitsSettings):
|
||||
lnbits_webpush_pubkey: str = Field(default=None)
|
||||
lnbits_webpush_privkey: str = Field(default=None)
|
||||
|
||||
|
||||
class NodeUISettings(LNbitsSettings):
|
||||
# on-off switch for node ui
|
||||
lnbits_node_ui: bool = Field(default=False)
|
||||
# whether to display the public node ui (only if lnbits_node_ui is True)
|
||||
lnbits_public_node_ui: bool = Field(default=False)
|
||||
# can be used to disable the transactions tab in the node ui
|
||||
# (recommended for large cln nodes)
|
||||
lnbits_node_ui_transactions: bool = Field(default=False)
|
||||
|
||||
|
||||
class EditableSettings(
|
||||
UsersSettings,
|
||||
ExtensionsSettings,
|
||||
@@ -235,8 +252,9 @@ class EditableSettings(
|
||||
OpsSettings,
|
||||
SecuritySettings,
|
||||
FundingSourcesSettings,
|
||||
BoltzExtensionSettings,
|
||||
LightningSettings,
|
||||
WebPushSettings,
|
||||
NodeUISettings,
|
||||
):
|
||||
@validator(
|
||||
"lnbits_admin_users",
|
||||
@@ -247,7 +265,7 @@ class EditableSettings(
|
||||
)
|
||||
@classmethod
|
||||
def validate_editable_settings(cls, val):
|
||||
return super().validate(val)
|
||||
return super().validate_list(val)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict):
|
||||
@@ -263,6 +281,11 @@ class EditableSettings(
|
||||
prop.pop("env_names", None)
|
||||
|
||||
|
||||
class UpdateSettings(EditableSettings):
|
||||
class Config:
|
||||
extra = Extra.forbid
|
||||
|
||||
|
||||
class EnvSettings(LNbitsSettings):
|
||||
debug: bool = Field(default=False)
|
||||
bundle_assets: bool = Field(default=True)
|
||||
@@ -271,9 +294,17 @@ class EnvSettings(LNbitsSettings):
|
||||
forwarded_allow_ips: str = Field(default="*")
|
||||
lnbits_title: str = Field(default="LNbits API")
|
||||
lnbits_path: str = Field(default=".")
|
||||
lnbits_commit: str = Field(default="unknown")
|
||||
lnbits_extensions_path: str = Field(default="lnbits")
|
||||
super_user: str = Field(default="")
|
||||
version: str = Field(default="0.0.0")
|
||||
enable_log_to_file: bool = Field(default=True)
|
||||
log_rotation: str = Field(default="100 MB")
|
||||
log_retention: str = Field(default="3 months")
|
||||
server_startup_time: int = Field(default=time())
|
||||
|
||||
@property
|
||||
def has_default_extension_path(self) -> bool:
|
||||
return self.lnbits_extensions_path == "lnbits"
|
||||
|
||||
|
||||
class SaaSSettings(LNbitsSettings):
|
||||
@@ -299,6 +330,7 @@ class SuperUserSettings(LNbitsSettings):
|
||||
"LndWallet",
|
||||
"LnTipsWallet",
|
||||
"LNPayWallet",
|
||||
"AlbyWallet",
|
||||
"LNbitsWallet",
|
||||
"OpenNodeWallet",
|
||||
]
|
||||
@@ -332,19 +364,25 @@ class ReadOnlySettings(
|
||||
)
|
||||
@classmethod
|
||||
def validate_readonly_settings(cls, val):
|
||||
return super().validate(val)
|
||||
return super().validate_list(val)
|
||||
|
||||
@classmethod
|
||||
def readonly_fields(cls):
|
||||
return [f for f in inspect.signature(cls).parameters if not f.startswith("_")]
|
||||
|
||||
|
||||
class Settings(EditableSettings, ReadOnlySettings, TransientSettings):
|
||||
class Settings(EditableSettings, ReadOnlySettings, TransientSettings, BaseSettings):
|
||||
@classmethod
|
||||
def from_row(cls, row: Row) -> "Settings":
|
||||
data = dict(row)
|
||||
return cls(**data)
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
env_file_encoding = "utf-8"
|
||||
case_sensitive = False
|
||||
json_loads = list_parse_fallback
|
||||
|
||||
|
||||
class SuperSettings(EditableSettings):
|
||||
super_user: str
|
||||
@@ -392,20 +430,14 @@ settings = Settings()
|
||||
|
||||
settings.lnbits_path = str(path.dirname(path.realpath(__file__)))
|
||||
|
||||
try:
|
||||
settings.lnbits_commit = (
|
||||
subprocess.check_output(
|
||||
["git", "-C", settings.lnbits_path, "rev-parse", "HEAD"],
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
.strip()
|
||||
.decode("ascii")
|
||||
)
|
||||
except Exception:
|
||||
settings.lnbits_commit = "docker"
|
||||
|
||||
settings.version = importlib.metadata.version("lnbits")
|
||||
|
||||
# printing environment variable for debugging
|
||||
if not settings.lnbits_admin_ui:
|
||||
logger.debug("Environment Settings:")
|
||||
for key, value in settings.dict(exclude_none=True).items():
|
||||
logger.debug(f"{key}: {value}")
|
||||
|
||||
|
||||
def get_wallet_class():
|
||||
"""
|
||||
|
||||
@@ -502,12 +502,6 @@ video {
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Material Icons";
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
src: url(/static/fonts/material-icons-v50.woff2) format("woff2");
|
||||
}
|
||||
.material-icons {
|
||||
font-family: "Material Icons";
|
||||
font-weight: normal;
|
||||
@@ -535,3 +529,24 @@ video {
|
||||
.q-card code {
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.qrcode__wrapper canvas {
|
||||
position: relative;
|
||||
width: 100% !important;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
.qrcode__image {
|
||||
width: 15%;
|
||||
height: 15%;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
left: 50%;
|
||||
overflow: hidden;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
padding: 0.2rem;
|
||||
border-radius: 0.2rem;
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 8.7 KiB After Width: | Height: | Size: 8.7 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 4.2 KiB |
@@ -18,7 +18,7 @@ window.localisation.br = {
|
||||
name_your_wallet: 'Nomeie sua carteira %{name}',
|
||||
paste_invoice_label: 'Cole uma fatura, pedido de pagamento ou código lnurl *',
|
||||
lnbits_description:
|
||||
'Fácil de configurar e leve, o LNbits pode ser executado em qualquer fonte de financiamento da lightning-network, atualmente suportando LND, c-lightning, OpenNode, LNPay e até mesmo o LNbits em si! Você pode executar o LNbits para si mesmo ou oferecer facilmente uma solução de custódia para outros. Cada carteira tem suas próprias chaves de API e não há limite para o número de carteiras que você pode criar. Ser capaz de particionar fundos torna o LNbits uma ferramenta útil para gerenciamento de dinheiro e como uma ferramenta de desenvolvimento. As extensões adicionam funcionalidades extras ao LNbits para que você possa experimentar uma série de tecnologias de ponta na rede lightning. Nós tornamos o desenvolvimento de extensões o mais fácil possível e, como um projeto gratuito e de código aberto, incentivamos as pessoas a desenvolver e enviar as suas próprias.',
|
||||
'Fácil de configurar e leve, o LNbits pode ser executado em qualquer fonte de financiamento da lightning-network, atualmente suportando LND, c-lightning, OpenNode, Alby, LNPay e até mesmo o LNbits em si! Você pode executar o LNbits para si mesmo ou oferecer facilmente uma solução de custódia para outros. Cada carteira tem suas próprias chaves de API e não há limite para o número de carteiras que você pode criar. Ser capaz de particionar fundos torna o LNbits uma ferramenta útil para gerenciamento de dinheiro e como uma ferramenta de desenvolvimento. As extensões adicionam funcionalidades extras ao LNbits para que você possa experimentar uma série de tecnologias de ponta na rede lightning. Nós tornamos o desenvolvimento de extensões o mais fácil possível e, como um projeto gratuito e de código aberto, incentivamos as pessoas a desenvolver e enviar as suas próprias.',
|
||||
export_to_phone: 'Exportar para o telefone com código QR',
|
||||
export_to_phone_desc:
|
||||
'Este código QR contém a URL da sua carteira com acesso total. Você pode escaneá-lo do seu telefone para abrir sua carteira a partir dele.',
|
||||
@@ -37,7 +37,6 @@ window.localisation.br = {
|
||||
toggle_darkmode: 'Alternar modo escuro',
|
||||
view_swagger_docs: 'Ver a documentação da API do LNbits Swagger',
|
||||
api_docs: 'Documentação da API',
|
||||
commit_version: 'Versão de commit',
|
||||
lnbits_version: 'Versão do LNbits',
|
||||
runs_on: 'Executa em',
|
||||
credit_hint: 'Pressione Enter para creditar a conta',
|
||||
@@ -74,8 +73,7 @@ window.localisation.br = {
|
||||
disclaimer_dialog:
|
||||
'Funcionalidade de login a ser lançada em uma atualização futura, por enquanto, certifique-se de marcar esta página para acesso futuro à sua carteira! Este serviço está em BETA, e não nos responsabilizamos por pessoas que perderem o acesso aos fundos.',
|
||||
no_transactions: 'Ainda não foram feitas transações',
|
||||
manage_extensions: 'Gerenciar extensões',
|
||||
manage_server: 'Gerenciar servidor',
|
||||
manage: 'Gerenciar',
|
||||
extensions: 'Extensões',
|
||||
no_extensions: 'Você não possui nenhuma extensão instalada :(',
|
||||
created: 'Criado',
|
||||
|
||||
@@ -18,7 +18,7 @@ window.localisation.cn = {
|
||||
name_your_wallet: '给你的 %{name}钱包起个名字',
|
||||
paste_invoice_label: '粘贴发票,付款请求或lnurl*',
|
||||
lnbits_description:
|
||||
'LNbits 设置简单、轻量级,可以运行在任何闪电网络的版本上,目前支持 LND、Core Lightning、OpenNode、LNPay,甚至 LNbits 本身!您可以为自己运行 LNbits,或者为他人轻松提供资金托管。每个钱包都有自己的 API 密钥,你可以创建的钱包数量没有限制。能够把资金分开管理使 LNbits 成为一款有用的资金管理和开发工具。扩展程序增加了 LNbits 的额外功能,所以你可以在闪电网络上尝试各种尖端技术。我们已经尽可能简化了开发扩展程序的过程,作为一个免费和开源的项目,我们鼓励人们开发并提交自己的扩展程序。',
|
||||
'LNbits 设置简单、轻量级,可以运行在任何闪电网络的版本上,目前支持 LND、Core Lightning、OpenNode、Alby, LNPay,甚至 LNbits 本身!您可以为自己运行 LNbits,或者为他人轻松提供资金托管。每个钱包都有自己的 API 密钥,你可以创建的钱包数量没有限制。能够把资金分开管理使 LNbits 成为一款有用的资金管理和开发工具。扩展程序增加了 LNbits 的额外功能,所以你可以在闪电网络上尝试各种尖端技术。我们已经尽可能简化了开发扩展程序的过程,作为一个免费和开源的项目,我们鼓励人们开发并提交自己的扩展程序。',
|
||||
export_to_phone: '通过二维码导出到手机',
|
||||
export_to_phone_desc:
|
||||
'这个二维码包含您钱包的URL。您可以使用手机扫描的方式打开您的钱包。',
|
||||
@@ -36,7 +36,6 @@ window.localisation.cn = {
|
||||
toggle_darkmode: '切换暗黑模式',
|
||||
view_swagger_docs: '查看 LNbits Swagger API 文档',
|
||||
api_docs: 'API文档',
|
||||
commit_version: '提交版本',
|
||||
lnbits_version: 'LNbits版本',
|
||||
runs_on: '可运行在',
|
||||
credit_hint: '按 Enter 键充值账户',
|
||||
@@ -73,14 +72,12 @@ window.localisation.cn = {
|
||||
disclaimer_dialog:
|
||||
'登录功能将在以后的更新中发布,请将此页面加为书签,以便将来访问您的钱包!此服务处于测试阶段,我们不对资金的丢失承担任何责任。',
|
||||
no_transactions: '尚未进行任何交易',
|
||||
manage_server: '管理服务器',
|
||||
manage: '管理',
|
||||
extensions: '扩展程序',
|
||||
no_extensions: '你没有安装任何扩展程序 :(',
|
||||
created: '已创建',
|
||||
|
||||
search_extensions: '搜索扩展程序',
|
||||
warning: '警告',
|
||||
manage: '管理',
|
||||
repository: '代码库',
|
||||
confirm_continue: '你确定要继续吗?',
|
||||
manage_extension_details: '安装/卸载扩展程序',
|
||||
@@ -109,9 +106,7 @@ window.localisation.cn = {
|
||||
extension_db_drop_info: '该扩展程序的所有数据将被永久删除。此操作无法撤销!',
|
||||
extension_db_drop_warning:
|
||||
'您即将删除该扩展的所有数据。请继续输入扩展程序名称以确认操作:',
|
||||
|
||||
extension_min_lnbits_version: '此版本要求最低的 LNbits 版本为',
|
||||
|
||||
payment_hash: '付款哈希',
|
||||
fee: '费',
|
||||
amount: '金额',
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
window.localisation.cs = {
|
||||
confirm: 'Ano',
|
||||
server: 'Server',
|
||||
theme: 'Téma',
|
||||
funding: 'Financování',
|
||||
users: 'Uživatelé',
|
||||
apps: 'Aplikace',
|
||||
channels: 'Kanály',
|
||||
transactions: 'Transakce',
|
||||
dashboard: 'Přehled',
|
||||
manage_node: 'Spravovat uzel',
|
||||
total_capacity: 'Celková kapacita',
|
||||
avg_channel_size: 'Průmerná velikost kanálu',
|
||||
biggest_channel_size: 'Největší velikost kanálu',
|
||||
smallest_channel_size: 'Nejmenší velikost kanálu',
|
||||
number_of_channels: 'Počet kanálů',
|
||||
active_channels: 'Aktivní kanály',
|
||||
connect_peer: 'Připojit peer',
|
||||
connect: 'Připojit',
|
||||
open_channel: 'Otevřít kanál',
|
||||
open: 'Otevřít',
|
||||
close_channel: 'Zavřít kanál',
|
||||
close: 'Zavřít',
|
||||
restart: 'Restartovat server',
|
||||
save: 'Uložit',
|
||||
save_tooltip: 'Uložit změny',
|
||||
topup: 'Dobít',
|
||||
topup_wallet: 'Dobít peněženku',
|
||||
topup_hint: 'Použijte ID peněženky pro dobíjení jakékoliv peněženky',
|
||||
restart_tooltip: 'Restartujte server pro aplikaci změn',
|
||||
add_funds_tooltip: 'Přidat prostředky do peněženky.',
|
||||
reset_defaults: 'Obnovit výchozí',
|
||||
reset_defaults_tooltip: 'Smazat všechna nastavení a obnovit výchozí.',
|
||||
download_backup: 'Stáhnout zálohu databáze',
|
||||
name_your_wallet: 'Pojmenujte svou %{name} peněženku',
|
||||
paste_invoice_label: 'Vložte fakturu, platební požadavek nebo lnurl kód *',
|
||||
lnbits_description:
|
||||
'Snadno nastavitelný a lehkotonážní, LNbits může běžet na jakémkoliv zdroji financování lightning-network, v současné době podporuje LND, Core Lightning, OpenNode, Alby, LNPay a dokonce LNbits samotné! LNbits můžete provozovat pro sebe, nebo snadno nabízet správu peněženek pro ostatní. Každá peněženka má své vlastní API klíče a není omezen počet peněženek, které můžete vytvořit. Možnost rozdělení prostředků dělá z LNbits užitečný nástroj pro správu peněz a jako vývojový nástroj. Rozšíření přidávají extra funkčnost k LNbits, takže můžete experimentovat s řadou špičkových technologií na lightning network. Vývoj rozšíření jsme učinili co nejjednodušší a jako svobodný a open-source projekt podporujeme lidi ve vývoji a zasílání vlastních rozšíření.',
|
||||
export_to_phone: 'Exportovat do telefonu pomocí QR kódu',
|
||||
export_to_phone_desc:
|
||||
'Tento QR kód obsahuje URL vaší peněženky s plným přístupem. Můžete jej naskenovat z telefonu a otevřít peněženku odtamtud.',
|
||||
wallets: 'Peněženky',
|
||||
add_wallet: 'Přidat novou peněženku',
|
||||
delete_wallet: 'Smazat peněženku',
|
||||
delete_wallet_desc:
|
||||
'Celá peněženka bude smazána, prostředky budou NEOBNOVITELNÉ.',
|
||||
rename_wallet: 'Přejmenovat peněženku',
|
||||
update_name: 'Aktualizovat název',
|
||||
fiat_tracking: 'Sledování fiatu',
|
||||
currency: 'Měna',
|
||||
update_currency: 'Aktualizovat měnu',
|
||||
press_to_claim: 'Stiskněte pro nárokování bitcoinu',
|
||||
donate: 'Darovat',
|
||||
view_github: 'Zobrazit na GitHubu',
|
||||
voidwallet_active: 'VoidWallet je aktivní! Platby zakázány',
|
||||
use_with_caution:
|
||||
'POUŽÍVEJTE S OBEZŘETNOSTÍ - %{name} peněženka je stále v BETĚ',
|
||||
toggle_darkmode: 'Přepnout tmavý režim',
|
||||
view_swagger_docs: 'Zobrazit LNbits Swagger API dokumentaci',
|
||||
api_docs: 'API dokumentace',
|
||||
lnbits_version: 'Verze LNbits',
|
||||
runs_on: 'Běží na',
|
||||
credit_hint: 'Stiskněte Enter pro připsání na účet',
|
||||
credit_label: '%{denomination} k připsání',
|
||||
paste: 'Vložit',
|
||||
paste_from_clipboard: 'Vložit ze schránky',
|
||||
paste_request: 'Vložit požadavek',
|
||||
create_invoice: 'Vytvořit fakturu',
|
||||
camera_tooltip: 'Použijte kameru pro skenování faktury/QR',
|
||||
export_csv: 'Exportovat do CSV',
|
||||
transactions: 'Transakce',
|
||||
chart_tooltip: 'Zobrazit graf',
|
||||
pending: 'Čeká na vyřízení',
|
||||
copy_invoice: 'Kopírovat fakturu',
|
||||
close: 'Zavřít',
|
||||
cancel: 'Zrušit',
|
||||
scan: 'Skenovat',
|
||||
read: 'Číst',
|
||||
pay: 'Platit',
|
||||
memo: 'Poznámka',
|
||||
date: 'Datum',
|
||||
processing_payment: 'Zpracování platby...',
|
||||
not_enough_funds: 'Nedostatek prostředků!',
|
||||
search_by_tag_memo_amount: 'Hledat podle tagu, poznámky, částky',
|
||||
invoice_waiting: 'Faktura čeká na platbu',
|
||||
payment_received: 'Platba přijata',
|
||||
payment_sent: 'Platba odeslána',
|
||||
receive: 'přijmout',
|
||||
send: 'odeslat',
|
||||
outgoing_payment_pending: 'Odchozí platba čeká na vyřízení',
|
||||
drain_funds: 'Vyčerpat prostředky',
|
||||
drain_funds_desc:
|
||||
'Toto je LNURL-withdraw QR kód pro vyčerpání všeho z této peněženky. Nesdílejte s nikým. Je kompatibilní s balanceCheck a balanceNotify, takže vaše peněženka může kontinuálně čerpat prostředky odsud po prvním výběru.',
|
||||
i_understand: 'Rozumím',
|
||||
copy_wallet_url: 'Kopírovat URL peněženky',
|
||||
disclaimer_dialog:
|
||||
'Funkcionalita přihlášení bude vydána v budoucí aktualizaci, zatím si ujistěte, že jste si tuto stránku uložili do záložek pro budoucí přístup k vaší peněžence! Tato služba je v BETA verzi a nepřebíráme žádnou zodpovědnost za ztrátu přístupu k prostředkům.',
|
||||
no_transactions: 'Zatím žádné transakce',
|
||||
manage: 'Spravovat',
|
||||
extensions: 'Rozšíření',
|
||||
no_extensions: 'Nemáte nainstalováno žádné rozšíření :(',
|
||||
created: 'Vytvořeno',
|
||||
search_extensions: 'Hledat rozšíření',
|
||||
warning: 'Varování',
|
||||
repository: 'Repositář',
|
||||
confirm_continue: 'Jste si jistí, že chcete pokračovat?',
|
||||
manage_extension_details: 'Instalovat/odinstalovat rozšíření',
|
||||
install: 'Instalovat',
|
||||
uninstall: 'Odinstalovat',
|
||||
drop_db: 'Odstranit data',
|
||||
open: 'Otevřít',
|
||||
enable: 'Povolit',
|
||||
enable_extension_details: 'Povolit rozšíření pro aktuálního uživatele',
|
||||
disable: 'Zakázat',
|
||||
installed: 'Nainstalováno',
|
||||
activated: 'Aktivováno',
|
||||
deactivated: 'Deaktivováno',
|
||||
release_notes: 'Poznámky k vydání',
|
||||
activate_extension_details: 'Zpřístupnit/zakázat rozšíření pro uživatele',
|
||||
featured: 'Doporučené',
|
||||
all: 'Vše',
|
||||
only_admins_can_install:
|
||||
'(Pouze administrátorské účty mohou instalovat rozšíření)',
|
||||
admin_only: 'Pouze pro adminy',
|
||||
new_version: 'Nová verze',
|
||||
extension_depends_on: 'Závisí na:',
|
||||
extension_rating_soon: 'Hodnocení brzy dostupné',
|
||||
extension_installed_version: 'Nainstalovaná verze',
|
||||
extension_uninstall_warning:
|
||||
'Chystáte se odstranit rozšíření pro všechny uživatele.',
|
||||
uninstall_confirm: 'Ano, odinstalovat',
|
||||
extension_db_drop_info:
|
||||
'Všechna data pro rozšíření budou trvale odstraněna. Tuto operaci nelze vrátit zpět!',
|
||||
extension_db_drop_warning:
|
||||
'Chystáte se odstranit všechna data pro rozšíření. Prosím, pokračujte zadáním názvu rozšíření:',
|
||||
extension_min_lnbits_version: 'Toto vydání vyžaduje alespoň verzi LNbits',
|
||||
payment_hash: 'Hash platby',
|
||||
fee: 'Poplatek',
|
||||
amount: 'Částka',
|
||||
unit: 'Jednotka',
|
||||
description: 'Popis',
|
||||
expiry: 'Expirace',
|
||||
webhook: 'Webhook',
|
||||
payment_proof: 'Důkaz platby',
|
||||
update_available: 'Dostupná aktualizace %{version}!',
|
||||
latest_update: 'Máte nejnovější verzi %{version}.',
|
||||
notifications: 'Notifikace',
|
||||
no_notifications: 'Žádné notifikace',
|
||||
notifications_disabled: 'Notifikace stavu LNbits jsou zakázány.',
|
||||
enable_notifications: 'Povolit notifikace',
|
||||
enable_notifications_desc:
|
||||
'Pokud je povoleno, bude stahovat nejnovější aktualizace stavu LNbits, jako jsou bezpečnostní incidenty a aktualizace.',
|
||||
enable_killswitch: 'Povolit Killswitch',
|
||||
enable_killswitch_desc:
|
||||
'Pokud je povoleno, automaticky změní zdroj financování na VoidWallet pokud LNbits odešle signál killswitch. Po aktualizaci budete muset povolit ručně.',
|
||||
killswitch_interval: 'Interval Killswitch',
|
||||
killswitch_interval_desc:
|
||||
'Jak často by měl úkol na pozadí kontrolovat signál killswitch od LNBits ze zdroje stavu (v minutách).',
|
||||
enable_watchdog: 'Povolit Watchdog',
|
||||
enable_watchdog_desc:
|
||||
'Pokud je povoleno, automaticky změní zdroj financování na VoidWallet pokud je váš zůstatek nižší než zůstatek LNbits. Po aktualizaci budete muset povolit ručně.',
|
||||
watchdog_interval: 'Interval Watchdog',
|
||||
watchdog_interval_desc:
|
||||
'Jak často by měl úkol na pozadí kontrolovat signál killswitch v watchdog delta [node_balance - lnbits_balance] (v minutách).',
|
||||
watchdog_delta: 'Delta Watchdog',
|
||||
watchdog_delta_desc:
|
||||
'Limit předtím, než killswitch změní zdroj financování na VoidWallet [lnbits_balance - node_balance > delta]',
|
||||
status: 'Stav',
|
||||
notification_source: 'Zdroj notifikací',
|
||||
notification_source_label:
|
||||
'URL zdroje (používejte pouze oficiální zdroj stavu LNbits a zdroje, kterým můžete věřit)',
|
||||
more: 'více',
|
||||
releases: 'Vydání',
|
||||
killswitch: 'Killswitch',
|
||||
watchdog: 'Watchdog',
|
||||
server_logs: 'Logy serveru',
|
||||
ip_blocker: 'Blokování IP',
|
||||
security: 'Bezpečnost',
|
||||
security_tools: 'Nástroje bezpečnosti',
|
||||
block_access_hint: 'Blokovat přístup podle IP',
|
||||
allow_access_hint: 'Povolit přístup podle IP (přepíše blokované IP)',
|
||||
enter_ip: 'Zadejte IP a stiskněte enter',
|
||||
rate_limiter: 'Omezovač počtu požadavků',
|
||||
number_of_requests: 'Počet požadavků',
|
||||
time_unit: 'Časová jednotka',
|
||||
minute: 'minuta',
|
||||
second: 'sekunda',
|
||||
hour: 'hodina',
|
||||
disable_server_log: 'Zakázat log serveru',
|
||||
enable_server_log: 'Povolit log serveru',
|
||||
coming_soon: 'Funkce brzy dostupná'
|
||||
}
|
||||
@@ -20,7 +20,7 @@ window.localisation.de = {
|
||||
paste_invoice_label:
|
||||
'Füge eine Rechnung, Zahlungsanforderung oder LNURL ein *',
|
||||
lnbits_description:
|
||||
'Einfach zu installieren und kompakt, LNbits kann auf jeder Funding-Quelle im Lightning Netzwerk aufsetzen. Derzeit unterstützt: LND, Core Lightning, OpenNode, LNPay und sogar LNbits selbst! Du kannst LNbits für dich selbst betreiben oder anderen die Verwaltung durch dich anbieten. Jede Wallet hat ihre eigenen API-Schlüssel und die Anzahl der Wallets ist unbegrenzt. Die Möglichkeit, Gelder auf verschiedene Accounts mit unterschiedlicher Logik aufteilen zu können macht LNbits zu einem nützlichen Werkzeug für deine Buchhaltung - aber auch als Entwicklungswerkzeug. Erweiterungen bereichern LNbits Accounts um zusätzliche Funktionalität, so dass du mit einer Reihe von neuartigen Technologien auf dem Lightning-Netzwerk experimentieren kannst. Wir haben es so einfach wie möglich gemacht, Erweiterungen zu entwickeln, und als freies und Open-Source-Projekt möchten wir Menschen ermutigen, sich selbst hieran zu versuchen und gemeinsam mit uns neue Funktionalitäten zu entwickeln.',
|
||||
'Einfach zu installieren und kompakt, LNbits kann auf jeder Funding-Quelle im Lightning Netzwerk aufsetzen. Derzeit unterstützt: LND, Core Lightning, OpenNode, Alby, LNPay und sogar LNbits selbst! Du kannst LNbits für dich selbst betreiben oder anderen die Verwaltung durch dich anbieten. Jede Wallet hat ihre eigenen API-Schlüssel und die Anzahl der Wallets ist unbegrenzt. Die Möglichkeit, Gelder auf verschiedene Accounts mit unterschiedlicher Logik aufteilen zu können macht LNbits zu einem nützlichen Werkzeug für deine Buchhaltung - aber auch als Entwicklungswerkzeug. Erweiterungen bereichern LNbits Accounts um zusätzliche Funktionalität, so dass du mit einer Reihe von neuartigen Technologien auf dem Lightning-Netzwerk experimentieren kannst. Wir haben es so einfach wie möglich gemacht, Erweiterungen zu entwickeln, und als freies und Open-Source-Projekt möchten wir Menschen ermutigen, sich selbst hieran zu versuchen und gemeinsam mit uns neue Funktionalitäten zu entwickeln.',
|
||||
export_to_phone: 'Auf dem Telefon öffnen',
|
||||
export_to_phone_desc:
|
||||
'Dieser QR-Code beinhaltet vollständige Rechte auf deine Wallet. Du kannst den QR-Code mit Deinem Telefon scannen, um deine Wallet dort zu öffnen.',
|
||||
@@ -40,7 +40,6 @@ window.localisation.de = {
|
||||
toggle_darkmode: 'Auf Dark Mode umschalten',
|
||||
view_swagger_docs: 'LNbits Swagger API-Dokumente',
|
||||
api_docs: 'API docs',
|
||||
commit_version: 'Commit Version',
|
||||
runs_on: 'Läuft auf',
|
||||
credit_hint: 'Klicke Enter, um das Konto zu belasten',
|
||||
credit_label: '%{denomination} zu belasten',
|
||||
@@ -77,15 +76,12 @@ window.localisation.de = {
|
||||
disclaimer_dialog:
|
||||
'Login-Funktionalität wird in einem zukünftigen Update veröffentlicht. Bis dahin ist die Speicherung der Wallet-URL als Lesezeichen absolut notwendig, um Zugriff auf die Wallet zu erhalten! Dieser Service ist in BETA und wir übernehmen keine Verantwortung für Verluste durch verlorene Zugriffe.',
|
||||
no_transactions: 'Keine Transaktionen',
|
||||
manage_extensions: 'Erweiterungen verwalten',
|
||||
manage_server: 'Server verwalten',
|
||||
manage: 'Verwalten',
|
||||
extensions: 'Erweiterungen',
|
||||
no_extensions: 'Du hast noch keine Erweiterungen installiert :(',
|
||||
created: 'Erstellt',
|
||||
|
||||
search_extensions: 'Sucherweiterungen',
|
||||
warning: 'Warnung',
|
||||
manage: 'Verwalten',
|
||||
repository: 'Repository',
|
||||
confirm_continue: 'Bist du sicher, dass du fortfahren möchtest?',
|
||||
manage_extension_details: 'Erweiterung installieren/deinstallieren',
|
||||
@@ -114,7 +110,6 @@ window.localisation.de = {
|
||||
uninstall_confirm: 'Ja, deinstallieren',
|
||||
extension_min_lnbits_version:
|
||||
'Diese Version erfordert mindestens die LNbits-Version',
|
||||
|
||||
payment_hash: 'Zahlungs-Hash',
|
||||
fee: 'Gebühr',
|
||||
amount: 'Menge',
|
||||
|
||||
@@ -4,6 +4,23 @@ window.localisation.en = {
|
||||
theme: 'Theme',
|
||||
funding: 'Funding',
|
||||
users: 'Users',
|
||||
apps: 'Apps',
|
||||
channels: 'Channels',
|
||||
transactions: 'Transactions',
|
||||
dashboard: 'Dashboard',
|
||||
node: 'Node',
|
||||
total_capacity: 'Total Capacity',
|
||||
avg_channel_size: 'Avg. Channel Size',
|
||||
biggest_channel_size: 'Biggest Channel Size',
|
||||
smallest_channel_size: 'Smallest Channel Size',
|
||||
number_of_channels: 'Number of Channels',
|
||||
active_channels: 'Active Channels',
|
||||
connect_peer: 'Connect Peer',
|
||||
connect: 'Connect',
|
||||
open_channel: 'Open Channel',
|
||||
open: 'Open',
|
||||
close_channel: 'Close Channel',
|
||||
close: 'Close',
|
||||
restart: 'Restart server',
|
||||
save: 'Save',
|
||||
save_tooltip: 'Save your changes',
|
||||
@@ -18,7 +35,7 @@ window.localisation.en = {
|
||||
name_your_wallet: 'Name your %{name} wallet',
|
||||
paste_invoice_label: 'Paste an invoice, payment request or lnurl code *',
|
||||
lnbits_description:
|
||||
'Easy to set up and lightweight, LNbits can run on any lightning-network funding source, currently supporting LND, Core Lightning, OpenNode, LNPay and even LNbits itself! You can run LNbits for yourself, or easily offer a custodian solution for others. Each wallet has its own API keys and there is no limit to the number of wallets you can make. Being able to partition funds makes LNbits a useful tool for money management and as a development tool. Extensions add extra functionality to LNbits so you can experiment with a range of cutting-edge technologies on the lightning network. We have made developing extensions as easy as possible, and as a free and open-source project, we encourage people to develop and submit their own.',
|
||||
'Easy to set up and lightweight, LNbits can run on any lightning-network funding source, currently supporting LND, Core Lightning, OpenNode, Alby, LNPay and even LNbits itself! You can run LNbits for yourself, or easily offer a custodian solution for others. Each wallet has its own API keys and there is no limit to the number of wallets you can make. Being able to partition funds makes LNbits a useful tool for money management and as a development tool. Extensions add extra functionality to LNbits so you can experiment with a range of cutting-edge technologies on the lightning network. We have made developing extensions as easy as possible, and as a free and open-source project, we encourage people to develop and submit their own.',
|
||||
export_to_phone: 'Export to Phone with QR Code',
|
||||
export_to_phone_desc:
|
||||
'This QR code contains your wallet URL with full access. You can scan it from your phone to open your wallet from there.',
|
||||
@@ -37,10 +54,13 @@ window.localisation.en = {
|
||||
view_github: 'View on GitHub',
|
||||
voidwallet_active: 'VoidWallet is active! Payments disabled',
|
||||
use_with_caution: 'USE WITH CAUTION - %{name} wallet is still in BETA',
|
||||
service_fee: 'Service fee: %{amount} % per transaction',
|
||||
service_fee_max: 'Service fee: %{amount} % per transaction (max %{max} sats)',
|
||||
service_fee_tooltip:
|
||||
'Service fee charged by the LNbits server admin per outgoing transaction',
|
||||
toggle_darkmode: 'Toggle Dark Mode',
|
||||
view_swagger_docs: 'View LNbits Swagger API docs',
|
||||
api_docs: 'Api docs',
|
||||
commit_version: 'Commit version',
|
||||
api_docs: 'API docs',
|
||||
lnbits_version: 'LNbits version',
|
||||
runs_on: 'Runs on',
|
||||
credit_hint: 'Press Enter to credit account',
|
||||
@@ -79,14 +99,12 @@ window.localisation.en = {
|
||||
disclaimer_dialog:
|
||||
'Login functionality to be released in a future update, for now, make sure you bookmark this page for future access to your wallet! This service is in BETA, and we hold no responsibility for people losing access to funds.',
|
||||
no_transactions: 'No transactions made yet',
|
||||
manage_server: 'Manage Server',
|
||||
manage: 'Manage',
|
||||
extensions: 'Extensions',
|
||||
no_extensions: "You don't have any extensions installed :(",
|
||||
created: 'Created',
|
||||
|
||||
search_extensions: 'Search extensions',
|
||||
warning: 'Warning',
|
||||
manage: 'Manage',
|
||||
repository: 'Repository',
|
||||
confirm_continue: 'Are you sure you want to continue?',
|
||||
manage_extension_details: 'Install/uninstall extension',
|
||||
@@ -117,9 +135,7 @@ window.localisation.en = {
|
||||
'All data for the extension will be permanently deleted. There is no way to undo this operation!',
|
||||
extension_db_drop_warning:
|
||||
'You are about to remove all data for the extension. Please type the extension name to continue:',
|
||||
|
||||
extension_min_lnbits_version: 'This release requires at least LNbits version',
|
||||
|
||||
payment_hash: 'Payment Hash',
|
||||
fee: 'Fee',
|
||||
amount: 'Amount',
|
||||
|
||||
@@ -19,7 +19,7 @@ window.localisation.es = {
|
||||
name_your_wallet: 'Nombre de su billetera %{name}',
|
||||
paste_invoice_label: 'Pegue la factura aquí',
|
||||
lnbits_description:
|
||||
'Fácil de instalar y liviano, LNbits puede ejecutarse en cualquier fuente de financiación de la red Lightning, actualmente compatible con LND, Core Lightning, OpenNode, LNPay y hasta LNbits mismo! Puede ejecutar LNbits para usted mismo o ofrecer una solución competente a otros. Cada billetera tiene su propia clave API y no hay límite para la cantidad de billeteras que puede crear. La capacidad de particionar fondos hace de LNbits una herramienta útil para la administración de fondos y como herramienta de desarrollo. Las extensiones agregan funcionalidad adicional a LNbits, por lo que puede experimentar con una variedad de tecnologías de vanguardia en la red Lightning. Lo hemos hecho lo más simple posible para desarrollar extensiones y, como un proyecto gratuito y de código abierto, animamos a las personas a que se desarrollen a sí mismas y envíen sus propios contribuciones.',
|
||||
'Fácil de instalar y liviano, LNbits puede ejecutarse en cualquier fuente de financiación de la red Lightning, actualmente compatible con LND, Core Lightning, OpenNode, Alby, LNPay y hasta LNbits mismo! Puede ejecutar LNbits para usted mismo o ofrecer una solución competente a otros. Cada billetera tiene su propia clave API y no hay límite para la cantidad de billeteras que puede crear. La capacidad de particionar fondos hace de LNbits una herramienta útil para la administración de fondos y como herramienta de desarrollo. Las extensiones agregan funcionalidad adicional a LNbits, por lo que puede experimentar con una variedad de tecnologías de vanguardia en la red Lightning. Lo hemos hecho lo más simple posible para desarrollar extensiones y, como un proyecto gratuito y de código abierto, animamos a las personas a que se desarrollen a sí mismas y envíen sus propios contribuciones.',
|
||||
export_to_phone: 'Exportar a teléfono con código QR',
|
||||
export_to_phone_desc:
|
||||
'Este código QR contiene su URL de billetera con acceso completo. Puede escanearlo desde su teléfono para abrir su billetera allí.',
|
||||
@@ -38,7 +38,6 @@ window.localisation.es = {
|
||||
toggle_darkmode: 'Cambiar modo oscuro',
|
||||
view_swagger_docs: 'Ver documentos de API de LNbits Swagger',
|
||||
api_docs: 'Documentos de API',
|
||||
commit_version: 'Versión de compromiso',
|
||||
runs_on: 'Corre en',
|
||||
credit_hint: 'Presione Enter para cargar la cuenta',
|
||||
credit_label: 'Cargar %{denomination}',
|
||||
@@ -74,14 +73,12 @@ window.localisation.es = {
|
||||
disclaimer_dialog:
|
||||
'La funcionalidad de inicio de sesión se lanzará en una actualización futura, por ahora, asegúrese de guardar esta página como marcador para acceder a su billetera en el futuro. Este servicio está en BETA y no asumimos ninguna responsabilidad por personas que pierdan el acceso a sus fondos.',
|
||||
no_transactions: 'No hay transacciones todavía',
|
||||
manage_server: 'Administrar servidor',
|
||||
manage: 'Administrar',
|
||||
extensions: 'Extensiones',
|
||||
no_extensions: 'No tienes extensiones instaladas :(',
|
||||
created: 'Creado',
|
||||
|
||||
search_extensions: 'Extensiones de búsqueda',
|
||||
warning: 'Advertencia',
|
||||
manage: 'Administrar',
|
||||
repository: 'Repositorio',
|
||||
confirm_continue: '¿Está seguro de que desea continuar?',
|
||||
manage_extension_details: 'Instalar/desinstalar extensión',
|
||||
@@ -110,7 +107,6 @@ window.localisation.es = {
|
||||
uninstall_confirm: 'Sí, desinstalar',
|
||||
extension_min_lnbits_version:
|
||||
'Esta versión requiere al menos una versión de LNbits',
|
||||
|
||||
payment_hash: 'Hash de pago',
|
||||
fee: 'Cuota',
|
||||
amount: 'Cantidad',
|
||||
|
||||
@@ -21,7 +21,7 @@ window.localisation.fr = {
|
||||
paste_invoice_label:
|
||||
'Coller une facture, une demande de paiement ou un code lnurl *',
|
||||
lnbits_description:
|
||||
"Facile à installer et léger, LNbits peut fonctionner sur n'importe quelle source de financement du réseau Lightning, prenant actuellement en charge LND, Core Lightning, OpenNode, LNPay et même LNbits lui-même! Vous pouvez exécuter LNbits pour vous-même ou offrir facilement une solution de gardien pour les autres. Chaque portefeuille a ses propres clés API et il n'y a pas de limite au nombre de portefeuilles que vous pouvez créer. La capacité de partitionner les fonds rend LNbits un outil utile pour la gestion de l'argent et comme outil de développement. Les extensions ajoutent une fonctionnalité supplémentaire à LNbits afin que vous puissiez expérimenter une gamme de technologies de pointe sur le réseau Lightning. Nous avons rendu le développement d'extensions aussi simple que possible et, en tant que projet gratuit et open source, nous encourageons les gens à développer et à soumettre les leurs.",
|
||||
"Facile à installer et léger, LNbits peut fonctionner sur n'importe quelle source de financement du réseau Lightning, prenant actuellement en charge LND, Core Lightning, OpenNode, Alby, LNPay et même LNbits lui-même! Vous pouvez exécuter LNbits pour vous-même ou offrir facilement une solution de gardien pour les autres. Chaque portefeuille a ses propres clés API et il n'y a pas de limite au nombre de portefeuilles que vous pouvez créer. La capacité de partitionner les fonds rend LNbits un outil utile pour la gestion de l'argent et comme outil de développement. Les extensions ajoutent une fonctionnalité supplémentaire à LNbits afin que vous puissiez expérimenter une gamme de technologies de pointe sur le réseau Lightning. Nous avons rendu le développement d'extensions aussi simple que possible et, en tant que projet gratuit et open source, nous encourageons les gens à développer et à soumettre les leurs.",
|
||||
export_to_phone: 'Exporter vers le téléphone avec un code QR',
|
||||
export_to_phone_desc:
|
||||
"Ce code QR contient l'URL de votre portefeuille avec un accès complet. Vous pouvez le scanner depuis votre téléphone pour ouvrir votre portefeuille depuis là-bas.",
|
||||
@@ -41,7 +41,6 @@ window.localisation.fr = {
|
||||
toggle_darkmode: 'Basculer le mode sombre',
|
||||
view_swagger_docs: "Voir les documents de l'API Swagger de LNbits",
|
||||
api_docs: "Documents de l'API",
|
||||
commit_version: 'Version de commit',
|
||||
lnbits_version: 'Version de LNbits',
|
||||
runs_on: 'Fonctionne sur',
|
||||
credit_hint: 'Appuyez sur Entrée pour créditer le compte',
|
||||
@@ -78,8 +77,7 @@ window.localisation.fr = {
|
||||
disclaimer_dialog:
|
||||
"La fonctionnalité de connexion sera publiée dans une future mise à jour, pour l'instant, assurez-vous de mettre cette page en favori pour accéder à votre portefeuille ultérieurement ! Ce service est en BETA, et nous ne sommes pas responsables des personnes qui perdent l'accès à leurs fonds.",
|
||||
no_transactions: 'Aucune transaction effectuée pour le moment',
|
||||
manage_extensions: 'Gérer les extensions',
|
||||
manage_server: 'Gérer le serveur',
|
||||
manage: 'Gérer',
|
||||
extensions: 'Extensions',
|
||||
no_extensions: "Vous n'avez installé aucune extension :(",
|
||||
created: 'Créé',
|
||||
|
||||
@@ -19,7 +19,7 @@ window.localisation.it = {
|
||||
paste_invoice_label:
|
||||
'Incolla una fattura, una richiesta di pagamento o un codice lnurl *',
|
||||
lnbits_description:
|
||||
"Leggero e facile da configurare, LNbits può funzionare su qualsiasi fonte di finanziamento lightning-network, attualmente supporta LND, Core Lightning, OpenNode, LNPay e persino LNbits stesso! Potete gestire LNbits per conto vostro o offrire facilmente una soluzione di custodia per altri. Ogni portafoglio ha le proprie chiavi API e non c'è limite al numero di portafogli che si possono creare. La possibilità di suddividere i fondi rende LNbits uno strumento utile per la gestione del denaro e come strumento di sviluppo. Le estensioni aggiungono ulteriori funzionalità a LNbits, consentendo di sperimentare una serie di tecnologie all'avanguardia sulla rete Lightning. Abbiamo reso lo sviluppo delle estensioni il più semplice possibile e, in quanto progetto libero e open-source, incoraggiamo le persone a sviluppare e inviare le proprie",
|
||||
"Leggero e facile da configurare, LNbits può funzionare su qualsiasi fonte di finanziamento lightning-network, attualmente supporta LND, Core Lightning, OpenNode, Alby, LNPay e persino LNbits stesso! Potete gestire LNbits per conto vostro o offrire facilmente una soluzione di custodia per altri. Ogni portafoglio ha le proprie chiavi API e non c'è limite al numero di portafogli che si possono creare. La possibilità di suddividere i fondi rende LNbits uno strumento utile per la gestione del denaro e come strumento di sviluppo. Le estensioni aggiungono ulteriori funzionalità a LNbits, consentendo di sperimentare una serie di tecnologie all'avanguardia sulla rete Lightning. Abbiamo reso lo sviluppo delle estensioni il più semplice possibile e, in quanto progetto libero e open-source, incoraggiamo le persone a sviluppare e inviare le proprie",
|
||||
export_to_phone: 'Esportazione su telefono con codice QR',
|
||||
export_to_phone_desc:
|
||||
"Questo codice QR contiene l'URL del portafoglio con accesso da amministratore. È possibile scansionarlo dal telefono per aprire il portafoglio da lì.",
|
||||
@@ -38,7 +38,6 @@ window.localisation.it = {
|
||||
toggle_darkmode: 'Attiva la modalità notturna',
|
||||
view_swagger_docs: "Visualizza i documenti dell'API Swagger di LNbits",
|
||||
api_docs: 'Documenti API',
|
||||
commit_version: 'Commit version',
|
||||
lnbits_version: 'Versione di LNbits',
|
||||
runs_on: 'Esegue su',
|
||||
credit_hint: 'Premere Invio per accreditare i fondi',
|
||||
@@ -75,8 +74,7 @@ window.localisation.it = {
|
||||
disclaimer_dialog:
|
||||
"La funzionalità di login sarà rilasciata in un futuro aggiornamento; per ora, assicuratevi di salvare tra i preferiti questa pagina per accedere nuovamente in futuro a questo portafoglio! Questo servizio è in fase BETA e non ci assumiamo alcuna responsabilità per la perdita all'accesso dei fondi",
|
||||
no_transactions: 'Nessuna transazione effettuata',
|
||||
manage_extensions: 'Gestisci le estensioni',
|
||||
manage_server: 'Gestisci server',
|
||||
manage: 'Gestisci',
|
||||
estensioni: 'Estensioni',
|
||||
no_extensions: 'Non ci sono estensioni installate :(',
|
||||
created: 'Creato',
|
||||
|
||||
@@ -18,7 +18,7 @@ window.localisation.jp = {
|
||||
name_your_wallet: 'あなたのウォレットの名前 %{name}',
|
||||
paste_invoice_label: '請求書を貼り付けてください',
|
||||
lnbits_description:
|
||||
'簡単にインストールでき、軽量で、LNbitsは現在LND、Core Lightning、OpenNode、LNPay、さらにLNbits自身で動作する任意のLightningネットワークの資金源で実行できます! LNbitsを自分で実行することも、他の人に優れたソリューションを提供することもできます。各ウォレットには独自のAPIキーがあり、作成できるウォレットの数に制限はありません。資金を分割する機能は、LNbitsを資金管理ツールとして使用したり、開発ツールとして使用したりするための便利なツールです。拡張機能は、LNbitsに追加の機能を追加します。そのため、LNbitsは最先端の技術をネットワークLightningで試すことができます。拡張機能を開発するのは簡単で、無料でオープンソースのプロジェクトであるため、人々が自分で開発し、自分の貢献を送信することを奨励しています。',
|
||||
'簡単にインストールでき、軽量で、LNbitsは現在LND、Core Lightning、OpenNode、Alby, LNPay、さらにLNbits自身で動作する任意のLightningネットワークの資金源で実行できます! LNbitsを自分で実行することも、他の人に優れたソリューションを提供することもできます。各ウォレットには独自のAPIキーがあり、作成できるウォレットの数に制限はありません。資金を分割する機能は、LNbitsを資金管理ツールとして使用したり、開発ツールとして使用したりするための便利なツールです。拡張機能は、LNbitsに追加の機能を追加します。そのため、LNbitsは最先端の技術をネットワークLightningで試すことができます。拡張機能を開発するのは簡単で、無料でオープンソースのプロジェクトであるため、人々が自分で開発し、自分の貢献を送信することを奨励しています。',
|
||||
export_to_phone: '電話にエクスポート',
|
||||
export_to_phone_desc:
|
||||
'ウォレットを電話にエクスポートすると、ウォレットを削除する前にウォレットを復元できます。ウォレットを削除すると、ウォレットの秘密鍵が削除され、ウォレットを復元することはできません。',
|
||||
@@ -36,7 +36,6 @@ window.localisation.jp = {
|
||||
toggle_dark_mode: 'ダークモードを切り替える',
|
||||
view_swagger_docs: 'Swaggerドキュメントを表示',
|
||||
api_docs: 'APIドキュメント',
|
||||
commit_version: 'コミットバージョン',
|
||||
runs_on: 'で実行',
|
||||
credit_hint:
|
||||
'クレジットカードを使用して資金を追加するには、LNbitsを使用してください。',
|
||||
@@ -72,13 +71,12 @@ window.localisation.jp = {
|
||||
disclaimer_dialog:
|
||||
'ウォレットを削除すると、ウォレットの秘密鍵が削除され、ウォレットを復元することはできません。ウォレットを削除する前に、ウォレットをエクスポートしてください。',
|
||||
no_transactions: 'トランザクションはありません',
|
||||
manage_server: 'サーバーを管理する',
|
||||
manage: '管理',
|
||||
extensions: '拡張機能',
|
||||
no_extensions: '拡張機能はありません',
|
||||
created: '作成済み',
|
||||
search_extensions: '検索拡張機能',
|
||||
warning: '警告',
|
||||
manage: '管理',
|
||||
repository: 'リポジトリ',
|
||||
confirm_continue: '続行してもよろしいですか?',
|
||||
manage_extension_details: '拡張機能のインストール/アンインストール',
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
window.localisation.kr = {
|
||||
confirm: '확인',
|
||||
server: '서버',
|
||||
theme: '테마',
|
||||
funding: '자금',
|
||||
users: '사용자',
|
||||
apps: '앱',
|
||||
channels: '채널',
|
||||
transactions: '거래 내역',
|
||||
dashboard: '현황판',
|
||||
node: '노드',
|
||||
total_capacity: '총 용량',
|
||||
avg_channel_size: '평균 채널 용량',
|
||||
biggest_channel_size: '가장 큰 채널 용량',
|
||||
smallest_channel_size: '가장 작은 채널 용량',
|
||||
number_of_channels: '채널 수',
|
||||
active_channels: '활성화된 채널',
|
||||
connect_peer: '피어 연결하기',
|
||||
connect: '연결하기',
|
||||
open_channel: '채널 개설하기',
|
||||
open: '개설',
|
||||
close_channel: '채널 폐쇄하기',
|
||||
close: '폐쇄',
|
||||
restart: '서버 재시작',
|
||||
save: '저장',
|
||||
save_tooltip: '변경 사항 저장',
|
||||
topup: '자금 추가',
|
||||
topup_wallet: '지갑에 자금 추가',
|
||||
topup_hint: '자금을 추가할 지갑의 ID를 넣어주세요',
|
||||
restart_tooltip: '변경 사항을 적용하려면 서버를 재시작해야 합니다.',
|
||||
add_funds_tooltip: '지갑에 자금을 추가합니다.',
|
||||
reset_defaults: '기본 설정으로 돌아가기',
|
||||
reset_defaults_tooltip:
|
||||
'설정했던 내용들을 모두 지우고, 기본 설정으로 돌아갑니다.',
|
||||
download_backup: '데이터베이스 백업 다운로드',
|
||||
name_your_wallet: '사용할 %{name}지갑의 이름을 정하세요',
|
||||
paste_invoice_label: '인보이스, 결제 요청, 혹은 lnurl 코드를 붙여넣으세요 *',
|
||||
lnbits_description:
|
||||
'설정이 쉽고 가벼운 LNBits는 어떤 라이트닝 네트워크의 예산 자원 위에서든 돌아갈 수 있습니다. 현재 지원하는 예산 자원의 형태는 LND, Core Lightning, OpenNode, Alby, LNPay, 그리고 다른 LNBits 지갑들입니다. 스스로 사용하기 위해, 또는 다른 사람들에게 수탁형 솔루션을 제공하기 위해 LNBits를 운영할 수 있습니다. 각 지갑들은 자신만의 API key를 가지며, 생성 가능한 지갑의 수에는 제한이 없습니다. 자금을 분할할 수 있는 기능으로 인해, LNBits는 자금 운영 도구로써뿐만 아니라 개발 도구로써도 유용합니다. 확장 기능들은 LNBits에 여러분들이 라이트닝 네트워크의 다양한 최신 기술들을 수행해볼 수 있게 하는 추가 기능을 제공합니다. LNBits 개발진들은 확장 기능들의 개발 또한 가능한 쉽게 만들었으며, 무료 오픈 소스 프로젝트답게 사람들이 자신만의 확장 기능들을 개발하고 제출하기를 응원합니다.',
|
||||
export_to_phone: 'QR 코드를 이용해 모바일 기기로 내보내기',
|
||||
export_to_phone_desc:
|
||||
'이 QR 코드는 선택된 지갑의 최대 접근 권한을 가진 전체 URL을 담고 있습니다. 스캔 후, 모바일 기기에서 지갑을 열 수 있습니다.',
|
||||
wallets: '지갑',
|
||||
add_wallet: '새로운 지갑을 추가합니다',
|
||||
delete_wallet: '지갑을 삭제합니다',
|
||||
delete_wallet_desc:
|
||||
'이 지갑은 삭제될 것이며, 삭제 시 지갑 내 자금은 복구가 불가능합니다.',
|
||||
rename_wallet: '지갑 이름 변경',
|
||||
update_name: '이름 변경하기',
|
||||
fiat_tracking: '법정통화 가격 표시',
|
||||
currency: '통화',
|
||||
update_currency: '통화 수정하기',
|
||||
press_to_claim: '비트코인을 수령하려면 눌러주세요',
|
||||
donate: '기부',
|
||||
view_github: 'GitHub 페이지 보기',
|
||||
voidwallet_active: 'VoidWallet이 활성화되었습니다! 결제가 불가능합니다.',
|
||||
use_with_caution: '주의하세요 - %{name} 지갑은 아직 BETA 단계입니다.',
|
||||
service_fee: '서비스 수수료: 거래액의 %{amount} %',
|
||||
service_fee_max: '서비스 수수료: 거래액의 %{amount} % (최대 %{max} sats)',
|
||||
service_fee_tooltip:
|
||||
'지불 결제 시마다 LNBits 서버 관리자에게 납부되는 서비스 수수료',
|
||||
toggle_darkmode: '다크 모드 전환',
|
||||
view_swagger_docs: 'LNbits Swagger API 문서를 봅니다',
|
||||
api_docs: 'API 문서',
|
||||
lnbits_version: 'LNbits 버전',
|
||||
runs_on: 'Runs on',
|
||||
credit_hint: '계정에 자금을 넣으려면 Enter를 눌러주세요',
|
||||
credit_label: '%{denomination} 단위로 충전하기',
|
||||
paste: '붙여넣기',
|
||||
paste_from_clipboard: '클립보드에서 붙여넣기',
|
||||
paste_request: '지불 요청 붙여넣기',
|
||||
create_invoice: '인보이스 생성하기',
|
||||
camera_tooltip: '카메라를 이용해서 인보이스/QR을 스캔하세요',
|
||||
export_csv: 'CSV 형태로 내보내기',
|
||||
transactions: '거래 내역',
|
||||
chart_tooltip: '그래프로 보여주기',
|
||||
pending: '대기 중',
|
||||
copy_invoice: '인보이스 복사하기',
|
||||
close: '닫기',
|
||||
cancel: '취소',
|
||||
scan: '스캔',
|
||||
read: '분석하기',
|
||||
pay: '지불하기',
|
||||
memo: 'Memo',
|
||||
date: '일시',
|
||||
processing_payment: '결제 처리 중...',
|
||||
not_enough_funds: '자금이 부족합니다!',
|
||||
search_by_tag_memo_amount: '태그, memo, 수량으로 검색하기',
|
||||
invoice_waiting: '결제를 기다리는 인보이스',
|
||||
payment_received: '받은 결제액',
|
||||
payment_sent: '보낸 결제액',
|
||||
receive: '받기',
|
||||
send: '보내기',
|
||||
outgoing_payment_pending: '지불 대기 중',
|
||||
drain_funds: '자금 비우기',
|
||||
drain_funds_desc:
|
||||
'이는 선택된 지갑으로부터 모든 자금을 인출하는 LNURL-withdraw QR 코드입니다. 그 누구와도 공유하지 마세요. balanceCheck 및 balanceNotify 기능과 호환되며, 당신의 지갑은 첫 출금 이후로도 계속 자금을 끌어당기고 있을 수 있습니다.',
|
||||
i_understand: '이해하였습니다',
|
||||
copy_wallet_url: '지갑 URL 복사하기',
|
||||
disclaimer_dialog:
|
||||
'로그인 기능은 향후 업데이트를 통해 지원될 계획이지만, 현재로써는 이 페이지에 향후 다시 접속하기 위해 북마크 설정하는 것을 잊지 마세요! 이 서비스는 아직 BETA 과정에 있고, LNbits 개발자들은 자금 손실에 대해 전혀 책임을 지지 않습니다.',
|
||||
no_transactions: '아직 아무런 거래도 이루어지지 않았습니다',
|
||||
manage: '관리',
|
||||
extensions: '확장 기능',
|
||||
no_extensions: '아직 설치된 확장 기능들이 없네요 :(',
|
||||
created: '생성됨',
|
||||
search_extensions: '확장 기능 검색하기',
|
||||
warning: '주의',
|
||||
repository: '저장소',
|
||||
confirm_continue: '정말로 계속할까요?',
|
||||
manage_extension_details: '확장 기능 설치/삭제하기',
|
||||
install: '설치',
|
||||
uninstall: '삭제',
|
||||
drop_db: '데이터 삭제',
|
||||
open: '열기',
|
||||
enable: '활성화',
|
||||
enable_extension_details: '현재 사용자 계정에 해당 확장 기능을 활성화합니다',
|
||||
disable: '비활성화',
|
||||
installed: '설치됨',
|
||||
activated: '작동됨',
|
||||
deactivated: '작동 중지',
|
||||
release_notes: '배포 노트',
|
||||
activate_extension_details:
|
||||
'사용자들의 확장 기능 사용 가능 여부를 결정합니다',
|
||||
featured: '추천',
|
||||
all: '전체',
|
||||
only_admins_can_install: '(관리자 계정만이 확장 기능을 설치할 수 있습니다)',
|
||||
admin_only: '관리자 전용',
|
||||
new_version: '새로운 버전',
|
||||
extension_depends_on: '의존성 존재:',
|
||||
extension_rating_soon: '평점 기능도 곧 구현됩니다',
|
||||
extension_installed_version: '설치된 버전',
|
||||
extension_uninstall_warning:
|
||||
'모든 사용자들로부터 이 확장 기능을 제거한다는 점에 유의하세요.',
|
||||
uninstall_confirm: '네, 삭제합니다',
|
||||
extension_db_drop_info:
|
||||
'해당 확장 기능의 모든 데이터가 영구적으로 삭제됩니다. 작업 수행 후에는 되돌릴 수 없습니다!',
|
||||
extension_db_drop_warning:
|
||||
'해당 확장 기능의 모든 데이터가 영구적으로 삭제될 겁니다. 계속하려면 확장 기능의 이름을 입력해주세요:',
|
||||
extension_min_lnbits_version:
|
||||
'이 배포 버전은 더 높은 버전의 lnbits가 설치되어 있어야 합니다.',
|
||||
payment_hash: '결제 해쉬값',
|
||||
fee: '수수료',
|
||||
amount: '액수',
|
||||
unit: '단위',
|
||||
description: '상세',
|
||||
expiry: '만료',
|
||||
webhook: 'Webhook',
|
||||
payment_proof: 'Payment 증거',
|
||||
update_available: '%{version}으로 업데이트가 가능합니다.',
|
||||
latest_update: '이미 %{version} 버전으로 업데이트되었습니다.',
|
||||
notifications: '알림',
|
||||
no_notifications: '알림 없음',
|
||||
notifications_disabled: 'LNbits 상태 알림이 비활성화되었습니다.',
|
||||
enable_notifications: '알림 활성화',
|
||||
enable_notifications_desc:
|
||||
'활성화 시, 가장 최신의 보안 사고나 소프트웨어 업데이트 등의 LNbits 상황 업데이트를 불러옵니다.',
|
||||
enable_killswitch: '비상 정지 활성화',
|
||||
enable_killswitch_desc:
|
||||
'활성화 시, LNbits 메인 서버에서 비상 정지 신호를 보내면 자동으로 자금의 원천을 VoidWallet으로 변경합니다. 업데이트 이후 수동으로 활성화해 주어야 합니다.',
|
||||
killswitch_interval: '비상 정지 시간 간격',
|
||||
killswitch_interval_desc:
|
||||
'LNbits 메인 서버에서 나오는 비상 정지 신호를 백그라운드 작업으로 얼마나 자주 확인할 것인지를 결정합니다. (분 단위)',
|
||||
enable_watchdog: '와치독 활성화',
|
||||
enable_watchdog_desc:
|
||||
'활성화 시, LNbits 잔금보다 당신의 잔금이 지정한 수준보다 더 낮아질 경우 자동으로 자금의 원천을 VoidWallet으로 변경합니다. 업데이트 이후 수동으로 활성화해 주어야 합니다.',
|
||||
watchdog_interval: '와치독 시간 간격',
|
||||
watchdog_interval_desc:
|
||||
'와치독 델타 값을 기반으로 하여 당신의 LNbits 서버에서 나오는 비상 정지 신호를 백그라운드 작업으로 얼마나 자주 확인할 것인지를 결정합니다. (분 단위)',
|
||||
watchdog_delta: '와치독 델타',
|
||||
watchdog_delta_desc:
|
||||
'당신의 자금 원천을 VoidWallet으로 변경하기까지의 기준 값 [LNbits 잔액 - 노드 잔액 > 델타 값]',
|
||||
status: '상황',
|
||||
notification_source: '알림 메세지 출처',
|
||||
notification_source_label:
|
||||
'알림 메세지를 가져올 URL (공식 LNbits 상황판 출처나, 당신이 신뢰할 수 있는 출처만을 사용하세요)',
|
||||
more: '더 알아보기',
|
||||
releases: '배포 버전들',
|
||||
killswitch: '비상 정지',
|
||||
watchdog: '와치독',
|
||||
server_logs: '서버 로그',
|
||||
ip_blocker: 'IP 기반 차단기',
|
||||
security: '보안',
|
||||
security_tools: '보안 도구들',
|
||||
block_access_hint: 'IP 기준으로 접속 차단하기',
|
||||
allow_access_hint: 'IP 기준으로 접속 허용하기 (차단한 IP들을 무시합니다)',
|
||||
enter_ip: 'IP 주소를 입력하고 Enter를 눌러주세요',
|
||||
rate_limiter: '횟수로 제한하기',
|
||||
number_of_requests: '요청 횟수',
|
||||
time_unit: '시간 단위',
|
||||
minute: '분',
|
||||
second: '초',
|
||||
hour: '시간',
|
||||
disable_server_log: '서버 로깅 중단하기',
|
||||
enable_server_log: '서버 로깅 활성화하기',
|
||||
coming_soon: '곧 구현될 기능들입니다'
|
||||
}
|
||||
@@ -19,7 +19,7 @@ window.localisation.nl = {
|
||||
name_your_wallet: 'Geef je %{name} portemonnee een naam',
|
||||
paste_invoice_label: 'Plak een factuur, betalingsverzoek of lnurl-code*',
|
||||
lnbits_description:
|
||||
'Gemakkelijk in te stellen en lichtgewicht, LNbits kan op elke lightning-netwerkfinancieringsbron draaien, ondersteunt op dit moment LND, Core Lightning, OpenNode, LNPay en zelfs LNbits zelf! U kunt LNbits voor uzelf laten draaien of gemakkelijk een bewaardersoplossing voor anderen bieden. Elke portemonnee heeft zijn eigen API-sleutels en er is geen limiet aan het aantal portemonnees dat u kunt maken. Het kunnen partitioneren van fondsen maakt LNbits een nuttige tool voor geldbeheer en als ontwikkelingstool. Extensies voegen extra functionaliteit toe aan LNbits, zodat u kunt experimenteren met een reeks toonaangevende technologieën op het bliksemschichtnetwerk. We hebben het ontwikkelen van extensies zo eenvoudig mogelijk gemaakt en als een gratis en opensource-project moedigen we mensen aan om hun eigen ontwikkelingen in te dienen.',
|
||||
'Gemakkelijk in te stellen en lichtgewicht, LNbits kan op elke lightning-netwerkfinancieringsbron draaien, ondersteunt op dit moment LND, Core Lightning, OpenNode, Alby, LNPay en zelfs LNbits zelf! U kunt LNbits voor uzelf laten draaien of gemakkelijk een bewaardersoplossing voor anderen bieden. Elke portemonnee heeft zijn eigen API-sleutels en er is geen limiet aan het aantal portemonnees dat u kunt maken. Het kunnen partitioneren van fondsen maakt LNbits een nuttige tool voor geldbeheer en als ontwikkelingstool. Extensies voegen extra functionaliteit toe aan LNbits, zodat u kunt experimenteren met een reeks toonaangevende technologieën op het bliksemschichtnetwerk. We hebben het ontwikkelen van extensies zo eenvoudig mogelijk gemaakt en als een gratis en opensource-project moedigen we mensen aan om hun eigen ontwikkelingen in te dienen.',
|
||||
export_to_phone: 'Exporteren naar telefoon met QR-code',
|
||||
export_to_phone_desc:
|
||||
'Deze QR-code bevat uw portemonnee-URL met volledige toegang. U kunt het vanaf uw telefoon scannen om uw portemonnee van daaruit te openen.',
|
||||
@@ -39,7 +39,6 @@ window.localisation.nl = {
|
||||
toggle_darkmode: 'Donkere modus aan/uit',
|
||||
view_swagger_docs: 'Bekijk LNbits Swagger API-documentatie',
|
||||
api_docs: 'API-documentatie',
|
||||
commit_version: 'Commit-versie',
|
||||
lnbits_version: 'LNbits-versie',
|
||||
runs_on: 'Draait op',
|
||||
credit_hint: 'Druk op Enter om de rekening te crediteren',
|
||||
@@ -76,8 +75,7 @@ window.localisation.nl = {
|
||||
disclaimer_dialog:
|
||||
'Inlogfunctionaliteit wordt uitgebracht in een toekomstige update. Zorg er nu voor dat je deze pagina als favoriet markeert om in de toekomst toegang te krijgen tot je portemonnee! Deze service is in BETA en we zijn niet verantwoordelijk voor mensen die de toegang tot hun fondsen verliezen.',
|
||||
no_transactions: 'Er zijn nog geen transacties gedaan',
|
||||
manage_extensions: 'Beheer extensies',
|
||||
manage_server: 'Beheer server',
|
||||
manage: 'Beheer',
|
||||
extensions: 'Extensies',
|
||||
no_extensions: 'Je hebt geen extensies geïnstalleerd :(',
|
||||
created: 'Aangemaakt',
|
||||
|
||||
@@ -18,7 +18,7 @@ window.localisation.pi = {
|
||||
name_your_wallet: 'Name yer %{name} treasure chest',
|
||||
paste_invoice_label: 'Paste a booty, payment request or lnurl code, matey!',
|
||||
lnbits_description:
|
||||
'Arr, easy to set up and lightweight, LNbits can run on any lightning-network funding source, currently supporting LND, Core Lightning, OpenNode, LNPay and even LNbits itself! Ye can run LNbits for yourself, or easily offer a custodian solution for others. Each chest has its own API keys and there be no limit to the number of chests ye can make. Being able to partition booty makes LNbits a useful tool for money management and as a development tool. Arr, extensions add extra functionality to LNbits so ye can experiment with a range of cutting-edge technologies on the lightning network. We have made developing extensions as easy as possible, and as a free and open-source project, we encourage scallywags to develop and submit their own.',
|
||||
'Arr, easy to set up and lightweight, LNbits can run on any lightning-network funding source, currently supporting LND, Core Lightning, OpenNode, Alby, LNPay and even LNbits itself! Ye can run LNbits for yourself, or easily offer a custodian solution for others. Each chest has its own API keys and there be no limit to the number of chests ye can make. Being able to partition booty makes LNbits a useful tool for money management and as a development tool. Arr, extensions add extra functionality to LNbits so ye can experiment with a range of cutting-edge technologies on the lightning network. We have made developing extensions as easy as possible, and as a free and open-source project, we encourage scallywags to develop and submit their own.',
|
||||
export_to_phone: 'Export to Phone with QR Code, me hearties',
|
||||
export_to_phone_desc:
|
||||
'This QR code contains yer chest URL with full access. Ye can scan it from yer phone to open yer chest from there, arr!',
|
||||
@@ -38,7 +38,6 @@ window.localisation.pi = {
|
||||
toggle_darkmode: 'Toggle Dark Mode, arr!',
|
||||
view_swagger_docs: 'View LNbits Swagger API docs and learn the secrets',
|
||||
api_docs: 'API docs for the scallywags',
|
||||
commit_version: 'Commit version like a true pirate',
|
||||
lnbits_version: 'LNbits version, arr!',
|
||||
runs_on: 'Runs on, matey',
|
||||
credit_hint: 'Press Enter to credit account and make it richer',
|
||||
@@ -76,8 +75,7 @@ window.localisation.pi = {
|
||||
disclaimer_dialog:
|
||||
'Login functionality to be released in a future update, for now, make sure ye bookmark this page for future access to your booty! This service be in BETA, and we hold no responsibility for people losing access to doubloons.',
|
||||
no_transactions: 'No transactions made yet, me hearties. Belay that!',
|
||||
manage_extensions: 'Manage Yer Extensions, ye landlubber',
|
||||
manage_server: 'Manage Yer Server, me hearty',
|
||||
manage: 'Manage, me hearty',
|
||||
extensions: 'Yer Extensions, ye scurvy dog',
|
||||
no_extensions:
|
||||
"Ye don't have any extensions installed, ye scallywag :(. Where be yer loot?",
|
||||
|
||||
@@ -17,7 +17,7 @@ window.localisation.pl = {
|
||||
name_your_wallet: 'Nazwij swój portfel %{name}',
|
||||
paste_invoice_label: 'Wklej fakturę, żądanie zapłaty lub kod lnurl *',
|
||||
lnbits_description:
|
||||
'Łatwy i lekki w konfiguracji, LNbits może działać w oparciu o dowolne źródło finansowania w sieci lightning, obecnie wspiera LND, Core Lightning, OpenNode, LNPay czy nawet inną instancję LNbits! Możesz uruchomić instancję LNbits dla siebie lub dla innych. Każdy portfel ma swoje klucze API i nie ma ograniczeń jeśli chodzi o ilość portfeli. LNbits umożliwia dzielenie środków w celu zarządzania nimi, jest również dobrym narzędziem deweloperskim. Rozszerzenia zwiększają funkcjonalność LNbits co umożliwia eksperymentowanie z nowym technologiami w sieci lightning. Tworzenie rozszerzeń jest proste dlatego zachęcamy innych deweloperów do tworzenia dodatkowych funkcjonalności i wysyłanie do nas PR',
|
||||
'Łatwy i lekki w konfiguracji, LNbits może działać w oparciu o dowolne źródło finansowania w sieci lightning, obecnie wspiera LND, Core Lightning, OpenNode, Alby, LNPay czy nawet inną instancję LNbits! Możesz uruchomić instancję LNbits dla siebie lub dla innych. Każdy portfel ma swoje klucze API i nie ma ograniczeń jeśli chodzi o ilość portfeli. LNbits umożliwia dzielenie środków w celu zarządzania nimi, jest również dobrym narzędziem deweloperskim. Rozszerzenia zwiększają funkcjonalność LNbits co umożliwia eksperymentowanie z nowym technologiami w sieci lightning. Tworzenie rozszerzeń jest proste dlatego zachęcamy innych deweloperów do tworzenia dodatkowych funkcjonalności i wysyłanie do nas PR',
|
||||
export_to_phone: 'Eksport kodu QR na telefon',
|
||||
export_to_phone_desc:
|
||||
'Ten kod QR zawiera adres URL Twojego portfela z pełnym dostępem do niego. Możesz go zeskanować na swoim telefonie aby otworzyć na nim ten portfel.',
|
||||
@@ -36,7 +36,6 @@ window.localisation.pl = {
|
||||
toggle_darkmode: 'Tryb nocny',
|
||||
view_swagger_docs: 'Dokumentacja Swagger API',
|
||||
api_docs: 'Dokumentacja API',
|
||||
commit_version: 'Commit',
|
||||
lnbits_version: 'Wersja LNbits',
|
||||
runs_on: 'Działa na',
|
||||
credit_hint: 'Naciśnij Enter aby doładować konto',
|
||||
@@ -73,8 +72,7 @@ window.localisation.pl = {
|
||||
disclaimer_dialog:
|
||||
'Funkcja logowania zostanie uruchomiona w przyszłości. Póki co upewnij się, że zapisałeś adres URL tej strony aby mieć dostęp do tego portfela. Nie udostępniaj adresu tej strony nikomu, kto nie ma mieć do tego portfela dostępu! Ta usługa działa w wersji BETA, nie odpowiadamy za utratę dostępu do środków przez osoby używające LNbits.',
|
||||
no_transactions: 'Brak transakcji',
|
||||
manage_extensions: 'Zarządzaj rozszerzeniami',
|
||||
manage_server: 'Zarządzaj serwerem',
|
||||
manage: 'Zarządzaj',
|
||||
extensions: 'Rozszerzenia',
|
||||
no_extensions: 'Nie masz zainstalowanych żadnych rozszerzeń :(',
|
||||
created: 'Utworzono',
|
||||
|
||||
@@ -18,7 +18,7 @@ window.localisation.pt = {
|
||||
name_your_wallet: 'Nomeie sua carteira %{name}',
|
||||
paste_invoice_label: 'Cole uma fatura, pedido de pagamento ou código lnurl *',
|
||||
lnbits_description:
|
||||
'Fácil de configurar e leve, o LNbits pode ser executado em qualquer fonte de financiamento da lightning-network, atualmente suportando LND, c-lightning, OpenNode, LNPay e até mesmo o LNbits em si! Você pode executar o LNbits para si mesmo ou oferecer facilmente uma solução de custódia para outros. Cada carteira tem suas próprias chaves de API e não há limite para o número de carteiras que você pode criar. Ser capaz de particionar fundos torna o LNbits uma ferramenta útil para gerenciamento de dinheiro e como uma ferramenta de desenvolvimento. As extensões adicionam funcionalidades extras ao LNbits para que você possa experimentar uma série de tecnologias de ponta na rede lightning. Nós tornamos o desenvolvimento de extensões o mais fácil possível e, como um projeto gratuito e de código aberto, incentivamos as pessoas a desenvolver e enviar as suas próprias.',
|
||||
'Fácil de configurar e leve, o LNbits pode ser executado em qualquer fonte de financiamento da lightning-network, atualmente suportando LND, c-lightning, OpenNode, Alby, LNPay e até mesmo o LNbits em si! Você pode executar o LNbits para si mesmo ou oferecer facilmente uma solução de custódia para outros. Cada carteira tem suas próprias chaves de API e não há limite para o número de carteiras que você pode criar. Ser capaz de particionar fundos torna o LNbits uma ferramenta útil para gerenciamento de dinheiro e como uma ferramenta de desenvolvimento. As extensões adicionam funcionalidades extras ao LNbits para que você possa experimentar uma série de tecnologias de ponta na rede lightning. Nós tornamos o desenvolvimento de extensões o mais fácil possível e, como um projeto gratuito e de código aberto, incentivamos as pessoas a desenvolver e enviar as suas próprias.',
|
||||
export_to_phone: 'Exportar para o telefone com código QR',
|
||||
export_to_phone_desc:
|
||||
'Este código QR contém a URL da sua carteira com acesso total. Você pode escaneá-lo do seu telefone para abrir sua carteira a partir dele.',
|
||||
@@ -37,7 +37,6 @@ window.localisation.pt = {
|
||||
toggle_darkmode: 'Alternar modo escuro',
|
||||
view_swagger_docs: 'Ver a documentação da API do LNbits Swagger',
|
||||
api_docs: 'Documentação da API',
|
||||
commit_version: 'Versão de commit',
|
||||
lnbits_version: 'Versão do LNbits',
|
||||
runs_on: 'Executa em',
|
||||
credit_hint: 'Pressione Enter para creditar a conta',
|
||||
@@ -74,8 +73,7 @@ window.localisation.pt = {
|
||||
disclaimer_dialog:
|
||||
'Funcionalidade de login a ser lançada numa atualização futura, por enquanto, certifique-se que marca esta página para acesso futuro à sua carteira! Este serviço está em BETA, e não nos responsabilizamos por pessoas que perderem o acesso aos fundos.',
|
||||
no_transactions: 'Ainda não foram feitas transações',
|
||||
manage_extensions: 'Gerir extensões',
|
||||
manage_server: 'Gerir servidor',
|
||||
manage: 'Gerir',
|
||||
extensions: 'Extensões',
|
||||
no_extensions: 'Não há nenhuma extensão instalada :(',
|
||||
created: 'Criado',
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
window.localisation.sk = {
|
||||
confirm: 'Áno',
|
||||
server: 'Server',
|
||||
theme: 'Téma',
|
||||
funding: 'Financovanie',
|
||||
users: 'Používatelia',
|
||||
apps: 'Aplikácie',
|
||||
channels: 'Kanály',
|
||||
transactions: 'Transakcie',
|
||||
dashboard: 'Prehľad',
|
||||
manage_node: 'Spravovať uzol',
|
||||
total_capacity: 'Celková kapacita',
|
||||
avg_channel_size: 'Priemerná veľkosť kanálu',
|
||||
biggest_channel_size: 'Najväčší kanál',
|
||||
smallest_channel_size: 'Najmenší kanál',
|
||||
number_of_channels: 'Počet kanálov',
|
||||
active_channels: 'Aktívne kanály',
|
||||
connect_peer: 'Pripojiť peer',
|
||||
connect: 'Pripojiť',
|
||||
open_channel: 'Otvoriť kanál',
|
||||
open: 'Otvoriť',
|
||||
close_channel: 'Zatvoriť kanál',
|
||||
close: 'Zatvoriť',
|
||||
restart: 'Reštartovať server',
|
||||
save: 'Uložiť',
|
||||
save_tooltip: 'Uložiť vaše zmeny',
|
||||
topup: 'Doplniť',
|
||||
topup_wallet: 'Doplniť peňaženku',
|
||||
topup_hint: 'Použite ID peňaženky na doplnenie ľubovoľnej peňaženky',
|
||||
restart_tooltip: 'Pre prejavenie zmien reštartujte server',
|
||||
add_funds_tooltip: 'Pridať prostriedky do peňaženky.',
|
||||
reset_defaults: 'Obnoviť predvolené',
|
||||
reset_defaults_tooltip: 'Odstrániť všetky nastavenia a obnoviť predvolené.',
|
||||
download_backup: 'Stiahnuť zálohu databázy',
|
||||
name_your_wallet: 'Pomenujte vašu %{name} peňaženku',
|
||||
paste_invoice_label: 'Vložte faktúru, platobnú požiadavku alebo lnurl kód *',
|
||||
lnbits_description:
|
||||
'Ľahko nastaviteľný a ľahkotonážny, LNbits môže bežať na akomkoľvek zdroji financovania lightning-network, momentálne podporuje LND, Core Lightning, OpenNode, Alby, LNPay a dokonca LNbits samotný! LNbits môžete používať pre seba, alebo ľahko ponúknuť správcovské riešenie pre iných. Každá peňaženka má svoje vlastné API kľúče a nie je limit na počet peňaženiek, ktoré môžete vytvoriť. Schopnosť rozdeľovať finančné prostriedky robí z LNbits užitočný nástroj pre správu peňazí a ako vývojový nástroj. Rozšírenia pridávajú extra funkčnosť do LNbits, takže môžete experimentovať s radou najnovších technológií na lightning sieti. Vývoj rozšírení sme urobili čo najjednoduchší a ako voľný a open-source projekt, podporujeme ľudí vývoj a odovzdávanie vlastných rozšírení.',
|
||||
export_to_phone: 'Exportovať do telefónu s QR kódom',
|
||||
export_to_phone_desc:
|
||||
'Tento QR kód obsahuje URL vašej peňaženky s plným prístupom. Môžete ho naskenovať z vášho telefónu a otvoriť vašu peňaženku odtiaľ.',
|
||||
wallets: 'Peňaženky',
|
||||
add_wallet: 'Pridať novú peňaženku',
|
||||
delete_wallet: 'Zmazať peňaženku',
|
||||
delete_wallet_desc:
|
||||
'Celá peňaženka bude zmazaná, prostriedky budú NEOBNOVITEĽNÉ.',
|
||||
rename_wallet: 'Premenovať peňaženku',
|
||||
update_name: 'Aktualizovať meno',
|
||||
fiat_tracking: 'Sledovanie fiat',
|
||||
currency: 'Mena',
|
||||
update_currency: 'Aktualizovať menu',
|
||||
press_to_claim: 'Stlačte pre nárok na bitcoin',
|
||||
donate: 'Prispieť',
|
||||
view_github: 'Zobraziť na GitHube',
|
||||
voidwallet_active: 'VoidWallet je aktívny! Platby zakázané',
|
||||
use_with_caution: 'POUŽÍVAJTE OPATRNE - %{name} peňaženka je stále v BETE',
|
||||
toggle_darkmode: 'Prepnúť Tmavý režim',
|
||||
view_swagger_docs: 'Zobraziť LNbits Swagger API dokumentáciu',
|
||||
api_docs: 'API dokumentácia',
|
||||
lnbits_version: 'Verzia LNbits',
|
||||
runs_on: 'Beží na',
|
||||
credit_hint: 'Stlačte Enter pre pripísanie na účet',
|
||||
credit_label: '%{denomination} na pripísanie',
|
||||
paste: 'Vložiť',
|
||||
paste_from_clipboard: 'Vložiť zo schránky',
|
||||
paste_request: 'Vložiť požiadavku',
|
||||
create_invoice: 'Vytvoriť faktúru',
|
||||
camera_tooltip: 'Použite kameru na naskenovanie faktúry/QR',
|
||||
export_csv: 'Exportovať do CSV',
|
||||
transactions: 'Transakcie',
|
||||
chart_tooltip: 'Zobraziť graf',
|
||||
pending: 'Čakajúce',
|
||||
copy_invoice: 'Kopírovať faktúru',
|
||||
close: 'Zatvoriť',
|
||||
cancel: 'Zrušiť',
|
||||
scan: 'Skenovať',
|
||||
read: 'Čítať',
|
||||
pay: 'Platiť',
|
||||
memo: 'Poznámka',
|
||||
date: 'Dátum',
|
||||
processing_payment: 'Spracovávanie platby...',
|
||||
not_enough_funds: 'Nedostatok prostriedkov!',
|
||||
search_by_tag_memo_amount: 'Vyhľadať podľa značky, poznámky, sumy',
|
||||
invoice_waiting: 'Faktúra čakajúca na zaplatenie',
|
||||
payment_received: 'Platba prijatá',
|
||||
payment_sent: 'Platba odoslaná',
|
||||
receive: 'prijímať',
|
||||
send: 'posielať',
|
||||
outgoing_payment_pending: 'Odchádzajúca platba čaká',
|
||||
drain_funds: 'Vyprázdniť prostriedky',
|
||||
drain_funds_desc:
|
||||
'Toto je LNURL-withdraw QR kód pre vyprázdnienie všetkého z tejto peňaženky. S nikým ho nezdieľajte. Je kompatibilný s balanceCheck a balanceNotify, takže vaša peňaženka môže naďalej kontinuálne vyťahovať prostriedky odtiaľto po prvom výbere.',
|
||||
i_understand: 'Rozumiem',
|
||||
copy_wallet_url: 'Kopírovať URL peňaženky',
|
||||
disclaimer_dialog:
|
||||
'Funkcionalita prihlásenia bude vydaná v budúcej aktualizácii, zatiaľ si uistite, že ste si túto stránku pridali medzi záložky pre budúci prístup k vašej peňaženke! Táto služba je v BETA verzii a nenesieme zodpovednosť za stratu prístupu k prostriedkom.',
|
||||
no_transactions: 'Zatiaľ žiadne transakcie',
|
||||
manage: 'Spravovať',
|
||||
extensions: 'Rozšírenia',
|
||||
no_extensions: 'Nemáte nainštalované žiadne rozšírenia :(',
|
||||
created: 'Vytvorené',
|
||||
search_extensions: 'Hľadať rozšírenia',
|
||||
warning: 'Upozornenie',
|
||||
repository: 'Repozitár',
|
||||
confirm_continue: 'Ste si istí, že chcete pokračovať?',
|
||||
manage_extension_details: 'Inštalovať/odinštalovať rozšírenie',
|
||||
install: 'Inštalovať',
|
||||
uninstall: 'Odinštalovať',
|
||||
drop_db: 'Odstrániť údaje',
|
||||
open: 'Otvoriť',
|
||||
enable: 'Povoliť',
|
||||
enable_extension_details: 'Povoliť rozšírenie pre aktuálneho používateľa',
|
||||
disable: 'Zakázať',
|
||||
installed: 'Nainštalované',
|
||||
activated: 'Aktivované',
|
||||
deactivated: 'Deaktivované',
|
||||
release_notes: 'Poznámky k vydaniu',
|
||||
activate_extension_details:
|
||||
'Sprístupniť/neprístupniť rozšírenie pre používateľov',
|
||||
featured: 'Odporúčané',
|
||||
all: 'Všetky',
|
||||
only_admins_can_install:
|
||||
'(Iba administrátorské účty môžu inštalovať rozšírenia)',
|
||||
admin_only: 'Iba pre administrátorov',
|
||||
new_version: 'Nová verzia',
|
||||
extension_depends_on: 'Závisí na:',
|
||||
extension_rating_soon: 'Hodnotenia budú čoskoro dostupné',
|
||||
extension_installed_version: 'Nainštalovaná verzia',
|
||||
extension_uninstall_warning:
|
||||
'Chystáte sa odstrániť rozšírenie pre všetkých používateľov.',
|
||||
uninstall_confirm: 'Áno, Odinštalovať',
|
||||
extension_db_drop_info:
|
||||
'Všetky údaje pre rozšírenie budú trvalo vymazané. Túto operáciu nie je možné vrátiť!',
|
||||
extension_db_drop_warning:
|
||||
'Chystáte sa odstrániť všetky údaje pre rozšírenie. Pre pokračovanie prosím napíšte názov rozšírenia:',
|
||||
extension_min_lnbits_version: 'Toto vydanie vyžaduje aspoň verziu LNbits',
|
||||
payment_hash: 'Hash platby',
|
||||
fee: 'Poplatok',
|
||||
amount: 'Suma',
|
||||
unit: 'Jednotka',
|
||||
description: 'Popis',
|
||||
expiry: 'Expirácia',
|
||||
webhook: 'Webhook',
|
||||
payment_proof: 'Dôkaz platby',
|
||||
update_available: 'Dostupná aktualizácia %{version}!',
|
||||
latest_update: 'Máte najnovšiu verziu %{version}.',
|
||||
notifications: 'Notifikácie',
|
||||
no_notifications: 'Žiadne notifikácie',
|
||||
notifications_disabled: 'Notifikácie stavu LNbits sú zakázané.',
|
||||
enable_notifications: 'Povoliť Notifikácie',
|
||||
enable_notifications_desc:
|
||||
'Ak povolené, budú sa načítavať najnovšie aktualizácie stavu LNbits, ako sú bezpečnostné incidenty a aktualizácie.',
|
||||
enable_killswitch: 'Povoliť Killswitch',
|
||||
enable_killswitch_desc:
|
||||
'Ak povolené, vaš zdroj financovania sa automaticky zmení na VoidWallet, ak LNbits vysielajú signál killswitch. Po aktualizácii bude treba povoliť manuálne.',
|
||||
killswitch_interval: 'Interval Killswitch',
|
||||
killswitch_interval_desc:
|
||||
'Ako často by malo pozadie kontrolovať signál killswitch od LNbits zo zdroja stavu (v minútach).',
|
||||
enable_watchdog: 'Povoliť Watchdog',
|
||||
enable_watchdog_desc:
|
||||
'Ak povolené, vaš zdroj financovania sa automaticky zmení na VoidWallet, ak je váš zostatok nižší ako zostatok LNbits. Po aktualizácii bude treba povoliť manuálne.',
|
||||
watchdog_interval: 'Interval Watchdog',
|
||||
watchdog_interval_desc:
|
||||
'Ako často by malo pozadie kontrolovať signál killswitch v watchdog delta [node_balance - lnbits_balance] (v minútach).',
|
||||
watchdog_delta: 'Delta Watchdog',
|
||||
watchdog_delta_desc:
|
||||
'Limit pred zmenou zdroja financovania na VoidWallet [lnbits_balance - node_balance > delta]',
|
||||
status: 'Stav',
|
||||
notification_source: 'Zdroj notifikácií',
|
||||
notification_source_label:
|
||||
'URL zdroja (používajte len oficiálny LNbits zdroj stavu a zdroje, ktorým môžete dôverovať)',
|
||||
more: 'viac',
|
||||
releases: 'Vydania',
|
||||
killswitch: 'Killswitch',
|
||||
watchdog: 'Watchdog',
|
||||
server_logs: 'Logy servera',
|
||||
ip_blocker: 'Blokovanie IP',
|
||||
security: 'Bezpečnosť',
|
||||
security_tools: 'Nástroje bezpečnosti',
|
||||
block_access_hint: 'Blokovať prístup podľa IP',
|
||||
allow_access_hint: 'Povoliť prístup podľa IP (prebije blokované IP)',
|
||||
enter_ip: 'Zadajte IP a stlačte enter',
|
||||
rate_limiter: 'Obmedzovač počtu požiadaviek',
|
||||
number_of_requests: 'Počet požiadaviek',
|
||||
time_unit: 'Časová jednotka',
|
||||
minute: 'minúta',
|
||||
second: 'sekunda',
|
||||
hour: 'hodina',
|
||||
disable_server_log: 'Zakázať Log servera',
|
||||
enable_server_log: 'Povoliť Log servera',
|
||||
coming_soon: 'Funkcia bude čoskoro dostupná'
|
||||
}
|
||||
@@ -17,7 +17,7 @@ window.localisation.we = {
|
||||
name_your_wallet: 'Enwch eich waled %{name}',
|
||||
paste_invoice_label: 'Gludwch anfoneb, cais am daliad neu god lnurl *',
|
||||
lnbits_description:
|
||||
'Yn hawdd iw sefydlu ac yn ysgafn, gall LNbits redeg ar unrhyw ffynhonnell ariannu rhwydwaith mellt, ar hyn o bryd yn cefnogi LND, Core Lightning, OpenNode, LNPay a hyd yn oed LNbits ei hun! Gallwch redeg LNbits i chi`ch hun, neu gynnig datrysiad ceidwad i eraill yn hawdd. Mae gan bob waled ei allweddi API ei hun ac nid oes cyfyngiad ar nifer y waledi y gallwch eu gwneud. Mae gallu rhannu cronfeydd yn gwneud LNbits yn arf defnyddiol ar gyfer rheoli arian ac fel offeryn datblygu. Mae estyniadau yn ychwanegu ymarferoldeb ychwanegol at LNbits fel y gallwch arbrofi gydag ystod o dechnolegau blaengar ar y rhwydwaith mellt. Rydym wedi gwneud datblygu estyniadau mor hawdd â phosibl, ac fel prosiect ffynhonnell agored am ddim, rydym yn annog pobl i ddatblygu a chyflwyno eu rhai eu hunain.',
|
||||
'Yn hawdd iw sefydlu ac yn ysgafn, gall LNbits redeg ar unrhyw ffynhonnell ariannu rhwydwaith mellt, ar hyn o bryd yn cefnogi LND, Core Lightning, OpenNode, Alby, LNPay a hyd yn oed LNbits ei hun! Gallwch redeg LNbits i chi`ch hun, neu gynnig datrysiad ceidwad i eraill yn hawdd. Mae gan bob waled ei allweddi API ei hun ac nid oes cyfyngiad ar nifer y waledi y gallwch eu gwneud. Mae gallu rhannu cronfeydd yn gwneud LNbits yn arf defnyddiol ar gyfer rheoli arian ac fel offeryn datblygu. Mae estyniadau yn ychwanegu ymarferoldeb ychwanegol at LNbits fel y gallwch arbrofi gydag ystod o dechnolegau blaengar ar y rhwydwaith mellt. Rydym wedi gwneud datblygu estyniadau mor hawdd â phosibl, ac fel prosiect ffynhonnell agored am ddim, rydym yn annog pobl i ddatblygu a chyflwyno eu rhai eu hunain.',
|
||||
export_to_phone: 'Allforio i Ffôn gyda chod QR',
|
||||
export_to_phone_desc:
|
||||
'Mae`r cod QR hwn yn cynnwys URL eich waled gyda mynediad llawn. Gallwch ei sganio o`ch ffôn i agor eich waled oddi yno.',
|
||||
@@ -36,8 +36,7 @@ window.localisation.we = {
|
||||
use_with_caution: 'DEFNYDDIO GYDA GOFAL - mae waled %{name} yn dal yn BETA',
|
||||
toggle_darkmode: 'Toglo Modd Tywyll',
|
||||
view_swagger_docs: 'Gweld dogfennau API LNbits Swagger',
|
||||
api_docs: 'Api docs',
|
||||
commit_version: 'fersiwn ymrwymo',
|
||||
api_docs: 'API docs',
|
||||
lnbits_version: 'Fersiwn LNbits',
|
||||
Runs_on: 'Yn rhedeg ymlaen',
|
||||
credit_hint: 'Pwyswch Enter i gyfrif credyd',
|
||||
@@ -74,8 +73,7 @@ window.localisation.we = {
|
||||
disclaimer_dialog:
|
||||
'Swyddogaeth mewngofnodi i`w ryddhau mewn diweddariad yn y dyfodol, am y tro, gwnewch yn siŵr eich bod yn rhoi nod tudalen ar y dudalen hon ar gyfer mynediad i`ch waled yn y dyfodol! Mae`r gwasanaeth hwn yn BETA, ac nid ydym yn gyfrifol am bobl sy`n colli mynediad at arian.',
|
||||
no_transactions: 'Dim trafodion wedi`u gwneud eto',
|
||||
manage_extensions: 'Rheoli Estyniadau',
|
||||
manage_server: 'Rheoli Gweinydd',
|
||||
manage: 'Rheoli',
|
||||
Extensions: 'Estyniadau',
|
||||
no_extensions: "Nid oes gennych unrhyw estyniadau wedi'u gosod :(",
|
||||
create: 'Crëwyd',
|
||||
|
||||
|
Before Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,4 @@
|
||||
<svg width="900" height="900" viewBox="0 0 900 900" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="450" cy="450" r="450" fill="#1E1E1E"/>
|
||||
<path d="M387.802 476.423L281.613 730.887L584.54 389.763H459.418L619.113 168.387H407.558L305.485 476.423H387.802Z" fill="#FF1FE1"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 290 B |
@@ -0,0 +1,316 @@
|
||||
new Vue({
|
||||
el: '#vue',
|
||||
mixins: [windowMixin],
|
||||
data: function () {
|
||||
return {
|
||||
settings: {},
|
||||
logs: [],
|
||||
serverlogEnabled: false,
|
||||
lnbits_theme_options: [
|
||||
'classic',
|
||||
'bitcoin',
|
||||
'flamingo',
|
||||
'cyber',
|
||||
'freedom',
|
||||
'mint',
|
||||
'autumn',
|
||||
'monochrome',
|
||||
'salvador'
|
||||
],
|
||||
auditData: {},
|
||||
statusData: {},
|
||||
statusDataTable: {
|
||||
columns: [
|
||||
{
|
||||
name: 'date',
|
||||
align: 'left',
|
||||
label: this.$t('date'),
|
||||
field: 'date'
|
||||
},
|
||||
{
|
||||
name: 'message',
|
||||
align: 'left',
|
||||
label: this.$t('memo'),
|
||||
field: 'message'
|
||||
}
|
||||
]
|
||||
},
|
||||
formData: {},
|
||||
formAddAdmin: '',
|
||||
formAddUser: '',
|
||||
formAddExtensionsManifest: '',
|
||||
formAllowedIPs: '',
|
||||
formBlockedIPs: '',
|
||||
isSuperUser: false,
|
||||
wallet: {},
|
||||
cancel: {},
|
||||
topUpDialog: {
|
||||
show: false
|
||||
},
|
||||
tab: 'funding',
|
||||
needsRestart: false
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.getSettings()
|
||||
this.getAudit()
|
||||
this.balance = +'{{ balance|safe }}'
|
||||
},
|
||||
computed: {
|
||||
lnbitsVersion() {
|
||||
return LNBITS_VERSION
|
||||
},
|
||||
checkChanges() {
|
||||
return !_.isEqual(this.settings, this.formData)
|
||||
},
|
||||
updateAvailable() {
|
||||
return LNBITS_VERSION !== this.statusData.version
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
addAdminUser() {
|
||||
let addUser = this.formAddAdmin
|
||||
let admin_users = this.formData.lnbits_admin_users
|
||||
if (addUser && addUser.length && !admin_users.includes(addUser)) {
|
||||
//admin_users = [...admin_users, addUser]
|
||||
this.formData.lnbits_admin_users = [...admin_users, addUser]
|
||||
this.formAddAdmin = ''
|
||||
}
|
||||
},
|
||||
removeAdminUser(user) {
|
||||
let admin_users = this.formData.lnbits_admin_users
|
||||
this.formData.lnbits_admin_users = admin_users.filter(u => u !== user)
|
||||
},
|
||||
addAllowedUser() {
|
||||
let addUser = this.formAddUser
|
||||
let allowed_users = this.formData.lnbits_allowed_users
|
||||
if (addUser && addUser.length && !allowed_users.includes(addUser)) {
|
||||
this.formData.lnbits_allowed_users = [...allowed_users, addUser]
|
||||
this.formAddUser = ''
|
||||
}
|
||||
},
|
||||
removeAllowedUser(user) {
|
||||
let allowed_users = this.formData.lnbits_allowed_users
|
||||
this.formData.lnbits_allowed_users = allowed_users.filter(u => u !== user)
|
||||
},
|
||||
addExtensionsManifest() {
|
||||
const addManifest = this.formAddExtensionsManifest.trim()
|
||||
const manifests = this.formData.lnbits_extensions_manifests
|
||||
if (
|
||||
addManifest &&
|
||||
addManifest.length &&
|
||||
!manifests.includes(addManifest)
|
||||
) {
|
||||
this.formData.lnbits_extensions_manifests = [...manifests, addManifest]
|
||||
this.formAddExtensionsManifest = ''
|
||||
}
|
||||
},
|
||||
removeExtensionsManifest(manifest) {
|
||||
const manifests = this.formData.lnbits_extensions_manifests
|
||||
this.formData.lnbits_extensions_manifests = manifests.filter(
|
||||
m => m !== manifest
|
||||
)
|
||||
},
|
||||
async toggleServerLog() {
|
||||
this.serverlogEnabled = !this.serverlogEnabled
|
||||
if (this.serverlogEnabled) {
|
||||
const wsProto = location.protocol !== 'http:' ? 'wss://' : 'ws://'
|
||||
const digestHex = await LNbits.utils.digestMessage(this.g.user.id)
|
||||
const localUrl =
|
||||
wsProto +
|
||||
document.domain +
|
||||
':' +
|
||||
location.port +
|
||||
'/api/v1/ws/' +
|
||||
digestHex
|
||||
this.ws = new WebSocket(localUrl)
|
||||
this.ws.addEventListener('message', async ({data}) => {
|
||||
this.logs.push(data.toString())
|
||||
const scrollArea = this.$refs.logScroll
|
||||
if (scrollArea) {
|
||||
const scrollTarget = scrollArea.getScrollTarget()
|
||||
const duration = 0
|
||||
scrollArea.setScrollPosition(scrollTarget.scrollHeight, duration)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
this.ws.close()
|
||||
}
|
||||
},
|
||||
addAllowedIPs() {
|
||||
const allowedIPs = this.formAllowedIPs.trim()
|
||||
const allowed_ips = this.formData.lnbits_allowed_ips
|
||||
if (
|
||||
allowedIPs &&
|
||||
allowedIPs.length &&
|
||||
!allowed_ips.includes(allowedIPs)
|
||||
) {
|
||||
this.formData.lnbits_allowed_ips = [...allowed_ips, allowedIPs]
|
||||
this.formAllowedIPs = ''
|
||||
}
|
||||
},
|
||||
removeAllowedIPs(allowed_ip) {
|
||||
const allowed_ips = this.formData.lnbits_allowed_ips
|
||||
this.formData.lnbits_allowed_ips = allowed_ips.filter(
|
||||
a => a !== allowed_ip
|
||||
)
|
||||
},
|
||||
addBlockedIPs() {
|
||||
const blockedIPs = this.formBlockedIPs.trim()
|
||||
const blocked_ips = this.formData.lnbits_blocked_ips
|
||||
if (
|
||||
blockedIPs &&
|
||||
blockedIPs.length &&
|
||||
!blocked_ips.includes(blockedIPs)
|
||||
) {
|
||||
this.formData.lnbits_blocked_ips = [...blocked_ips, blockedIPs]
|
||||
this.formBlockedIPs = ''
|
||||
}
|
||||
},
|
||||
removeBlockedIPs(blocked_ip) {
|
||||
const blocked_ips = this.formData.lnbits_blocked_ips
|
||||
this.formData.lnbits_blocked_ips = blocked_ips.filter(
|
||||
b => b !== blocked_ip
|
||||
)
|
||||
},
|
||||
restartServer() {
|
||||
LNbits.api
|
||||
.request('GET', '/admin/api/v1/restart/?usr=' + this.g.user.id)
|
||||
.then(response => {
|
||||
this.$q.notify({
|
||||
type: 'positive',
|
||||
message: 'Success! Restarted Server',
|
||||
icon: null
|
||||
})
|
||||
this.needsRestart = false
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
topupWallet() {
|
||||
LNbits.api
|
||||
.request(
|
||||
'PUT',
|
||||
'/admin/api/v1/topup/?usr=' + this.g.user.id,
|
||||
this.g.user.wallets[0].adminkey,
|
||||
this.wallet
|
||||
)
|
||||
.then(response => {
|
||||
this.$q.notify({
|
||||
type: 'positive',
|
||||
message:
|
||||
'Success! Added ' + this.wallet.amount + ' to ' + this.wallet.id,
|
||||
icon: null
|
||||
})
|
||||
this.wallet = {}
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
formatDate(date) {
|
||||
return moment(date * 1000).fromNow()
|
||||
},
|
||||
getNotifications() {
|
||||
if (this.settings.lnbits_notifications) {
|
||||
axios
|
||||
.get(this.settings.lnbits_status_manifest)
|
||||
.then(response => {
|
||||
this.statusData = response.data
|
||||
})
|
||||
.catch(error => {
|
||||
this.formData.lnbits_notifications = false
|
||||
error.response.data = {}
|
||||
error.response.data.message = 'Could not fetch status manifest.'
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
}
|
||||
},
|
||||
getAudit() {
|
||||
LNbits.api
|
||||
.request(
|
||||
'GET',
|
||||
'/admin/api/v1/audit/?usr=' + this.g.user.id,
|
||||
this.g.user.wallets[0].adminkey
|
||||
)
|
||||
.then(response => {
|
||||
this.auditData = response.data
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
getSettings() {
|
||||
LNbits.api
|
||||
.request(
|
||||
'GET',
|
||||
'/admin/api/v1/settings/?usr=' + this.g.user.id,
|
||||
this.g.user.wallets[0].adminkey
|
||||
)
|
||||
.then(response => {
|
||||
this.isSuperUser = response.data.is_super_user || false
|
||||
this.settings = response.data
|
||||
this.formData = {...this.settings}
|
||||
this.getNotifications()
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
updateSettings() {
|
||||
let data = _.omit(this.formData, [
|
||||
'is_super_user',
|
||||
'lnbits_allowed_funding_sources'
|
||||
])
|
||||
LNbits.api
|
||||
.request(
|
||||
'PUT',
|
||||
'/admin/api/v1/settings/?usr=' + this.g.user.id,
|
||||
this.g.user.wallets[0].adminkey,
|
||||
data
|
||||
)
|
||||
.then(response => {
|
||||
this.needsRestart =
|
||||
this.settings.lnbits_backend_wallet_class !==
|
||||
this.formData.lnbits_backend_wallet_class ||
|
||||
this.settings.lnbits_killswitch !== this.formData.lnbits_killswitch
|
||||
this.settings = this.formData
|
||||
this.formData = _.clone(this.settings)
|
||||
this.$q.notify({
|
||||
type: 'positive',
|
||||
message: `Success! Settings changed! ${
|
||||
this.needsRestart ? 'Restart required!' : ''
|
||||
}`,
|
||||
icon: null
|
||||
})
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
deleteSettings() {
|
||||
LNbits.utils
|
||||
.confirmDialog('Are you sure you want to restore settings to default?')
|
||||
.onOk(() => {
|
||||
LNbits.api
|
||||
.request('DELETE', '/admin/api/v1/settings/?usr=' + this.g.user.id)
|
||||
.then(response => {
|
||||
this.$q.notify({
|
||||
type: 'positive',
|
||||
message:
|
||||
'Success! Restored settings to defaults, restart required!',
|
||||
icon: null
|
||||
})
|
||||
this.needsRestart = true
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
})
|
||||
},
|
||||
downloadBackup() {
|
||||
window.open('/admin/api/v1/backup/?usr=' + this.g.user.id, '_blank')
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -64,9 +64,35 @@ window.LNbits = {
|
||||
callback
|
||||
})
|
||||
},
|
||||
createAccount: function (name) {
|
||||
return this.request('post', '/api/v1/account', null, {
|
||||
name: name
|
||||
})
|
||||
},
|
||||
getWallet: function (wallet) {
|
||||
return this.request('get', '/api/v1/wallet', wallet.inkey)
|
||||
},
|
||||
createWallet: function (wallet, name) {
|
||||
return this.request('post', '/api/v1/wallet', wallet.adminkey, {
|
||||
name: name
|
||||
}).then(res => {
|
||||
window.location = '/wallet?usr=' + res.data.user + '&wal=' + res.data.id
|
||||
})
|
||||
},
|
||||
updateWallet: function (name, wallet) {
|
||||
return this.request('patch', '/api/v1/wallet', wallet.adminkey, {
|
||||
name: name
|
||||
})
|
||||
},
|
||||
deleteWallet: function (wallet) {
|
||||
return this.request('delete', '/api/v1/wallet', wallet.adminkey).then(
|
||||
_ => {
|
||||
let url = new URL(window.location.href)
|
||||
url.searchParams.delete('wal')
|
||||
window.location = url
|
||||
}
|
||||
)
|
||||
},
|
||||
getPayments: function (wallet, query) {
|
||||
const params = new URLSearchParams(query)
|
||||
return this.request(
|
||||
@@ -118,18 +144,6 @@ window.LNbits = {
|
||||
}
|
||||
}
|
||||
},
|
||||
href: {
|
||||
createWallet: function (walletName, userId) {
|
||||
window.location.href =
|
||||
'/wallet?' + (userId ? 'usr=' + userId + '&' : '') + 'nme=' + walletName
|
||||
},
|
||||
updateWallet: function (walletName, userId, walletId) {
|
||||
window.location.href = `/wallet?usr=${userId}&wal=${walletId}&nme=${walletName}`
|
||||
},
|
||||
deleteWallet: function (walletId, userId) {
|
||||
window.location.href = '/deletewallet?usr=' + userId + '&wal=' + walletId
|
||||
}
|
||||
},
|
||||
map: {
|
||||
extension: function (data) {
|
||||
var obj = _.object(
|
||||
@@ -182,7 +196,7 @@ window.LNbits = {
|
||||
currency: data.currency
|
||||
}
|
||||
newWallet.msat = data.balance_msat
|
||||
newWallet.sat = Math.round(data.balance_msat / 1000)
|
||||
newWallet.sat = Math.floor(data.balance_msat / 1000)
|
||||
newWallet.fsat = new Intl.NumberFormat(window.LOCALE).format(
|
||||
newWallet.sat
|
||||
)
|
||||
@@ -221,7 +235,7 @@ window.LNbits = {
|
||||
obj.expirydateFrom = moment(obj.expirydate).fromNow()
|
||||
obj.msat = obj.amount
|
||||
obj.sat = obj.msat / 1000
|
||||
obj.tag = obj.extra.tag
|
||||
obj.tag = obj.extra?.tag
|
||||
obj.fsat = new Intl.NumberFormat(window.LOCALE).format(obj.sat)
|
||||
obj.isIn = obj.amount > 0
|
||||
obj.isOut = obj.amount < 0
|
||||
@@ -262,6 +276,9 @@ window.LNbits = {
|
||||
formatSat: function (value) {
|
||||
return new Intl.NumberFormat(window.LOCALE).format(value)
|
||||
},
|
||||
formatMsat: function (value) {
|
||||
return this.formatSat(value / 1000)
|
||||
},
|
||||
notifyApiError: function (error) {
|
||||
var types = {
|
||||
400: 'warning',
|
||||
@@ -340,6 +357,12 @@ window.LNbits = {
|
||||
icon: null
|
||||
})
|
||||
}
|
||||
},
|
||||
convertMarkdown(text) {
|
||||
const converter = new showdown.Converter()
|
||||
converter.setFlavor('github')
|
||||
converter.setOption('simpleLineBreaks', true)
|
||||
return converter.makeHtml(text)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -348,6 +371,7 @@ window.windowMixin = {
|
||||
i18n: window.i18n,
|
||||
data: function () {
|
||||
return {
|
||||
toggleSubs: true,
|
||||
g: {
|
||||
offline: !navigator.onLine,
|
||||
visibleDrawer: false,
|
||||
@@ -451,7 +475,7 @@ window.windowMixin = {
|
||||
return !obj.hidden
|
||||
})
|
||||
.filter(function (obj) {
|
||||
if (window.user.admin) return obj
|
||||
if (window.user?.admin) return obj
|
||||
return !obj.isAdminOnly
|
||||
})
|
||||
.map(function (obj) {
|
||||
|
||||
@@ -86,7 +86,7 @@ Vue.component('lnbits-wallet-list', {
|
||||
},
|
||||
methods: {
|
||||
createWallet: function () {
|
||||
LNbits.href.createWallet(this.walletName, this.user.id)
|
||||
LNbits.api.createWallet(this.user.wallets[0], this.walletName)
|
||||
},
|
||||
updateWalletBalance: function (payload) {
|
||||
this.activeBalance = payload
|
||||
@@ -111,7 +111,7 @@ Vue.component('lnbits-extension-list', {
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<q-list v-if="user" dense class="lnbits-drawer__q-list">
|
||||
<q-list v-if="user && userExtensions.length > 0" dense class="lnbits-drawer__q-list">
|
||||
<q-item-label header v-text="$t('extensions')"></q-item-label>
|
||||
<q-item v-for="extension in userExtensions" :key="extension.code"
|
||||
clickable
|
||||
@@ -132,14 +132,6 @@ Vue.component('lnbits-extension-list', {
|
||||
<q-icon name="chevron_right" color="grey-5" size="md"></q-icon>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item clickable tag="a" :href="['/extensions?usr=', user.id].join('')">
|
||||
<q-item-section side>
|
||||
<q-icon name="clear_all" color="grey-5" size="md"></q-icon>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label lines="1" class="text-caption" v-text="$t('extensions')"></q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<div class="lt-md q-mt-xl q-mb-xl"></div>
|
||||
</q-list>
|
||||
`,
|
||||
@@ -177,7 +169,8 @@ Vue.component('lnbits-extension-list', {
|
||||
}
|
||||
})
|
||||
|
||||
Vue.component('lnbits-admin-ui', {
|
||||
Vue.component('lnbits-manage', {
|
||||
props: ['showAdmin', 'showNode'],
|
||||
data: function () {
|
||||
return {
|
||||
extensions: [],
|
||||
@@ -185,14 +178,32 @@ Vue.component('lnbits-admin-ui', {
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<q-list v-if="user && user.admin" dense class="lnbits-drawer__q-list">
|
||||
<q-item-label header>Admin</q-item-label>
|
||||
<q-item clickable tag="a" :href="['/admin?usr=', user.id].join('')">
|
||||
<q-list v-if="user" dense class="lnbits-drawer__q-list">
|
||||
<q-item-label header v-text="$t('manage')"></q-item-label>
|
||||
<div v-if="user.admin">
|
||||
<q-item v-if='showAdmin' clickable tag="a" :href="['/admin?usr=', user.id].join('')">
|
||||
<q-item-section side>
|
||||
<q-icon name="admin_panel_settings" color="grey-5" size="md"></q-icon>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label lines="1" class="text-caption" v-text="$t('server')"></q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item v-if='showNode' clickable tag="a" :href="['/node?usr=', user.id].join('')">
|
||||
<q-item-section side>
|
||||
<q-icon name="developer_board" color="grey-5" size="md"></q-icon>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label lines="1" class="text-caption" v-text="$t('node')"></q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</div>
|
||||
<q-item clickable tag="a" :href="['/extensions?usr=', user.id].join('')">
|
||||
<q-item-section side>
|
||||
<q-icon name="admin_panel_settings" color="grey-5" size="md"></q-icon>
|
||||
<q-icon name="extension" color="grey-5" size="md"></q-icon>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label lines="1" class="text-caption" v-text="$t('manage_server')"></q-item-label>
|
||||
<q-item-label lines="1" class="text-caption" v-text="$t('extensions')"></q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
@@ -215,38 +226,38 @@ Vue.component('lnbits-payment-details', {
|
||||
},
|
||||
template: `
|
||||
<div class="q-py-md" style="text-align: left">
|
||||
|
||||
|
||||
<div v-if="payment.tag" class="row justify-center q-mb-md">
|
||||
<q-badge v-if="hasTag" color="yellow" text-color="black">
|
||||
#{{ payment.tag }}
|
||||
</q-badge>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="row">
|
||||
<b v-text="$t('created')"></b>:
|
||||
{{ payment.date }} ({{ payment.dateFrom }})
|
||||
</div>
|
||||
|
||||
|
||||
<div class="row">
|
||||
<b v-text="$t('expiry')"></b>:
|
||||
{{ payment.expirydate }} ({{ payment.expirydateFrom }})
|
||||
</div>
|
||||
|
||||
|
||||
<div class="row">
|
||||
<b v-text="$t('amount')"></b>:
|
||||
{{ (payment.amount / 1000).toFixed(3) }} {{LNBITS_DENOMINATION}}
|
||||
</div>
|
||||
|
||||
|
||||
<div class="row">
|
||||
<b v-text="$t('fee')"></b>:
|
||||
{{ (payment.fee / 1000).toFixed(3) }} {{LNBITS_DENOMINATION}}
|
||||
</div>
|
||||
|
||||
|
||||
<div class="text-wrap">
|
||||
<b style="white-space: nowrap;" v-text="$t('payment_hash')"></b>: {{ payment.payment_hash }}
|
||||
<q-icon name="content_copy" @click="copyText(payment.payment_hash)" size="1em" color="grey" class="q-mb-xs cursor-pointer" />
|
||||
</div>
|
||||
|
||||
|
||||
<div class="text-wrap">
|
||||
<b style="white-space: nowrap;" v-text="$t('memo')"></b>: {{ payment.memo }}
|
||||
</div>
|
||||
@@ -346,3 +357,307 @@ Vue.component('lnbits-lnurlpay-success-action', {
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
Vue.component('lnbits-qrcode', {
|
||||
mixins: [windowMixin],
|
||||
props: ['value'],
|
||||
components: {[VueQrcode.name]: VueQrcode},
|
||||
data() {
|
||||
return {
|
||||
logo: LNBITS_QR_LOGO
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div class="qrcode__wrapper">
|
||||
<qrcode :value="value"
|
||||
:options="{errorCorrectionLevel: 'Q', width: 800}" class="rounded-borders"></qrcode>
|
||||
<img class="qrcode__image" :src="logo" alt="..." />
|
||||
</div>
|
||||
`
|
||||
})
|
||||
|
||||
Vue.component('lnbits-notifications-btn', {
|
||||
mixins: [windowMixin],
|
||||
props: ['pubkey'],
|
||||
data() {
|
||||
return {
|
||||
isSupported: false,
|
||||
isSubscribed: false,
|
||||
isPermissionGranted: false,
|
||||
isPermissionDenied: false
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<q-btn
|
||||
v-if="g.user.wallets"
|
||||
:disabled="!this.isSupported"
|
||||
dense
|
||||
flat
|
||||
round
|
||||
@click="toggleNotifications()"
|
||||
:icon="this.isSubscribed ? 'notifications_active' : 'notifications_off'"
|
||||
size="sm"
|
||||
type="a"
|
||||
>
|
||||
<q-tooltip v-if="this.isSupported && !this.isSubscribed">Subscribe to notifications</q-tooltip>
|
||||
<q-tooltip v-if="this.isSupported && this.isSubscribed">Unsubscribe from notifications</q-tooltip>
|
||||
<q-tooltip v-if="this.isSupported && this.isPermissionDenied">
|
||||
Notifications are disabled,<br/>please enable or reset permissions
|
||||
</q-tooltip>
|
||||
<q-tooltip v-if="!this.isSupported">Notifications are not supported</q-tooltip>
|
||||
</q-btn>
|
||||
`,
|
||||
methods: {
|
||||
// converts base64 to Array buffer
|
||||
urlB64ToUint8Array(base64String) {
|
||||
const padding = '='.repeat((4 - (base64String.length % 4)) % 4)
|
||||
const base64 = (base64String + padding)
|
||||
.replace(/\-/g, '+')
|
||||
.replace(/_/g, '/')
|
||||
const rawData = atob(base64)
|
||||
const outputArray = new Uint8Array(rawData.length)
|
||||
|
||||
for (let i = 0; i < rawData.length; ++i) {
|
||||
outputArray[i] = rawData.charCodeAt(i)
|
||||
}
|
||||
|
||||
return outputArray
|
||||
},
|
||||
toggleNotifications() {
|
||||
this.isSubscribed ? this.unsubscribe() : this.subscribe()
|
||||
},
|
||||
saveUserSubscribed(user) {
|
||||
let subscribedUsers =
|
||||
JSON.parse(
|
||||
this.$q.localStorage.getItem('lnbits.webpush.subscribedUsers')
|
||||
) || []
|
||||
if (!subscribedUsers.includes(user)) subscribedUsers.push(user)
|
||||
this.$q.localStorage.set(
|
||||
'lnbits.webpush.subscribedUsers',
|
||||
JSON.stringify(subscribedUsers)
|
||||
)
|
||||
},
|
||||
removeUserSubscribed(user) {
|
||||
let subscribedUsers =
|
||||
JSON.parse(
|
||||
this.$q.localStorage.getItem('lnbits.webpush.subscribedUsers')
|
||||
) || []
|
||||
subscribedUsers = subscribedUsers.filter(arr => arr !== user)
|
||||
this.$q.localStorage.set(
|
||||
'lnbits.webpush.subscribedUsers',
|
||||
JSON.stringify(subscribedUsers)
|
||||
)
|
||||
},
|
||||
isUserSubscribed(user) {
|
||||
let subscribedUsers =
|
||||
JSON.parse(
|
||||
this.$q.localStorage.getItem('lnbits.webpush.subscribedUsers')
|
||||
) || []
|
||||
return subscribedUsers.includes(user)
|
||||
},
|
||||
subscribe() {
|
||||
var self = this
|
||||
|
||||
// catch clicks from disabled type='a' button (https://github.com/quasarframework/quasar/issues/9258)
|
||||
if (!this.isSupported || this.isPermissionDenied) {
|
||||
return
|
||||
}
|
||||
|
||||
// ask for notification permission
|
||||
Notification.requestPermission()
|
||||
.then(permission => {
|
||||
this.isPermissionGranted = permission === 'granted'
|
||||
this.isPermissionDenied = permission === 'denied'
|
||||
})
|
||||
.catch(function (e) {
|
||||
console.log(e)
|
||||
})
|
||||
|
||||
// create push subscription
|
||||
navigator.serviceWorker.ready.then(registration => {
|
||||
navigator.serviceWorker.getRegistration().then(registration => {
|
||||
registration.pushManager
|
||||
.getSubscription()
|
||||
.then(function (subscription) {
|
||||
if (
|
||||
subscription === null ||
|
||||
!self.isUserSubscribed(self.g.user.id)
|
||||
) {
|
||||
const applicationServerKey = self.urlB64ToUint8Array(
|
||||
self.pubkey
|
||||
)
|
||||
const options = {applicationServerKey, userVisibleOnly: true}
|
||||
|
||||
registration.pushManager
|
||||
.subscribe(options)
|
||||
.then(function (subscription) {
|
||||
LNbits.api
|
||||
.request(
|
||||
'POST',
|
||||
'/api/v1/webpush',
|
||||
self.g.user.wallets[0].adminkey,
|
||||
{
|
||||
subscription: JSON.stringify(subscription)
|
||||
}
|
||||
)
|
||||
.then(function (response) {
|
||||
self.saveUserSubscribed(response.data.user)
|
||||
self.isSubscribed = true
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch(function (e) {
|
||||
console.log(e)
|
||||
})
|
||||
})
|
||||
})
|
||||
},
|
||||
unsubscribe() {
|
||||
var self = this
|
||||
|
||||
navigator.serviceWorker.ready
|
||||
.then(registration => {
|
||||
registration.pushManager.getSubscription().then(subscription => {
|
||||
if (subscription) {
|
||||
LNbits.api
|
||||
.request(
|
||||
'DELETE',
|
||||
'/api/v1/webpush?endpoint=' + btoa(subscription.endpoint),
|
||||
self.g.user.wallets[0].adminkey
|
||||
)
|
||||
.then(function () {
|
||||
self.removeUserSubscribed(self.g.user.id)
|
||||
self.isSubscribed = false
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
.catch(function (e) {
|
||||
console.log(e)
|
||||
})
|
||||
},
|
||||
checkSupported: function () {
|
||||
let https = window.location.protocol === 'https:'
|
||||
let serviceWorkerApi = 'serviceWorker' in navigator
|
||||
let notificationApi = 'Notification' in window
|
||||
let pushApi = 'PushManager' in window
|
||||
|
||||
this.isSupported = https && serviceWorkerApi && notificationApi && pushApi
|
||||
|
||||
if (!this.isSupported) {
|
||||
console.log(
|
||||
'Notifications disabled because requirements are not met:',
|
||||
{
|
||||
HTTPS: https,
|
||||
'Service Worker API': serviceWorkerApi,
|
||||
'Notification API': notificationApi,
|
||||
'Push API': pushApi
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
return this.isSupported
|
||||
},
|
||||
updateSubscriptionStatus: async function () {
|
||||
var self = this
|
||||
|
||||
await navigator.serviceWorker.ready
|
||||
.then(registration => {
|
||||
registration.pushManager.getSubscription().then(subscription => {
|
||||
self.isSubscribed =
|
||||
!!subscription && self.isUserSubscribed(self.g.user.id)
|
||||
})
|
||||
})
|
||||
.catch(function (e) {
|
||||
console.log(e)
|
||||
})
|
||||
}
|
||||
},
|
||||
created: function () {
|
||||
this.isPermissionDenied = Notification.permission === 'denied'
|
||||
|
||||
if (this.checkSupported()) {
|
||||
this.updateSubscriptionStatus()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
Vue.component('lnbits-dynamic-fields', {
|
||||
mixins: [windowMixin],
|
||||
props: ['options', 'value'],
|
||||
data() {
|
||||
return {
|
||||
formData: null
|
||||
}
|
||||
},
|
||||
|
||||
template: `
|
||||
<div v-if="formData">
|
||||
<div class="row q-mb-lg" v-for="o in options">
|
||||
<div class="col auto-width">
|
||||
<p v-if=o.options?.length class="q-ml-xl">
|
||||
<span v-text="o.name"></span> <small v-if="o.description"> (<span v-text="o.description"></span>)</small>
|
||||
</p>
|
||||
<lnbits-dynamic-fields v-if="o.options?.length" :options="o.options" v-model="formData[o.name]"
|
||||
@input="handleValueChanged" class="q-ml-xl">
|
||||
</lnbits-dynamic-fields>
|
||||
<div v-else>
|
||||
<q-input v-if="o.type === 'number'" v-model="formData[o.name]" @input="handleValueChanged" type="number"
|
||||
:label="o.name" :hint="o.description" filled dense>
|
||||
</q-input>
|
||||
<q-input v-else-if="o.type === 'text'" v-model="formData[o.name]" @input="handleValueChanged" type="textarea"
|
||||
rows="5" :label="o.name" :hint="o.description" filled dense>
|
||||
</q-input>
|
||||
<div v-else-if="o.type === 'bool'">
|
||||
<q-item tag="label" v-ripple>
|
||||
<q-item-section avatar top>
|
||||
<q-checkbox v-model="formData[o.name]" @input="handleValueChanged" />
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label><span v-text="o.name"></span></q-item-label>
|
||||
<q-item-label caption> <span v-text="o.description"></span> </q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</div>
|
||||
<q-select v-else-if="o.type === 'select'" v-model="formData[o.name]" @input="handleValueChanged" :label="o.name"
|
||||
:hint="o.description" :options="o.values"></q-select>
|
||||
|
||||
<q-select v-else-if="o.isList" filled multiple dense v-model.trim="formData[o.name]" use-input use-chips
|
||||
@input="handleValueChanged" multiple hide-dropdown-icon input-debounce="0" new-value-mode="add-unique"
|
||||
:label="o.name" :hint="o.description">
|
||||
</q-select>
|
||||
<q-input v-else v-model="formData[o.name]" @input="handleValueChanged" :label="o.name" :hint="o.description"
|
||||
filled dense>
|
||||
</q-input>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
methods: {
|
||||
buildData(options, data = {}) {
|
||||
return options.reduce((d, option) => {
|
||||
if (option.options?.length) {
|
||||
d[option.name] = this.buildData(option.options, data[option.name])
|
||||
} else {
|
||||
d[option.name] = data[option.name] ?? option.default
|
||||
}
|
||||
return d
|
||||
}, {})
|
||||
},
|
||||
handleValueChanged() {
|
||||
this.$emit('input', this.formData)
|
||||
}
|
||||
},
|
||||
created: function () {
|
||||
this.formData = this.buildData(this.options, this.value)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
Vue.component('lnbits-extension-settings-form', {
|
||||
name: 'lnbits-extension-settings-form',
|
||||
props: ['options', 'adminkey', 'endpoint'],
|
||||
methods: {
|
||||
updateSettings: async function () {
|
||||
if (!this.settings) {
|
||||
return Quasar.plugins.Notify.create({
|
||||
message: 'No settings to update',
|
||||
type: 'negative'
|
||||
})
|
||||
}
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
'PUT',
|
||||
this.endpoint,
|
||||
this.adminkey,
|
||||
this.settings
|
||||
)
|
||||
this.settings = data
|
||||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
}
|
||||
},
|
||||
getSettings: async function () {
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
'GET',
|
||||
this.endpoint,
|
||||
this.adminkey
|
||||
)
|
||||
this.settings = data
|
||||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
}
|
||||
},
|
||||
resetSettings: async function () {
|
||||
LNbits.utils
|
||||
.confirmDialog('Are you sure you want to reset the settings?')
|
||||
.onOk(async () => {
|
||||
try {
|
||||
await LNbits.api.request('DELETE', this.endpoint, this.adminkey)
|
||||
await this.getSettings()
|
||||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
created: async function () {
|
||||
await this.getSettings()
|
||||
},
|
||||
template: `
|
||||
<q-form v-if="settings" @submit="updateSettings" class="q-gutter-md">
|
||||
<lnbits-dynamic-fields :options="options" v-model="settings"></lnbits-dynamic-fields>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn v-close-popup unelevated color="primary" type="submit">Update</q-btn>
|
||||
<q-btn v-close-popup unelevated color="danger" @click="resetSettings" >Reset</q-btn>
|
||||
<slot name="actions"></slot>
|
||||
</div>
|
||||
</q-form>
|
||||
`,
|
||||
data: function () {
|
||||
return {
|
||||
settings: undefined
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
Vue.component('lnbits-extension-settings-btn-dialog', {
|
||||
name: 'lnbits-extension-settings-btn-dialog',
|
||||
props: ['options', 'adminkey', 'endpoint'],
|
||||
template: `
|
||||
<q-btn v-if="options" unelevated @click="show = true" color="primary" icon="settings" class="float-right">
|
||||
<q-dialog v-model="show" position="top">
|
||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<lnbits-extension-settings-form :options="options" :adminkey="adminkey" :endpoint="endpoint">
|
||||
<template v-slot:actions>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
|
||||
</template>
|
||||
</lnbits-extension-settings-form>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</q-btn>
|
||||
`,
|
||||
data: function () {
|
||||
return {
|
||||
show: false
|
||||
}
|
||||
}
|
||||
})
|
||||