Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7783f34998 | ||
|
|
7dcb2fcdd5 | ||
|
|
033a57cad4 | ||
|
|
299228b7b5 | ||
|
|
5b022e2ef3 | ||
|
|
c8818f5774 | ||
|
|
1398857688 | ||
|
|
2a1505bc0d | ||
|
|
4bba6ecc82 | ||
|
|
522c4df44c | ||
|
|
c03b81d2ea | ||
|
|
43a797444a | ||
|
|
d0cb961b49 | ||
|
|
d208e68885 | ||
|
|
8dcb53aea0 | ||
|
|
241b286e21 | ||
|
|
17839f6a25 | ||
|
|
e4d3faefa0 | ||
|
|
116ca7f011 | ||
|
|
d44339b018 | ||
|
|
352fd23c0b | ||
|
|
65b8868c36 | ||
|
|
54dec171f9 | ||
|
|
5b4398911a | ||
|
|
4c0bd132b1 | ||
|
|
7ce4eddb0e | ||
|
|
14519135d8 | ||
|
|
60839481ad | ||
|
|
3ef1941fc0 | ||
|
|
16cb1a8026 | ||
|
|
e3b9bd6a70 | ||
|
|
a1ea04acf8 | ||
|
|
3e341a3555 | ||
|
|
cb5c9b03bf | ||
|
|
884a1b9d6f | ||
|
|
e8aa498683 | ||
|
|
1b7efd854a | ||
|
|
8b32c3dcb6 | ||
|
|
0821b28eac | ||
|
|
4c71d5ac42 | ||
|
|
a8e0b5a5ac | ||
|
|
9b9014a042 | ||
|
|
96b42b1784 | ||
|
|
26d854ebe8 | ||
|
|
ed36d01b40 | ||
|
|
646556a457 | ||
|
|
d20a35eddc | ||
|
|
d69946db8a | ||
|
|
ddab4075d2 | ||
|
|
d6c8ad1d0d | ||
|
|
54c6faa4b6 | ||
|
|
c88f05c5c0 | ||
|
|
e419c74ebb | ||
|
|
b6dc66b070 | ||
|
|
7b4d9bf5db | ||
|
|
c51e7351e8 | ||
|
|
fe12eccc42 | ||
|
|
17b7826753 | ||
|
|
14ae6d8b1a | ||
|
|
7b559991c7 | ||
|
|
decd4cdf0a | ||
|
|
dc7a6551c5 | ||
|
|
526467747e | ||
|
|
b8d295a5b7 | ||
|
|
293b6267be | ||
|
|
20e70854a1 | ||
|
|
11173e6460 | ||
|
|
069a7922fc | ||
|
|
98338ffb00 | ||
|
|
62dec118af | ||
|
|
84b406534d | ||
|
|
d9d2d59b73 | ||
|
|
20d4b954c0 | ||
|
|
5edb845d8f | ||
|
|
cfe2b43d2a | ||
|
|
a0ac41a9ce | ||
|
|
194a527736 | ||
|
|
6f135c0696 | ||
|
|
9a767b627f | ||
|
|
c1c4eda69d | ||
|
|
6c46867698 | ||
|
|
a73eb569ae | ||
|
|
f07b40735e | ||
|
|
94f74d3f56 | ||
|
|
d9a5e63e51 | ||
|
|
c8e5c23ac2 | ||
|
|
59968e38d1 | ||
|
|
4f45781319 | ||
|
|
22ea83e843 | ||
|
|
55fed49be1 | ||
|
|
311d9a668b | ||
|
|
165f5093f4 | ||
|
|
43d92ddb51 | ||
|
|
10944bf100 | ||
|
|
ce9b2c3b77 | ||
|
|
4c102a08e8 | ||
|
|
1d2b939e06 | ||
|
|
e1bb2113ed | ||
|
|
b71113700a | ||
|
|
ebfcc46c9e | ||
|
|
c047ae6808 | ||
|
|
023a1a088e | ||
|
|
8eabf53642 | ||
|
|
824a8fa7c8 | ||
|
|
a3b7c76523 | ||
|
|
26ca8c71d7 | ||
|
|
0d2447faf3 | ||
|
|
4cea06c5a5 | ||
|
|
031ce14857 | ||
|
|
bd143f5c14 | ||
|
|
4e9c992a91 | ||
|
|
d6ac3d2672 | ||
|
|
1933d896cb | ||
|
|
168a3bcddb | ||
|
|
adc342ab9a | ||
|
|
bbf3cd96f1 | ||
|
|
e8c9304519 | ||
|
|
815c3e61e4 | ||
|
|
4e55ea18e5 | ||
|
|
81b70acf5f | ||
|
|
165d7499ea | ||
|
|
abbcfbeb6a | ||
|
|
bff44b7097 | ||
|
|
95f6ae2bb6 | ||
|
|
a15af87f9b | ||
|
|
97f8a4ab38 | ||
|
|
7b9d370452 | ||
|
|
db72953310 | ||
|
|
bb918a8523 | ||
|
|
24b02cc656 | ||
|
|
9877ca602f | ||
|
|
c9093715b7 | ||
|
|
e76ba62b46 | ||
|
|
2d00f4c41c | ||
|
|
3ad18ca365 | ||
|
|
cf4d575062 | ||
|
|
9f835f8350 | ||
|
|
297c42371d | ||
|
|
8352223704 | ||
|
|
f9c9c52d75 | ||
|
|
7704029e59 | ||
|
|
a83fb13335 | ||
|
|
b8e6e80bdf | ||
|
|
ba90bc5fea | ||
|
|
d7257af7a0 | ||
|
|
f36771db9d | ||
|
|
aa68d67b5e | ||
|
|
5fc472a8d9 | ||
|
|
fa49133bd7 | ||
|
|
47bd96246f | ||
|
|
cae39bc19f | ||
|
|
4839cdf2f8 | ||
|
|
08571368c0 | ||
|
|
9fcf566540 | ||
|
|
5f36800100 | ||
|
|
e46b12d267 | ||
|
|
62b0e3fe89 | ||
|
|
992e3bfb9a | ||
|
|
43b8c744e6 | ||
|
|
d0107f7830 | ||
|
|
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,66 +1,17 @@
|
|||||||
#For more information on .env files, their content and format: https://pypi.org/project/python-dotenv/
|
#For more information on .env files, their content and format: https://pypi.org/project/python-dotenv/
|
||||||
|
|
||||||
HOST=127.0.0.1
|
######################################
|
||||||
PORT=5000
|
########### Admin Settings ###########
|
||||||
|
######################################
|
||||||
|
|
||||||
# uvicorn variable, uncomment to allow https behind a proxy
|
# Enable Admin GUI, available for the first user in LNBITS_ADMIN_USERS if available.
|
||||||
# FORWARDED_ALLOW_IPS="*"
|
# Warning: Enabling this will make LNbits ignore most configurations in file. Only the
|
||||||
|
# configurations defined in `ReadOnlySettings` will still be read from the environment variables.
|
||||||
DEBUG=false
|
# The rest of the settings will be stored in your database and you will be able to change them
|
||||||
|
# only through the Admin UI.
|
||||||
# Server security, rate limiting ips, blocked ips, allowed ips
|
|
||||||
LNBITS_RATE_LIMIT_NO="200"
|
|
||||||
LNBITS_RATE_LIMIT_UNIT="minute"
|
|
||||||
LNBITS_ALLOWED_IPS=""
|
|
||||||
LNBITS_BLOCKED_IPS=""
|
|
||||||
|
|
||||||
# Allow users and admins by user IDs (comma separated list)
|
|
||||||
LNBITS_ALLOWED_USERS=""
|
|
||||||
LNBITS_ADMIN_USERS=""
|
|
||||||
# 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.
|
# Disable this to make LNbits use this config file again.
|
||||||
LNBITS_ADMIN_UI=false
|
LNBITS_ADMIN_UI=false
|
||||||
|
|
||||||
LNBITS_DEFAULT_WALLET_NAME="LNbits wallet"
|
|
||||||
|
|
||||||
# Ad space description
|
|
||||||
# LNBITS_AD_SPACE_TITLE="Supported by"
|
|
||||||
# csv ad space, format "<url>;<img-light>;<img-dark>, <url>;<img-light>;<img-dark>", extensions can choose to honor
|
|
||||||
# LNBITS_AD_SPACE="https://shop.lnbits.com/;https://raw.githubusercontent.com/lnbits/lnbits/main/lnbits/static/images/lnbits-shop-light.png;https://raw.githubusercontent.com/lnbits/lnbits/main/lnbits/static/images/lnbits-shop-dark.png"
|
|
||||||
|
|
||||||
# Hides wallet api, extensions can choose to honor
|
|
||||||
LNBITS_HIDE_API=false
|
|
||||||
|
|
||||||
# LNBITS_EXTENSIONS_MANIFESTS="https://raw.githubusercontent.com/lnbits/lnbits-extensions/main/extensions.json,https://raw.githubusercontent.com/lnbits/lnbits-extensions/main/extensions-trial.json"
|
|
||||||
# GitHub has rate-limits for its APIs. The limit can be increased specifying a GITHUB_TOKEN
|
|
||||||
# LNBITS_EXT_GITHUB_TOKEN=github_pat_xxxxxxxxxxxxxxxxxx
|
|
||||||
|
|
||||||
# Extensions to be installed by default. If an extension from this list is uninstalled then it will be re-installed on the next restart.
|
|
||||||
# The extension must be removed from this list in order to not be re-installed.
|
|
||||||
LNBITS_EXTENSIONS_DEFAULT_INSTALL="tpos"
|
|
||||||
|
|
||||||
# Database: to use SQLite, specify LNBITS_DATA_FOLDER
|
|
||||||
# to use PostgreSQL, specify LNBITS_DATABASE_URL=postgres://...
|
|
||||||
# to use CockroachDB, specify LNBITS_DATABASE_URL=cockroachdb://...
|
|
||||||
# for both PostgreSQL and CockroachDB, you'll need to install
|
|
||||||
# psycopg2 as an additional dependency
|
|
||||||
LNBITS_DATA_FOLDER="./data"
|
|
||||||
# LNBITS_DATABASE_URL="postgres://user:password@host:port/databasename"
|
|
||||||
|
|
||||||
LNBITS_SERVICE_FEE="0.0"
|
|
||||||
# value in millisats
|
|
||||||
LNBITS_RESERVE_FEE_MIN=2000
|
|
||||||
# value in percent
|
|
||||||
LNBITS_RESERVE_FEE_PERCENT=1.0
|
|
||||||
|
|
||||||
# Limit fiat currencies allowed to see in UI
|
|
||||||
# LNBITS_ALLOWED_CURRENCIES="EUR, USD"
|
|
||||||
|
|
||||||
# Change theme
|
# Change theme
|
||||||
LNBITS_SITE_TITLE="LNbits"
|
LNBITS_SITE_TITLE="LNbits"
|
||||||
LNBITS_SITE_TAGLINE="free and open-source lightning wallet"
|
LNBITS_SITE_TAGLINE="free and open-source lightning wallet"
|
||||||
@@ -69,8 +20,15 @@ LNBITS_SITE_DESCRIPTION="Some description about your service, will display if ti
|
|||||||
LNBITS_THEME_OPTIONS="classic, bitcoin, flamingo, freedom, mint, autumn, monochrome, salvador, cyber"
|
LNBITS_THEME_OPTIONS="classic, bitcoin, flamingo, freedom, mint, autumn, monochrome, salvador, cyber"
|
||||||
# LNBITS_CUSTOM_LOGO="https://lnbits.com/assets/images/logo/logo.svg"
|
# LNBITS_CUSTOM_LOGO="https://lnbits.com/assets/images/logo/logo.svg"
|
||||||
|
|
||||||
|
HOST=127.0.0.1
|
||||||
|
PORT=5000
|
||||||
|
|
||||||
|
######################################
|
||||||
|
########## Funding Source ############
|
||||||
|
######################################
|
||||||
|
|
||||||
# which fundingsources are allowed in the admin ui
|
# which fundingsources are allowed in the admin ui
|
||||||
LNBITS_ALLOWED_FUNDING_SOURCES="VoidWallet, FakeWallet, CoreLightningWallet, CoreLightningRestWallet, LndRestWallet, EclairWallet, LndWallet, LnTipsWallet, LNPayWallet, LNbitsWallet, OpenNodeWallet"
|
LNBITS_ALLOWED_FUNDING_SOURCES="VoidWallet, FakeWallet, CoreLightningWallet, CoreLightningRestWallet, LndRestWallet, EclairWallet, LndWallet, LnTipsWallet, LNPayWallet, LNbitsWallet, AlbyWallet, ZBDWallet, OpenNodeWallet"
|
||||||
|
|
||||||
LNBITS_BACKEND_WALLET_CLASS=VoidWallet
|
LNBITS_BACKEND_WALLET_CLASS=VoidWallet
|
||||||
# VoidWallet is just a fallback that works without any actual Lightning capabilities,
|
# VoidWallet is just a fallback that works without any actual Lightning capabilities,
|
||||||
@@ -91,6 +49,11 @@ SPARK_TOKEN=myaccesstoken
|
|||||||
# CoreLightningWallet
|
# CoreLightningWallet
|
||||||
CORELIGHTNING_RPC="/home/bob/.lightning/bitcoin/lightning-rpc"
|
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
|
# LnbitsWallet
|
||||||
LNBITS_ENDPOINT=https://legend.lnbits.com
|
LNBITS_ENDPOINT=https://legend.lnbits.com
|
||||||
LNBITS_KEY=LNBITS_ADMIN_KEY
|
LNBITS_KEY=LNBITS_ADMIN_KEY
|
||||||
@@ -98,15 +61,15 @@ LNBITS_KEY=LNBITS_ADMIN_KEY
|
|||||||
# LndWallet
|
# LndWallet
|
||||||
LND_GRPC_ENDPOINT=127.0.0.1
|
LND_GRPC_ENDPOINT=127.0.0.1
|
||||||
LND_GRPC_PORT=10009
|
LND_GRPC_PORT=10009
|
||||||
LND_GRPC_CERT="/home/bob/.config/Zap/lnd/bitcoin/mainnet/wallet-1/data/chain/bitcoin/mainnet/tls.cert"
|
LND_GRPC_CERT="/home/bob/.lnd/tls.cert"
|
||||||
LND_GRPC_MACAROON="/home/bob/.config/Zap/lnd/bitcoin/mainnet/wallet-1/data/chain/bitcoin/mainnet/admin.macaroon or HEXSTRING"
|
LND_GRPC_MACAROON="/home/bob/.lnd/data/chain/bitcoin/mainnet/admin.macaroon" # or HEXSTRING
|
||||||
# To use an AES-encrypted macaroon, set
|
# To use an AES-encrypted macaroon, set
|
||||||
# LND_GRPC_MACAROON="eNcRyPtEdMaCaRoOn"
|
# LND_GRPC_MACAROON="eNcRyPtEdMaCaRoOn"
|
||||||
|
|
||||||
# LndRestWallet
|
# LndRestWallet
|
||||||
LND_REST_ENDPOINT=https://127.0.0.1:8080/
|
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_CERT="/home/bob/.lnd/tls.cert"
|
||||||
LND_REST_MACAROON="/home/bob/.config/Zap/lnd/bitcoin/mainnet/wallet-1/data/chain/bitcoin/mainnet/admin.macaroon or HEXSTRING"
|
LND_REST_MACAROON="/home/bob/.lnd/data/chain/bitcoin/mainnet/admin.macaroon" # or HEXSTRING
|
||||||
# To use an AES-encrypted macaroon, set
|
# To use an AES-encrypted macaroon, set
|
||||||
# LND_REST_MACAROON_ENCRYPTED="eNcRyPtEdMaCaRoOn"
|
# LND_REST_MACAROON_ENCRYPTED="eNcRyPtEdMaCaRoOn"
|
||||||
|
|
||||||
@@ -117,6 +80,14 @@ LNPAY_API_KEY=LNPAY_API_KEY
|
|||||||
# Wallet Admin in Wallet Access Keys
|
# Wallet Admin in Wallet Access Keys
|
||||||
LNPAY_WALLET_KEY=LNPAY_ADMIN_KEY
|
LNPAY_WALLET_KEY=LNPAY_ADMIN_KEY
|
||||||
|
|
||||||
|
# AlbyWallet
|
||||||
|
ALBY_API_ENDPOINT=https://api.getalby.com/
|
||||||
|
ALBY_ACCESS_TOKEN=ALBY_ACCESS_TOKEN
|
||||||
|
|
||||||
|
# ZBDWallet
|
||||||
|
ZBD_API_ENDPOINT=https://api.zebedee.io/v0/
|
||||||
|
ZBD_API_KEY=ZBD_ACCESS_TOKEN
|
||||||
|
|
||||||
# OpenNodeWallet
|
# OpenNodeWallet
|
||||||
OPENNODE_API_ENDPOINT=https://api.opennode.com/
|
OPENNODE_API_ENDPOINT=https://api.opennode.com/
|
||||||
OPENNODE_KEY=OPENNODE_ADMIN_KEY
|
OPENNODE_KEY=OPENNODE_ADMIN_KEY
|
||||||
@@ -134,7 +105,136 @@ ECLAIR_PASS=eclairpw
|
|||||||
LNTIPS_API_KEY=LNTIPS_ADMIN_KEY
|
LNTIPS_API_KEY=LNTIPS_ADMIN_KEY
|
||||||
LNTIPS_API_ENDPOINT=https://ln.tips
|
LNTIPS_API_ENDPOINT=https://ln.tips
|
||||||
|
|
||||||
# Cashu Mint
|
######################################
|
||||||
# Use a long-enough random (!) private key.
|
####### Auth Configurations ##########
|
||||||
# Once set, you cannot change this key as for now.
|
######################################
|
||||||
CASHU_PRIVATE_KEY="SuperSecretPrivateKey"
|
# Secret Key: will default to the hash of the super user. It is strongly recommended that you set your own value.
|
||||||
|
AUTH_SECRET_KEY=""
|
||||||
|
AUTH_TOKEN_EXPIRE_MINUTES=525600
|
||||||
|
# Possible authorization methods: user-id-only, username-password, google-auth, github-auth, keycloak-auth
|
||||||
|
AUTH_ALLOWED_METHODS="user-id-only, username-password"
|
||||||
|
# Set this flag if HTTP is used for OAuth
|
||||||
|
# OAUTHLIB_INSECURE_TRANSPORT="1"
|
||||||
|
|
||||||
|
# Google OAuth Config
|
||||||
|
# Make sure that the authorized redirect URIs contain https://{domain}/api/v1/auth/google/token
|
||||||
|
GOOGLE_CLIENT_ID=""
|
||||||
|
GOOGLE_CLIENT_SECRET=""
|
||||||
|
|
||||||
|
# GitHub OAuth Config
|
||||||
|
# Make sure that the authorization callback URL is set to https://{domain}/api/v1/auth/github/token
|
||||||
|
GITHUB_CLIENT_ID=""
|
||||||
|
GITHUB_CLIENT_SECRET=""
|
||||||
|
|
||||||
|
# Keycloak OAuth Config
|
||||||
|
# Make sure that the valid redirect URIs contain https://{domain}/api/v1/auth/keycloak/token
|
||||||
|
KEYCLOAK_CLIENT_ID=""
|
||||||
|
KEYCLOAK_CLIENT_SECRET=""
|
||||||
|
KEYCLOAK_DISCOVERY_URL=""
|
||||||
|
|
||||||
|
|
||||||
|
######################################
|
||||||
|
|
||||||
|
# uvicorn variable, uncomment to allow https behind a proxy
|
||||||
|
# FORWARDED_ALLOW_IPS="*"
|
||||||
|
|
||||||
|
# Server security, rate limiting ips, blocked ips, allowed ips
|
||||||
|
LNBITS_RATE_LIMIT_NO="200"
|
||||||
|
LNBITS_RATE_LIMIT_UNIT="minute"
|
||||||
|
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"
|
||||||
|
|
||||||
|
# Start LNbits core only. The extensions are not loaded.
|
||||||
|
# LNBITS_EXTENSIONS_DEACTIVATE_ALL=true
|
||||||
|
|
||||||
|
# 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"
|
||||||
|
|
||||||
|
# Ad space description
|
||||||
|
# LNBITS_AD_SPACE_TITLE="Supported by"
|
||||||
|
# csv ad space, format "<url>;<img-light>;<img-dark>, <url>;<img-light>;<img-dark>", extensions can choose to honor
|
||||||
|
# LNBITS_AD_SPACE="https://shop.lnbits.com/;https://raw.githubusercontent.com/lnbits/lnbits/main/lnbits/static/images/lnbits-shop-light.png;https://raw.githubusercontent.com/lnbits/lnbits/main/lnbits/static/images/lnbits-shop-dark.png"
|
||||||
|
|
||||||
|
# Hides wallet api, extensions can choose to honor
|
||||||
|
LNBITS_HIDE_API=false
|
||||||
|
|
||||||
|
# LNBITS_EXTENSIONS_MANIFESTS="https://raw.githubusercontent.com/lnbits/lnbits-extensions/main/extensions.json,https://raw.githubusercontent.com/lnbits/lnbits-extensions/main/extensions-trial.json"
|
||||||
|
# GitHub has rate-limits for its APIs. The limit can be increased specifying a GITHUB_TOKEN
|
||||||
|
# LNBITS_EXT_GITHUB_TOKEN=github_pat_xxxxxxxxxxxxxxxxxx
|
||||||
|
|
||||||
|
# Path where extensions will be installed (defaults to `./lnbits/`).
|
||||||
|
# Inside this directory the `extensions` and `upgrades` sub-directories will be created.
|
||||||
|
# LNBITS_EXTENSIONS_PATH="/path/to/some/dir"
|
||||||
|
|
||||||
|
# Extensions to be installed by default. If an extension from this list is uninstalled then it will be re-installed on the next restart.
|
||||||
|
# The extension must be removed from this list in order to not be re-installed.
|
||||||
|
LNBITS_EXTENSIONS_DEFAULT_INSTALL="tpos"
|
||||||
|
|
||||||
|
# Database: to use SQLite, specify LNBITS_DATA_FOLDER
|
||||||
|
# to use PostgreSQL, specify LNBITS_DATABASE_URL=postgres://...
|
||||||
|
# to use CockroachDB, specify LNBITS_DATABASE_URL=cockroachdb://...
|
||||||
|
# for both PostgreSQL and CockroachDB, you'll need to install
|
||||||
|
# psycopg2 as an additional dependency
|
||||||
|
LNBITS_DATA_FOLDER="./data"
|
||||||
|
# LNBITS_DATABASE_URL="postgres://user:password@host:port/databasename"
|
||||||
|
|
||||||
|
# the service fee (in percent)
|
||||||
|
LNBITS_SERVICE_FEE=0.0
|
||||||
|
# the wallet where fees go to
|
||||||
|
# LNBITS_SERVICE_FEE_WALLET=
|
||||||
|
# the maximum fee per transaction (in satoshis)
|
||||||
|
# LNBITS_SERVICE_FEE_MAX=1000
|
||||||
|
# disable fees for internal transactions
|
||||||
|
# LNBITS_SERVICE_FEE_IGNORE_INTERNAL=true
|
||||||
|
|
||||||
|
# value in millisats
|
||||||
|
LNBITS_RESERVE_FEE_MIN=2000
|
||||||
|
# value in percent
|
||||||
|
LNBITS_RESERVE_FEE_PERCENT=1.0
|
||||||
|
|
||||||
|
# limit the maximum balance for each wallet
|
||||||
|
# throw an error if the wallet attempts to create a new invoice
|
||||||
|
|
||||||
|
# LNBITS_WALLET_LIMIT_MAX_BALANCE=1000000
|
||||||
|
# LNBITS_WALLET_LIMIT_DAILY_MAX_WITHDRAW=1000000
|
||||||
|
# LNBITS_WALLET_LIMIT_SECS_BETWEEN_TRANS=60
|
||||||
|
|
||||||
|
# Limit fiat currencies allowed to see in UI
|
||||||
|
# LNBITS_ALLOWED_CURRENCIES="EUR, USD"
|
||||||
|
|
||||||
|
######################################
|
||||||
|
###### Logging and Development #######
|
||||||
|
######################################
|
||||||
|
|
||||||
|
DEBUG=false
|
||||||
|
DEBUG_DATABASE=false
|
||||||
|
BUNDLE_ASSETS=true
|
||||||
|
|
||||||
|
# logging into LNBITS_DATA_FOLDER/logs/
|
||||||
|
ENABLE_LOG_TO_FILE=true
|
||||||
|
|
||||||
|
# https://loguru.readthedocs.io/en/stable/api/logger.html#file
|
||||||
|
LOG_ROTATION="100 MB"
|
||||||
|
LOG_RETENTION="3 months"
|
||||||
|
|
||||||
|
# for database cleanup commands
|
||||||
|
# CLEANUP_WALLETS_DAYS=90
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ inputs:
|
|||||||
default: "3.9"
|
default: "3.9"
|
||||||
poetry-version:
|
poetry-version:
|
||||||
description: "Poetry Version"
|
description: "Poetry Version"
|
||||||
default: "1.5.1"
|
default: "1.7.0"
|
||||||
node-version:
|
node-version:
|
||||||
description: "Node Version"
|
description: "Node Version"
|
||||||
default: "18.x"
|
default: "18.x"
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
name: LNbits CI
|
name: LNbits CI
|
||||||
on: [push, pull_request]
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- dev
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|
||||||
@@ -32,9 +38,6 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
make: openapi
|
make: openapi
|
||||||
|
|
||||||
# docker:
|
|
||||||
# uses: ./.github/workflows/docker.yml
|
|
||||||
|
|
||||||
regtest:
|
regtest:
|
||||||
needs: [ lint ]
|
needs: [ lint ]
|
||||||
uses: ./.github/workflows/regtest.yml
|
uses: ./.github/workflows/regtest.yml
|
||||||
@@ -45,3 +48,13 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
backend-wallet-class: ${{ matrix.backend-wallet-class }}
|
backend-wallet-class: ${{ matrix.backend-wallet-class }}
|
||||||
|
|
||||||
|
jmeter:
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
python-version: ["3.9"]
|
||||||
|
poetry-version: ["1.5.1"]
|
||||||
|
uses: ./.github/workflows/jmeter.yml
|
||||||
|
with:
|
||||||
|
python-version: ${{ matrix.python-version }}
|
||||||
|
poetry-version: ${{ matrix.poetry-version }}
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ name: codeql
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main, ]
|
branches: [main, dev]
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
branches: [main, dev]
|
||||||
schedule:
|
schedule:
|
||||||
- cron: '0 12 * * 5'
|
- cron: '0 12 * * 5'
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +1,58 @@
|
|||||||
name: docker
|
name: docker
|
||||||
|
|
||||||
on:
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tag:
|
||||||
|
default: latest
|
||||||
|
type: string
|
||||||
workflow_call:
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
tag:
|
||||||
|
default: latest
|
||||||
|
type: string
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
docker:
|
push_to_dockerhub:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Login to Docker Hub
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v2
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Cache Docker layers
|
||||||
|
uses: actions/cache@v4
|
||||||
|
id: cache
|
||||||
|
with:
|
||||||
|
path: /tmp/.buildx-cache
|
||||||
|
key: ${{ runner.os }}-buildx-${{ github.sha }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-buildx-
|
||||||
|
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
uses: docker/build-push-action@v4
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
push: false
|
push: true
|
||||||
tags: lnbitsdocker/lnbits-legend:latest
|
tags: ${{ secrets.DOCKER_USERNAME }}/lnbits:${{ inputs.tag }}
|
||||||
cache-from: type=registry,ref=lnbitsdocker/lnbits-legend:latest
|
platforms: linux/amd64,linux/arm64
|
||||||
cache-to: type=inline
|
cache-from: type=local,src=/tmp/.buildx-cache
|
||||||
|
cache-to: type=local,dest=/tmp/.buildx-cache
|
||||||
|
|
||||||
|
- name: Build and push latest tag
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
tags: ${{ secrets.DOCKER_USERNAME }}/lnbits:latest
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
cache-from: type=local,src=/tmp/.buildx-cache
|
||||||
|
cache-to: type=local,dest=/tmp/.buildx-cache
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
name: JMeter Tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
python-version:
|
||||||
|
description: "Python Version"
|
||||||
|
required: true
|
||||||
|
default: "3.9"
|
||||||
|
type: string
|
||||||
|
poetry-version:
|
||||||
|
description: "Poetry Version"
|
||||||
|
required: true
|
||||||
|
default: "1.5.1"
|
||||||
|
type: string
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
action_build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v2
|
||||||
|
|
||||||
|
- name: Set up Python ${{ inputs.python-version }}
|
||||||
|
uses: actions/setup-python@v4
|
||||||
|
with:
|
||||||
|
python-version: ${{ inputs.python-version }}
|
||||||
|
cache: "pip"
|
||||||
|
|
||||||
|
- name: Set up Poetry ${{ inputs.poetry-version }}
|
||||||
|
uses: abatilo/actions-poetry@v2
|
||||||
|
with:
|
||||||
|
poetry-version: ${{ inputs.poetry-version }}
|
||||||
|
|
||||||
|
- name: create logs and reports dir
|
||||||
|
run: |
|
||||||
|
mkdir logs
|
||||||
|
mkdir reports
|
||||||
|
|
||||||
|
- name: install packages
|
||||||
|
run: poetry install
|
||||||
|
|
||||||
|
- name: run LNbits
|
||||||
|
env:
|
||||||
|
LNBITS_ADMIN_UI: true
|
||||||
|
LNBITS_EXTENSIONS_DEFAULT_INSTALL: "watchonly, satspay, tipjar, tpos, lnurlp, withdraw"
|
||||||
|
LNBITS_BACKEND_WALLET_CLASS: FakeWallet
|
||||||
|
run: |
|
||||||
|
poetry run lnbits > logs/lnbits.log &
|
||||||
|
sleep 5
|
||||||
|
|
||||||
|
|
||||||
|
- name: install jmeter
|
||||||
|
run: |
|
||||||
|
java -version
|
||||||
|
wget https://downloads.apache.org//jmeter/binaries/apache-jmeter-5.6.2.zip
|
||||||
|
unzip apache-jmeter-5.6.2.zip
|
||||||
|
$GITHUB_WORKSPACE/apache-jmeter-5.6.2/bin/jmeter -v
|
||||||
|
|
||||||
|
- name: start mirror server
|
||||||
|
run: |
|
||||||
|
libs_dir=$GITHUB_WORKSPACE/apache-jmeter-5.6.2/lib/
|
||||||
|
echo "Fix bad Jmeter lib names. Lib dir: $libs_dir"
|
||||||
|
|
||||||
|
mv $libs_dir/slf4j-api-1.7.36.jar $libs_dir/slf4j-api-1.7.25.jar
|
||||||
|
mv $libs_dir/log4j-slf4j-impl-2.20.0.jar $libs_dir/log4j-slf4j-impl-2.11.0.jar
|
||||||
|
mv $libs_dir/log4j-api-2.20.0.jar $libs_dir/log4j-api-2.11.1.jar
|
||||||
|
mv $libs_dir/log4j-core-2.20.0.jar $libs_dir/log4j-core-2.11.1.jar
|
||||||
|
mv $libs_dir/log4j-1.2-api-2.20.0.jar $libs_dir/og4j-1.2-api-2.11.1.jar
|
||||||
|
|
||||||
|
echo "Starting the mirror server on dfault port 8081."
|
||||||
|
$GITHUB_WORKSPACE/apache-jmeter-5.6.2/bin/mirror-server &
|
||||||
|
|
||||||
|
- name: run jmx scripts
|
||||||
|
run: |
|
||||||
|
for file in $( ls $GITHUB_WORKSPACE/integration/*.jmx); do
|
||||||
|
echo "Running test with $file"
|
||||||
|
filename=$(basename "$file" ".jmx")
|
||||||
|
$GITHUB_WORKSPACE/apache-jmeter-5.6.2/bin/jmeter -n -t $file -l logs/$filename.log -e -o reports ;
|
||||||
|
error_count=$(cat jmeter.log | grep "summary =" | awk '{print $19}')
|
||||||
|
echo "Error count: $error_count"
|
||||||
|
if [[ "$error_count" == "0" ]]; then
|
||||||
|
echo "Test $filename OK."
|
||||||
|
rm -r reports/*
|
||||||
|
else
|
||||||
|
echo "Test $filename failed. Error count: $error_count."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v3
|
||||||
|
if: ${{ always() }}
|
||||||
|
with:
|
||||||
|
name: jmeter-test-results
|
||||||
|
path: |
|
||||||
|
reports/
|
||||||
|
logs/
|
||||||
|
|
||||||
@@ -3,25 +3,53 @@ on:
|
|||||||
workflow_call:
|
workflow_call:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|
||||||
black:
|
black:
|
||||||
uses: ./.github/workflows/make.yml
|
uses: ./.github/workflows/make.yml
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
python-version: ["3.9", "3.10"]
|
||||||
with:
|
with:
|
||||||
make: checkblack
|
make: checkblack
|
||||||
|
python-version: ${{ matrix.python-version }}
|
||||||
|
|
||||||
ruff:
|
ruff:
|
||||||
uses: ./.github/workflows/make.yml
|
uses: ./.github/workflows/make.yml
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
python-version: ["3.9", "3.10"]
|
||||||
with:
|
with:
|
||||||
make: checkruff
|
make: checkruff
|
||||||
|
python-version: ${{ matrix.python-version }}
|
||||||
|
|
||||||
mypy:
|
mypy:
|
||||||
uses: ./.github/workflows/make.yml
|
uses: ./.github/workflows/make.yml
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
python-version: ["3.9", "3.10"]
|
||||||
with:
|
with:
|
||||||
make: mypy
|
make: mypy
|
||||||
|
python-version: ${{ matrix.python-version }}
|
||||||
|
|
||||||
pyright:
|
pyright:
|
||||||
uses: ./.github/workflows/make.yml
|
uses: ./.github/workflows/make.yml
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
python-version: ["3.9", "3.10"]
|
||||||
with:
|
with:
|
||||||
make: pyright
|
make: pyright
|
||||||
|
python-version: ${{ matrix.python-version }}
|
||||||
npm: true
|
npm: true
|
||||||
|
|
||||||
|
|
||||||
prettier:
|
prettier:
|
||||||
uses: ./.github/workflows/make.yml
|
uses: ./.github/workflows/make.yml
|
||||||
with:
|
with:
|
||||||
make: prettier
|
make: checkprettier
|
||||||
|
npm: true
|
||||||
|
|
||||||
|
bundle:
|
||||||
|
uses: ./.github/workflows/make.yml
|
||||||
|
with:
|
||||||
|
make: checkbundle
|
||||||
npm: true
|
npm: true
|
||||||
|
|||||||
@@ -11,21 +11,24 @@ on:
|
|||||||
description: "use npm install"
|
description: "use npm install"
|
||||||
default: false
|
default: false
|
||||||
type: boolean
|
type: boolean
|
||||||
|
python-version:
|
||||||
|
description: "python version"
|
||||||
|
type: string
|
||||||
|
default: "3.10"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
make:
|
make:
|
||||||
name: ${{ inputs.make }} (${{ matrix.python-version }})
|
name: ${{ inputs.make }} (${{ inputs.python-version }})
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
os-version: ["ubuntu-latest"]
|
os-version: ["ubuntu-latest"]
|
||||||
python-version: ["3.9", "3.10"]
|
|
||||||
node-version: ["18.x"]
|
node-version: ["18.x"]
|
||||||
runs-on: ${{ matrix.os-version }}
|
runs-on: ${{ matrix.os-version }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
- uses: ./.github/actions/prepare
|
- uses: ./.github/actions/prepare
|
||||||
with:
|
with:
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ inputs.python-version }}
|
||||||
node-version: ${{ matrix.node-version }}
|
node-version: ${{ matrix.node-version }}
|
||||||
npm: ${{ inputs.npm }}
|
npm: ${{ inputs.npm }}
|
||||||
- run: make ${{ inputs.make }}
|
- run: make ${{ inputs.make }}
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
name: LNbits CI / nix
|
||||||
|
|
||||||
|
# - run : on main, dev, nix and cachix branches when relevant files change
|
||||||
|
# - cache : on main, dev and cachix branches when relevant files change
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- dev
|
||||||
|
- nix
|
||||||
|
- cachix
|
||||||
|
paths:
|
||||||
|
- 'flake.nix'
|
||||||
|
- 'flake.lock'
|
||||||
|
- 'pyproject.toml'
|
||||||
|
- 'poetry.lock'
|
||||||
|
- '.github/workflows/nix.yml'
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- 'flake.nix'
|
||||||
|
- 'flake.lock'
|
||||||
|
- 'pyproject.toml'
|
||||||
|
- 'poetry.lock'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
nix:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: cachix/install-nix-action@v24
|
||||||
|
with:
|
||||||
|
nix_path: nixpkgs=channel:nixos-23.11
|
||||||
|
- uses: cachix/cachix-action@v13
|
||||||
|
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/cachix'
|
||||||
|
with:
|
||||||
|
name: lnbits
|
||||||
|
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
||||||
|
- run: nix build -L
|
||||||
|
- run: cachix push lnbits ./result
|
||||||
|
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/cachix'
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
name: Build and push Docker image on tag
|
|
||||||
|
|
||||||
env:
|
|
||||||
DOCKER_CLI_EXPERIMENTAL: enabled
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- "[0-9]+.[0-9]+.[0-9]+"
|
|
||||||
- "[0-9]+.[0-9]+.[0-9]+.[0-9]+"
|
|
||||||
- "[0-9]+.[0-9]+.[0-9]+-*"
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-20.04
|
|
||||||
name: Build and push lnbits image
|
|
||||||
steps:
|
|
||||||
- name: Login to Docker Hub
|
|
||||||
run: echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_USERNAME }}" --password-stdin
|
|
||||||
|
|
||||||
- name: Checkout project
|
|
||||||
uses: actions/checkout@v3
|
|
||||||
|
|
||||||
- name: Import environment variables
|
|
||||||
id: import-env
|
|
||||||
shell: bash
|
|
||||||
run: echo "TAG=${GITHUB_REF/refs\/tags\//}" >> $GITHUB_ENV
|
|
||||||
|
|
||||||
- name: Show set environment variables
|
|
||||||
run: |
|
|
||||||
printf " TAG: %s\n" "$TAG"
|
|
||||||
|
|
||||||
- name: Set up QEMU
|
|
||||||
uses: docker/setup-qemu-action@v1
|
|
||||||
id: qemu
|
|
||||||
|
|
||||||
- name: Setup Docker buildx action
|
|
||||||
uses: docker/setup-buildx-action@v1
|
|
||||||
id: buildx
|
|
||||||
|
|
||||||
- name: Show available Docker buildx platforms
|
|
||||||
run: echo ${{ steps.buildx.outputs.platforms }}
|
|
||||||
|
|
||||||
- name: Cache Docker layers
|
|
||||||
uses: actions/cache@v2
|
|
||||||
id: cache
|
|
||||||
with:
|
|
||||||
path: /tmp/.buildx-cache
|
|
||||||
key: ${{ runner.os }}-buildx-${{ github.sha }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-buildx-
|
|
||||||
|
|
||||||
- name: Run Docker buildx against tag
|
|
||||||
run: |
|
|
||||||
docker buildx build \
|
|
||||||
--cache-from "type=local,src=/tmp/.buildx-cache" \
|
|
||||||
--cache-to "type=local,dest=/tmp/.buildx-cache" \
|
|
||||||
--platform linux/amd64,linux/arm64 \
|
|
||||||
--tag ${{ secrets.DOCKER_USERNAME }}/lnbits-legend:${TAG} \
|
|
||||||
--output "type=registry" ./
|
|
||||||
|
|
||||||
- name: Run Docker buildx against latest
|
|
||||||
run: |
|
|
||||||
docker buildx build \
|
|
||||||
--cache-from "type=local,src=/tmp/.buildx-cache" \
|
|
||||||
--cache-to "type=local,dest=/tmp/.buildx-cache" \
|
|
||||||
--platform linux/amd64,linux/arm64 \
|
|
||||||
--tag ${{ secrets.DOCKER_USERNAME }}/lnbits-legend:latest \
|
|
||||||
--output "type=registry" ./
|
|
||||||
@@ -33,8 +33,8 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
push: false
|
push: false
|
||||||
tags: lnbitsdocker/lnbits-legend:latest
|
tags: lnbits/lnbits:latest
|
||||||
cache-from: type=registry,ref=lnbitsdocker/lnbits-legend:latest
|
cache-from: type=registry,ref=lnbits/lnbits:latest
|
||||||
cache-to: type=inline
|
cache-to: type=inline
|
||||||
|
|
||||||
- name: Setup Regtest
|
- name: Setup Regtest
|
||||||
@@ -51,7 +51,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Create fake admin
|
- name: Create fake admin
|
||||||
if: ${{ inputs.backend-wallet-class == 'LNbitsWallet' }}
|
if: ${{ inputs.backend-wallet-class == 'LNbitsWallet' }}
|
||||||
run: docker exec lnbits-legend-lnbits-1 poetry run python tools/create_fake_admin.py
|
run: docker exec lnbits-lnbits-1 poetry run python tools/create_fake_admin.py
|
||||||
|
|
||||||
- name: Run Tests
|
- name: Run Tests
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
name: release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v[0-9]+.[0-9]+.[0-9]+"
|
||||||
|
- "[0-9]+.[0-9]+.[0-9]+"
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
- name: Create github release
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
tag: ${{ github.ref_name }}
|
||||||
|
run: |
|
||||||
|
gh release create "$tag" --generate-notes --draft
|
||||||
|
|
||||||
|
docker:
|
||||||
|
needs: [ release ]
|
||||||
|
uses: ./.github/workflows/docker.yml
|
||||||
|
with:
|
||||||
|
tag: ${{ github.ref_name }}
|
||||||
@@ -23,7 +23,6 @@ tests/data/*.sqlite3
|
|||||||
*.pyc
|
*.pyc
|
||||||
*.env
|
*.env
|
||||||
.env
|
.env
|
||||||
.super_user
|
|
||||||
.pre-commit-config.yaml
|
.pre-commit-config.yaml
|
||||||
|
|
||||||
data
|
data
|
||||||
@@ -36,6 +35,8 @@ coverage.xml
|
|||||||
node_modules
|
node_modules
|
||||||
lnbits/static/bundle.js
|
lnbits/static/bundle.js
|
||||||
lnbits/static/bundle.css
|
lnbits/static/bundle.css
|
||||||
|
lnbits/static/bundle.min.js.old
|
||||||
|
lnbits/static/bundle.min.css.old
|
||||||
docker
|
docker
|
||||||
|
|
||||||
# Nix
|
# Nix
|
||||||
|
|||||||
@@ -14,11 +14,11 @@ repos:
|
|||||||
- id: mixed-line-ending
|
- id: mixed-line-ending
|
||||||
- id: check-case-conflict
|
- id: check-case-conflict
|
||||||
- repo: https://github.com/psf/black
|
- repo: https://github.com/psf/black
|
||||||
rev: 23.7.0
|
rev: 24.2.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: black
|
- id: black
|
||||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
rev: v0.0.283
|
rev: v0.3.2
|
||||||
hooks:
|
hooks:
|
||||||
- id: ruff
|
- id: ruff
|
||||||
args: [ --fix, --exit-non-zero-on-fix ]
|
args: [ --fix, --exit-non-zero-on-fix ]
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ FROM python:3.10-slim-bullseye
|
|||||||
|
|
||||||
RUN apt-get clean
|
RUN apt-get clean
|
||||||
RUN apt-get update
|
RUN apt-get update
|
||||||
RUN apt-get install -y curl pkg-config build-essential
|
RUN apt-get install -y curl pkg-config build-essential libnss-myhostname
|
||||||
|
|
||||||
RUN curl -sSL https://install.python-poetry.org | python3 -
|
RUN curl -sSL https://install.python-poetry.org | python3 -
|
||||||
ENV PATH="/root/.local/bin:$PATH"
|
ENV PATH="/root/.local/bin:$PATH"
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ all: format check
|
|||||||
|
|
||||||
format: prettier black ruff
|
format: prettier black ruff
|
||||||
|
|
||||||
check: mypy pyright checkblack checkruff checkprettier
|
check: mypy pyright checkblack checkruff checkprettier checkbundle
|
||||||
|
|
||||||
prettier:
|
prettier:
|
||||||
poetry run ./node_modules/.bin/prettier --write lnbits
|
poetry run ./node_modules/.bin/prettier --write lnbits
|
||||||
@@ -30,6 +30,9 @@ checkprettier:
|
|||||||
checkblack:
|
checkblack:
|
||||||
poetry run black --check .
|
poetry run black --check .
|
||||||
|
|
||||||
|
checkeditorconfig:
|
||||||
|
editorconfig-checker
|
||||||
|
|
||||||
dev:
|
dev:
|
||||||
poetry run lnbits --reload
|
poetry run lnbits --reload
|
||||||
|
|
||||||
@@ -48,18 +51,17 @@ test-real-wallet:
|
|||||||
poetry run pytest
|
poetry run pytest
|
||||||
|
|
||||||
test-migration:
|
test-migration:
|
||||||
rm -rf ./migration-data
|
LNBITS_ADMIN_UI=True \
|
||||||
mkdir -p ./migration-data
|
make test
|
||||||
unzip tests/data/mock_data.zip -d ./migration-data
|
|
||||||
HOST=0.0.0.0 \
|
HOST=0.0.0.0 \
|
||||||
PORT=5002 \
|
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
|
timeout 5s poetry run lnbits --host 0.0.0.0 --port 5002 || code=$?; if [[ $code -ne 124 && $code -ne 0 ]]; then exit $code; fi
|
||||||
HOST=0.0.0.0 \
|
HOST=0.0.0.0 \
|
||||||
PORT=5002 \
|
PORT=5002 \
|
||||||
LNBITS_DATABASE_URL="postgres://lnbits:lnbits@localhost:5432/migration" \
|
LNBITS_DATABASE_URL="postgres://lnbits:lnbits@localhost:5432/migration" \
|
||||||
timeout 5s poetry run lnbits --host 0.0.0.0 --port 5002 || code=$?; if [[ $code -ne 124 && $code -ne 0 ]]; then exit $code; fi
|
timeout 5s 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" \
|
LNBITS_DATABASE_URL="postgres://lnbits:lnbits@localhost:5432/migration" \
|
||||||
poetry run python tools/conv.py
|
poetry run python tools/conv.py
|
||||||
|
|
||||||
@@ -94,9 +96,16 @@ bundle:
|
|||||||
npm run vendor_minify_css
|
npm run vendor_minify_css
|
||||||
npm run vendor_bundle_js
|
npm run vendor_bundle_js
|
||||||
npm run vendor_minify_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)/" \
|
checkbundle:
|
||||||
lnbits/core/static/js/service-worker.js
|
cp lnbits/static/bundle.min.js lnbits/static/bundle.min.js.old
|
||||||
|
cp lnbits/static/bundle.min.css lnbits/static/bundle.min.css.old
|
||||||
|
make bundle
|
||||||
|
diff -q lnbits/static/bundle.min.js lnbits/static/bundle.min.js.old || exit 1
|
||||||
|
diff -q lnbits/static/bundle.min.css lnbits/static/bundle.min.css.old || exit 1
|
||||||
|
@echo "Bundle is OK"
|
||||||
|
rm lnbits/static/bundle.min.js.old
|
||||||
|
rm lnbits/static/bundle.min.css.old
|
||||||
|
|
||||||
install-pre-commit-hook:
|
install-pre-commit-hook:
|
||||||
@echo "Installing pre-commit hook to git"
|
@echo "Installing pre-commit hook to git"
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
# LNbits
|
# LNbits BETA
|
||||||
|
|
||||||
[![license-badge]](LICENSE)
|
[![license-badge]](LICENSE)
|
||||||
[![docs-badge]][docs]
|
[![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))
|
(Join us on [https://t.me/lnbits](https://t.me/lnbits))
|
||||||
|
|
||||||
@@ -21,9 +21,18 @@ LNbits is a Python server that sits on top of any funding source. It can be used
|
|||||||
- Fallback wallet for the LNURL scheme
|
- Fallback wallet for the LNURL scheme
|
||||||
- Instant wallet for LN demonstrations
|
- 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
|
||||||
|
- ZBD
|
||||||
|
- 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.
|
Checkout the LNbits [YouTube](https://www.youtube.com/playlist?list=PLPj3KCksGbSYG0ciIQUWJru1dWstPHshe) video series.
|
||||||
|
|
||||||
@@ -52,7 +61,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.
|
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,7 +1,7 @@
|
|||||||
title: "LNbits docs"
|
title: "LNbits docs"
|
||||||
remote_theme: pmarsceill/just-the-docs
|
remote_theme: pmarsceill/just-the-docs
|
||||||
color_scheme: dark
|
color_scheme: dark
|
||||||
logo: "/logos/lnbits-full--inverse.png"
|
logo: "/logos/lnbits-full-inverse.png"
|
||||||
search_enabled: true
|
search_enabled: true
|
||||||
url: https://docs.lnbits.org
|
url: https://docs.lnbits.org
|
||||||
aux_links:
|
aux_links:
|
||||||
|
|||||||
@@ -9,4 +9,4 @@ nav_order: 3
|
|||||||
API reference
|
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
|
```sh
|
||||||
cd lnbits/extensions
|
cd [my-working-folder]
|
||||||
git clone https://github.com/lnbits/example.git --depth=1 mysuperplugin # Let's not use dashes or anything; it doesn't like those.
|
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
|
cd mysuperplugin
|
||||||
rm -rf .git/
|
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'.
|
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.
|
- 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_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.
|
* 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.
|
* migrations.py: Create database tables for your plugin. They'll be created automatically when you start lnbits.
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ nav_order: 4
|
|||||||
Admin UI
|
Admin UI
|
||||||
========
|
========
|
||||||
The LNbits Admin UI lets you change LNbits settings via the LNbits frontend.
|
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.
|
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.
|
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
|
Admin Users
|
||||||
===========
|
===========
|
||||||
environment variable: LNBITS_ADMIN_USERS, comma-separated list of user ids
|
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.
|
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
|
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.
|
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
|
How to activate
|
||||||
=============
|
=============
|
||||||
@@ -57,7 +60,7 @@ $ poetry run lnbits
|
|||||||
```
|
```
|
||||||
You can now `cat` the Super User ID:
|
You can now `cat` the Super User ID:
|
||||||
```
|
```
|
||||||
$ cat .super_user
|
$ cat data/.super_user
|
||||||
123de4bfdddddbbeb48c8bc8382fe123
|
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.
|
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.
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ allow-self-payment=1
|
|||||||
|
|
||||||
<details><summary>Which funding sources can I use for LNbits?</summary>
|
<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>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>
|
</details>
|
||||||
|
|
||||||
<!--Later to be added
|
<!--Later to be added
|
||||||
@@ -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>
|
<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>
|
<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/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>
|
</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>
|
<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>
|
</details>
|
||||||
@@ -263,6 +263,6 @@ allow-self-payment=1
|
|||||||
|
|
||||||
## Developing for LNbits
|
## Developing for LNbits
|
||||||
<ul>
|
<ul>
|
||||||
<li><a href="http://docs.lnbits.org/devs/development.html">Making Estension / how to use Websockets / API reference</a></li>
|
<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>
|
<li><a href="https://t.me/lnbits">Telegram LNbits Support Group</a></li></ul>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ Mininum poetry version has is ^1.2, but it is recommended to use latest poetry.
|
|||||||
```sh
|
```sh
|
||||||
git clone https://github.com/lnbits/lnbits.git
|
git clone https://github.com/lnbits/lnbits.git
|
||||||
cd lnbits
|
cd lnbits
|
||||||
|
git checkout main
|
||||||
|
|
||||||
# for making sure python 3.9 is installed, skip if installed. To check your installed version: python3 --version
|
# for making sure python 3.9 is installed, skip if installed. To check your installed version: python3 --version
|
||||||
sudo apt update
|
sudo apt update
|
||||||
@@ -60,44 +61,59 @@ poetry install --only main
|
|||||||
|
|
||||||
## Option 2: Nix
|
## Option 2: Nix
|
||||||
|
|
||||||
> note: currently not supported while we make some architectural changes on the path to leave beta
|
|
||||||
|
|
||||||
```sh
|
```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
|
||||||
|
|
||||||
|
# Enable nix-command and flakes experimental features for nix:
|
||||||
|
echo 'experimental-features = nix-command flakes' >> /etc/nix/nix.conf
|
||||||
|
|
||||||
|
# Add cachix for cached binaries
|
||||||
|
nix-env -iA cachix -f https://cachix.org/api/v1/install
|
||||||
|
cachix use lnbits
|
||||||
|
|
||||||
|
# Clone and build LNbits
|
||||||
git clone https://github.com/lnbits/lnbits.git
|
git clone https://github.com/lnbits/lnbits.git
|
||||||
cd lnbits
|
cd lnbits
|
||||||
# Modern debian distros usually include Nix, however you can install with:
|
nix build
|
||||||
# '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
|
mkdir data
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Running the server
|
#### Running the server
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
# .env variables are currently passed when running
|
nix run
|
||||||
LNBITS_DATA_FOLDER=data LNBITS_BACKEND_WALLET_CLASS=LNbitsWallet LNBITS_ENDPOINT=https://legend.lnbits.com LNBITS_KEY=7b1a78d6c78f48b09a202f2dcb2d22eb ./result/bin/lnbits --port 9000
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Ideally you would set the environment via the `.env` file,
|
||||||
|
but you can also set the env variables or pass command line arguments:
|
||||||
|
|
||||||
|
``` sh
|
||||||
|
# .env variables are currently passed when running, but LNbits can be managed with the admin UI.
|
||||||
|
LNBITS_ADMIN_UI=true ./result/bin/lnbits --port 9000
|
||||||
|
|
||||||
|
# Once you have created a user, you can set as the super_user
|
||||||
|
SUPER_USER=be54db7f245346c8833eaa430e1e0405 LNBITS_ADMIN_UI=true ./result/bin/lnbits --port 9000
|
||||||
|
```
|
||||||
|
|
||||||
## Option 3: Docker
|
## Option 3: Docker
|
||||||
|
|
||||||
use latest version from docker hub
|
use latest version from docker hub
|
||||||
```sh
|
```sh
|
||||||
docker pull lnbitsdocker/lnbits-legend
|
docker pull lnbits/lnbits
|
||||||
wget https://raw.githubusercontent.com/lnbits/lnbits/main/.env.example -O .env
|
wget https://raw.githubusercontent.com/lnbits/lnbits/main/.env.example -O .env
|
||||||
mkdir data
|
mkdir data
|
||||||
docker run --detach --publish 5000:5000 --name lnbits --volume ${PWD}/.env:/app/.env --volume ${PWD}/data/:/app/data lnbitsdocker/lnbits-legend
|
docker run --detach --publish 5000:5000 --name lnbits --volume ${PWD}/.env:/app/.env --volume ${PWD}/data/:/app/data lnbits/lnbits
|
||||||
```
|
```
|
||||||
build the image yourself
|
build the image yourself
|
||||||
```sh
|
```sh
|
||||||
git clone https://github.com/lnbits/lnbits.git
|
git clone https://github.com/lnbits/lnbits.git
|
||||||
cd lnbits
|
cd lnbits
|
||||||
docker build -t lnbitsdocker/lnbits-legend .
|
docker build -t lnbits/lnbits .
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
mkdir data
|
mkdir data
|
||||||
docker run --detach --publish 5000:5000 --name lnbits --volume ${PWD}/.env:/app/.env --volume ${PWD}/data/:/app/data lnbitsdocker/lnbits-legend
|
docker run --detach --publish 5000:5000 --name lnbits --volume ${PWD}/.env:/app/.env --volume ${PWD}/data/:/app/data lnbits/lnbits
|
||||||
```
|
```
|
||||||
|
|
||||||
## Option 4: Fly.io
|
## Option 4: Fly.io
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ nav_order: 3
|
|||||||
Backend wallets
|
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:
|
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**
|
- `LNBITS_BACKEND_WALLET_CLASS`: **CoreLightningWallet**
|
||||||
- `CORELIGHTNING_RPC`: /file/path/lightning-rpc
|
- `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)
|
### Spark (Core Lightning)
|
||||||
|
|
||||||
- `LNBITS_BACKEND_WALLET_CLASS`: **SparkWallet**
|
- `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**
|
- `LNBITS_BACKEND_WALLET_CLASS`: **LndRestWallet**
|
||||||
- `LND_REST_ENDPOINT`: http://10.147.17.230:8080/
|
- `LND_REST_ENDPOINT`: http://10.147.17.230:8080/
|
||||||
- `LND_REST_CERT`: /file/path/tls.cert
|
- `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
|
or
|
||||||
|
|
||||||
@@ -41,7 +48,7 @@ or
|
|||||||
- `LND_GRPC_ENDPOINT`: ip_address
|
- `LND_GRPC_ENDPOINT`: ip_address
|
||||||
- `LND_GRPC_PORT`: port
|
- `LND_GRPC_PORT`: port
|
||||||
- `LND_GRPC_CERT`: /file/path/tls.cert
|
- `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
|
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_API_KEY`: sak_apiKey
|
||||||
- `LNPAY_WALLET_KEY`: waka_apiKey
|
- `LNPAY_WALLET_KEY`: waka_apiKey
|
||||||
|
|
||||||
|
|
||||||
### OpenNode
|
### OpenNode
|
||||||
|
|
||||||
For the invoice to work you must have a publicly accessible URL in your LNbits. No manual webhook setting is necessary.
|
For the invoice to work you must have a publicly accessible URL in your LNbits. No manual webhook setting is necessary.
|
||||||
@@ -73,6 +79,21 @@ For the invoice to work you must have a publicly accessible URL in your LNbits.
|
|||||||
- `OPENNODE_API_ENDPOINT`: https://api.opennode.com/
|
- `OPENNODE_API_ENDPOINT`: https://api.opennode.com/
|
||||||
- `OPENNODE_KEY`: opennodeAdminApiKey
|
- `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
|
||||||
|
|
||||||
|
### ZBD
|
||||||
|
|
||||||
|
For the invoice to work you must have a publicly accessible URL in your LNbits. No manual webhook setting is necessary. You can generate an ZBD API Key here: https://zbd.dev/docs/dashboard/projects/api
|
||||||
|
|
||||||
|
- `LNBITS_BACKEND_WALLET_CLASS`: **ZBDWallet**
|
||||||
|
- `ZBD_API_ENDPOINT`: https://api.zebedee.io/v0/
|
||||||
|
- `ZBD_API_KEY`: ZBDApiKey
|
||||||
|
|
||||||
### Cliche Wallet
|
### Cliche Wallet
|
||||||
|
|
||||||
|
|||||||
|
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">
|
<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"/>
|
<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"/>
|
<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>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 290 B |
@@ -1,12 +1,15 @@
|
|||||||
{
|
{
|
||||||
"nodes": {
|
"nodes": {
|
||||||
"flake-utils": {
|
"flake-utils": {
|
||||||
|
"inputs": {
|
||||||
|
"systems": "systems"
|
||||||
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1676283394,
|
"lastModified": 1694529238,
|
||||||
"narHash": "sha256-XX2f9c3iySLCw54rJ/CZs+ZK6IQy7GXNY4nSOyu2QG4=",
|
"narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=",
|
||||||
"owner": "numtide",
|
"owner": "numtide",
|
||||||
"repo": "flake-utils",
|
"repo": "flake-utils",
|
||||||
"rev": "3db36a8b464d0c4532ba1c7dda728f4576d6d073",
|
"rev": "ff7b65b44d01cf9ba6a71320833626af21126384",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -15,18 +18,39 @@
|
|||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"nix-github-actions": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": [
|
||||||
|
"poetry2nix",
|
||||||
|
"nixpkgs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1698974481,
|
||||||
|
"narHash": "sha256-yPncV9Ohdz1zPZxYHQf47S8S0VrnhV7nNhCawY46hDA=",
|
||||||
|
"owner": "nix-community",
|
||||||
|
"repo": "nix-github-actions",
|
||||||
|
"rev": "4bb5e752616262457bc7ca5882192a564c0472d2",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nix-community",
|
||||||
|
"repo": "nix-github-actions",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1678470307,
|
"lastModified": 1702233072,
|
||||||
"narHash": "sha256-OEeMUr3ueLIXyW/OaFUX5jUdimyQwMg/7e+/Q0gC/QE=",
|
"narHash": "sha256-H5G2wgbim2Ku6G6w+NSaQaauv6B6DlPhY9fMvArKqRo=",
|
||||||
"owner": "nixos",
|
"owner": "nixos",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "0c4800d579af4ed98ecc47d464a5e7b0870c4b1f",
|
"rev": "781e2a9797ecf0f146e81425c822dca69fe4a348",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
"owner": "nixos",
|
"owner": "nixos",
|
||||||
"ref": "nixos-unstable",
|
"ref": "nixos-23.11",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
@@ -34,16 +58,19 @@
|
|||||||
"poetry2nix": {
|
"poetry2nix": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"flake-utils": "flake-utils",
|
"flake-utils": "flake-utils",
|
||||||
|
"nix-github-actions": "nix-github-actions",
|
||||||
"nixpkgs": [
|
"nixpkgs": [
|
||||||
"nixpkgs"
|
"nixpkgs"
|
||||||
]
|
],
|
||||||
|
"systems": "systems_2",
|
||||||
|
"treefmt-nix": "treefmt-nix"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1678513806,
|
"lastModified": 1702334837,
|
||||||
"narHash": "sha256-bEto1lp9bIQ+DlJsXQyRxN5l6B/oy3Skb7DueYRJJBo=",
|
"narHash": "sha256-QZG6+zFshyY+L8m2tlOTm75U5m9y7z01g0josVK+8Os=",
|
||||||
"owner": "nix-community",
|
"owner": "nix-community",
|
||||||
"repo": "poetry2nix",
|
"repo": "poetry2nix",
|
||||||
"rev": "bf239d53fafb53cf439a72b3a50e86dd6a3984a5",
|
"rev": "1f4bcbf1be73abc232a972a77102a3e820485a99",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -57,6 +84,56 @@
|
|||||||
"nixpkgs": "nixpkgs",
|
"nixpkgs": "nixpkgs",
|
||||||
"poetry2nix": "poetry2nix"
|
"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"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"systems_2": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1681028828,
|
||||||
|
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"id": "systems",
|
||||||
|
"type": "indirect"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"treefmt-nix": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": [
|
||||||
|
"poetry2nix",
|
||||||
|
"nixpkgs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1699786194,
|
||||||
|
"narHash": "sha256-3h3EH1FXQkIeAuzaWB+nK0XK54uSD46pp+dMD3gAcB4=",
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "treefmt-nix",
|
||||||
|
"rev": "e82f32aa7f06bbbd56d7b12186d555223dc399d1",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "treefmt-nix",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"root": "root",
|
"root": "root",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
description = "LNbits, free and open-source Lightning wallet and accounts system";
|
description = "LNbits, free and open-source Lightning wallet and accounts system";
|
||||||
|
|
||||||
inputs = {
|
inputs = {
|
||||||
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
|
nixpkgs.url = "github:nixos/nixpkgs/nixos-23.11";
|
||||||
poetry2nix = {
|
poetry2nix = {
|
||||||
url = "github:nix-community/poetry2nix";
|
url = "github:nix-community/poetry2nix";
|
||||||
inputs.nixpkgs.follows = "nixpkgs";
|
inputs.nixpkgs.follows = "nixpkgs";
|
||||||
@@ -13,28 +13,27 @@
|
|||||||
supportedSystems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ];
|
supportedSystems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ];
|
||||||
forSystems = systems: f:
|
forSystems = systems: f:
|
||||||
nixpkgs.lib.genAttrs systems
|
nixpkgs.lib.genAttrs systems
|
||||||
(system: f system (import nixpkgs { inherit system; overlays = [ poetry2nix.overlay self.overlays.default ]; }));
|
(system: f system (import nixpkgs { inherit system; overlays = [ poetry2nix.overlays.default self.overlays.default ]; }));
|
||||||
forAllSystems = forSystems supportedSystems;
|
forAllSystems = forSystems supportedSystems;
|
||||||
projectName = "lnbits";
|
projectName = "lnbits";
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
devShells = forAllSystems (system: pkgs: {
|
|
||||||
default = pkgs.mkShell {
|
|
||||||
buildInputs = with pkgs; [
|
|
||||||
nodePackages.prettier
|
|
||||||
poetry
|
|
||||||
];
|
|
||||||
};
|
|
||||||
});
|
|
||||||
overlays = {
|
overlays = {
|
||||||
default = final: prev: {
|
default = final: prev: {
|
||||||
${projectName} = self.packages.${final.hostPlatform.system}.${projectName};
|
${projectName} = self.packages.${prev.stdenv.hostPlatform.system}.${projectName};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
packages = forAllSystems (system: pkgs: {
|
packages = forAllSystems (system: pkgs: {
|
||||||
default = self.packages.${system}.${projectName};
|
default = self.packages.${system}.${projectName};
|
||||||
${projectName} = pkgs.poetry2nix.mkPoetryApplication {
|
${projectName} = pkgs.poetry2nix.mkPoetryApplication {
|
||||||
projectDir = ./.;
|
projectDir = ./.;
|
||||||
|
meta.rev = self.dirtyRev or self.rev;
|
||||||
|
meta.mainProgram = projectName;
|
||||||
|
overrides = pkgs.poetry2nix.overrides.withDefaults (final: prev: {
|
||||||
|
protobuf = prev.protobuf.override { preferWheel = true; };
|
||||||
|
ruff = prev.ruff.override { preferWheel = true; };
|
||||||
|
wallycore = prev.wallycore.override { preferWheel = true; };
|
||||||
|
});
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
nixosModules = {
|
nixosModules = {
|
||||||
|
|||||||
@@ -0,0 +1,488 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<jmeterTestPlan version="1.2" properties="5.0" jmeter="5.5">
|
||||||
|
<hashTree>
|
||||||
|
<TestFragmentController guiclass="TestFragmentControllerGui" testclass="TestFragmentController" testname="Test Fragment" enabled="false"/>
|
||||||
|
<hashTree>
|
||||||
|
<GenericController guiclass="LogicControllerGui" testclass="GenericController" testname="Init Account" enabled="true"/>
|
||||||
|
<hashTree>
|
||||||
|
<HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="[wallet] Get main page" enabled="true">
|
||||||
|
<elementProp name="HTTPsampler.Arguments" elementType="Arguments" guiclass="HTTPArgumentsPanel" testclass="Arguments" enabled="true">
|
||||||
|
<collectionProp name="Arguments.arguments"/>
|
||||||
|
</elementProp>
|
||||||
|
<stringProp name="HTTPSampler.domain">${host}</stringProp>
|
||||||
|
<stringProp name="HTTPSampler.port">${port}</stringProp>
|
||||||
|
<stringProp name="HTTPSampler.protocol">${scheme}</stringProp>
|
||||||
|
<stringProp name="HTTPSampler.contentEncoding">UTF-8</stringProp>
|
||||||
|
<stringProp name="HTTPSampler.path">/</stringProp>
|
||||||
|
<stringProp name="HTTPSampler.method">GET</stringProp>
|
||||||
|
<boolProp name="HTTPSampler.follow_redirects">true</boolProp>
|
||||||
|
<boolProp name="HTTPSampler.auto_redirects">false</boolProp>
|
||||||
|
<boolProp name="HTTPSampler.use_keepalive">true</boolProp>
|
||||||
|
<boolProp name="HTTPSampler.DO_MULTIPART_POST">false</boolProp>
|
||||||
|
<stringProp name="HTTPSampler.embedded_url_re"></stringProp>
|
||||||
|
<stringProp name="HTTPSampler.connect_timeout"></stringProp>
|
||||||
|
<stringProp name="HTTPSampler.response_timeout"></stringProp>
|
||||||
|
</HTTPSamplerProxy>
|
||||||
|
<hashTree>
|
||||||
|
<HeaderManager guiclass="HeaderPanel" testclass="HeaderManager" testname="HTTP Header Manager" enabled="true">
|
||||||
|
<collectionProp name="HeaderManager.headers">
|
||||||
|
<elementProp name="Accept-Language" elementType="Header">
|
||||||
|
<stringProp name="Header.name">Accept-Language</stringProp>
|
||||||
|
<stringProp name="Header.value">en-US,en;q=0.5</stringProp>
|
||||||
|
</elementProp>
|
||||||
|
<elementProp name="Upgrade-Insecure-Requests" elementType="Header">
|
||||||
|
<stringProp name="Header.name">Upgrade-Insecure-Requests</stringProp>
|
||||||
|
<stringProp name="Header.value">1</stringProp>
|
||||||
|
</elementProp>
|
||||||
|
<elementProp name="Accept-Encoding" elementType="Header">
|
||||||
|
<stringProp name="Header.name">Accept-Encoding</stringProp>
|
||||||
|
<stringProp name="Header.value">gzip, deflate</stringProp>
|
||||||
|
</elementProp>
|
||||||
|
<elementProp name="User-Agent" elementType="Header">
|
||||||
|
<stringProp name="Header.name">User-Agent</stringProp>
|
||||||
|
<stringProp name="Header.value">Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:105.0) Gecko/20100101 Firefox/105.0</stringProp>
|
||||||
|
</elementProp>
|
||||||
|
<elementProp name="Accept" elementType="Header">
|
||||||
|
<stringProp name="Header.name">Accept</stringProp>
|
||||||
|
<stringProp name="Header.value">text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8</stringProp>
|
||||||
|
</elementProp>
|
||||||
|
</collectionProp>
|
||||||
|
</HeaderManager>
|
||||||
|
<hashTree/>
|
||||||
|
<ResponseAssertion guiclass="AssertionGui" testclass="ResponseAssertion" testname="Check Status Code 200" enabled="true">
|
||||||
|
<collectionProp name="Asserion.test_strings">
|
||||||
|
<stringProp name="49586">200</stringProp>
|
||||||
|
</collectionProp>
|
||||||
|
<stringProp name="Assertion.custom_message"></stringProp>
|
||||||
|
<stringProp name="Assertion.test_field">Assertion.response_code</stringProp>
|
||||||
|
<boolProp name="Assertion.assume_success">false</boolProp>
|
||||||
|
<intProp name="Assertion.test_type">8</intProp>
|
||||||
|
</ResponseAssertion>
|
||||||
|
<hashTree/>
|
||||||
|
</hashTree>
|
||||||
|
<HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="[wallet] Create new account" enabled="true">
|
||||||
|
<boolProp name="HTTPSampler.postBodyRaw">true</boolProp>
|
||||||
|
<elementProp name="HTTPsampler.Arguments" elementType="Arguments">
|
||||||
|
<collectionProp name="Arguments.arguments">
|
||||||
|
<elementProp name="" elementType="HTTPArgument">
|
||||||
|
<boolProp name="HTTPArgument.always_encode">false</boolProp>
|
||||||
|
<stringProp name="Argument.value">{"name":"a1"}</stringProp>
|
||||||
|
<stringProp name="Argument.metadata">=</stringProp>
|
||||||
|
</elementProp>
|
||||||
|
</collectionProp>
|
||||||
|
</elementProp>
|
||||||
|
<stringProp name="HTTPSampler.domain">${host}</stringProp>
|
||||||
|
<stringProp name="HTTPSampler.port">${port}</stringProp>
|
||||||
|
<stringProp name="HTTPSampler.protocol">${scheme}</stringProp>
|
||||||
|
<stringProp name="HTTPSampler.contentEncoding">UTF-8</stringProp>
|
||||||
|
<stringProp name="HTTPSampler.path">/api/v1/account</stringProp>
|
||||||
|
<stringProp name="HTTPSampler.method">POST</stringProp>
|
||||||
|
<boolProp name="HTTPSampler.follow_redirects">true</boolProp>
|
||||||
|
<boolProp name="HTTPSampler.auto_redirects">false</boolProp>
|
||||||
|
<boolProp name="HTTPSampler.use_keepalive">true</boolProp>
|
||||||
|
<boolProp name="HTTPSampler.DO_MULTIPART_POST">false</boolProp>
|
||||||
|
<stringProp name="HTTPSampler.embedded_url_re"></stringProp>
|
||||||
|
<stringProp name="HTTPSampler.connect_timeout"></stringProp>
|
||||||
|
<stringProp name="HTTPSampler.response_timeout"></stringProp>
|
||||||
|
</HTTPSamplerProxy>
|
||||||
|
<hashTree>
|
||||||
|
<HeaderManager guiclass="HeaderPanel" testclass="HeaderManager" testname="HTTP Header Manager" enabled="true">
|
||||||
|
<collectionProp name="HeaderManager.headers">
|
||||||
|
<elementProp name="Referer" elementType="Header">
|
||||||
|
<stringProp name="Header.name">Referer</stringProp>
|
||||||
|
<stringProp name="Header.value">${scheme}://${host}:${port}/</stringProp>
|
||||||
|
</elementProp>
|
||||||
|
<elementProp name="Accept-Language" elementType="Header">
|
||||||
|
<stringProp name="Header.name">Accept-Language</stringProp>
|
||||||
|
<stringProp name="Header.value">en-US,en;q=0.${paidChargeCount}</stringProp>
|
||||||
|
</elementProp>
|
||||||
|
<elementProp name="Origin" elementType="Header">
|
||||||
|
<stringProp name="Header.name">Origin</stringProp>
|
||||||
|
<stringProp name="Header.value">${scheme}://${host}:${port}</stringProp>
|
||||||
|
</elementProp>
|
||||||
|
<elementProp name="Content-Type" elementType="Header">
|
||||||
|
<stringProp name="Header.name">Content-Type</stringProp>
|
||||||
|
<stringProp name="Header.value">application/json</stringProp>
|
||||||
|
</elementProp>
|
||||||
|
<elementProp name="Accept-Encoding" elementType="Header">
|
||||||
|
<stringProp name="Header.name">Accept-Encoding</stringProp>
|
||||||
|
<stringProp name="Header.value">gzip, deflate</stringProp>
|
||||||
|
</elementProp>
|
||||||
|
<elementProp name="User-Agent" elementType="Header">
|
||||||
|
<stringProp name="Header.name">User-Agent</stringProp>
|
||||||
|
<stringProp name="Header.value">Mozilla/${paidChargeCount}.0 (Macintosh; Intel Mac OS X 10.15; rv:109.0) Gecko/20100101 Firefox/117.0</stringProp>
|
||||||
|
</elementProp>
|
||||||
|
<elementProp name="Accept" elementType="Header">
|
||||||
|
<stringProp name="Header.name">Accept</stringProp>
|
||||||
|
<stringProp name="Header.value">application/json, text/plain, */*</stringProp>
|
||||||
|
</elementProp>
|
||||||
|
</collectionProp>
|
||||||
|
</HeaderManager>
|
||||||
|
<hashTree/>
|
||||||
|
<ResponseAssertion guiclass="AssertionGui" testclass="ResponseAssertion" testname="Check Status Code 200" enabled="true">
|
||||||
|
<collectionProp name="Asserion.test_strings">
|
||||||
|
<stringProp name="49586">200</stringProp>
|
||||||
|
</collectionProp>
|
||||||
|
<stringProp name="Assertion.custom_message"></stringProp>
|
||||||
|
<stringProp name="Assertion.test_field">Assertion.response_code</stringProp>
|
||||||
|
<boolProp name="Assertion.assume_success">false</boolProp>
|
||||||
|
<intProp name="Assertion.test_type">8</intProp>
|
||||||
|
</ResponseAssertion>
|
||||||
|
<hashTree/>
|
||||||
|
<JSR223PostProcessor guiclass="TestBeanGUI" testclass="JSR223PostProcessor" testname="Extract user and wallet" enabled="true">
|
||||||
|
<stringProp name="cacheKey">true</stringProp>
|
||||||
|
<stringProp name="filename"></stringProp>
|
||||||
|
<stringProp name="parameters"></stringProp>
|
||||||
|
<stringProp name="script">var resp = JSON.parse(prev.getResponseDataAsString())
|
||||||
|
|
||||||
|
vars.put("userId", resp.user || "no-user-id");
|
||||||
|
vars.put("walletId", resp.id || "no-wallet-id");
|
||||||
|
vars.put("inkey", resp.inkey || 'no-inkey');
|
||||||
|
vars.put("adminkey", resp.adminkey || 'no-adminkey');
|
||||||
|
</stringProp>
|
||||||
|
<stringProp name="scriptLanguage">javascript</stringProp>
|
||||||
|
</JSR223PostProcessor>
|
||||||
|
<hashTree/>
|
||||||
|
</hashTree>
|
||||||
|
<HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="[wallet] Login with user id" enabled="true">
|
||||||
|
<boolProp name="HTTPSampler.postBodyRaw">true</boolProp>
|
||||||
|
<elementProp name="HTTPsampler.Arguments" elementType="Arguments">
|
||||||
|
<collectionProp name="Arguments.arguments">
|
||||||
|
<elementProp name="" elementType="HTTPArgument">
|
||||||
|
<boolProp name="HTTPArgument.always_encode">false</boolProp>
|
||||||
|
<stringProp name="Argument.value">{"usr":"${userId}"}</stringProp>
|
||||||
|
<stringProp name="Argument.metadata">=</stringProp>
|
||||||
|
</elementProp>
|
||||||
|
</collectionProp>
|
||||||
|
</elementProp>
|
||||||
|
<stringProp name="HTTPSampler.domain">${host}</stringProp>
|
||||||
|
<stringProp name="HTTPSampler.port">${port}</stringProp>
|
||||||
|
<stringProp name="HTTPSampler.protocol">${scheme}</stringProp>
|
||||||
|
<stringProp name="HTTPSampler.contentEncoding">UTF-8</stringProp>
|
||||||
|
<stringProp name="HTTPSampler.path">/api/v1/auth/usr</stringProp>
|
||||||
|
<stringProp name="HTTPSampler.method">POST</stringProp>
|
||||||
|
<boolProp name="HTTPSampler.follow_redirects">true</boolProp>
|
||||||
|
<boolProp name="HTTPSampler.auto_redirects">false</boolProp>
|
||||||
|
<boolProp name="HTTPSampler.use_keepalive">true</boolProp>
|
||||||
|
<boolProp name="HTTPSampler.DO_MULTIPART_POST">false</boolProp>
|
||||||
|
<stringProp name="HTTPSampler.embedded_url_re"></stringProp>
|
||||||
|
<stringProp name="HTTPSampler.connect_timeout"></stringProp>
|
||||||
|
<stringProp name="HTTPSampler.response_timeout"></stringProp>
|
||||||
|
</HTTPSamplerProxy>
|
||||||
|
<hashTree>
|
||||||
|
<HeaderManager guiclass="HeaderPanel" testclass="HeaderManager" testname="HTTP Header Manager" enabled="true">
|
||||||
|
<collectionProp name="HeaderManager.headers">
|
||||||
|
<elementProp name="Referer" elementType="Header">
|
||||||
|
<stringProp name="Header.name">Referer</stringProp>
|
||||||
|
<stringProp name="Header.value">${scheme}://${host}:${port}/</stringProp>
|
||||||
|
</elementProp>
|
||||||
|
<elementProp name="Accept-Language" elementType="Header">
|
||||||
|
<stringProp name="Header.name">Accept-Language</stringProp>
|
||||||
|
<stringProp name="Header.value">en-US,en;q=0.${paidChargeCount}</stringProp>
|
||||||
|
</elementProp>
|
||||||
|
<elementProp name="Origin" elementType="Header">
|
||||||
|
<stringProp name="Header.name">Origin</stringProp>
|
||||||
|
<stringProp name="Header.value">${scheme}://${host}:${port}</stringProp>
|
||||||
|
</elementProp>
|
||||||
|
<elementProp name="Content-Type" elementType="Header">
|
||||||
|
<stringProp name="Header.name">Content-Type</stringProp>
|
||||||
|
<stringProp name="Header.value">application/json</stringProp>
|
||||||
|
</elementProp>
|
||||||
|
<elementProp name="Accept-Encoding" elementType="Header">
|
||||||
|
<stringProp name="Header.name">Accept-Encoding</stringProp>
|
||||||
|
<stringProp name="Header.value">gzip, deflate</stringProp>
|
||||||
|
</elementProp>
|
||||||
|
<elementProp name="User-Agent" elementType="Header">
|
||||||
|
<stringProp name="Header.name">User-Agent</stringProp>
|
||||||
|
<stringProp name="Header.value">Mozilla/${paidChargeCount}.0 (Macintosh; Intel Mac OS X 10.15; rv:109.0) Gecko/20100101 Firefox/117.0</stringProp>
|
||||||
|
</elementProp>
|
||||||
|
<elementProp name="Accept" elementType="Header">
|
||||||
|
<stringProp name="Header.name">Accept</stringProp>
|
||||||
|
<stringProp name="Header.value">application/json, text/plain, */*</stringProp>
|
||||||
|
</elementProp>
|
||||||
|
</collectionProp>
|
||||||
|
</HeaderManager>
|
||||||
|
<hashTree/>
|
||||||
|
<ResponseAssertion guiclass="AssertionGui" testclass="ResponseAssertion" testname="Check Status Code 200" enabled="true">
|
||||||
|
<collectionProp name="Asserion.test_strings">
|
||||||
|
<stringProp name="49586">200</stringProp>
|
||||||
|
</collectionProp>
|
||||||
|
<stringProp name="Assertion.custom_message"></stringProp>
|
||||||
|
<stringProp name="Assertion.test_field">Assertion.response_code</stringProp>
|
||||||
|
<boolProp name="Assertion.assume_success">false</boolProp>
|
||||||
|
<intProp name="Assertion.test_type">8</intProp>
|
||||||
|
</ResponseAssertion>
|
||||||
|
<hashTree/>
|
||||||
|
</hashTree>
|
||||||
|
<HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="[wallet] Go to wallet page" enabled="true">
|
||||||
|
<elementProp name="HTTPsampler.Arguments" elementType="Arguments" guiclass="HTTPArgumentsPanel" testclass="Arguments" enabled="true">
|
||||||
|
<collectionProp name="Arguments.arguments">
|
||||||
|
<elementProp name="wal" elementType="HTTPArgument">
|
||||||
|
<boolProp name="HTTPArgument.always_encode">false</boolProp>
|
||||||
|
<stringProp name="Argument.name">wal</stringProp>
|
||||||
|
<stringProp name="Argument.value">${walletId}</stringProp>
|
||||||
|
<stringProp name="Argument.metadata">=</stringProp>
|
||||||
|
<boolProp name="HTTPArgument.use_equals">true</boolProp>
|
||||||
|
</elementProp>
|
||||||
|
</collectionProp>
|
||||||
|
</elementProp>
|
||||||
|
<stringProp name="HTTPSampler.domain">${host}</stringProp>
|
||||||
|
<stringProp name="HTTPSampler.port">${port}</stringProp>
|
||||||
|
<stringProp name="HTTPSampler.protocol">${scheme}</stringProp>
|
||||||
|
<stringProp name="HTTPSampler.contentEncoding">UTF-8</stringProp>
|
||||||
|
<stringProp name="HTTPSampler.path">/wallet</stringProp>
|
||||||
|
<stringProp name="HTTPSampler.method">GET</stringProp>
|
||||||
|
<boolProp name="HTTPSampler.follow_redirects">true</boolProp>
|
||||||
|
<boolProp name="HTTPSampler.auto_redirects">false</boolProp>
|
||||||
|
<boolProp name="HTTPSampler.use_keepalive">true</boolProp>
|
||||||
|
<boolProp name="HTTPSampler.DO_MULTIPART_POST">false</boolProp>
|
||||||
|
<stringProp name="HTTPSampler.embedded_url_re"></stringProp>
|
||||||
|
<stringProp name="HTTPSampler.connect_timeout"></stringProp>
|
||||||
|
<stringProp name="HTTPSampler.response_timeout"></stringProp>
|
||||||
|
</HTTPSamplerProxy>
|
||||||
|
<hashTree>
|
||||||
|
<HeaderManager guiclass="HeaderPanel" testclass="HeaderManager" testname="HTTP Header Manager" enabled="true">
|
||||||
|
<collectionProp name="HeaderManager.headers">
|
||||||
|
<elementProp name="Referer" elementType="Header">
|
||||||
|
<stringProp name="Header.name">Referer</stringProp>
|
||||||
|
<stringProp name="Header.value">${scheme}://${host}:${port}/</stringProp>
|
||||||
|
</elementProp>
|
||||||
|
<elementProp name="Accept-Language" elementType="Header">
|
||||||
|
<stringProp name="Header.name">Accept-Language</stringProp>
|
||||||
|
<stringProp name="Header.value">en-US,en;q=0.5</stringProp>
|
||||||
|
</elementProp>
|
||||||
|
<elementProp name="Upgrade-Insecure-Requests" elementType="Header">
|
||||||
|
<stringProp name="Header.name">Upgrade-Insecure-Requests</stringProp>
|
||||||
|
<stringProp name="Header.value">1</stringProp>
|
||||||
|
</elementProp>
|
||||||
|
<elementProp name="Accept-Encoding" elementType="Header">
|
||||||
|
<stringProp name="Header.name">Accept-Encoding</stringProp>
|
||||||
|
<stringProp name="Header.value">gzip, deflate</stringProp>
|
||||||
|
</elementProp>
|
||||||
|
<elementProp name="User-Agent" elementType="Header">
|
||||||
|
<stringProp name="Header.name">User-Agent</stringProp>
|
||||||
|
<stringProp name="Header.value">Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:105.0) Gecko/20100101 Firefox/105.0</stringProp>
|
||||||
|
</elementProp>
|
||||||
|
<elementProp name="Accept" elementType="Header">
|
||||||
|
<stringProp name="Header.name">Accept</stringProp>
|
||||||
|
<stringProp name="Header.value">text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8</stringProp>
|
||||||
|
</elementProp>
|
||||||
|
</collectionProp>
|
||||||
|
</HeaderManager>
|
||||||
|
<hashTree/>
|
||||||
|
<ResponseAssertion guiclass="AssertionGui" testclass="ResponseAssertion" testname="Check Status Code 200" enabled="true">
|
||||||
|
<collectionProp name="Asserion.test_strings">
|
||||||
|
<stringProp name="49586">200</stringProp>
|
||||||
|
</collectionProp>
|
||||||
|
<stringProp name="Assertion.custom_message"></stringProp>
|
||||||
|
<stringProp name="Assertion.test_field">Assertion.response_code</stringProp>
|
||||||
|
<boolProp name="Assertion.assume_success">false</boolProp>
|
||||||
|
<intProp name="Assertion.test_type">8</intProp>
|
||||||
|
</ResponseAssertion>
|
||||||
|
<hashTree/>
|
||||||
|
</hashTree>
|
||||||
|
<HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="[wallet] Get currencies" enabled="true">
|
||||||
|
<elementProp name="HTTPsampler.Arguments" elementType="Arguments" guiclass="HTTPArgumentsPanel" testclass="Arguments" enabled="true">
|
||||||
|
<collectionProp name="Arguments.arguments"/>
|
||||||
|
</elementProp>
|
||||||
|
<stringProp name="HTTPSampler.domain">${host}</stringProp>
|
||||||
|
<stringProp name="HTTPSampler.port">${port}</stringProp>
|
||||||
|
<stringProp name="HTTPSampler.protocol">${scheme}</stringProp>
|
||||||
|
<stringProp name="HTTPSampler.contentEncoding">UTF-8</stringProp>
|
||||||
|
<stringProp name="HTTPSampler.path">/api/v1/currencies</stringProp>
|
||||||
|
<stringProp name="HTTPSampler.method">GET</stringProp>
|
||||||
|
<boolProp name="HTTPSampler.follow_redirects">true</boolProp>
|
||||||
|
<boolProp name="HTTPSampler.auto_redirects">false</boolProp>
|
||||||
|
<boolProp name="HTTPSampler.use_keepalive">true</boolProp>
|
||||||
|
<boolProp name="HTTPSampler.DO_MULTIPART_POST">false</boolProp>
|
||||||
|
<stringProp name="HTTPSampler.embedded_url_re"></stringProp>
|
||||||
|
<stringProp name="HTTPSampler.connect_timeout"></stringProp>
|
||||||
|
<stringProp name="HTTPSampler.response_timeout"></stringProp>
|
||||||
|
</HTTPSamplerProxy>
|
||||||
|
<hashTree>
|
||||||
|
<HeaderManager guiclass="HeaderPanel" testclass="HeaderManager" testname="HTTP Header Manager" enabled="true">
|
||||||
|
<collectionProp name="HeaderManager.headers">
|
||||||
|
<elementProp name="Referer" elementType="Header">
|
||||||
|
<stringProp name="Header.name">Referer</stringProp>
|
||||||
|
<stringProp name="Header.value">${scheme}://${host}:${port}/wallet?wal=${walletId}</stringProp>
|
||||||
|
</elementProp>
|
||||||
|
<elementProp name="Accept-Language" elementType="Header">
|
||||||
|
<stringProp name="Header.name">Accept-Language</stringProp>
|
||||||
|
<stringProp name="Header.value">en-US,en;q=0.5</stringProp>
|
||||||
|
</elementProp>
|
||||||
|
<elementProp name="X-Api-Key" elementType="Header">
|
||||||
|
<stringProp name="Header.name">X-Api-Key</stringProp>
|
||||||
|
<stringProp name="Header.value">undefined</stringProp>
|
||||||
|
</elementProp>
|
||||||
|
<elementProp name="Accept-Encoding" elementType="Header">
|
||||||
|
<stringProp name="Header.name">Accept-Encoding</stringProp>
|
||||||
|
<stringProp name="Header.value">gzip, deflate</stringProp>
|
||||||
|
</elementProp>
|
||||||
|
<elementProp name="User-Agent" elementType="Header">
|
||||||
|
<stringProp name="Header.name">User-Agent</stringProp>
|
||||||
|
<stringProp name="Header.value">Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:105.0) Gecko/20100101 Firefox/105.0</stringProp>
|
||||||
|
</elementProp>
|
||||||
|
<elementProp name="Accept" elementType="Header">
|
||||||
|
<stringProp name="Header.name">Accept</stringProp>
|
||||||
|
<stringProp name="Header.value">application/json, text/plain, */*</stringProp>
|
||||||
|
</elementProp>
|
||||||
|
</collectionProp>
|
||||||
|
</HeaderManager>
|
||||||
|
<hashTree/>
|
||||||
|
<ResponseAssertion guiclass="AssertionGui" testclass="ResponseAssertion" testname="Check Status Code 200" enabled="true">
|
||||||
|
<collectionProp name="Asserion.test_strings">
|
||||||
|
<stringProp name="49586">200</stringProp>
|
||||||
|
</collectionProp>
|
||||||
|
<stringProp name="Assertion.custom_message"></stringProp>
|
||||||
|
<stringProp name="Assertion.test_field">Assertion.response_code</stringProp>
|
||||||
|
<boolProp name="Assertion.assume_success">false</boolProp>
|
||||||
|
<intProp name="Assertion.test_type">8</intProp>
|
||||||
|
</ResponseAssertion>
|
||||||
|
<hashTree/>
|
||||||
|
<ResponseAssertion guiclass="AssertionGui" testclass="ResponseAssertion" testname="Check Currency List" enabled="true">
|
||||||
|
<collectionProp name="Asserion.test_strings">
|
||||||
|
<stringProp name="1438956759">["AED","AFN","ALL","AMD","ANG","AOA","ARS","AUD","AWG","AZN","BAM","BBD","BDT","BGN","BHD","BIF","BMD","BND","BOB","BRL","BSD","BTN","BWP","BYN","BYR","BZD","CAD","CDF","CHF","CLF","CLP","CNH","CNY","COP","CRC","CUC","CVE","CZK","DJF","DKK","DOP","DZD","EGP","ERN","ETB","EUR","FJD","FKP","GBP","GEL","GGP","GHS","GIP","GMD","GNF","GTQ","GYD","HKD","HNL","HRK","HTG","HUF","IDR","ILS","IMP","INR","IQD","IRT","ISK","JEP","JMD","JOD","JPY","KES","KGS","KHR","KMF","KRW","KWD","KYD","KZT","LAK","LBP","LKR","LRD","LSL","LYD","MAD","MDL","MGA","MKD","MMK","MNT","MOP","MRO","MUR","MVR","MWK","MXN","MYR","MZN","NAD","NGN","NIO","NOK","NPR","NZD","OMR","PAB","PEN","PGK","PHP","PKR","PLN","PYG","QAR","RON","RSD","RUB","RWF","SAR","SBD","SCR","SEK","SGD","SHP","SLL","SOS","SRD","SSP","STD","SVC","SZL","THB","TJS","TMT","TND","TOP","TRY","TTD","TWD","TZS","UAH","UGX","USD","UYU","UZS","VEF","VES","VND","VUV","WST","XAF","XAG","XAU","XCD","XDR","XOF","XPD","XPF","XPT","YER","ZAR","ZMW","ZWL"]</stringProp>
|
||||||
|
</collectionProp>
|
||||||
|
<stringProp name="Assertion.custom_message"></stringProp>
|
||||||
|
<stringProp name="Assertion.test_field">Assertion.response_data</stringProp>
|
||||||
|
<boolProp name="Assertion.assume_success">false</boolProp>
|
||||||
|
<intProp name="Assertion.test_type">8</intProp>
|
||||||
|
</ResponseAssertion>
|
||||||
|
<hashTree/>
|
||||||
|
</hashTree>
|
||||||
|
<HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="[wallet] Get wallet balance" enabled="true">
|
||||||
|
<elementProp name="HTTPsampler.Arguments" elementType="Arguments" guiclass="HTTPArgumentsPanel" testclass="Arguments" enabled="true">
|
||||||
|
<collectionProp name="Arguments.arguments"/>
|
||||||
|
</elementProp>
|
||||||
|
<stringProp name="HTTPSampler.domain">${host}</stringProp>
|
||||||
|
<stringProp name="HTTPSampler.port">${port}</stringProp>
|
||||||
|
<stringProp name="HTTPSampler.protocol">${scheme}</stringProp>
|
||||||
|
<stringProp name="HTTPSampler.contentEncoding">UTF-8</stringProp>
|
||||||
|
<stringProp name="HTTPSampler.path">/api/v1/wallet</stringProp>
|
||||||
|
<stringProp name="HTTPSampler.method">GET</stringProp>
|
||||||
|
<boolProp name="HTTPSampler.follow_redirects">true</boolProp>
|
||||||
|
<boolProp name="HTTPSampler.auto_redirects">false</boolProp>
|
||||||
|
<boolProp name="HTTPSampler.use_keepalive">true</boolProp>
|
||||||
|
<boolProp name="HTTPSampler.DO_MULTIPART_POST">false</boolProp>
|
||||||
|
<stringProp name="HTTPSampler.embedded_url_re"></stringProp>
|
||||||
|
<stringProp name="HTTPSampler.connect_timeout"></stringProp>
|
||||||
|
<stringProp name="HTTPSampler.response_timeout"></stringProp>
|
||||||
|
</HTTPSamplerProxy>
|
||||||
|
<hashTree>
|
||||||
|
<HeaderManager guiclass="HeaderPanel" testclass="HeaderManager" testname="HTTP Header Manager" enabled="true">
|
||||||
|
<collectionProp name="HeaderManager.headers">
|
||||||
|
<elementProp name="Referer" elementType="Header">
|
||||||
|
<stringProp name="Header.name">Referer</stringProp>
|
||||||
|
<stringProp name="Header.value">${scheme}://${host}:${port}/wallet?wal=${walletId}</stringProp>
|
||||||
|
</elementProp>
|
||||||
|
<elementProp name="Accept-Language" elementType="Header">
|
||||||
|
<stringProp name="Header.name">Accept-Language</stringProp>
|
||||||
|
<stringProp name="Header.value">en-US,en;q=0.5</stringProp>
|
||||||
|
</elementProp>
|
||||||
|
<elementProp name="X-Api-Key" elementType="Header">
|
||||||
|
<stringProp name="Header.name">X-Api-Key</stringProp>
|
||||||
|
<stringProp name="Header.value">${inkey}</stringProp>
|
||||||
|
</elementProp>
|
||||||
|
<elementProp name="Accept-Encoding" elementType="Header">
|
||||||
|
<stringProp name="Header.name">Accept-Encoding</stringProp>
|
||||||
|
<stringProp name="Header.value">gzip, deflate</stringProp>
|
||||||
|
</elementProp>
|
||||||
|
<elementProp name="User-Agent" elementType="Header">
|
||||||
|
<stringProp name="Header.name">User-Agent</stringProp>
|
||||||
|
<stringProp name="Header.value">Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:105.0) Gecko/20100101 Firefox/105.0</stringProp>
|
||||||
|
</elementProp>
|
||||||
|
<elementProp name="Accept" elementType="Header">
|
||||||
|
<stringProp name="Header.name">Accept</stringProp>
|
||||||
|
<stringProp name="Header.value">application/json, text/plain, */*</stringProp>
|
||||||
|
</elementProp>
|
||||||
|
</collectionProp>
|
||||||
|
</HeaderManager>
|
||||||
|
<hashTree/>
|
||||||
|
<ResponseAssertion guiclass="AssertionGui" testclass="ResponseAssertion" testname="Check Status Code 200" enabled="true">
|
||||||
|
<collectionProp name="Asserion.test_strings">
|
||||||
|
<stringProp name="49586">200</stringProp>
|
||||||
|
</collectionProp>
|
||||||
|
<stringProp name="Assertion.custom_message"></stringProp>
|
||||||
|
<stringProp name="Assertion.test_field">Assertion.response_code</stringProp>
|
||||||
|
<boolProp name="Assertion.assume_success">false</boolProp>
|
||||||
|
<intProp name="Assertion.test_type">8</intProp>
|
||||||
|
</ResponseAssertion>
|
||||||
|
<hashTree/>
|
||||||
|
<JSR223Assertion guiclass="TestBeanGUI" testclass="JSR223Assertion" testname="Check Balance is Zero" enabled="true">
|
||||||
|
<stringProp name="cacheKey">true</stringProp>
|
||||||
|
<stringProp name="filename"></stringProp>
|
||||||
|
<stringProp name="parameters"></stringProp>
|
||||||
|
<stringProp name="script">var resp = JSON.parse(prev.getResponseDataAsString())
|
||||||
|
|
||||||
|
if (resp.balance !== 0) {
|
||||||
|
AssertionResult.setFailureMessage("Balance is not zero, but: "+ resp.balance);
|
||||||
|
AssertionResult.setFailure(true)
|
||||||
|
}
|
||||||
|
</stringProp>
|
||||||
|
<stringProp name="scriptLanguage">javascript</stringProp>
|
||||||
|
</JSR223Assertion>
|
||||||
|
<hashTree/>
|
||||||
|
</hashTree>
|
||||||
|
<HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="[wallet] Get payments" enabled="true">
|
||||||
|
<elementProp name="HTTPsampler.Arguments" elementType="Arguments" guiclass="HTTPArgumentsPanel" testclass="Arguments" enabled="true">
|
||||||
|
<collectionProp name="Arguments.arguments"/>
|
||||||
|
</elementProp>
|
||||||
|
<stringProp name="HTTPSampler.domain">${host}</stringProp>
|
||||||
|
<stringProp name="HTTPSampler.port">${port}</stringProp>
|
||||||
|
<stringProp name="HTTPSampler.protocol">${scheme}</stringProp>
|
||||||
|
<stringProp name="HTTPSampler.contentEncoding">UTF-8</stringProp>
|
||||||
|
<stringProp name="HTTPSampler.path">/api/v1/payments</stringProp>
|
||||||
|
<stringProp name="HTTPSampler.method">GET</stringProp>
|
||||||
|
<boolProp name="HTTPSampler.follow_redirects">true</boolProp>
|
||||||
|
<boolProp name="HTTPSampler.auto_redirects">false</boolProp>
|
||||||
|
<boolProp name="HTTPSampler.use_keepalive">true</boolProp>
|
||||||
|
<boolProp name="HTTPSampler.DO_MULTIPART_POST">false</boolProp>
|
||||||
|
<stringProp name="HTTPSampler.embedded_url_re"></stringProp>
|
||||||
|
<stringProp name="HTTPSampler.connect_timeout"></stringProp>
|
||||||
|
<stringProp name="HTTPSampler.response_timeout"></stringProp>
|
||||||
|
</HTTPSamplerProxy>
|
||||||
|
<hashTree>
|
||||||
|
<HeaderManager guiclass="HeaderPanel" testclass="HeaderManager" testname="HTTP Header Manager" enabled="true">
|
||||||
|
<collectionProp name="HeaderManager.headers">
|
||||||
|
<elementProp name="Referer" elementType="Header">
|
||||||
|
<stringProp name="Header.name">Referer</stringProp>
|
||||||
|
<stringProp name="Header.value">${scheme}://${host}:${port}/wallet?wal=${walletId}</stringProp>
|
||||||
|
</elementProp>
|
||||||
|
<elementProp name="Accept-Language" elementType="Header">
|
||||||
|
<stringProp name="Header.name">Accept-Language</stringProp>
|
||||||
|
<stringProp name="Header.value">en-US,en;q=0.5</stringProp>
|
||||||
|
</elementProp>
|
||||||
|
<elementProp name="X-Api-Key" elementType="Header">
|
||||||
|
<stringProp name="Header.name">X-Api-Key</stringProp>
|
||||||
|
<stringProp name="Header.value">${inkey}</stringProp>
|
||||||
|
</elementProp>
|
||||||
|
<elementProp name="Accept-Encoding" elementType="Header">
|
||||||
|
<stringProp name="Header.name">Accept-Encoding</stringProp>
|
||||||
|
<stringProp name="Header.value">gzip, deflate</stringProp>
|
||||||
|
</elementProp>
|
||||||
|
<elementProp name="User-Agent" elementType="Header">
|
||||||
|
<stringProp name="Header.name">User-Agent</stringProp>
|
||||||
|
<stringProp name="Header.value">Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:105.0) Gecko/20100101 Firefox/105.0</stringProp>
|
||||||
|
</elementProp>
|
||||||
|
<elementProp name="Accept" elementType="Header">
|
||||||
|
<stringProp name="Header.name">Accept</stringProp>
|
||||||
|
<stringProp name="Header.value">application/json, text/plain, */*</stringProp>
|
||||||
|
</elementProp>
|
||||||
|
</collectionProp>
|
||||||
|
</HeaderManager>
|
||||||
|
<hashTree/>
|
||||||
|
<ResponseAssertion guiclass="AssertionGui" testclass="ResponseAssertion" testname="Check empty payment list" enabled="true">
|
||||||
|
<collectionProp name="Asserion.test_strings">
|
||||||
|
<stringProp name="2914">[]</stringProp>
|
||||||
|
</collectionProp>
|
||||||
|
<stringProp name="Assertion.custom_message"></stringProp>
|
||||||
|
<stringProp name="Assertion.test_field">Assertion.response_data</stringProp>
|
||||||
|
<boolProp name="Assertion.assume_success">false</boolProp>
|
||||||
|
<intProp name="Assertion.test_type">8</intProp>
|
||||||
|
</ResponseAssertion>
|
||||||
|
<hashTree/>
|
||||||
|
</hashTree>
|
||||||
|
</hashTree>
|
||||||
|
</hashTree>
|
||||||
|
</hashTree>
|
||||||
|
</jmeterTestPlan>
|
||||||
@@ -0,0 +1,487 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<jmeterTestPlan version="1.2" properties="5.0" jmeter="5.5">
|
||||||
|
<hashTree>
|
||||||
|
<TestFragmentController guiclass="TestFragmentControllerGui" testclass="TestFragmentController" testname="Test Fragment" enabled="false"/>
|
||||||
|
<hashTree>
|
||||||
|
<GenericController guiclass="LogicControllerGui" testclass="GenericController" testname="Init Server" enabled="true"/>
|
||||||
|
<hashTree>
|
||||||
|
<HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="[server] Go to home page" enabled="true">
|
||||||
|
<elementProp name="HTTPsampler.Arguments" elementType="Arguments" guiclass="HTTPArgumentsPanel" testclass="Arguments" enabled="true">
|
||||||
|
<collectionProp name="Arguments.arguments"/>
|
||||||
|
</elementProp>
|
||||||
|
<stringProp name="HTTPSampler.domain">${host}</stringProp>
|
||||||
|
<stringProp name="HTTPSampler.port">${port}</stringProp>
|
||||||
|
<stringProp name="HTTPSampler.protocol">${scheme}</stringProp>
|
||||||
|
<stringProp name="HTTPSampler.contentEncoding">UTF-8</stringProp>
|
||||||
|
<stringProp name="HTTPSampler.path">/</stringProp>
|
||||||
|
<stringProp name="HTTPSampler.method">GET</stringProp>
|
||||||
|
<boolProp name="HTTPSampler.follow_redirects">false</boolProp>
|
||||||
|
<boolProp name="HTTPSampler.auto_redirects">false</boolProp>
|
||||||
|
<boolProp name="HTTPSampler.use_keepalive">true</boolProp>
|
||||||
|
<boolProp name="HTTPSampler.DO_MULTIPART_POST">false</boolProp>
|
||||||
|
<stringProp name="HTTPSampler.embedded_url_re"></stringProp>
|
||||||
|
<stringProp name="HTTPSampler.connect_timeout"></stringProp>
|
||||||
|
<stringProp name="HTTPSampler.response_timeout"></stringProp>
|
||||||
|
<stringProp name="TestPlan.comments">Detected the start of a redirect chain</stringProp>
|
||||||
|
</HTTPSamplerProxy>
|
||||||
|
<hashTree>
|
||||||
|
<HeaderManager guiclass="HeaderPanel" testclass="HeaderManager" testname="HTTP Header Manager" enabled="true">
|
||||||
|
<collectionProp name="HeaderManager.headers">
|
||||||
|
<elementProp name="Accept-Language" elementType="Header">
|
||||||
|
<stringProp name="Header.name">Accept-Language</stringProp>
|
||||||
|
<stringProp name="Header.value">en-US,en;q=0.5</stringProp>
|
||||||
|
</elementProp>
|
||||||
|
<elementProp name="Upgrade-Insecure-Requests" elementType="Header">
|
||||||
|
<stringProp name="Header.name">Upgrade-Insecure-Requests</stringProp>
|
||||||
|
<stringProp name="Header.value">1</stringProp>
|
||||||
|
</elementProp>
|
||||||
|
<elementProp name="Accept-Encoding" elementType="Header">
|
||||||
|
<stringProp name="Header.name">Accept-Encoding</stringProp>
|
||||||
|
<stringProp name="Header.value">gzip, deflate</stringProp>
|
||||||
|
</elementProp>
|
||||||
|
<elementProp name="User-Agent" elementType="Header">
|
||||||
|
<stringProp name="Header.name">User-Agent</stringProp>
|
||||||
|
<stringProp name="Header.value">Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:121.0) Gecko/20100101 Firefox/121.0</stringProp>
|
||||||
|
</elementProp>
|
||||||
|
<elementProp name="Accept" elementType="Header">
|
||||||
|
<stringProp name="Header.name">Accept</stringProp>
|
||||||
|
<stringProp name="Header.value">text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8</stringProp>
|
||||||
|
</elementProp>
|
||||||
|
</collectionProp>
|
||||||
|
</HeaderManager>
|
||||||
|
<hashTree/>
|
||||||
|
<ResponseAssertion guiclass="AssertionGui" testclass="ResponseAssertion" testname="Check Status Code 200" enabled="true">
|
||||||
|
<collectionProp name="Asserion.test_strings">
|
||||||
|
<stringProp name="49586">200</stringProp>
|
||||||
|
<stringProp name="50554">307</stringProp>
|
||||||
|
</collectionProp>
|
||||||
|
<stringProp name="Assertion.custom_message"></stringProp>
|
||||||
|
<stringProp name="Assertion.test_field">Assertion.response_code</stringProp>
|
||||||
|
<boolProp name="Assertion.assume_success">false</boolProp>
|
||||||
|
<intProp name="Assertion.test_type">40</intProp>
|
||||||
|
</ResponseAssertion>
|
||||||
|
<hashTree/>
|
||||||
|
<RegexExtractor guiclass="RegexExtractorGui" testclass="RegexExtractor" testname="Extract `is_first_install` var" enabled="true">
|
||||||
|
<stringProp name="RegexExtractor.useHeaders">true</stringProp>
|
||||||
|
<stringProp name="RegexExtractor.refname">is_first_install</stringProp>
|
||||||
|
<stringProp name="RegexExtractor.regex">location: (.*)</stringProp>
|
||||||
|
<stringProp name="RegexExtractor.template">$1$</stringProp>
|
||||||
|
<stringProp name="RegexExtractor.default">not-first-install</stringProp>
|
||||||
|
<stringProp name="RegexExtractor.match_number">0</stringProp>
|
||||||
|
<stringProp name="Sample.scope">all</stringProp>
|
||||||
|
</RegexExtractor>
|
||||||
|
<hashTree/>
|
||||||
|
</hashTree>
|
||||||
|
<IfController guiclass="IfControllerPanel" testclass="IfController" testname="If First Install" enabled="true">
|
||||||
|
<stringProp name="IfController.condition">${__groovy(vars.get('is_first_install').contains("first_install"),)}</stringProp>
|
||||||
|
<boolProp name="IfController.evaluateAll">false</boolProp>
|
||||||
|
<boolProp name="IfController.useExpression">true</boolProp>
|
||||||
|
</IfController>
|
||||||
|
<hashTree>
|
||||||
|
<HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="[server] Go to first_install" enabled="true">
|
||||||
|
<elementProp name="HTTPsampler.Arguments" elementType="Arguments" guiclass="HTTPArgumentsPanel" testclass="Arguments" enabled="true">
|
||||||
|
<collectionProp name="Arguments.arguments"/>
|
||||||
|
</elementProp>
|
||||||
|
<stringProp name="HTTPSampler.domain">${host}</stringProp>
|
||||||
|
<stringProp name="HTTPSampler.port">${port}</stringProp>
|
||||||
|
<stringProp name="HTTPSampler.protocol">${scheme}</stringProp>
|
||||||
|
<stringProp name="HTTPSampler.contentEncoding">UTF-8</stringProp>
|
||||||
|
<stringProp name="HTTPSampler.path">/first_install</stringProp>
|
||||||
|
<stringProp name="HTTPSampler.method">GET</stringProp>
|
||||||
|
<boolProp name="HTTPSampler.follow_redirects">true</boolProp>
|
||||||
|
<boolProp name="HTTPSampler.auto_redirects">false</boolProp>
|
||||||
|
<boolProp name="HTTPSampler.use_keepalive">true</boolProp>
|
||||||
|
<boolProp name="HTTPSampler.DO_MULTIPART_POST">false</boolProp>
|
||||||
|
<stringProp name="HTTPSampler.embedded_url_re"></stringProp>
|
||||||
|
<stringProp name="HTTPSampler.connect_timeout"></stringProp>
|
||||||
|
<stringProp name="HTTPSampler.response_timeout"></stringProp>
|
||||||
|
</HTTPSamplerProxy>
|
||||||
|
<hashTree>
|
||||||
|
<HeaderManager guiclass="HeaderPanel" testclass="HeaderManager" testname="HTTP Header Manager" enabled="true">
|
||||||
|
<collectionProp name="HeaderManager.headers">
|
||||||
|
<elementProp name="Accept-Language" elementType="Header">
|
||||||
|
<stringProp name="Header.name">Accept-Language</stringProp>
|
||||||
|
<stringProp name="Header.value">en-US,en;q=0.5</stringProp>
|
||||||
|
</elementProp>
|
||||||
|
<elementProp name="Upgrade-Insecure-Requests" elementType="Header">
|
||||||
|
<stringProp name="Header.name">Upgrade-Insecure-Requests</stringProp>
|
||||||
|
<stringProp name="Header.value">1</stringProp>
|
||||||
|
</elementProp>
|
||||||
|
<elementProp name="Accept-Encoding" elementType="Header">
|
||||||
|
<stringProp name="Header.name">Accept-Encoding</stringProp>
|
||||||
|
<stringProp name="Header.value">gzip, deflate</stringProp>
|
||||||
|
</elementProp>
|
||||||
|
<elementProp name="User-Agent" elementType="Header">
|
||||||
|
<stringProp name="Header.name">User-Agent</stringProp>
|
||||||
|
<stringProp name="Header.value">Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:121.0) Gecko/20100101 Firefox/121.0</stringProp>
|
||||||
|
</elementProp>
|
||||||
|
<elementProp name="Accept" elementType="Header">
|
||||||
|
<stringProp name="Header.name">Accept</stringProp>
|
||||||
|
<stringProp name="Header.value">text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8</stringProp>
|
||||||
|
</elementProp>
|
||||||
|
</collectionProp>
|
||||||
|
</HeaderManager>
|
||||||
|
<hashTree/>
|
||||||
|
<ResponseAssertion guiclass="AssertionGui" testclass="ResponseAssertion" testname="Check Status Code 200" enabled="true">
|
||||||
|
<collectionProp name="Asserion.test_strings">
|
||||||
|
<stringProp name="49586">200</stringProp>
|
||||||
|
</collectionProp>
|
||||||
|
<stringProp name="Assertion.custom_message"></stringProp>
|
||||||
|
<stringProp name="Assertion.test_field">Assertion.response_code</stringProp>
|
||||||
|
<boolProp name="Assertion.assume_success">false</boolProp>
|
||||||
|
<intProp name="Assertion.test_type">8</intProp>
|
||||||
|
</ResponseAssertion>
|
||||||
|
<hashTree/>
|
||||||
|
</hashTree>
|
||||||
|
<HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="[server] Init super user" enabled="true">
|
||||||
|
<boolProp name="HTTPSampler.postBodyRaw">true</boolProp>
|
||||||
|
<elementProp name="HTTPsampler.Arguments" elementType="Arguments">
|
||||||
|
<collectionProp name="Arguments.arguments">
|
||||||
|
<elementProp name="" elementType="HTTPArgument">
|
||||||
|
<boolProp name="HTTPArgument.always_encode">false</boolProp>
|
||||||
|
<stringProp name="Argument.value">{"username":"admin","password":"admin_password","password_repeat":"admin_password"}</stringProp>
|
||||||
|
<stringProp name="Argument.metadata">=</stringProp>
|
||||||
|
</elementProp>
|
||||||
|
</collectionProp>
|
||||||
|
</elementProp>
|
||||||
|
<stringProp name="HTTPSampler.domain">${host}</stringProp>
|
||||||
|
<stringProp name="HTTPSampler.port">${port}</stringProp>
|
||||||
|
<stringProp name="HTTPSampler.protocol">${scheme}</stringProp>
|
||||||
|
<stringProp name="HTTPSampler.contentEncoding">UTF-8</stringProp>
|
||||||
|
<stringProp name="HTTPSampler.path">/api/v1/auth/first_install</stringProp>
|
||||||
|
<stringProp name="HTTPSampler.method">PUT</stringProp>
|
||||||
|
<boolProp name="HTTPSampler.follow_redirects">true</boolProp>
|
||||||
|
<boolProp name="HTTPSampler.auto_redirects">false</boolProp>
|
||||||
|
<boolProp name="HTTPSampler.use_keepalive">true</boolProp>
|
||||||
|
<boolProp name="HTTPSampler.DO_MULTIPART_POST">false</boolProp>
|
||||||
|
<stringProp name="HTTPSampler.embedded_url_re"></stringProp>
|
||||||
|
<stringProp name="HTTPSampler.connect_timeout"></stringProp>
|
||||||
|
<stringProp name="HTTPSampler.response_timeout"></stringProp>
|
||||||
|
</HTTPSamplerProxy>
|
||||||
|
<hashTree>
|
||||||
|
<HeaderManager guiclass="HeaderPanel" testclass="HeaderManager" testname="HTTP Header Manager" enabled="true">
|
||||||
|
<collectionProp name="HeaderManager.headers">
|
||||||
|
<elementProp name="Referer" elementType="Header">
|
||||||
|
<stringProp name="Header.name">Referer</stringProp>
|
||||||
|
<stringProp name="Header.value">${scheme}://${host}:${port}/first_install</stringProp>
|
||||||
|
</elementProp>
|
||||||
|
<elementProp name="Accept-Language" elementType="Header">
|
||||||
|
<stringProp name="Header.name">Accept-Language</stringProp>
|
||||||
|
<stringProp name="Header.value">en-US,en;q=0.5</stringProp>
|
||||||
|
</elementProp>
|
||||||
|
<elementProp name="Origin" elementType="Header">
|
||||||
|
<stringProp name="Header.name">Origin</stringProp>
|
||||||
|
<stringProp name="Header.value">${scheme}://${host}:${port}</stringProp>
|
||||||
|
</elementProp>
|
||||||
|
<elementProp name="Content-Type" elementType="Header">
|
||||||
|
<stringProp name="Header.name">Content-Type</stringProp>
|
||||||
|
<stringProp name="Header.value">application/json</stringProp>
|
||||||
|
</elementProp>
|
||||||
|
<elementProp name="Accept-Encoding" elementType="Header">
|
||||||
|
<stringProp name="Header.name">Accept-Encoding</stringProp>
|
||||||
|
<stringProp name="Header.value">gzip, deflate</stringProp>
|
||||||
|
</elementProp>
|
||||||
|
<elementProp name="User-Agent" elementType="Header">
|
||||||
|
<stringProp name="Header.name">User-Agent</stringProp>
|
||||||
|
<stringProp name="Header.value">Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:121.0) Gecko/20100101 Firefox/121.0</stringProp>
|
||||||
|
</elementProp>
|
||||||
|
<elementProp name="Accept" elementType="Header">
|
||||||
|
<stringProp name="Header.name">Accept</stringProp>
|
||||||
|
<stringProp name="Header.value">application/json, text/plain, */*</stringProp>
|
||||||
|
</elementProp>
|
||||||
|
</collectionProp>
|
||||||
|
</HeaderManager>
|
||||||
|
<hashTree/>
|
||||||
|
<ResponseAssertion guiclass="AssertionGui" testclass="ResponseAssertion" testname="Check Status Code 200" enabled="true">
|
||||||
|
<collectionProp name="Asserion.test_strings">
|
||||||
|
<stringProp name="49586">200</stringProp>
|
||||||
|
</collectionProp>
|
||||||
|
<stringProp name="Assertion.custom_message"></stringProp>
|
||||||
|
<stringProp name="Assertion.test_field">Assertion.response_code</stringProp>
|
||||||
|
<boolProp name="Assertion.assume_success">false</boolProp>
|
||||||
|
<intProp name="Assertion.test_type">8</intProp>
|
||||||
|
</ResponseAssertion>
|
||||||
|
<hashTree/>
|
||||||
|
</hashTree>
|
||||||
|
</hashTree>
|
||||||
|
<IfController guiclass="IfControllerPanel" testclass="IfController" testname="Else (not first install)" enabled="true">
|
||||||
|
<stringProp name="IfController.condition">${__groovy(!vars.get('is_first_install').contains("first_install"),)}</stringProp>
|
||||||
|
<boolProp name="IfController.evaluateAll">false</boolProp>
|
||||||
|
<boolProp name="IfController.useExpression">true</boolProp>
|
||||||
|
</IfController>
|
||||||
|
<hashTree>
|
||||||
|
<HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="[server] Login super user" enabled="true">
|
||||||
|
<boolProp name="HTTPSampler.postBodyRaw">true</boolProp>
|
||||||
|
<elementProp name="HTTPsampler.Arguments" elementType="Arguments">
|
||||||
|
<collectionProp name="Arguments.arguments">
|
||||||
|
<elementProp name="" elementType="HTTPArgument">
|
||||||
|
<boolProp name="HTTPArgument.always_encode">false</boolProp>
|
||||||
|
<stringProp name="Argument.value">{"username":"admin","password":"admin_password"}</stringProp>
|
||||||
|
<stringProp name="Argument.metadata">=</stringProp>
|
||||||
|
</elementProp>
|
||||||
|
</collectionProp>
|
||||||
|
</elementProp>
|
||||||
|
<stringProp name="HTTPSampler.domain">${host}</stringProp>
|
||||||
|
<stringProp name="HTTPSampler.port">${port}</stringProp>
|
||||||
|
<stringProp name="HTTPSampler.protocol">${scheme}</stringProp>
|
||||||
|
<stringProp name="HTTPSampler.contentEncoding">UTF-8</stringProp>
|
||||||
|
<stringProp name="HTTPSampler.path">/api/v1/auth</stringProp>
|
||||||
|
<stringProp name="HTTPSampler.method">POST</stringProp>
|
||||||
|
<boolProp name="HTTPSampler.follow_redirects">true</boolProp>
|
||||||
|
<boolProp name="HTTPSampler.auto_redirects">false</boolProp>
|
||||||
|
<boolProp name="HTTPSampler.use_keepalive">true</boolProp>
|
||||||
|
<boolProp name="HTTPSampler.DO_MULTIPART_POST">false</boolProp>
|
||||||
|
<stringProp name="HTTPSampler.embedded_url_re"></stringProp>
|
||||||
|
<stringProp name="HTTPSampler.connect_timeout"></stringProp>
|
||||||
|
<stringProp name="HTTPSampler.response_timeout"></stringProp>
|
||||||
|
</HTTPSamplerProxy>
|
||||||
|
<hashTree>
|
||||||
|
<HeaderManager guiclass="HeaderPanel" testclass="HeaderManager" testname="HTTP Header Manager" enabled="true">
|
||||||
|
<collectionProp name="HeaderManager.headers">
|
||||||
|
<elementProp name="Referer" elementType="Header">
|
||||||
|
<stringProp name="Header.name">Referer</stringProp>
|
||||||
|
<stringProp name="Header.value">${scheme}://${host}:${port}/</stringProp>
|
||||||
|
</elementProp>
|
||||||
|
<elementProp name="Accept-Language" elementType="Header">
|
||||||
|
<stringProp name="Header.name">Accept-Language</stringProp>
|
||||||
|
<stringProp name="Header.value">en-US,en;q=0.5</stringProp>
|
||||||
|
</elementProp>
|
||||||
|
<elementProp name="Origin" elementType="Header">
|
||||||
|
<stringProp name="Header.name">Origin</stringProp>
|
||||||
|
<stringProp name="Header.value">${scheme}://${host}:${port}</stringProp>
|
||||||
|
</elementProp>
|
||||||
|
<elementProp name="Content-Type" elementType="Header">
|
||||||
|
<stringProp name="Header.name">Content-Type</stringProp>
|
||||||
|
<stringProp name="Header.value">application/json</stringProp>
|
||||||
|
</elementProp>
|
||||||
|
<elementProp name="Accept-Encoding" elementType="Header">
|
||||||
|
<stringProp name="Header.name">Accept-Encoding</stringProp>
|
||||||
|
<stringProp name="Header.value">gzip, deflate</stringProp>
|
||||||
|
</elementProp>
|
||||||
|
<elementProp name="User-Agent" elementType="Header">
|
||||||
|
<stringProp name="Header.name">User-Agent</stringProp>
|
||||||
|
<stringProp name="Header.value">Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:123.0) Gecko/20100101 Firefox/123.0</stringProp>
|
||||||
|
</elementProp>
|
||||||
|
<elementProp name="Accept" elementType="Header">
|
||||||
|
<stringProp name="Header.name">Accept</stringProp>
|
||||||
|
<stringProp name="Header.value">application/json, text/plain, */*</stringProp>
|
||||||
|
</elementProp>
|
||||||
|
</collectionProp>
|
||||||
|
</HeaderManager>
|
||||||
|
<hashTree/>
|
||||||
|
<ResponseAssertion guiclass="AssertionGui" testclass="ResponseAssertion" testname="Check Status Code 200" enabled="true">
|
||||||
|
<collectionProp name="Asserion.test_strings">
|
||||||
|
<stringProp name="49586">200</stringProp>
|
||||||
|
</collectionProp>
|
||||||
|
<stringProp name="Assertion.custom_message"></stringProp>
|
||||||
|
<stringProp name="Assertion.test_field">Assertion.response_code</stringProp>
|
||||||
|
<boolProp name="Assertion.assume_success">false</boolProp>
|
||||||
|
<intProp name="Assertion.test_type">8</intProp>
|
||||||
|
</ResponseAssertion>
|
||||||
|
<hashTree/>
|
||||||
|
</hashTree>
|
||||||
|
</hashTree>
|
||||||
|
<HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="[server] Get all admin wallets" enabled="true">
|
||||||
|
<elementProp name="HTTPsampler.Arguments" elementType="Arguments" guiclass="HTTPArgumentsPanel" testclass="Arguments" enabled="true">
|
||||||
|
<collectionProp name="Arguments.arguments"/>
|
||||||
|
</elementProp>
|
||||||
|
<stringProp name="HTTPSampler.domain">${host}</stringProp>
|
||||||
|
<stringProp name="HTTPSampler.port">${port}</stringProp>
|
||||||
|
<stringProp name="HTTPSampler.protocol">${scheme}</stringProp>
|
||||||
|
<stringProp name="HTTPSampler.contentEncoding">UTF-8</stringProp>
|
||||||
|
<stringProp name="HTTPSampler.path">/api/v1/wallets</stringProp>
|
||||||
|
<stringProp name="HTTPSampler.method">GET</stringProp>
|
||||||
|
<boolProp name="HTTPSampler.follow_redirects">true</boolProp>
|
||||||
|
<boolProp name="HTTPSampler.auto_redirects">false</boolProp>
|
||||||
|
<boolProp name="HTTPSampler.use_keepalive">true</boolProp>
|
||||||
|
<boolProp name="HTTPSampler.DO_MULTIPART_POST">false</boolProp>
|
||||||
|
<stringProp name="HTTPSampler.embedded_url_re"></stringProp>
|
||||||
|
<stringProp name="HTTPSampler.connect_timeout"></stringProp>
|
||||||
|
<stringProp name="HTTPSampler.response_timeout"></stringProp>
|
||||||
|
</HTTPSamplerProxy>
|
||||||
|
<hashTree>
|
||||||
|
<HeaderManager guiclass="HeaderPanel" testclass="HeaderManager" testname="HTTP Header Manager" enabled="true">
|
||||||
|
<collectionProp name="HeaderManager.headers">
|
||||||
|
<elementProp name="Accept-Language" elementType="Header">
|
||||||
|
<stringProp name="Header.name">Accept-Language</stringProp>
|
||||||
|
<stringProp name="Header.value">en-US,en;q=0.5</stringProp>
|
||||||
|
</elementProp>
|
||||||
|
<elementProp name="Upgrade-Insecure-Requests" elementType="Header">
|
||||||
|
<stringProp name="Header.name">Upgrade-Insecure-Requests</stringProp>
|
||||||
|
<stringProp name="Header.value">1</stringProp>
|
||||||
|
</elementProp>
|
||||||
|
<elementProp name="Accept-Encoding" elementType="Header">
|
||||||
|
<stringProp name="Header.name">Accept-Encoding</stringProp>
|
||||||
|
<stringProp name="Header.value">gzip, deflate</stringProp>
|
||||||
|
</elementProp>
|
||||||
|
<elementProp name="User-Agent" elementType="Header">
|
||||||
|
<stringProp name="Header.name">User-Agent</stringProp>
|
||||||
|
<stringProp name="Header.value">Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:121.0) Gecko/20100101 Firefox/121.0</stringProp>
|
||||||
|
</elementProp>
|
||||||
|
<elementProp name="Accept" elementType="Header">
|
||||||
|
<stringProp name="Header.name">Accept</stringProp>
|
||||||
|
<stringProp name="Header.value">text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8</stringProp>
|
||||||
|
</elementProp>
|
||||||
|
</collectionProp>
|
||||||
|
</HeaderManager>
|
||||||
|
<hashTree/>
|
||||||
|
<ResponseAssertion guiclass="AssertionGui" testclass="ResponseAssertion" testname="Check Status Code 200" enabled="true">
|
||||||
|
<collectionProp name="Asserion.test_strings">
|
||||||
|
<stringProp name="49586">200</stringProp>
|
||||||
|
</collectionProp>
|
||||||
|
<stringProp name="Assertion.custom_message"></stringProp>
|
||||||
|
<stringProp name="Assertion.test_field">Assertion.response_code</stringProp>
|
||||||
|
<boolProp name="Assertion.assume_success">false</boolProp>
|
||||||
|
<intProp name="Assertion.test_type">8</intProp>
|
||||||
|
</ResponseAssertion>
|
||||||
|
<hashTree/>
|
||||||
|
<JSR223PostProcessor guiclass="TestBeanGUI" testclass="JSR223PostProcessor" testname="Extract admin wallet and keys" enabled="true">
|
||||||
|
<stringProp name="cacheKey">true</stringProp>
|
||||||
|
<stringProp name="filename"></stringProp>
|
||||||
|
<stringProp name="parameters"></stringProp>
|
||||||
|
<stringProp name="script">var resp = JSON.parse(prev.getResponseDataAsString())[0]
|
||||||
|
|
||||||
|
vars.put("adminWalletId", resp.id || "no-admin-wallet-id");
|
||||||
|
vars.put("adminWalletKey", resp.adminkey || 'no-adminkey');
|
||||||
|
</stringProp>
|
||||||
|
<stringProp name="scriptLanguage">javascript</stringProp>
|
||||||
|
</JSR223PostProcessor>
|
||||||
|
<hashTree/>
|
||||||
|
</hashTree>
|
||||||
|
<HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="[server] Top up wallet" enabled="true">
|
||||||
|
<boolProp name="HTTPSampler.postBodyRaw">true</boolProp>
|
||||||
|
<elementProp name="HTTPsampler.Arguments" elementType="Arguments">
|
||||||
|
<collectionProp name="Arguments.arguments">
|
||||||
|
<elementProp name="" elementType="HTTPArgument">
|
||||||
|
<boolProp name="HTTPArgument.always_encode">false</boolProp>
|
||||||
|
<stringProp name="Argument.value">{"amount":"1000000","id":"${adminWalletId}"}</stringProp>
|
||||||
|
<stringProp name="Argument.metadata">=</stringProp>
|
||||||
|
</elementProp>
|
||||||
|
</collectionProp>
|
||||||
|
</elementProp>
|
||||||
|
<stringProp name="HTTPSampler.domain">${host}</stringProp>
|
||||||
|
<stringProp name="HTTPSampler.port">${port}</stringProp>
|
||||||
|
<stringProp name="HTTPSampler.protocol">${scheme}</stringProp>
|
||||||
|
<stringProp name="HTTPSampler.contentEncoding">UTF-8</stringProp>
|
||||||
|
<stringProp name="HTTPSampler.path">/admin/api/v1/topup/</stringProp>
|
||||||
|
<stringProp name="HTTPSampler.method">PUT</stringProp>
|
||||||
|
<boolProp name="HTTPSampler.follow_redirects">true</boolProp>
|
||||||
|
<boolProp name="HTTPSampler.auto_redirects">false</boolProp>
|
||||||
|
<boolProp name="HTTPSampler.use_keepalive">true</boolProp>
|
||||||
|
<boolProp name="HTTPSampler.DO_MULTIPART_POST">false</boolProp>
|
||||||
|
<stringProp name="HTTPSampler.embedded_url_re"></stringProp>
|
||||||
|
<stringProp name="HTTPSampler.connect_timeout"></stringProp>
|
||||||
|
<stringProp name="HTTPSampler.response_timeout"></stringProp>
|
||||||
|
</HTTPSamplerProxy>
|
||||||
|
<hashTree>
|
||||||
|
<HeaderManager guiclass="HeaderPanel" testclass="HeaderManager" testname="HTTP Header Manager" enabled="true">
|
||||||
|
<collectionProp name="HeaderManager.headers">
|
||||||
|
<elementProp name="Referer" elementType="Header">
|
||||||
|
<stringProp name="Header.name">Referer</stringProp>
|
||||||
|
<stringProp name="Header.value">${scheme}://${host}:${port}/wallet?&wal=c1daa33fc4014ef69cd1505f14f322c2</stringProp>
|
||||||
|
</elementProp>
|
||||||
|
<elementProp name="Accept-Language" elementType="Header">
|
||||||
|
<stringProp name="Header.name">Accept-Language</stringProp>
|
||||||
|
<stringProp name="Header.value">en-US,en;q=0.5</stringProp>
|
||||||
|
</elementProp>
|
||||||
|
<elementProp name="Origin" elementType="Header">
|
||||||
|
<stringProp name="Header.name">Origin</stringProp>
|
||||||
|
<stringProp name="Header.value">${scheme}://${host}:${port}</stringProp>
|
||||||
|
</elementProp>
|
||||||
|
<elementProp name="Content-Type" elementType="Header">
|
||||||
|
<stringProp name="Header.name">Content-Type</stringProp>
|
||||||
|
<stringProp name="Header.value">application/json</stringProp>
|
||||||
|
</elementProp>
|
||||||
|
<elementProp name="Accept-Encoding" elementType="Header">
|
||||||
|
<stringProp name="Header.name">Accept-Encoding</stringProp>
|
||||||
|
<stringProp name="Header.value">gzip, deflate</stringProp>
|
||||||
|
</elementProp>
|
||||||
|
<elementProp name="User-Agent" elementType="Header">
|
||||||
|
<stringProp name="Header.name">User-Agent</stringProp>
|
||||||
|
<stringProp name="Header.value">Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:121.0) Gecko/20100101 Firefox/121.0</stringProp>
|
||||||
|
</elementProp>
|
||||||
|
<elementProp name="Accept" elementType="Header">
|
||||||
|
<stringProp name="Header.name">Accept</stringProp>
|
||||||
|
<stringProp name="Header.value">application/json, text/plain, */*</stringProp>
|
||||||
|
</elementProp>
|
||||||
|
</collectionProp>
|
||||||
|
</HeaderManager>
|
||||||
|
<hashTree/>
|
||||||
|
<ResponseAssertion guiclass="AssertionGui" testclass="ResponseAssertion" testname="Check Status Code 200" enabled="true">
|
||||||
|
<collectionProp name="Asserion.test_strings">
|
||||||
|
<stringProp name="49586">200</stringProp>
|
||||||
|
</collectionProp>
|
||||||
|
<stringProp name="Assertion.custom_message"></stringProp>
|
||||||
|
<stringProp name="Assertion.test_field">Assertion.response_code</stringProp>
|
||||||
|
<boolProp name="Assertion.assume_success">false</boolProp>
|
||||||
|
<intProp name="Assertion.test_type">8</intProp>
|
||||||
|
</ResponseAssertion>
|
||||||
|
<hashTree/>
|
||||||
|
</hashTree>
|
||||||
|
<HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="[server] Logout super user" enabled="true">
|
||||||
|
<elementProp name="HTTPsampler.Arguments" elementType="Arguments" guiclass="HTTPArgumentsPanel" testclass="Arguments" enabled="true">
|
||||||
|
<collectionProp name="Arguments.arguments"/>
|
||||||
|
</elementProp>
|
||||||
|
<stringProp name="HTTPSampler.domain">${host}</stringProp>
|
||||||
|
<stringProp name="HTTPSampler.port">${port}</stringProp>
|
||||||
|
<stringProp name="HTTPSampler.protocol">${scheme}</stringProp>
|
||||||
|
<stringProp name="HTTPSampler.contentEncoding">UTF-8</stringProp>
|
||||||
|
<stringProp name="HTTPSampler.path">/api/v1/auth/logout</stringProp>
|
||||||
|
<stringProp name="HTTPSampler.method">POST</stringProp>
|
||||||
|
<boolProp name="HTTPSampler.follow_redirects">true</boolProp>
|
||||||
|
<boolProp name="HTTPSampler.auto_redirects">false</boolProp>
|
||||||
|
<boolProp name="HTTPSampler.use_keepalive">true</boolProp>
|
||||||
|
<boolProp name="HTTPSampler.DO_MULTIPART_POST">false</boolProp>
|
||||||
|
<stringProp name="HTTPSampler.embedded_url_re"></stringProp>
|
||||||
|
<stringProp name="HTTPSampler.connect_timeout"></stringProp>
|
||||||
|
<stringProp name="HTTPSampler.response_timeout"></stringProp>
|
||||||
|
</HTTPSamplerProxy>
|
||||||
|
<hashTree>
|
||||||
|
<HeaderManager guiclass="HeaderPanel" testclass="HeaderManager" testname="HTTP Header Manager" enabled="true">
|
||||||
|
<collectionProp name="HeaderManager.headers">
|
||||||
|
<elementProp name="Referer" elementType="Header">
|
||||||
|
<stringProp name="Header.name">Referer</stringProp>
|
||||||
|
<stringProp name="Header.value">${scheme}://${host}:${port}/</stringProp>
|
||||||
|
</elementProp>
|
||||||
|
<elementProp name="Accept-Language" elementType="Header">
|
||||||
|
<stringProp name="Header.name">Accept-Language</stringProp>
|
||||||
|
<stringProp name="Header.value">en-US,en;q=0.5</stringProp>
|
||||||
|
</elementProp>
|
||||||
|
<elementProp name="Origin" elementType="Header">
|
||||||
|
<stringProp name="Header.name">Origin</stringProp>
|
||||||
|
<stringProp name="Header.value">${scheme}://${host}:${port}</stringProp>
|
||||||
|
</elementProp>
|
||||||
|
<elementProp name="Content-Type" elementType="Header">
|
||||||
|
<stringProp name="Header.name">Content-Type</stringProp>
|
||||||
|
<stringProp name="Header.value">application/json</stringProp>
|
||||||
|
</elementProp>
|
||||||
|
<elementProp name="Accept-Encoding" elementType="Header">
|
||||||
|
<stringProp name="Header.name">Accept-Encoding</stringProp>
|
||||||
|
<stringProp name="Header.value">gzip, deflate</stringProp>
|
||||||
|
</elementProp>
|
||||||
|
<elementProp name="User-Agent" elementType="Header">
|
||||||
|
<stringProp name="Header.name">User-Agent</stringProp>
|
||||||
|
<stringProp name="Header.value">Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:123.0) Gecko/20100101 Firefox/123.0</stringProp>
|
||||||
|
</elementProp>
|
||||||
|
<elementProp name="Accept" elementType="Header">
|
||||||
|
<stringProp name="Header.name">Accept</stringProp>
|
||||||
|
<stringProp name="Header.value">application/json, text/plain, */*</stringProp>
|
||||||
|
</elementProp>
|
||||||
|
</collectionProp>
|
||||||
|
</HeaderManager>
|
||||||
|
<hashTree/>
|
||||||
|
<ResponseAssertion guiclass="AssertionGui" testclass="ResponseAssertion" testname="Check Status Code 200" enabled="true">
|
||||||
|
<collectionProp name="Asserion.test_strings">
|
||||||
|
<stringProp name="49586">200</stringProp>
|
||||||
|
</collectionProp>
|
||||||
|
<stringProp name="Assertion.custom_message"></stringProp>
|
||||||
|
<stringProp name="Assertion.test_field">Assertion.response_code</stringProp>
|
||||||
|
<boolProp name="Assertion.assume_success">false</boolProp>
|
||||||
|
<intProp name="Assertion.test_type">8</intProp>
|
||||||
|
</ResponseAssertion>
|
||||||
|
<hashTree/>
|
||||||
|
</hashTree>
|
||||||
|
</hashTree>
|
||||||
|
</hashTree>
|
||||||
|
</hashTree>
|
||||||
|
</jmeterTestPlan>
|
||||||
@@ -9,44 +9,54 @@ import sys
|
|||||||
import traceback
|
import traceback
|
||||||
from hashlib import sha256
|
from hashlib import sha256
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
from typing import Callable, List
|
from pathlib import Path
|
||||||
|
from typing import Callable, List, Optional
|
||||||
|
|
||||||
from fastapi import FastAPI, HTTPException, Request
|
from fastapi import FastAPI, HTTPException, Request
|
||||||
from fastapi.exceptions import RequestValidationError
|
from fastapi.exceptions import RequestValidationError
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from slowapi import Limiter
|
from slowapi import Limiter
|
||||||
from slowapi.util import get_remote_address
|
from slowapi.util import get_remote_address
|
||||||
|
from starlette.middleware.sessions import SessionMiddleware
|
||||||
from starlette.responses import JSONResponse
|
from starlette.responses import JSONResponse
|
||||||
|
|
||||||
from lnbits.core.crud import get_installed_extensions
|
from lnbits.core.crud import get_dbversions, get_installed_extensions
|
||||||
from lnbits.core.helpers import migrate_extension_database
|
from lnbits.core.helpers import migrate_extension_database
|
||||||
from lnbits.core.services import websocketUpdater
|
from lnbits.core.services import websocketUpdater
|
||||||
from lnbits.core.tasks import ( # register_watchdog,; unregister_watchdog,
|
from lnbits.core.tasks import ( # watchdog_task
|
||||||
register_killswitch,
|
killswitch_task,
|
||||||
register_task_listeners,
|
wait_for_paid_invoices,
|
||||||
)
|
)
|
||||||
from lnbits.settings import settings
|
from lnbits.settings import settings
|
||||||
from lnbits.tasks import cancel_all_tasks, create_permanent_task
|
from lnbits.tasks import (
|
||||||
|
cancel_all_tasks,
|
||||||
|
create_permanent_task,
|
||||||
|
register_invoice_listener,
|
||||||
|
)
|
||||||
from lnbits.utils.cache import cache
|
from lnbits.utils.cache import cache
|
||||||
from lnbits.wallets import get_wallet_class, set_wallet_class
|
from lnbits.wallets import get_wallet_class, set_wallet_class
|
||||||
|
|
||||||
from .commands import db_versions, load_disabled_extension_list, migrate_databases
|
from .commands import migrate_databases
|
||||||
from .core import (
|
from .core import init_core_routers
|
||||||
add_installed_extension,
|
from .core.db import core_app_extra
|
||||||
core_app,
|
from .core.services import check_admin_settings, check_webpush_settings
|
||||||
core_app_extra,
|
from .core.views.api import add_installed_extension
|
||||||
update_installed_extension_state,
|
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 .helpers import template_renderer
|
||||||
from .middleware import (
|
from .middleware import (
|
||||||
CustomGZipMiddleware,
|
CustomGZipMiddleware,
|
||||||
ExtensionsRedirectMiddleware,
|
ExtensionsRedirectMiddleware,
|
||||||
InstalledExtensionMiddleware,
|
InstalledExtensionMiddleware,
|
||||||
|
add_first_install_middleware,
|
||||||
add_ip_block_middleware,
|
add_ip_block_middleware,
|
||||||
add_ratelimit_middleware,
|
add_ratelimit_middleware,
|
||||||
)
|
)
|
||||||
@@ -55,7 +65,6 @@ from .tasks import (
|
|||||||
check_pending_payments,
|
check_pending_payments,
|
||||||
internal_invoice_listener,
|
internal_invoice_listener,
|
||||||
invoice_listener,
|
invoice_listener,
|
||||||
webhook_handler,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -74,12 +83,14 @@ def create_app() -> FastAPI:
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
app.mount("/static", StaticFiles(packages=[("lnbits", "static")]), name="static")
|
# Allow registering new extensions routes without direct access to the `app` object
|
||||||
app.mount(
|
setattr(core_app_extra, "register_new_ext_routes", register_new_ext_routes(app))
|
||||||
"/core/static",
|
setattr(core_app_extra, "register_new_ratelimiter", register_new_ratelimiter(app))
|
||||||
StaticFiles(packages=[("lnbits.core", "static")]),
|
|
||||||
name="core_static",
|
# 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}"
|
g().base_url = f"http://{settings.host}:{settings.port}"
|
||||||
|
|
||||||
@@ -91,20 +102,26 @@ def create_app() -> FastAPI:
|
|||||||
CustomGZipMiddleware, minimum_size=1000, exclude_paths=["/api/v1/payments/sse"]
|
CustomGZipMiddleware, minimum_size=1000, exclude_paths=["/api/v1/payments/sse"]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# required for SSO login
|
||||||
|
app.add_middleware(SessionMiddleware, secret_key=settings.auth_secret_key)
|
||||||
|
|
||||||
# order of these two middlewares is important
|
# order of these two middlewares is important
|
||||||
app.add_middleware(InstalledExtensionMiddleware)
|
app.add_middleware(InstalledExtensionMiddleware)
|
||||||
app.add_middleware(ExtensionsRedirectMiddleware)
|
app.add_middleware(ExtensionsRedirectMiddleware)
|
||||||
|
|
||||||
|
register_custom_extensions_path()
|
||||||
|
|
||||||
|
add_first_install_middleware(app)
|
||||||
|
|
||||||
|
# adds security middleware
|
||||||
|
add_ip_block_middleware(app)
|
||||||
|
add_ratelimit_middleware(app)
|
||||||
|
|
||||||
register_startup(app)
|
register_startup(app)
|
||||||
register_routes(app)
|
|
||||||
register_async_tasks(app)
|
register_async_tasks(app)
|
||||||
register_exception_handlers(app)
|
register_exception_handlers(app)
|
||||||
register_shutdown(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
|
return app
|
||||||
|
|
||||||
|
|
||||||
@@ -155,7 +172,7 @@ async def check_funding_source() -> None:
|
|||||||
|
|
||||||
signal.signal(signal.SIGINT, original_sigint_handler)
|
signal.signal(signal.SIGINT, original_sigint_handler)
|
||||||
|
|
||||||
logger.info(
|
logger.success(
|
||||||
f"✔️ Backend {WALLET.__class__.__name__} connected "
|
f"✔️ Backend {WALLET.__class__.__name__} connected "
|
||||||
f"and with a balance of {balance} msat."
|
f"and with a balance of {balance} msat."
|
||||||
)
|
)
|
||||||
@@ -177,12 +194,11 @@ async def check_installed_extensions(app: FastAPI):
|
|||||||
persist state. Zips that are missing will be re-downloaded.
|
persist state. Zips that are missing will be re-downloaded.
|
||||||
"""
|
"""
|
||||||
shutil.rmtree(os.path.join("lnbits", "upgrades"), True)
|
shutil.rmtree(os.path.join("lnbits", "upgrades"), True)
|
||||||
await load_disabled_extension_list()
|
installed_extensions = await build_all_installed_extensions_list(False)
|
||||||
installed_extensions = await build_all_installed_extensions_list()
|
|
||||||
|
|
||||||
for ext in installed_extensions:
|
for ext in installed_extensions:
|
||||||
try:
|
try:
|
||||||
installed = check_installed_extension_files(ext)
|
installed = await check_installed_extension_files(ext)
|
||||||
if not installed:
|
if not installed:
|
||||||
await restore_installed_extension(app, ext)
|
await restore_installed_extension(app, ext)
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -200,7 +216,9 @@ async def check_installed_extensions(app: FastAPI):
|
|||||||
logger.info(f"{ext.id} ({ext.installed_version})")
|
logger.info(f"{ext.id} ({ext.installed_version})")
|
||||||
|
|
||||||
|
|
||||||
async def build_all_installed_extensions_list() -> List[InstallableExtension]:
|
async def build_all_installed_extensions_list(
|
||||||
|
include_deactivated: Optional[bool] = True,
|
||||||
|
) -> List[InstallableExtension]:
|
||||||
"""
|
"""
|
||||||
Returns a list of all the installed extensions plus the extensions that
|
Returns a list of all the installed extensions plus the extensions that
|
||||||
MUST be installed by default (see LNBITS_EXTENSIONS_DEFAULT_INSTALL).
|
MUST be installed by default (see LNBITS_EXTENSIONS_DEFAULT_INSTALL).
|
||||||
@@ -213,7 +231,11 @@ async def build_all_installed_extensions_list() -> List[InstallableExtension]:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
ext_releases = await InstallableExtension.get_extension_releases(ext_id)
|
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:
|
if release:
|
||||||
ext_info = InstallableExtension(
|
ext_info = InstallableExtension(
|
||||||
@@ -221,19 +243,27 @@ async def build_all_installed_extensions_list() -> List[InstallableExtension]:
|
|||||||
)
|
)
|
||||||
installed_extensions.append(ext_info)
|
installed_extensions.append(ext_info)
|
||||||
|
|
||||||
|
if include_deactivated:
|
||||||
return installed_extensions
|
return installed_extensions
|
||||||
|
|
||||||
|
if settings.lnbits_extensions_deactivate_all:
|
||||||
|
return []
|
||||||
|
|
||||||
def check_installed_extension_files(ext: InstallableExtension) -> bool:
|
return [
|
||||||
|
e
|
||||||
|
for e in installed_extensions
|
||||||
|
if e.id not in settings.lnbits_deactivated_extensions
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def check_installed_extension_files(ext: InstallableExtension) -> bool:
|
||||||
if ext.has_installed_version:
|
if ext.has_installed_version:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
zip_files = glob.glob(
|
zip_files = glob.glob(os.path.join(settings.lnbits_data_folder, "zips", "*.zip"))
|
||||||
os.path.join(settings.lnbits_data_folder, "extensions", "*.zip")
|
|
||||||
)
|
|
||||||
|
|
||||||
if f"./{str(ext.zip_path)}" not in zip_files:
|
if f"./{str(ext.zip_path)}" not in zip_files:
|
||||||
ext.download_archive()
|
await ext.download_archive()
|
||||||
ext.extract_archive()
|
ext.extract_archive()
|
||||||
|
|
||||||
return False
|
return False
|
||||||
@@ -246,7 +276,7 @@ async def restore_installed_extension(app: FastAPI, ext: InstallableExtension):
|
|||||||
extension = Extension.from_installable_ext(ext)
|
extension = Extension.from_installable_ext(ext)
|
||||||
register_ext_routes(app, extension)
|
register_ext_routes(app, extension)
|
||||||
|
|
||||||
current_version = (await db_versions()).get(ext.id, 0)
|
current_version = (await get_dbversions()).get(ext.id, 0)
|
||||||
await migrate_extension_database(extension, current_version)
|
await migrate_extension_database(extension, current_version)
|
||||||
|
|
||||||
# mount routes for the new version
|
# mount routes for the new version
|
||||||
@@ -257,17 +287,32 @@ async def restore_installed_extension(app: FastAPI, ext: InstallableExtension):
|
|||||||
|
|
||||||
def register_routes(app: FastAPI) -> None:
|
def register_routes(app: FastAPI) -> None:
|
||||||
"""Register FastAPI routes / LNbits extensions."""
|
"""Register FastAPI routes / LNbits extensions."""
|
||||||
app.include_router(core_app)
|
init_core_routers(app)
|
||||||
app.include_router(core_html_routes)
|
|
||||||
|
|
||||||
for ext in get_valid_extensions():
|
for ext in get_valid_extensions(False):
|
||||||
try:
|
try:
|
||||||
register_ext_routes(app, ext)
|
register_ext_routes(app, ext)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(str(e))
|
logger.error(f"Could not load extension `{ext.code}`: {str(e)}")
|
||||||
raise ImportError(
|
|
||||||
f"Please make sure that the extension `{ext.code}` follows conventions."
|
|
||||||
|
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:
|
def register_new_ext_routes(app: FastAPI) -> Callable:
|
||||||
@@ -306,7 +351,10 @@ def register_ext_routes(app: FastAPI, ext: Extension) -> None:
|
|||||||
if hasattr(ext_module, f"{ext.code}_static_files"):
|
if hasattr(ext_module, f"{ext.code}_static_files"):
|
||||||
ext_statics = getattr(ext_module, f"{ext.code}_static_files")
|
ext_statics = getattr(ext_module, f"{ext.code}_static_files")
|
||||||
for s in ext_statics:
|
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"):
|
if hasattr(ext_module, f"{ext.code}_redirect_paths"):
|
||||||
ext_redirects = getattr(ext_module, f"{ext.code}_redirect_paths")
|
ext_redirects = getattr(ext_module, f"{ext.code}_redirect_paths")
|
||||||
@@ -332,20 +380,16 @@ def register_startup(app: FastAPI):
|
|||||||
|
|
||||||
# setup admin settings
|
# setup admin settings
|
||||||
await check_admin_settings()
|
await check_admin_settings()
|
||||||
|
await check_webpush_settings()
|
||||||
|
|
||||||
log_server_info()
|
log_server_info()
|
||||||
|
|
||||||
# adds security middleware
|
|
||||||
add_ratelimit_middleware(app)
|
|
||||||
add_ip_block_middleware(app)
|
|
||||||
|
|
||||||
# initialize WALLET
|
# initialize WALLET
|
||||||
try:
|
try:
|
||||||
set_wallet_class()
|
set_wallet_class()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(
|
logger.error(
|
||||||
f"Error initializing {settings.lnbits_backend_wallet_class}: "
|
f"Error initializing {settings.lnbits_backend_wallet_class}: {e}"
|
||||||
f"{str(e)}"
|
|
||||||
)
|
)
|
||||||
set_void_wallet_class()
|
set_void_wallet_class()
|
||||||
|
|
||||||
@@ -355,6 +399,9 @@ def register_startup(app: FastAPI):
|
|||||||
# check extensions after restart
|
# check extensions after restart
|
||||||
await check_installed_extensions(app)
|
await check_installed_extensions(app)
|
||||||
|
|
||||||
|
# register core and extension routes
|
||||||
|
register_routes(app)
|
||||||
|
|
||||||
if settings.lnbits_admin_ui:
|
if settings.lnbits_admin_ui:
|
||||||
initialize_server_logger()
|
initialize_server_logger()
|
||||||
|
|
||||||
@@ -401,9 +448,10 @@ def log_server_info():
|
|||||||
logger.info(f"Site title: {settings.lnbits_site_title}")
|
logger.info(f"Site title: {settings.lnbits_site_title}")
|
||||||
logger.info(f"Funding source: {settings.lnbits_backend_wallet_class}")
|
logger.info(f"Funding source: {settings.lnbits_backend_wallet_class}")
|
||||||
logger.info(f"Data folder: {settings.lnbits_data_folder}")
|
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"Database: {get_db_vendor_name()}")
|
||||||
logger.info(f"Service fee: {settings.lnbits_service_fee}")
|
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():
|
def get_db_vendor_name():
|
||||||
@@ -420,19 +468,21 @@ def get_db_vendor_name():
|
|||||||
|
|
||||||
|
|
||||||
def register_async_tasks(app):
|
def register_async_tasks(app):
|
||||||
@app.route("/wallet/webhook")
|
|
||||||
async def webhook_listener():
|
|
||||||
return await webhook_handler()
|
|
||||||
|
|
||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
async def listeners():
|
async def listeners():
|
||||||
create_permanent_task(check_pending_payments)
|
create_permanent_task(check_pending_payments)
|
||||||
create_permanent_task(invoice_listener)
|
create_permanent_task(invoice_listener)
|
||||||
create_permanent_task(internal_invoice_listener)
|
create_permanent_task(internal_invoice_listener)
|
||||||
create_permanent_task(cache.invalidate_forever)
|
create_permanent_task(cache.invalidate_forever)
|
||||||
register_task_listeners()
|
|
||||||
register_killswitch()
|
# core invoice listener
|
||||||
# await run_deferred_async() # calle: doesn't do anyting?
|
invoice_queue = asyncio.Queue(5)
|
||||||
|
register_invoice_listener(invoice_queue, "core")
|
||||||
|
create_permanent_task(lambda: wait_for_paid_invoices(invoice_queue))
|
||||||
|
|
||||||
|
# TODO: implement watchdog properly
|
||||||
|
# create_permanent_task(watchdog_task)
|
||||||
|
create_permanent_task(killswitch_task)
|
||||||
|
|
||||||
|
|
||||||
def register_exception_handlers(app: FastAPI):
|
def register_exception_handlers(app: FastAPI):
|
||||||
@@ -491,6 +541,13 @@ def register_exception_handlers(app: FastAPI):
|
|||||||
and "accept" in request.headers
|
and "accept" in request.headers
|
||||||
and "text/html" in request.headers["accept"]
|
and "text/html" in request.headers["accept"]
|
||||||
):
|
):
|
||||||
|
if exc.headers and "token-expired" in exc.headers:
|
||||||
|
response = RedirectResponse("/")
|
||||||
|
response.delete_cookie("cookie_access_token")
|
||||||
|
response.delete_cookie("is_lnbits_user_authorized")
|
||||||
|
response.set_cookie("is_access_token_expired", "true")
|
||||||
|
return response
|
||||||
|
|
||||||
return template_renderer().TemplateResponse(
|
return template_renderer().TemplateResponse(
|
||||||
"error.html",
|
"error.html",
|
||||||
{
|
{
|
||||||
@@ -510,11 +567,34 @@ def configure_logger() -> None:
|
|||||||
log_level: str = "DEBUG" if settings.debug else "INFO"
|
log_level: str = "DEBUG" if settings.debug else "INFO"
|
||||||
formatter = Formatter()
|
formatter = Formatter()
|
||||||
logger.add(sys.stdout, level=log_level, format=formatter.format)
|
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").handlers = [InterceptHandler()]
|
||||||
logging.getLogger("uvicorn.access").handlers = [InterceptHandler()]
|
logging.getLogger("uvicorn.access").handlers = [InterceptHandler()]
|
||||||
logging.getLogger("uvicorn.error").handlers = [InterceptHandler()]
|
logging.getLogger("uvicorn.error").handlers = [InterceptHandler()]
|
||||||
logging.getLogger("uvicorn.error").propagate = False
|
logging.getLogger("uvicorn.error").propagate = False
|
||||||
|
|
||||||
|
logging.getLogger("sqlalchemy").handlers = [InterceptHandler()]
|
||||||
|
logging.getLogger("sqlalchemy.engine.base").handlers = [InterceptHandler()]
|
||||||
|
logging.getLogger("sqlalchemy.engine.base").propagate = False
|
||||||
|
logging.getLogger("sqlalchemy.engine.base.Engine").handlers = [InterceptHandler()]
|
||||||
|
logging.getLogger("sqlalchemy.engine.base.Engine").propagate = False
|
||||||
|
|
||||||
|
|
||||||
class Formatter:
|
class Formatter:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
|
|||||||
@@ -1,365 +1,7 @@
|
|||||||
import hashlib
|
from bolt11 import (
|
||||||
import re
|
Bolt11 as Invoice, # noqa: F401
|
||||||
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)
|
from bolt11 import (
|
||||||
|
decode, # noqa: F401
|
||||||
# BOLT #11:
|
encode, # noqa: F401
|
||||||
# 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
|
|
||||||
|
|||||||
@@ -1,45 +1,112 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
from functools import wraps
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional, Tuple
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
import click
|
import click
|
||||||
|
import httpx
|
||||||
|
from fastapi.exceptions import HTTPException
|
||||||
from loguru import logger
|
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 lnbits.settings import settings
|
||||||
|
|
||||||
from .core import db as core_db
|
from .core import db as core_db
|
||||||
from .core import migrations as core_migrations
|
from .core import migrations as core_migrations
|
||||||
from .core.crud import get_dbversions, get_inactive_extensions
|
from .core.crud import (
|
||||||
|
delete_accounts_no_wallets,
|
||||||
|
delete_unused_wallets,
|
||||||
|
get_dbversions,
|
||||||
|
get_inactive_extensions,
|
||||||
|
get_installed_extension,
|
||||||
|
get_installed_extensions,
|
||||||
|
remove_deleted_wallets,
|
||||||
|
)
|
||||||
from .core.helpers import migrate_extension_database, run_migration
|
from .core.helpers import migrate_extension_database, run_migration
|
||||||
from .db import COCKROACH, POSTGRES, SQLITE
|
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()
|
@click.group()
|
||||||
def command_group():
|
def lnbits_cli():
|
||||||
"""
|
"""
|
||||||
Python CLI for LNbits
|
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():
|
def superuser():
|
||||||
"""Prints the superuser"""
|
"""Prints the superuser"""
|
||||||
with open(".super_user", "r") as file:
|
try:
|
||||||
print(f"http://{settings.host}:{settings.port}/wallet?usr={file.readline()}")
|
click.echo(get_super_user())
|
||||||
|
except ValueError as e:
|
||||||
|
click.echo(str(e))
|
||||||
|
|
||||||
|
|
||||||
@click.command("delete-settings")
|
@lnbits_cli.command("superuser-url")
|
||||||
def delete_settings():
|
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"""
|
"""Deletes the settings"""
|
||||||
|
|
||||||
async def wrap():
|
|
||||||
async with core_db.connect() as conn:
|
async with core_db.connect() as conn:
|
||||||
await conn.execute("DELETE from settings")
|
await conn.execute("DELETE from settings")
|
||||||
|
|
||||||
loop = asyncio.get_event_loop()
|
|
||||||
loop.run_until_complete(wrap())
|
|
||||||
|
|
||||||
|
@db.command("migrate")
|
||||||
@click.command("database-migrate")
|
|
||||||
def database_migrate():
|
def database_migrate():
|
||||||
"""Migrate databases"""
|
"""Migrate databases"""
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
@@ -70,26 +137,58 @@ async def migrate_databases():
|
|||||||
|
|
||||||
current_versions = await get_dbversions(conn)
|
current_versions = await get_dbversions(conn)
|
||||||
core_version = current_versions.get("core", 0)
|
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():
|
# here is the first place we can be sure that the
|
||||||
|
# `installed_extensions` table has been created
|
||||||
|
await load_disabled_extension_list()
|
||||||
|
|
||||||
|
for ext in get_valid_extensions(False):
|
||||||
current_version = current_versions.get(ext.code, 0)
|
current_version = current_versions.get(ext.code, 0)
|
||||||
|
try:
|
||||||
await migrate_extension_database(ext, current_version)
|
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.")
|
logger.info("✔️ All migrations done.")
|
||||||
|
|
||||||
|
|
||||||
@click.command("database-versions")
|
@db.command("versions")
|
||||||
def database_versions():
|
@coro
|
||||||
"""Show current database versions"""
|
|
||||||
loop = asyncio.get_event_loop()
|
|
||||||
loop.run_until_complete(db_versions())
|
|
||||||
|
|
||||||
|
|
||||||
async def db_versions():
|
async def db_versions():
|
||||||
"""Show current database versions"""
|
"""Show current database versions"""
|
||||||
async with core_db.connect() as conn:
|
async with core_db.connect() as conn:
|
||||||
return await get_dbversions(conn)
|
click.echo(await get_dbversions(conn))
|
||||||
|
|
||||||
|
|
||||||
|
@db.command("cleanup-wallets")
|
||||||
|
@click.argument("days", type=int, required=False)
|
||||||
|
@coro
|
||||||
|
async def database_cleanup_wallets(days: Optional[int] = None):
|
||||||
|
"""Delete all wallets that never had any transaction"""
|
||||||
|
async with core_db.connect() as conn:
|
||||||
|
delta = days or settings.cleanup_wallets_days
|
||||||
|
delta = delta * 24 * 60 * 60
|
||||||
|
await delete_unused_wallets(delta, conn)
|
||||||
|
|
||||||
|
|
||||||
|
@db.command("cleanup-deleted-wallets")
|
||||||
|
@coro
|
||||||
|
async def database_cleanup_deleted_wallets():
|
||||||
|
"""Delete all wallets that has been marked deleted"""
|
||||||
|
async with core_db.connect() as conn:
|
||||||
|
await remove_deleted_wallets(conn)
|
||||||
|
|
||||||
|
|
||||||
|
@db.command("cleanup-accounts")
|
||||||
|
@click.argument("days", type=int, required=False)
|
||||||
|
@coro
|
||||||
|
async def database_cleanup_accounts(days: Optional[int] = None):
|
||||||
|
"""Delete all accounts that have no wallets"""
|
||||||
|
async with core_db.connect() as conn:
|
||||||
|
delta = days or settings.cleanup_wallets_days
|
||||||
|
delta = delta * 24 * 60 * 60
|
||||||
|
await delete_accounts_no_wallets(delta, conn)
|
||||||
|
|
||||||
|
|
||||||
async def load_disabled_extension_list() -> None:
|
async def load_disabled_extension_list() -> None:
|
||||||
@@ -98,14 +197,401 @@ async def load_disabled_extension_list() -> None:
|
|||||||
settings.lnbits_deactivated_extensions += inactive_extensions
|
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():
|
def main():
|
||||||
"""main function"""
|
"""main function"""
|
||||||
command_group.add_command(superuser)
|
lnbits_cli()
|
||||||
command_group.add_command(delete_settings)
|
|
||||||
command_group.add_command(database_migrate)
|
|
||||||
command_group.add_command(database_versions)
|
|
||||||
command_group()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
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,
|
||||||
|
version=release.version,
|
||||||
|
)
|
||||||
|
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,
|
||||||
|
version=release.version,
|
||||||
|
)
|
||||||
|
|
||||||
|
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,30 @@
|
|||||||
from fastapi.routing import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
from lnbits.core.models import CoreAppExtra
|
from .db import core_app_extra, db
|
||||||
from lnbits.db import Database
|
from .views.admin_api import admin_router
|
||||||
|
from .views.api import api_router
|
||||||
|
from .views.auth_api import auth_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
|
||||||
|
from .views.tinyurl_api import tinyurl_router
|
||||||
|
from .views.webpush_api import webpush_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 *
|
def init_core_routers(app):
|
||||||
from .views.api import *
|
app.include_router(core_app)
|
||||||
from .views.generic import *
|
app.include_router(generic_router)
|
||||||
from .views.public_api import *
|
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)
|
||||||
|
app.include_router(tinyurl_router)
|
||||||
|
app.include_router(webpush_router)
|
||||||
|
app.include_router(auth_router)
|
||||||
|
|||||||
@@ -1,26 +1,85 @@
|
|||||||
import datetime
|
import datetime
|
||||||
import json
|
import json
|
||||||
from typing import Any, Dict, List, Optional
|
from time import time
|
||||||
|
from typing import Any, Dict, List, Literal, Optional
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
from uuid import UUID, uuid4
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
import shortuuid
|
import shortuuid
|
||||||
|
from passlib.context import CryptContext
|
||||||
|
|
||||||
from lnbits import bolt11
|
from lnbits.core.db import db
|
||||||
from lnbits.core.models import WalletType
|
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.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 (
|
||||||
from .models import BalanceCheck, Payment, PaymentFilters, TinyURL, User, Wallet
|
BalanceCheck,
|
||||||
|
CreateUser,
|
||||||
|
Payment,
|
||||||
|
PaymentFilters,
|
||||||
|
PaymentHistoryPoint,
|
||||||
|
TinyURL,
|
||||||
|
UpdateUserPassword,
|
||||||
|
User,
|
||||||
|
UserConfig,
|
||||||
|
Wallet,
|
||||||
|
WebPushSubscription,
|
||||||
|
)
|
||||||
|
|
||||||
# accounts
|
# accounts
|
||||||
# --------
|
# --------
|
||||||
|
|
||||||
|
|
||||||
|
async def create_user(
|
||||||
|
data: CreateUser, user_config: Optional[UserConfig] = None
|
||||||
|
) -> User:
|
||||||
|
if not settings.new_accounts_allowed:
|
||||||
|
raise ValueError("Account creation is disabled.")
|
||||||
|
if await get_account_by_username(data.username):
|
||||||
|
raise ValueError("Username already exists.")
|
||||||
|
|
||||||
|
if data.email and await get_account_by_email(data.email):
|
||||||
|
raise ValueError("Email already exists.")
|
||||||
|
|
||||||
|
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
|
|
||||||
|
user_id = uuid4().hex
|
||||||
|
tsph = db.timestamp_placeholder
|
||||||
|
now = int(time())
|
||||||
|
await db.execute(
|
||||||
|
f"""
|
||||||
|
INSERT INTO accounts
|
||||||
|
(id, email, username, pass, extra, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, {tsph}, {tsph})
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
user_id,
|
||||||
|
data.email,
|
||||||
|
data.username,
|
||||||
|
pwd_context.hash(data.password),
|
||||||
|
json.dumps(dict(user_config)) if user_config else "{}",
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
new_account = await get_account(user_id=user_id)
|
||||||
|
assert new_account, "Newly created account couldn't be retrieved"
|
||||||
|
return new_account
|
||||||
|
|
||||||
|
|
||||||
async def create_account(
|
async def create_account(
|
||||||
conn: Optional[Connection] = None, user_id: Optional[str] = None
|
conn: Optional[Connection] = None,
|
||||||
|
user_id: Optional[str] = None,
|
||||||
|
email: Optional[str] = None,
|
||||||
|
user_config: Optional[UserConfig] = None,
|
||||||
) -> User:
|
) -> User:
|
||||||
if user_id:
|
if user_id:
|
||||||
user_uuid4 = UUID(hex=user_id, version=4)
|
user_uuid4 = UUID(hex=user_id, version=4)
|
||||||
@@ -28,7 +87,15 @@ async def create_account(
|
|||||||
else:
|
else:
|
||||||
user_id = uuid4().hex
|
user_id = uuid4().hex
|
||||||
|
|
||||||
await (conn or db).execute("INSERT INTO accounts (id) VALUES (?)", (user_id,))
|
extra = json.dumps(dict(user_config)) if user_config else "{}"
|
||||||
|
now = int(time())
|
||||||
|
await (conn or db).execute(
|
||||||
|
f"""
|
||||||
|
INSERT INTO accounts (id, email, extra, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, {db.timestamp_placeholder}, {db.timestamp_placeholder})
|
||||||
|
""",
|
||||||
|
(user_id, email, extra, now, now),
|
||||||
|
)
|
||||||
|
|
||||||
new_account = await get_account(user_id=user_id, conn=conn)
|
new_account = await get_account(user_id=user_id, conn=conn)
|
||||||
assert new_account, "Newly created account couldn't be retrieved"
|
assert new_account, "Newly created account couldn't be retrieved"
|
||||||
@@ -36,19 +103,176 @@ async def create_account(
|
|||||||
return new_account
|
return new_account
|
||||||
|
|
||||||
|
|
||||||
|
async def update_account(
|
||||||
|
user_id: str,
|
||||||
|
username: Optional[str] = None,
|
||||||
|
email: Optional[str] = None,
|
||||||
|
user_config: Optional[UserConfig] = None,
|
||||||
|
) -> Optional[User]:
|
||||||
|
user = await get_account(user_id)
|
||||||
|
assert user, "User not found"
|
||||||
|
|
||||||
|
if email:
|
||||||
|
assert not user.email or email == user.email, "Cannot change email."
|
||||||
|
account = await get_account_by_email(email)
|
||||||
|
assert not account or account.id == user_id, "Email already in use."
|
||||||
|
|
||||||
|
if username:
|
||||||
|
assert not user.username or username == user.username, "Cannot change username."
|
||||||
|
account = await get_account_by_username(username)
|
||||||
|
assert not account or account.id == user_id, "Username already in exists."
|
||||||
|
|
||||||
|
username = user.username or username
|
||||||
|
email = user.email or email
|
||||||
|
extra = user_config or user.config
|
||||||
|
|
||||||
|
now = int(time())
|
||||||
|
await db.execute(
|
||||||
|
f"""
|
||||||
|
UPDATE accounts SET (username, email, extra, updated_at) =
|
||||||
|
(?, ?, ?, {db.timestamp_placeholder})
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
username,
|
||||||
|
email,
|
||||||
|
json.dumps(dict(extra)) if extra else "{}",
|
||||||
|
now,
|
||||||
|
user_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
user = await get_user(user_id)
|
||||||
|
assert user, "Updated account couldn't be retrieved"
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
async def get_account(
|
async def get_account(
|
||||||
user_id: str, conn: Optional[Connection] = None
|
user_id: str, conn: Optional[Connection] = None
|
||||||
) -> Optional[User]:
|
) -> Optional[User]:
|
||||||
row = await (conn or db).fetchone(
|
row = await (conn or db).fetchone(
|
||||||
"SELECT id, email, pass as password FROM accounts WHERE id = ?", (user_id,)
|
"""
|
||||||
|
SELECT id, email, username, created_at, updated_at, extra
|
||||||
|
FROM accounts WHERE id = ?
|
||||||
|
""",
|
||||||
|
(user_id,),
|
||||||
|
)
|
||||||
|
|
||||||
|
user = User(**row) if row else None
|
||||||
|
if user and row["extra"]:
|
||||||
|
user.config = UserConfig(**json.loads(row["extra"]))
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_accounts_no_wallets(
|
||||||
|
time_delta: int,
|
||||||
|
conn: Optional[Connection] = None,
|
||||||
|
) -> None:
|
||||||
|
await (conn or db).execute(
|
||||||
|
f"""
|
||||||
|
DELETE FROM accounts
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT wallets.id FROM wallets WHERE wallets.user = accounts.id
|
||||||
|
) AND updated_at < {db.timestamp_placeholder}
|
||||||
|
""",
|
||||||
|
(int(time()) - time_delta,),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_user_password(user_id: str) -> Optional[str]:
|
||||||
|
row = await db.fetchone(
|
||||||
|
"SELECT pass FROM accounts WHERE id = ?",
|
||||||
|
(user_id,),
|
||||||
|
)
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return row[0]
|
||||||
|
|
||||||
|
|
||||||
|
async def verify_user_password(user_id: str, password: str) -> bool:
|
||||||
|
existing_password = await get_user_password(user_id)
|
||||||
|
if not existing_password:
|
||||||
|
return False
|
||||||
|
|
||||||
|
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
|
return pwd_context.verify(password, existing_password)
|
||||||
|
|
||||||
|
|
||||||
|
# todo: , conn: Optional[Connection] = None ??
|
||||||
|
async def update_user_password(data: UpdateUserPassword) -> Optional[User]:
|
||||||
|
assert data.password == data.password_repeat, "Passwords do not match."
|
||||||
|
|
||||||
|
# old accounts do not have a pasword
|
||||||
|
if await get_user_password(data.user_id):
|
||||||
|
assert data.password_old, "Missing old password"
|
||||||
|
old_pwd_ok = await verify_user_password(data.user_id, data.password_old)
|
||||||
|
assert old_pwd_ok, "Invalid credentials."
|
||||||
|
|
||||||
|
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
|
|
||||||
|
now = int(time())
|
||||||
|
await db.execute(
|
||||||
|
f"""
|
||||||
|
UPDATE accounts SET pass = ?, updated_at = {db.timestamp_placeholder}
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
pwd_context.hash(data.password),
|
||||||
|
now,
|
||||||
|
data.user_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
user = await get_user(data.user_id)
|
||||||
|
assert user, "Updated account couldn't be retrieved"
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
async def get_account_by_username(
|
||||||
|
username: str, conn: Optional[Connection] = None
|
||||||
|
) -> Optional[User]:
|
||||||
|
row = await (conn or db).fetchone(
|
||||||
|
"""
|
||||||
|
SELECT id, username, email, created_at, updated_at
|
||||||
|
FROM accounts WHERE username = ?
|
||||||
|
""",
|
||||||
|
(username,),
|
||||||
)
|
)
|
||||||
|
|
||||||
return User(**row) if row else None
|
return User(**row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_account_by_email(
|
||||||
|
email: str, conn: Optional[Connection] = None
|
||||||
|
) -> Optional[User]:
|
||||||
|
row = await (conn or db).fetchone(
|
||||||
|
"""
|
||||||
|
SELECT id, username, email, created_at, updated_at
|
||||||
|
FROM accounts WHERE email = ?
|
||||||
|
""",
|
||||||
|
(email,),
|
||||||
|
)
|
||||||
|
|
||||||
|
return User(**row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_account_by_username_or_email(
|
||||||
|
username_or_email: str, conn: Optional[Connection] = None
|
||||||
|
) -> Optional[User]:
|
||||||
|
user = await get_account_by_username(username_or_email, conn)
|
||||||
|
if not user:
|
||||||
|
user = await get_account_by_email(username_or_email, conn)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
async def get_user(user_id: str, conn: Optional[Connection] = None) -> Optional[User]:
|
async def get_user(user_id: str, conn: Optional[Connection] = None) -> Optional[User]:
|
||||||
user = await (conn or db).fetchone(
|
user = await (conn or db).fetchone(
|
||||||
"SELECT id, email FROM accounts WHERE id = ?", (user_id,)
|
"""
|
||||||
|
SELECT id, email, username, pass, extra, created_at, updated_at
|
||||||
|
FROM accounts WHERE id = ?
|
||||||
|
""",
|
||||||
|
(user_id,),
|
||||||
)
|
)
|
||||||
|
|
||||||
if user:
|
if user:
|
||||||
@@ -62,7 +286,7 @@ async def get_user(user_id: str, conn: Optional[Connection] = None) -> Optional[
|
|||||||
SELECT balance FROM balances WHERE wallet = wallets.id
|
SELECT balance FROM balances WHERE wallet = wallets.id
|
||||||
), 0) AS balance_msat
|
), 0) AS balance_msat
|
||||||
FROM wallets
|
FROM wallets
|
||||||
WHERE "user" = ?
|
WHERE "user" = ? and wallets.deleted = false
|
||||||
""",
|
""",
|
||||||
(user_id,),
|
(user_id,),
|
||||||
)
|
)
|
||||||
@@ -72,6 +296,7 @@ async def get_user(user_id: str, conn: Optional[Connection] = None) -> Optional[
|
|||||||
return User(
|
return User(
|
||||||
id=user["id"],
|
id=user["id"],
|
||||||
email=user["email"],
|
email=user["email"],
|
||||||
|
username=user["username"],
|
||||||
extensions=[
|
extensions=[
|
||||||
e[0] for e in extensions if User.is_extension_for_user(e[0], user["id"])
|
e[0] for e in extensions if User.is_extension_for_user(e[0], user["id"])
|
||||||
],
|
],
|
||||||
@@ -79,6 +304,8 @@ async def get_user(user_id: str, conn: Optional[Connection] = None) -> Optional[
|
|||||||
admin=user["id"] == settings.super_user
|
admin=user["id"] == settings.super_user
|
||||||
or user["id"] in settings.lnbits_admin_users,
|
or user["id"] in settings.lnbits_admin_users,
|
||||||
super_user=user["id"] == settings.super_user,
|
super_user=user["id"] == settings.super_user,
|
||||||
|
has_password=True if user["pass"] else False,
|
||||||
|
config=UserConfig(**json.loads(user["extra"])) if user["extra"] else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -95,6 +322,7 @@ async def add_installed_extension(
|
|||||||
dict(ext.installed_release) if ext.installed_release else None
|
dict(ext.installed_release) if ext.installed_release else None
|
||||||
),
|
),
|
||||||
"dependencies": ext.dependencies,
|
"dependencies": ext.dependencies,
|
||||||
|
"payments": [dict(p) for p in ext.payments] if ext.payments else None,
|
||||||
}
|
}
|
||||||
|
|
||||||
version = ext.installed_release.version if ext.installed_release else ""
|
version = ext.installed_release.version if ext.installed_release else ""
|
||||||
@@ -167,13 +395,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(
|
row = await (conn or db).fetchone(
|
||||||
"SELECT * FROM installed_extensions WHERE id = ?",
|
"SELECT * FROM installed_extensions WHERE id = ?",
|
||||||
(ext_id,),
|
(ext_id,),
|
||||||
)
|
)
|
||||||
|
|
||||||
return dict(row) if row else None
|
return InstallableExtension.from_row(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
async def get_installed_extensions(
|
async def get_installed_extensions(
|
||||||
@@ -217,10 +447,11 @@ async def create_wallet(
|
|||||||
conn: Optional[Connection] = None,
|
conn: Optional[Connection] = None,
|
||||||
) -> Wallet:
|
) -> Wallet:
|
||||||
wallet_id = uuid4().hex
|
wallet_id = uuid4().hex
|
||||||
|
now = int(time())
|
||||||
await (conn or db).execute(
|
await (conn or db).execute(
|
||||||
"""
|
f"""
|
||||||
INSERT INTO wallets (id, name, "user", adminkey, inkey)
|
INSERT INTO wallets (id, name, "user", adminkey, inkey, created_at, updated_at)
|
||||||
VALUES (?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, {db.timestamp_placeholder}, {db.timestamp_placeholder})
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
wallet_id,
|
wallet_id,
|
||||||
@@ -228,6 +459,8 @@ async def create_wallet(
|
|||||||
user_id,
|
user_id,
|
||||||
uuid4().hex,
|
uuid4().hex,
|
||||||
uuid4().hex,
|
uuid4().hex,
|
||||||
|
now,
|
||||||
|
now,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -244,7 +477,10 @@ async def update_wallet(
|
|||||||
conn: Optional[Connection] = None,
|
conn: Optional[Connection] = None,
|
||||||
) -> Optional[Wallet]:
|
) -> Optional[Wallet]:
|
||||||
set_clause = []
|
set_clause = []
|
||||||
values = []
|
values: list = []
|
||||||
|
set_clause.append(f"updated_at = {db.timestamp_placeholder}")
|
||||||
|
now = int(time())
|
||||||
|
values.append(now)
|
||||||
if name:
|
if name:
|
||||||
set_clause.append("name = ?")
|
set_clause.append("name = ?")
|
||||||
values.append(name)
|
values.append(name)
|
||||||
@@ -266,16 +502,33 @@ async def update_wallet(
|
|||||||
async def delete_wallet(
|
async def delete_wallet(
|
||||||
*, user_id: str, wallet_id: str, conn: Optional[Connection] = None
|
*, user_id: str, wallet_id: str, conn: Optional[Connection] = None
|
||||||
) -> None:
|
) -> None:
|
||||||
|
now = int(time())
|
||||||
await (conn or db).execute(
|
await (conn or db).execute(
|
||||||
"""
|
f"""
|
||||||
UPDATE wallets AS w
|
UPDATE wallets
|
||||||
SET
|
SET deleted = true, updated_at = {db.timestamp_placeholder}
|
||||||
"user" = 'del:' || w."user",
|
|
||||||
adminkey = 'del:' || w.adminkey,
|
|
||||||
inkey = 'del:' || w.inkey
|
|
||||||
WHERE id = ? AND "user" = ?
|
WHERE id = ? AND "user" = ?
|
||||||
""",
|
""",
|
||||||
(wallet_id, user_id),
|
(now, wallet_id, user_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def remove_deleted_wallets(conn: Optional[Connection] = None) -> None:
|
||||||
|
await (conn or db).execute("DELETE FROM wallets WHERE deleted = true")
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_unused_wallets(
|
||||||
|
time_delta: int,
|
||||||
|
conn: Optional[Connection] = None,
|
||||||
|
) -> None:
|
||||||
|
await (conn or db).execute(
|
||||||
|
f"""
|
||||||
|
DELETE FROM wallets
|
||||||
|
WHERE (
|
||||||
|
SELECT COUNT(*) FROM apipayments WHERE wallet = wallets.id
|
||||||
|
) = 0 AND updated_at < {db.timestamp_placeholder}
|
||||||
|
""",
|
||||||
|
(int(time()) - time_delta,),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -301,7 +554,8 @@ async def get_wallet_for_key(
|
|||||||
row = await (conn or db).fetchone(
|
row = await (conn or db).fetchone(
|
||||||
"""
|
"""
|
||||||
SELECT *, COALESCE((SELECT balance FROM balances WHERE wallet = wallets.id), 0)
|
SELECT *, COALESCE((SELECT balance FROM balances WHERE wallet = wallets.id), 0)
|
||||||
AS balance_msat FROM wallets WHERE adminkey = ? OR inkey = ?
|
AS balance_msat FROM wallets
|
||||||
|
WHERE (adminkey = ? OR inkey = ?) AND deleted = false
|
||||||
""",
|
""",
|
||||||
(key, key),
|
(key, key),
|
||||||
)
|
)
|
||||||
@@ -349,6 +603,7 @@ async def get_standalone_payment(
|
|||||||
SELECT *
|
SELECT *
|
||||||
FROM apipayments
|
FROM apipayments
|
||||||
WHERE {clause}
|
WHERE {clause}
|
||||||
|
ORDER BY amount
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
""",
|
""",
|
||||||
tuple(values),
|
tuple(values),
|
||||||
@@ -523,22 +778,17 @@ async def create_payment(
|
|||||||
memo: str,
|
memo: str,
|
||||||
fee: int = 0,
|
fee: int = 0,
|
||||||
preimage: Optional[str] = None,
|
preimage: Optional[str] = None,
|
||||||
|
expiry: Optional[datetime.datetime] = None,
|
||||||
pending: bool = True,
|
pending: bool = True,
|
||||||
extra: Optional[Dict] = None,
|
extra: Optional[Dict] = None,
|
||||||
webhook: Optional[str] = None,
|
webhook: Optional[str] = None,
|
||||||
conn: Optional[Connection] = None,
|
conn: Optional[Connection] = None,
|
||||||
) -> Payment:
|
) -> Payment:
|
||||||
# todo: add this when tests are fixed
|
# we don't allow the creation of the same invoice twice
|
||||||
previous_payment = await get_wallet_payment(wallet_id, payment_hash, conn=conn)
|
# 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"
|
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(
|
await (conn or db).execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO apipayments
|
INSERT INTO apipayments
|
||||||
@@ -558,11 +808,11 @@ async def create_payment(
|
|||||||
fee,
|
fee,
|
||||||
(
|
(
|
||||||
json.dumps(extra)
|
json.dumps(extra)
|
||||||
if extra and extra != {} and type(extra) is dict
|
if extra and extra != {} and isinstance(extra, dict)
|
||||||
else None
|
else None
|
||||||
),
|
),
|
||||||
webhook,
|
webhook,
|
||||||
db.datetime_to_timestamp(expiration_date),
|
db.datetime_to_timestamp(expiry) if expiry else None,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -643,10 +893,77 @@ async def update_payment_extra(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def delete_payment(checking_id: str, conn: Optional[Connection] = None) -> None:
|
async def update_pending_payments(wallet_id: str):
|
||||||
await (conn or db).execute(
|
pending_payments = await get_payments(
|
||||||
"DELETE FROM apipayments WHERE checking_id = ?", (checking_id,)
|
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(
|
async def delete_wallet_payment(
|
||||||
@@ -661,6 +978,10 @@ async def delete_wallet_payment(
|
|||||||
async def check_internal(
|
async def check_internal(
|
||||||
payment_hash: str, conn: Optional[Connection] = None
|
payment_hash: str, conn: Optional[Connection] = None
|
||||||
) -> Optional[str]:
|
) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Returns the checking_id of the internal payment if it exists,
|
||||||
|
otherwise None
|
||||||
|
"""
|
||||||
row = await (conn or db).fetchone(
|
row = await (conn or db).fetchone(
|
||||||
"""
|
"""
|
||||||
SELECT checking_id FROM apipayments
|
SELECT checking_id FROM apipayments
|
||||||
@@ -694,6 +1015,16 @@ async def check_internal_pending(
|
|||||||
return row["pending"]
|
return row["pending"]
|
||||||
|
|
||||||
|
|
||||||
|
async def mark_webhook_sent(payment_hash: str, status: int) -> None:
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
UPDATE apipayments SET webhook_status = ?
|
||||||
|
WHERE hash = ?
|
||||||
|
""",
|
||||||
|
(status, payment_hash),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# balance_check
|
# balance_check
|
||||||
# -------------
|
# -------------
|
||||||
|
|
||||||
@@ -779,6 +1110,8 @@ async def get_admin_settings(is_super_user: bool = False) -> Optional[AdminSetti
|
|||||||
return None
|
return None
|
||||||
row_dict = dict(sets)
|
row_dict = dict(sets)
|
||||||
row_dict.pop("super_user")
|
row_dict.pop("super_user")
|
||||||
|
row_dict.pop("auth_all_methods")
|
||||||
|
|
||||||
admin_settings = AdminSettings(
|
admin_settings = AdminSettings(
|
||||||
is_super_user=is_super_user,
|
is_super_user=is_super_user,
|
||||||
lnbits_allowed_funding_sources=settings.lnbits_allowed_funding_sources,
|
lnbits_allowed_funding_sources=settings.lnbits_allowed_funding_sources,
|
||||||
@@ -787,12 +1120,17 @@ async def get_admin_settings(is_super_user: bool = False) -> Optional[AdminSetti
|
|||||||
return admin_settings
|
return admin_settings
|
||||||
|
|
||||||
|
|
||||||
async def delete_admin_settings():
|
async def delete_admin_settings() -> None:
|
||||||
await db.execute("DELETE FROM settings")
|
await db.execute("DELETE FROM settings")
|
||||||
|
|
||||||
|
|
||||||
async def update_admin_settings(data: EditableSettings):
|
async def update_admin_settings(data: EditableSettings) -> None:
|
||||||
await db.execute("UPDATE settings SET editable_settings = ?", (json.dumps(data),))
|
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:
|
async def update_super_user(super_user: str) -> SuperSettings:
|
||||||
@@ -875,3 +1213,82 @@ async def delete_tinyurl(tinyurl_id: str):
|
|||||||
"DELETE FROM tiny_url WHERE id = ?",
|
"DELETE FROM tiny_url WHERE id = ?",
|
||||||
(tinyurl_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()
|
||||||
@@ -1,16 +1,16 @@
|
|||||||
import importlib
|
import importlib
|
||||||
import re
|
import re
|
||||||
from typing import Any
|
from typing import Any, Optional
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
|
from lnbits.core.db import db as core_db
|
||||||
from lnbits.db import Connection
|
from lnbits.db import Connection
|
||||||
from lnbits.extension_manager import Extension
|
from lnbits.extension_manager import Extension
|
||||||
from lnbits.settings import settings
|
from lnbits.settings import settings
|
||||||
|
|
||||||
from . import db as core_db
|
|
||||||
from .crud import update_migration_version
|
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:
|
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)_")
|
matcher = re.compile(r"^m(\d\d\d)_")
|
||||||
db_name = migrations_module.__name__.split(".")[-2]
|
|
||||||
for key, migrate in migrations_module.__dict__.items():
|
for key, migrate in migrations_module.__dict__.items():
|
||||||
match = matcher.match(key)
|
match = matcher.match(key)
|
||||||
if match:
|
if match:
|
||||||
@@ -47,23 +48,63 @@ async def run_migration(db: Connection, migrations_module: Any, current_version:
|
|||||||
await update_migration_version(conn, db_name, version)
|
await update_migration_version(conn, db_name, version)
|
||||||
|
|
||||||
|
|
||||||
async def stop_extension_background_work(ext_id: str, user: str):
|
async def stop_extension_background_work(
|
||||||
|
ext_id: str, user: str, access_token: Optional[str] = None
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
Stop background work for extension (like asyncio.Tasks, WebSockets, etc).
|
Stop background work for extension (like asyncio.Tasks, WebSockets, etc).
|
||||||
Extensions SHOULD expose a DELETE enpoint at the root level of their API.
|
Extensions SHOULD expose a `api_stop()` function and/or a DELETE enpoint
|
||||||
This function tries first to call the endpoint using `http`
|
at the root level of their API.
|
||||||
and if it fails it tries using `https`.
|
|
||||||
"""
|
"""
|
||||||
|
stopped = await _stop_extension_background_work(ext_id)
|
||||||
|
|
||||||
|
if not stopped:
|
||||||
|
# fallback to REST API call
|
||||||
|
await _stop_extension_background_work_via_api(ext_id, user, access_token)
|
||||||
|
|
||||||
|
|
||||||
|
async def _stop_extension_background_work(ext_id) -> bool:
|
||||||
|
upgrade_hash = settings.extension_upgrade_hash(ext_id) or ""
|
||||||
|
ext = Extension(ext_id, True, False, upgrade_hash=upgrade_hash)
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.info(f"Stopping background work for extension '{ext.module_name}'.")
|
||||||
|
old_module = importlib.import_module(ext.module_name)
|
||||||
|
|
||||||
|
# Extensions must expose an `{ext_id}_stop()` function at the module level
|
||||||
|
# The `api_stop()` function is for backwards compatibility (will be deprecated)
|
||||||
|
stop_fns = [f"{ext_id}_stop", "api_stop"]
|
||||||
|
stop_fn_name = next((fn for fn in stop_fns if hasattr(old_module, fn)), None)
|
||||||
|
assert stop_fn_name, "No stop function found for '{ext.module_name}'"
|
||||||
|
|
||||||
|
await getattr(old_module, stop_fn_name)()
|
||||||
|
|
||||||
|
logger.info(f"Stopped background work for extension '{ext.module_name}'.")
|
||||||
|
except Exception as ex:
|
||||||
|
logger.warning(f"Failed to stop background work for '{ext.module_name}'.")
|
||||||
|
logger.warning(ex)
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def _stop_extension_background_work_via_api(ext_id, user, access_token):
|
||||||
|
logger.info(
|
||||||
|
f"Stopping background work for extension '{ext_id}' using the REST API."
|
||||||
|
)
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
try:
|
try:
|
||||||
url = f"http://{settings.host}:{settings.port}/{ext_id}/api/v1?usr={user}"
|
url = f"http://{settings.host}:{settings.port}/{ext_id}/api/v1?usr={user}"
|
||||||
await client.delete(url)
|
headers = (
|
||||||
except Exception as ex:
|
{"Authorization": "Bearer " + access_token} if access_token else None
|
||||||
logger.warning(ex)
|
)
|
||||||
try:
|
resp = await client.delete(url=url, headers=headers)
|
||||||
# try https
|
resp.raise_for_status()
|
||||||
url = f"https://{settings.host}:{settings.port}/{ext_id}/api/v1?usr={user}"
|
logger.info(f"Stopped background work for extension '{ext_id}'.")
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
|
logger.warning(
|
||||||
|
f"Failed to stop background work for '{ext_id}' using the REST API."
|
||||||
|
)
|
||||||
logger.warning(ex)
|
logger.warning(ex)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import datetime
|
import datetime
|
||||||
|
from time import time
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from sqlalchemy.exc import OperationalError
|
from sqlalchemy.exc import OperationalError
|
||||||
@@ -327,3 +328,146 @@ async def m012_add_currency_to_wallet(db):
|
|||||||
ALTER TABLE wallets ADD COLUMN currency TEXT
|
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")
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def m016_add_username_column_to_accounts(db):
|
||||||
|
"""
|
||||||
|
Adds username column to accounts.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
await db.execute("ALTER TABLE accounts ADD COLUMN username TEXT")
|
||||||
|
await db.execute("ALTER TABLE accounts ADD COLUMN extra TEXT")
|
||||||
|
except OperationalError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def m017_add_timestamp_columns_to_accounts_and_wallets(db):
|
||||||
|
"""
|
||||||
|
Adds created_at and updated_at column to accounts and wallets.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
await db.execute(
|
||||||
|
"ALTER TABLE accounts "
|
||||||
|
f"ADD COLUMN created_at TIMESTAMP DEFAULT {db.timestamp_column_default}"
|
||||||
|
)
|
||||||
|
await db.execute(
|
||||||
|
"ALTER TABLE accounts "
|
||||||
|
f"ADD COLUMN updated_at TIMESTAMP DEFAULT {db.timestamp_column_default}"
|
||||||
|
)
|
||||||
|
await db.execute(
|
||||||
|
"ALTER TABLE wallets "
|
||||||
|
f"ADD COLUMN created_at TIMESTAMP DEFAULT {db.timestamp_column_default}"
|
||||||
|
)
|
||||||
|
await db.execute(
|
||||||
|
"ALTER TABLE wallets "
|
||||||
|
f"ADD COLUMN updated_at TIMESTAMP DEFAULT {db.timestamp_column_default}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# # set their wallets created_at with the first payment
|
||||||
|
# await db.execute(
|
||||||
|
# """
|
||||||
|
# UPDATE wallets SET created_at = (
|
||||||
|
# SELECT time FROM apipayments
|
||||||
|
# WHERE apipayments.wallet = wallets.id
|
||||||
|
# ORDER BY time ASC LIMIT 1
|
||||||
|
# )
|
||||||
|
# """
|
||||||
|
# )
|
||||||
|
|
||||||
|
# # then set their accounts created_at with the wallet
|
||||||
|
# await db.execute(
|
||||||
|
# """
|
||||||
|
# UPDATE accounts SET created_at = (
|
||||||
|
# SELECT created_at FROM wallets
|
||||||
|
# WHERE wallets.user = accounts.id
|
||||||
|
# ORDER BY created_at ASC LIMIT 1
|
||||||
|
# )
|
||||||
|
# """
|
||||||
|
# )
|
||||||
|
|
||||||
|
# set all to now where they are null
|
||||||
|
now = int(time())
|
||||||
|
await db.execute(
|
||||||
|
f"""
|
||||||
|
UPDATE wallets SET created_at = {db.timestamp_placeholder}
|
||||||
|
WHERE created_at IS NULL
|
||||||
|
""",
|
||||||
|
(now,),
|
||||||
|
)
|
||||||
|
await db.execute(
|
||||||
|
f"""
|
||||||
|
UPDATE accounts SET created_at = {db.timestamp_placeholder}
|
||||||
|
WHERE created_at IS NULL
|
||||||
|
""",
|
||||||
|
(now,),
|
||||||
|
)
|
||||||
|
|
||||||
|
except OperationalError as exc:
|
||||||
|
logger.error(f"Migration 17 failed: {exc}")
|
||||||
|
pass
|
||||||
|
|||||||
@@ -10,26 +10,32 @@ from typing import Callable, Dict, List, Optional
|
|||||||
|
|
||||||
from ecdsa import SECP256k1, SigningKey
|
from ecdsa import SECP256k1, SigningKey
|
||||||
from fastapi import Query
|
from fastapi import Query
|
||||||
from lnurl import encode as lnurl_encode
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from lnbits.db import Connection, FilterModel, FromRowModel
|
from lnbits.db import Connection, FilterModel, FromRowModel
|
||||||
from lnbits.helpers import url_for
|
from lnbits.helpers import url_for
|
||||||
|
from lnbits.lnurl import encode as lnurl_encode
|
||||||
from lnbits.settings import settings
|
from lnbits.settings import settings
|
||||||
from lnbits.wallets import get_wallet_class
|
from lnbits.wallets import get_wallet_class
|
||||||
from lnbits.wallets.base import PaymentStatus
|
from lnbits.wallets.base import PaymentPendingStatus, PaymentStatus
|
||||||
|
|
||||||
|
|
||||||
class Wallet(BaseModel):
|
class BaseWallet(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
name: str
|
name: str
|
||||||
user: str
|
|
||||||
adminkey: str
|
adminkey: str
|
||||||
inkey: str
|
inkey: str
|
||||||
currency: Optional[str]
|
|
||||||
balance_msat: int
|
balance_msat: int
|
||||||
|
|
||||||
|
|
||||||
|
class Wallet(BaseWallet):
|
||||||
|
user: str
|
||||||
|
currency: Optional[str]
|
||||||
|
deleted: bool
|
||||||
|
created_at: Optional[int] = None
|
||||||
|
updated_at: Optional[int] = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def balance(self) -> int:
|
def balance(self) -> int:
|
||||||
return self.balance_msat // 1000
|
return self.balance_msat // 1000
|
||||||
@@ -78,14 +84,31 @@ class WalletTypeInfo:
|
|||||||
wallet: Wallet
|
wallet: Wallet
|
||||||
|
|
||||||
|
|
||||||
|
class UserConfig(BaseModel):
|
||||||
|
email_verified: Optional[bool] = False
|
||||||
|
first_name: Optional[str] = None
|
||||||
|
last_name: Optional[str] = None
|
||||||
|
display_name: Optional[str] = None
|
||||||
|
picture: Optional[str] = None
|
||||||
|
# Auth provider, possible values:
|
||||||
|
# - "env": the user was created automatically by the system
|
||||||
|
# - "lnbits": the user was created via register form (username/pass or user_id only)
|
||||||
|
# - "google | github | ...": the user was created using an SSO provider
|
||||||
|
provider: Optional[str] = "lnbits" # auth provider
|
||||||
|
|
||||||
|
|
||||||
class User(BaseModel):
|
class User(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
email: Optional[str] = None
|
email: Optional[str] = None
|
||||||
|
username: Optional[str] = None
|
||||||
extensions: List[str] = []
|
extensions: List[str] = []
|
||||||
wallets: List[Wallet] = []
|
wallets: List[Wallet] = []
|
||||||
password: Optional[str] = None
|
|
||||||
admin: bool = False
|
admin: bool = False
|
||||||
super_user: bool = False
|
super_user: bool = False
|
||||||
|
has_password: bool = False
|
||||||
|
config: Optional[UserConfig] = None
|
||||||
|
created_at: Optional[int] = None
|
||||||
|
updated_at: Optional[int] = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def wallet_ids(self) -> List[str]:
|
def wallet_ids(self) -> List[str]:
|
||||||
@@ -106,6 +129,43 @@ class User(BaseModel):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class CreateUser(BaseModel):
|
||||||
|
email: Optional[str] = Query(default=None)
|
||||||
|
username: str = Query(default=..., min_length=2, max_length=20)
|
||||||
|
password: str = Query(default=..., min_length=8, max_length=50)
|
||||||
|
password_repeat: str = Query(default=..., min_length=8, max_length=50)
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateUser(BaseModel):
|
||||||
|
user_id: str
|
||||||
|
email: Optional[str] = Query(default=None)
|
||||||
|
username: Optional[str] = Query(default=..., min_length=2, max_length=20)
|
||||||
|
config: Optional[UserConfig] = None
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateUserPassword(BaseModel):
|
||||||
|
user_id: str
|
||||||
|
password: str = Query(default=..., min_length=8, max_length=50)
|
||||||
|
password_repeat: str = Query(default=..., min_length=8, max_length=50)
|
||||||
|
password_old: Optional[str] = Query(default=None, min_length=8, max_length=50)
|
||||||
|
username: Optional[str] = Query(default=..., min_length=2, max_length=20)
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateSuperuserPassword(BaseModel):
|
||||||
|
username: str = Query(default=..., min_length=2, max_length=20)
|
||||||
|
password: str = Query(default=..., min_length=8, max_length=50)
|
||||||
|
password_repeat: str = Query(default=..., min_length=8, max_length=50)
|
||||||
|
|
||||||
|
|
||||||
|
class LoginUsr(BaseModel):
|
||||||
|
usr: str
|
||||||
|
|
||||||
|
|
||||||
|
class LoginUsernamePassword(BaseModel):
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
class Payment(FromRowModel):
|
class Payment(FromRowModel):
|
||||||
checking_id: str
|
checking_id: str
|
||||||
pending: bool
|
pending: bool
|
||||||
@@ -198,7 +258,7 @@ class Payment(FromRowModel):
|
|||||||
conn: Optional[Connection] = None,
|
conn: Optional[Connection] = None,
|
||||||
) -> PaymentStatus:
|
) -> PaymentStatus:
|
||||||
if self.is_uncheckable:
|
if self.is_uncheckable:
|
||||||
return PaymentStatus(None)
|
return PaymentPendingStatus()
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"Checking {'outgoing' if self.is_out else 'incoming'} "
|
f"Checking {'outgoing' if self.is_out else 'incoming'} "
|
||||||
@@ -220,11 +280,18 @@ class Payment(FromRowModel):
|
|||||||
f"expired {expiration_date}"
|
f"expired {expiration_date}"
|
||||||
)
|
)
|
||||||
await self.delete(conn)
|
await self.delete(conn)
|
||||||
|
# wait at least 15 minutes before deleting failed outgoing payments
|
||||||
elif self.is_out and status.failed:
|
elif self.is_out and status.failed:
|
||||||
|
if self.time + 900 < int(time.time()):
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"Deleting outgoing failed payment {self.checking_id}: {status}"
|
f"Deleting outgoing failed payment {self.checking_id}: {status}"
|
||||||
)
|
)
|
||||||
await self.delete(conn)
|
await self.delete(conn)
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
f"Tried to delete outgoing payment {self.checking_id}: "
|
||||||
|
"skipping because it's not old enough"
|
||||||
|
)
|
||||||
elif not status.pending:
|
elif not status.pending:
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Marking '{'in' if self.is_in else 'out'}' "
|
f"Marking '{'in' if self.is_in else 'out'}' "
|
||||||
@@ -234,9 +301,9 @@ class Payment(FromRowModel):
|
|||||||
return status
|
return status
|
||||||
|
|
||||||
async def delete(self, conn: Optional[Connection] = None) -> None:
|
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):
|
class PaymentFilters(FilterModel):
|
||||||
@@ -257,6 +324,13 @@ class PaymentFilters(FilterModel):
|
|||||||
webhook_status: Optional[int]
|
webhook_status: Optional[int]
|
||||||
|
|
||||||
|
|
||||||
|
class PaymentHistoryPoint(BaseModel):
|
||||||
|
date: datetime.datetime
|
||||||
|
income: int
|
||||||
|
spending: int
|
||||||
|
balance: int
|
||||||
|
|
||||||
|
|
||||||
class BalanceCheck(BaseModel):
|
class BalanceCheck(BaseModel):
|
||||||
wallet: str
|
wallet: str
|
||||||
service: str
|
service: str
|
||||||
@@ -267,8 +341,12 @@ class BalanceCheck(BaseModel):
|
|||||||
return cls(wallet=row["wallet"], service=row["service"], url=row["url"])
|
return cls(wallet=row["wallet"], service=row["service"], url=row["url"])
|
||||||
|
|
||||||
|
|
||||||
|
def _do_nothing(*_):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class CoreAppExtra:
|
class CoreAppExtra:
|
||||||
register_new_ext_routes: Callable
|
register_new_ext_routes: Callable = _do_nothing
|
||||||
register_new_ratelimiter: Callable
|
register_new_ratelimiter: Callable
|
||||||
|
|
||||||
|
|
||||||
@@ -304,6 +382,7 @@ class CreateLnurl(BaseModel):
|
|||||||
amount: int
|
amount: int
|
||||||
comment: Optional[str] = None
|
comment: Optional[str] = None
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
|
unit: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class CreateInvoice(BaseModel):
|
class CreateInvoice(BaseModel):
|
||||||
@@ -329,3 +408,19 @@ class CreateTopup(BaseModel):
|
|||||||
|
|
||||||
class CreateLnurlAuth(BaseModel):
|
class CreateLnurlAuth(BaseModel):
|
||||||
callback: str
|
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,19 +1,27 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
import datetime
|
||||||
import json
|
import json
|
||||||
|
import time
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
|
from pathlib import Path
|
||||||
from typing import Dict, List, Optional, Tuple, TypedDict
|
from typing import Dict, List, Optional, Tuple, TypedDict
|
||||||
from urllib.parse import parse_qs, urlparse
|
from urllib.parse import parse_qs, urlparse
|
||||||
|
|
||||||
import httpx
|
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 fastapi import Depends, WebSocket
|
||||||
from lnurl import LnurlErrorResponse
|
|
||||||
from lnurl import decode as decode_lnurl
|
|
||||||
from loguru import logger
|
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.db import Connection
|
||||||
from lnbits.decorators import WalletTypeInfo, require_admin_key
|
from lnbits.decorators import WalletTypeInfo, require_admin_key
|
||||||
from lnbits.helpers import url_for
|
from lnbits.helpers import url_for
|
||||||
|
from lnbits.lnurl import LnurlErrorResponse
|
||||||
|
from lnbits.lnurl import decode as decode_lnurl
|
||||||
from lnbits.settings import (
|
from lnbits.settings import (
|
||||||
EditableSettings,
|
EditableSettings,
|
||||||
SuperSettings,
|
SuperSettings,
|
||||||
@@ -23,9 +31,13 @@ from lnbits.settings import (
|
|||||||
)
|
)
|
||||||
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis, satoshis_amount_as_fiat
|
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis, satoshis_amount_as_fiat
|
||||||
from lnbits.wallets import FAKE_WALLET, get_wallet_class, set_wallet_class
|
from lnbits.wallets import FAKE_WALLET, get_wallet_class, set_wallet_class
|
||||||
from lnbits.wallets.base import PaymentResponse, PaymentStatus
|
from lnbits.wallets.base import (
|
||||||
|
PaymentPendingStatus,
|
||||||
|
PaymentResponse,
|
||||||
|
PaymentStatus,
|
||||||
|
PaymentSuccessStatus,
|
||||||
|
)
|
||||||
|
|
||||||
from . import db
|
|
||||||
from .crud import (
|
from .crud import (
|
||||||
check_internal,
|
check_internal,
|
||||||
check_internal_pending,
|
check_internal_pending,
|
||||||
@@ -35,17 +47,19 @@ from .crud import (
|
|||||||
create_wallet,
|
create_wallet,
|
||||||
delete_wallet_payment,
|
delete_wallet_payment,
|
||||||
get_account,
|
get_account,
|
||||||
|
get_payments,
|
||||||
get_standalone_payment,
|
get_standalone_payment,
|
||||||
get_super_settings,
|
get_super_settings,
|
||||||
get_total_balance,
|
get_total_balance,
|
||||||
get_wallet,
|
get_wallet,
|
||||||
get_wallet_payment,
|
get_wallet_payment,
|
||||||
|
update_admin_settings,
|
||||||
update_payment_details,
|
update_payment_details,
|
||||||
update_payment_status,
|
update_payment_status,
|
||||||
update_super_user,
|
update_super_user,
|
||||||
)
|
)
|
||||||
from .helpers import to_valid_user_id
|
from .helpers import to_valid_user_id
|
||||||
from .models import Payment, Wallet
|
from .models import Payment, UserConfig, Wallet
|
||||||
|
|
||||||
|
|
||||||
class PaymentFailure(Exception):
|
class PaymentFailure(Exception):
|
||||||
@@ -87,7 +101,9 @@ async def calculate_fiat_amounts(
|
|||||||
extra["wallet_fiat_amount"] = round(fiat_amount, ndigits=3)
|
extra["wallet_fiat_amount"] = round(fiat_amount, ndigits=3)
|
||||||
extra["wallet_fiat_rate"] = amount_sat / fiat_amount
|
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
|
return amount_sat, extra
|
||||||
|
|
||||||
@@ -109,6 +125,10 @@ async def create_invoice(
|
|||||||
if not amount > 0:
|
if not amount > 0:
|
||||||
raise InvoiceFailure("Amountless invoices not supported.")
|
raise InvoiceFailure("Amountless invoices not supported.")
|
||||||
|
|
||||||
|
user_wallet = await get_wallet(wallet_id, conn=conn)
|
||||||
|
if not user_wallet:
|
||||||
|
raise InvoiceFailure(f"Could not fetch wallet '{wallet_id}'.")
|
||||||
|
|
||||||
invoice_memo = None if description_hash else memo
|
invoice_memo = None if description_hash else memo
|
||||||
|
|
||||||
# use the fake wallet if the invoice is for internal use only
|
# use the fake wallet if the invoice is for internal use only
|
||||||
@@ -118,6 +138,14 @@ async def create_invoice(
|
|||||||
amount, wallet_id, currency=currency, extra=extra, conn=conn
|
amount, wallet_id, currency=currency, extra=extra, conn=conn
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if settings.is_wallet_max_balance_exceeded(
|
||||||
|
user_wallet.balance_msat / 1000 + amount_sat
|
||||||
|
):
|
||||||
|
raise InvoiceFailure(
|
||||||
|
f"Wallet balance cannot exceed "
|
||||||
|
f"{settings.lnbits_wallet_limit_max_balance} sats."
|
||||||
|
)
|
||||||
|
|
||||||
ok, checking_id, payment_request, error_message = await wallet.create_invoice(
|
ok, checking_id, payment_request, error_message = await wallet.create_invoice(
|
||||||
amount=amount_sat,
|
amount=amount_sat,
|
||||||
memo=invoice_memo,
|
memo=invoice_memo,
|
||||||
@@ -128,7 +156,7 @@ async def create_invoice(
|
|||||||
if not ok or not payment_request or not checking_id:
|
if not ok or not payment_request or not checking_id:
|
||||||
raise InvoiceFailure(error_message or "unexpected backend error.")
|
raise InvoiceFailure(error_message or "unexpected backend error.")
|
||||||
|
|
||||||
invoice = bolt11.decode(payment_request)
|
invoice = bolt11_decode(payment_request)
|
||||||
|
|
||||||
amount_msat = 1000 * amount_sat
|
amount_msat = 1000 * amount_sat
|
||||||
await create_payment(
|
await create_payment(
|
||||||
@@ -137,6 +165,7 @@ async def create_invoice(
|
|||||||
payment_request=payment_request,
|
payment_request=payment_request,
|
||||||
payment_hash=invoice.payment_hash,
|
payment_hash=invoice.payment_hash,
|
||||||
amount=amount_msat,
|
amount=amount_msat,
|
||||||
|
expiry=get_bolt11_expiry(invoice),
|
||||||
memo=memo,
|
memo=memo,
|
||||||
extra=extra,
|
extra=extra,
|
||||||
webhook=webhook,
|
webhook=webhook,
|
||||||
@@ -165,19 +194,24 @@ async def pay_invoice(
|
|||||||
If the payment is still in flight, we hope that some other process
|
If the payment is still in flight, we hope that some other process
|
||||||
will regularly check for the payment.
|
will regularly check for the payment.
|
||||||
"""
|
"""
|
||||||
invoice = bolt11.decode(payment_request)
|
try:
|
||||||
fee_reserve_msat = fee_reserve(invoice.amount_msat)
|
invoice = bolt11_decode(payment_request)
|
||||||
|
except Exception:
|
||||||
|
raise InvoiceFailure("Bolt11 decoding failed.")
|
||||||
|
|
||||||
|
if not invoice.amount_msat or not invoice.amount_msat > 0:
|
||||||
|
raise InvoiceFailure("Amountless invoices not supported.")
|
||||||
|
if max_sat and invoice.amount_msat > max_sat * 1000:
|
||||||
|
raise InvoiceFailure("Amount in invoice is too high.")
|
||||||
|
|
||||||
|
await check_wallet_limits(wallet_id, conn, invoice.amount_msat)
|
||||||
|
|
||||||
async with db.reuse_conn(conn) if conn else db.connect() as conn:
|
async with db.reuse_conn(conn) if conn else db.connect() as conn:
|
||||||
temp_id = invoice.payment_hash
|
temp_id = invoice.payment_hash
|
||||||
internal_id = f"internal_{invoice.payment_hash}"
|
internal_id = f"internal_{invoice.payment_hash}"
|
||||||
|
|
||||||
if 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.")
|
|
||||||
|
|
||||||
_, extra = await calculate_fiat_amounts(
|
_, 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
|
# put all parameters that don't change here
|
||||||
@@ -187,6 +221,7 @@ async def pay_invoice(
|
|||||||
payment_hash: str
|
payment_hash: str
|
||||||
amount: int
|
amount: int
|
||||||
memo: str
|
memo: str
|
||||||
|
expiry: Optional[datetime.datetime]
|
||||||
extra: Optional[Dict]
|
extra: Optional[Dict]
|
||||||
|
|
||||||
payment_kwargs: PaymentKwargs = PaymentKwargs(
|
payment_kwargs: PaymentKwargs = PaymentKwargs(
|
||||||
@@ -194,6 +229,7 @@ async def pay_invoice(
|
|||||||
payment_request=payment_request,
|
payment_request=payment_request,
|
||||||
payment_hash=invoice.payment_hash,
|
payment_hash=invoice.payment_hash,
|
||||||
amount=-invoice.amount_msat,
|
amount=-invoice.amount_msat,
|
||||||
|
expiry=get_bolt11_expiry(invoice),
|
||||||
memo=description or invoice.description or "",
|
memo=description or invoice.description or "",
|
||||||
extra=extra,
|
extra=extra,
|
||||||
)
|
)
|
||||||
@@ -207,6 +243,9 @@ async def pay_invoice(
|
|||||||
# (pending only)
|
# (pending only)
|
||||||
internal_checking_id = await check_internal(invoice.payment_hash, conn=conn)
|
internal_checking_id = await check_internal(invoice.payment_hash, conn=conn)
|
||||||
if internal_checking_id:
|
if internal_checking_id:
|
||||||
|
fee_reserve_total_msat = fee_reserve_total(
|
||||||
|
invoice.amount_msat, internal=True
|
||||||
|
)
|
||||||
# perform additional checks on the internal payment
|
# perform additional checks on the internal payment
|
||||||
# the payment hash is not enough to make sure that this is the same invoice
|
# the payment hash is not enough to make sure that this is the same invoice
|
||||||
internal_invoice = await get_standalone_payment(
|
internal_invoice = await get_standalone_payment(
|
||||||
@@ -223,19 +262,22 @@ async def pay_invoice(
|
|||||||
# create a new payment from this wallet
|
# create a new payment from this wallet
|
||||||
new_payment = await create_payment(
|
new_payment = await create_payment(
|
||||||
checking_id=internal_id,
|
checking_id=internal_id,
|
||||||
fee=0,
|
fee=0 + abs(fee_reserve_total_msat),
|
||||||
pending=False,
|
pending=False,
|
||||||
conn=conn,
|
conn=conn,
|
||||||
**payment_kwargs,
|
**payment_kwargs,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
|
fee_reserve_total_msat = fee_reserve_total(
|
||||||
|
invoice.amount_msat, internal=False
|
||||||
|
)
|
||||||
logger.debug(f"creating temporary payment with id {temp_id}")
|
logger.debug(f"creating temporary payment with id {temp_id}")
|
||||||
# create a temporary payment here so we can check if
|
# create a temporary payment here so we can check if
|
||||||
# the balance is enough in the next step
|
# the balance is enough in the next step
|
||||||
try:
|
try:
|
||||||
new_payment = await create_payment(
|
new_payment = await create_payment(
|
||||||
checking_id=temp_id,
|
checking_id=temp_id,
|
||||||
fee=-fee_reserve_msat,
|
fee=-abs(fee_reserve_total_msat),
|
||||||
conn=conn,
|
conn=conn,
|
||||||
**payment_kwargs,
|
**payment_kwargs,
|
||||||
)
|
)
|
||||||
@@ -249,14 +291,18 @@ async def pay_invoice(
|
|||||||
assert wallet, "Wallet for balancecheck could not be fetched"
|
assert wallet, "Wallet for balancecheck could not be fetched"
|
||||||
if wallet.balance_msat < 0:
|
if wallet.balance_msat < 0:
|
||||||
logger.debug("balance is too low, deleting temporary payment")
|
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(
|
raise PaymentFailure(
|
||||||
f"You must reserve at least ({round(fee_reserve_msat/1000)} sat) to"
|
f"You must reserve at least ({round(fee_reserve_total_msat/1000)}"
|
||||||
" cover potential routing fees."
|
" sat) to cover potential routing fees."
|
||||||
)
|
)
|
||||||
raise PermissionError("Insufficient balance.")
|
raise PermissionError("Insufficient balance.")
|
||||||
|
|
||||||
if internal_checking_id:
|
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}")
|
logger.debug(f"marking temporary payment as not pending {internal_checking_id}")
|
||||||
# mark the invoice from the other side as not pending anymore
|
# 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
|
# so the other side only has access to his new money when we are sure
|
||||||
@@ -273,6 +319,8 @@ async def pay_invoice(
|
|||||||
logger.debug(f"enqueuing internal invoice {internal_checking_id}")
|
logger.debug(f"enqueuing internal invoice {internal_checking_id}")
|
||||||
await internal_invoice_queue.put(internal_checking_id)
|
await internal_invoice_queue.put(internal_checking_id)
|
||||||
else:
|
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}")
|
logger.debug(f"backend: sending payment {temp_id}")
|
||||||
# actually pay the external invoice
|
# actually pay the external invoice
|
||||||
WALLET = get_wallet_class()
|
WALLET = get_wallet_class()
|
||||||
@@ -294,7 +342,10 @@ async def pay_invoice(
|
|||||||
await update_payment_details(
|
await update_payment_details(
|
||||||
checking_id=temp_id,
|
checking_id=temp_id,
|
||||||
pending=payment.ok is not True,
|
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,
|
preimage=payment.preimage,
|
||||||
new_checking_id=payment.checking_id,
|
new_checking_id=payment.checking_id,
|
||||||
conn=conn,
|
conn=conn,
|
||||||
@@ -322,9 +373,73 @@ async def pay_invoice(
|
|||||||
f" database: {temp_id}"
|
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
|
return invoice.payment_hash
|
||||||
|
|
||||||
|
|
||||||
|
async def check_wallet_limits(wallet_id, conn, amount_msat):
|
||||||
|
await check_time_limit_between_transactions(conn, wallet_id)
|
||||||
|
await check_wallet_daily_withdraw_limit(conn, wallet_id, amount_msat)
|
||||||
|
|
||||||
|
|
||||||
|
async def check_time_limit_between_transactions(conn, wallet_id):
|
||||||
|
limit = settings.lnbits_wallet_limit_secs_between_trans
|
||||||
|
if not limit or limit <= 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
payments = await get_payments(
|
||||||
|
since=int(time.time()) - limit,
|
||||||
|
wallet_id=wallet_id,
|
||||||
|
limit=1,
|
||||||
|
conn=conn,
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(payments) == 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
raise ValueError(
|
||||||
|
f"The time limit of {limit} seconds between payments has been reached."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def check_wallet_daily_withdraw_limit(conn, wallet_id, amount_msat):
|
||||||
|
limit = settings.lnbits_wallet_limit_daily_max_withdraw
|
||||||
|
if not limit or limit <= 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
payments = await get_payments(
|
||||||
|
since=int(time.time()) - 60 * 60 * 24,
|
||||||
|
outgoing=True,
|
||||||
|
wallet_id=wallet_id,
|
||||||
|
limit=1,
|
||||||
|
conn=conn,
|
||||||
|
)
|
||||||
|
if len(payments) == 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
total = 0
|
||||||
|
for pay in payments:
|
||||||
|
total += pay.amount
|
||||||
|
total = total - amount_msat
|
||||||
|
if limit * 1000 + total < 0:
|
||||||
|
raise ValueError(
|
||||||
|
"Daily withdrawal limit of "
|
||||||
|
+ str(settings.lnbits_wallet_limit_daily_max_withdraw)
|
||||||
|
+ " sats reached."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def redeem_lnurl_withdraw(
|
async def redeem_lnurl_withdraw(
|
||||||
wallet_id: str,
|
wallet_id: str,
|
||||||
lnurl_request: str,
|
lnurl_request: str,
|
||||||
@@ -338,7 +453,8 @@ async def redeem_lnurl_withdraw(
|
|||||||
|
|
||||||
res = {}
|
res = {}
|
||||||
|
|
||||||
async with httpx.AsyncClient() as client:
|
headers = {"User-Agent": settings.user_agent}
|
||||||
|
async with httpx.AsyncClient(headers=headers) as client:
|
||||||
lnurl = decode_lnurl(lnurl_request)
|
lnurl = decode_lnurl(lnurl_request)
|
||||||
r = await client.get(str(lnurl))
|
r = await client.get(str(lnurl))
|
||||||
res = r.json()
|
res = r.json()
|
||||||
@@ -372,7 +488,8 @@ async def redeem_lnurl_withdraw(
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
async with httpx.AsyncClient() as client:
|
headers = {"User-Agent": settings.user_agent}
|
||||||
|
async with httpx.AsyncClient(headers=headers) as client:
|
||||||
try:
|
try:
|
||||||
await client.get(res["callback"], params=params)
|
await client.get(res["callback"], params=params)
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -435,7 +552,8 @@ async def perform_lnurlauth(
|
|||||||
|
|
||||||
sig = key.sign_digest_deterministic(k1, sigencode=encode_strict_der)
|
sig = key.sign_digest_deterministic(k1, sigencode=encode_strict_der)
|
||||||
|
|
||||||
async with httpx.AsyncClient() as client:
|
headers = {"User-Agent": settings.user_agent}
|
||||||
|
async with httpx.AsyncClient(headers=headers) as client:
|
||||||
assert key.verifying_key, "LNURLauth verifying_key does not exist"
|
assert key.verifying_key, "LNURLauth verifying_key does not exist"
|
||||||
r = await client.get(
|
r = await client.get(
|
||||||
callback,
|
callback,
|
||||||
@@ -464,10 +582,10 @@ async def check_transaction_status(
|
|||||||
wallet_id, payment_hash, conn=conn
|
wallet_id, payment_hash, conn=conn
|
||||||
)
|
)
|
||||||
if not payment:
|
if not payment:
|
||||||
return PaymentStatus(None)
|
return PaymentPendingStatus()
|
||||||
if not payment.pending:
|
if not payment.pending:
|
||||||
# note: before, we still checked the status of the payment again
|
# note: before, we still checked the status of the payment again
|
||||||
return PaymentStatus(True, fee_msat=payment.fee)
|
return PaymentSuccessStatus(fee_msat=payment.fee)
|
||||||
|
|
||||||
status: PaymentStatus = await payment.check_status()
|
status: PaymentStatus = await payment.check_status()
|
||||||
return status
|
return status
|
||||||
@@ -475,12 +593,33 @@ async def check_transaction_status(
|
|||||||
|
|
||||||
# WARN: this same value must be used for balance check and passed to
|
# 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
|
# 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:
|
||||||
|
if internal:
|
||||||
|
return 0
|
||||||
reserve_min = settings.lnbits_reserve_fee_min
|
reserve_min = settings.lnbits_reserve_fee_min
|
||||||
reserve_percent = settings.lnbits_reserve_fee_percent
|
reserve_percent = settings.lnbits_reserve_fee_percent
|
||||||
return max(int(reserve_min), int(amount_msat * reserve_percent / 100.0))
|
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):
|
async def send_payment_notification(wallet: Wallet, payment: Payment):
|
||||||
await websocketUpdater(
|
await websocketUpdater(
|
||||||
wallet.id,
|
wallet.id,
|
||||||
@@ -520,7 +659,7 @@ async def check_admin_settings():
|
|||||||
# create new settings if table is empty
|
# create new settings if table is empty
|
||||||
logger.warning("Settings DB empty. Inserting default settings.")
|
logger.warning("Settings DB empty. Inserting default settings.")
|
||||||
settings_db = await init_admin_settings(settings.super_user)
|
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:
|
if settings.super_user and settings.super_user != settings_db.super_user:
|
||||||
# .env super_user overwrites DB super_user
|
# .env super_user overwrites DB super_user
|
||||||
@@ -528,11 +667,8 @@ async def check_admin_settings():
|
|||||||
|
|
||||||
update_cached_settings(settings_db.dict())
|
update_cached_settings(settings_db.dict())
|
||||||
|
|
||||||
admin_url = f'{settings.lnbits_baseurl}wallet?usr=<ID from ".super_user" file>'
|
# saving superuser to {data_dir}/.super_user file
|
||||||
logger.success(f"✔️ Access super user account at: {admin_url}")
|
with open(Path(settings.lnbits_data_folder) / ".super_user", "w") as file:
|
||||||
|
|
||||||
# saving it to .super_user file
|
|
||||||
with open(".super_user", "w") as file:
|
|
||||||
file.write(settings.super_user)
|
file.write(settings.super_user)
|
||||||
|
|
||||||
# callback for saas
|
# callback for saas
|
||||||
@@ -543,10 +679,45 @@ async def check_admin_settings():
|
|||||||
):
|
):
|
||||||
send_admin_user_to_saas()
|
send_admin_user_to_saas()
|
||||||
|
|
||||||
|
account = await get_account(settings.super_user)
|
||||||
|
if account and account.config and account.config.provider == "env":
|
||||||
|
settings.first_install = True
|
||||||
|
|
||||||
|
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):
|
def update_cached_settings(sets_dict: dict):
|
||||||
for key, value in sets_dict.items():
|
for key, value in sets_dict.items():
|
||||||
if key not in readonly_variables:
|
if key in readonly_variables:
|
||||||
|
continue
|
||||||
|
if key not in settings.dict().keys():
|
||||||
|
continue
|
||||||
try:
|
try:
|
||||||
setattr(settings, key, value)
|
setattr(settings, key, value)
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -560,7 +731,9 @@ async def init_admin_settings(super_user: Optional[str] = None) -> SuperSettings
|
|||||||
if super_user:
|
if super_user:
|
||||||
account = await get_account(super_user)
|
account = await get_account(super_user)
|
||||||
if not account:
|
if not account:
|
||||||
account = await create_account(user_id=super_user)
|
account = await create_account(
|
||||||
|
user_id=super_user, user_config=UserConfig(provider="env")
|
||||||
|
)
|
||||||
if not account.wallets or len(account.wallets) == 0:
|
if not account.wallets or len(account.wallets) == 0:
|
||||||
await create_wallet(user_id=account.id)
|
await create_wallet(user_id=account.id)
|
||||||
|
|
||||||
@@ -609,3 +782,11 @@ async def get_balance_delta() -> Tuple[int, int, int]:
|
|||||||
if error_message:
|
if error_message:
|
||||||
raise Exception(error_message)
|
raise Exception(error_message)
|
||||||
return node_balance - total_balance, node_balance, total_balance
|
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)
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"""Keycloak SSO Login Helper
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi_sso.sso.base import DiscoveryDocument, OpenID, SSOBase
|
||||||
|
|
||||||
|
|
||||||
|
class KeycloakSSO(SSOBase):
|
||||||
|
"""Class providing login via Keycloak OAuth"""
|
||||||
|
|
||||||
|
provider = "keycloak"
|
||||||
|
scope = ["openid", "email", "profile"]
|
||||||
|
discovery_url = ""
|
||||||
|
|
||||||
|
async def openid_from_response(
|
||||||
|
self, response: dict, session: Optional["httpx.AsyncClient"] = None
|
||||||
|
) -> OpenID:
|
||||||
|
"""Return OpenID from user information provided by Keycloak"""
|
||||||
|
return OpenID(
|
||||||
|
email=response.get("email", ""),
|
||||||
|
provider=self.provider,
|
||||||
|
id=response.get("sub"),
|
||||||
|
first_name=response.get("given_name"),
|
||||||
|
last_name=response.get("family_name"),
|
||||||
|
display_name=response.get("name"),
|
||||||
|
picture=response.get("picture"),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_discovery_document(self) -> DiscoveryDocument:
|
||||||
|
"""Get document containing handy urls"""
|
||||||
|
async with httpx.AsyncClient() as session:
|
||||||
|
response = await session.get(self.discovery_url)
|
||||||
|
content = response.json()
|
||||||
|
|
||||||
|
return content
|
||||||
|
Before Width: | Height: | Size: 8.7 KiB |
@@ -1,25 +0,0 @@
|
|||||||
new Vue({
|
|
||||||
el: '#vue',
|
|
||||||
mixins: [windowMixin],
|
|
||||||
data: function () {
|
|
||||||
return {
|
|
||||||
disclaimerDialog: {
|
|
||||||
show: false,
|
|
||||||
data: {}
|
|
||||||
},
|
|
||||||
walletName: ''
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
createWallet: function () {
|
|
||||||
LNbits.href.createWallet(this.walletName)
|
|
||||||
},
|
|
||||||
processing: function () {
|
|
||||||
this.$q.notify({
|
|
||||||
timeout: 0,
|
|
||||||
message: 'Processing...',
|
|
||||||
icon: null
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -4,34 +4,29 @@ from typing import Dict
|
|||||||
import httpx
|
import httpx
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
|
from lnbits.core.crud import (
|
||||||
|
get_balance_notify,
|
||||||
|
get_wallet,
|
||||||
|
get_webpush_subscriptions_for_user,
|
||||||
|
mark_webhook_sent,
|
||||||
|
)
|
||||||
|
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.settings import get_wallet_class, settings
|
||||||
from lnbits.tasks import (
|
from lnbits.tasks import send_push_notification
|
||||||
SseListenersDict,
|
|
||||||
create_permanent_task,
|
|
||||||
create_task,
|
|
||||||
register_invoice_listener,
|
|
||||||
)
|
|
||||||
|
|
||||||
from . import db
|
api_invoice_listeners: Dict[str, asyncio.Queue] = {}
|
||||||
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"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def register_killswitch():
|
|
||||||
"""
|
|
||||||
Registers a killswitch which will check lnbits-status repository for a signal from
|
|
||||||
LNbits and will switch to VoidWallet if the killswitch is triggered.
|
|
||||||
"""
|
|
||||||
logger.debug("Starting killswitch task")
|
|
||||||
create_permanent_task(killswitch_task)
|
|
||||||
|
|
||||||
|
|
||||||
async def killswitch_task():
|
async def killswitch_task():
|
||||||
|
"""
|
||||||
|
killswitch will check lnbits-status repository for a signal from
|
||||||
|
LNbits and will switch to VoidWallet if the killswitch is triggered.
|
||||||
|
"""
|
||||||
while True:
|
while True:
|
||||||
WALLET = get_wallet_class()
|
WALLET = get_wallet_class()
|
||||||
if settings.lnbits_killswitch and WALLET.__class__.__name__ != "VoidWallet":
|
if settings.lnbits_killswitch and WALLET.__class__.__name__ != "VoidWallet":
|
||||||
@@ -46,7 +41,7 @@ async def killswitch_task():
|
|||||||
"Switching to VoidWallet. Killswitch triggered."
|
"Switching to VoidWallet. Killswitch triggered."
|
||||||
)
|
)
|
||||||
await switch_to_voidwallet()
|
await switch_to_voidwallet()
|
||||||
except (httpx.ConnectError, httpx.RequestError):
|
except (httpx.RequestError, httpx.HTTPStatusError):
|
||||||
logger.error(
|
logger.error(
|
||||||
"Cannot fetch lnbits status manifest."
|
"Cannot fetch lnbits status manifest."
|
||||||
f" {settings.lnbits_status_manifest}"
|
f" {settings.lnbits_status_manifest}"
|
||||||
@@ -54,17 +49,11 @@ async def killswitch_task():
|
|||||||
await asyncio.sleep(settings.lnbits_killswitch_interval * 60)
|
await asyncio.sleep(settings.lnbits_killswitch_interval * 60)
|
||||||
|
|
||||||
|
|
||||||
async def register_watchdog():
|
async def watchdog_task():
|
||||||
"""
|
"""
|
||||||
Registers a watchdog which will check lnbits balance and nodebalance
|
Registers a watchdog which will check lnbits balance and nodebalance
|
||||||
and will switch to VoidWallet if the watchdog delta is reached.
|
and will switch to VoidWallet if the watchdog delta is reached.
|
||||||
"""
|
"""
|
||||||
# TODO: implement watchdog properly
|
|
||||||
# logger.debug("Starting watchdog task")
|
|
||||||
# create_permanent_task(watchdog_task)
|
|
||||||
|
|
||||||
|
|
||||||
async def watchdog_task():
|
|
||||||
while True:
|
while True:
|
||||||
WALLET = get_wallet_class()
|
WALLET = get_wallet_class()
|
||||||
if settings.lnbits_watchdog and WALLET.__class__.__name__ != "VoidWallet":
|
if settings.lnbits_watchdog and WALLET.__class__.__name__ != "VoidWallet":
|
||||||
@@ -79,45 +68,47 @@ async def watchdog_task():
|
|||||||
await asyncio.sleep(settings.lnbits_watchdog_interval * 60)
|
await asyncio.sleep(settings.lnbits_watchdog_interval * 60)
|
||||||
|
|
||||||
|
|
||||||
def register_task_listeners():
|
|
||||||
"""
|
|
||||||
Registers an invoice listener queue for the core tasks. Incoming payments in this
|
|
||||||
queue will eventually trigger the signals sent to all other extensions
|
|
||||||
and fulfill other core tasks such as dispatching webhooks.
|
|
||||||
"""
|
|
||||||
invoice_paid_queue = asyncio.Queue(5)
|
|
||||||
# we register invoice_paid_queue to receive all incoming invoices
|
|
||||||
register_invoice_listener(invoice_paid_queue, "core/tasks.py")
|
|
||||||
# register a worker that will react to invoices
|
|
||||||
create_task(wait_for_paid_invoices(invoice_paid_queue))
|
|
||||||
|
|
||||||
|
|
||||||
async def wait_for_paid_invoices(invoice_paid_queue: asyncio.Queue):
|
async def wait_for_paid_invoices(invoice_paid_queue: asyncio.Queue):
|
||||||
"""
|
"""
|
||||||
This worker dispatches events to all extensions,
|
This task dispatches events to all api_invoice_listeners,
|
||||||
dispatches webhooks and balance notifys.
|
webhooks, push notifications and balance notifications.
|
||||||
"""
|
"""
|
||||||
while True:
|
while True:
|
||||||
payment = await invoice_paid_queue.get()
|
payment = await invoice_paid_queue.get()
|
||||||
logger.trace("received invoice paid event")
|
logger.trace("received invoice paid event")
|
||||||
# send information to sse channel
|
# dispatch api_invoice_listeners
|
||||||
await dispatch_api_invoice_listeners(payment)
|
await dispatch_api_invoice_listeners(payment)
|
||||||
|
# payment notification
|
||||||
wallet = await get_wallet(payment.wallet_id)
|
wallet = await get_wallet(payment.wallet_id)
|
||||||
if wallet:
|
if wallet:
|
||||||
await send_payment_notification(wallet, payment)
|
await send_payment_notification(wallet, payment)
|
||||||
# dispatch webhook
|
# dispatch webhook
|
||||||
if payment.webhook and not payment.webhook_status:
|
if payment.webhook and not payment.webhook_status:
|
||||||
await dispatch_webhook(payment)
|
await dispatch_webhook(payment)
|
||||||
|
|
||||||
# dispatch balance_notify
|
# dispatch balance_notify
|
||||||
url = await get_balance_notify(payment.wallet_id)
|
url = await get_balance_notify(payment.wallet_id)
|
||||||
if url:
|
if url:
|
||||||
async with httpx.AsyncClient() as client:
|
headers = {"User-Agent": settings.user_agent}
|
||||||
|
async with httpx.AsyncClient(headers=headers) as client:
|
||||||
try:
|
try:
|
||||||
r = await client.post(url, timeout=4)
|
r = await client.post(url, timeout=4)
|
||||||
await mark_webhook_sent(payment, r.status_code)
|
r.raise_for_status()
|
||||||
except (httpx.ConnectError, httpx.RequestError):
|
await mark_webhook_sent(payment.payment_hash, r.status_code)
|
||||||
pass
|
except httpx.HTTPStatusError as exc:
|
||||||
|
status_code = exc.response.status_code
|
||||||
|
await mark_webhook_sent(payment.payment_hash, status_code)
|
||||||
|
logger.warning(
|
||||||
|
f"balance_notify returned a bad status_code: {status_code} "
|
||||||
|
f"while requesting {exc.request.url!r}."
|
||||||
|
)
|
||||||
|
logger.warning(exc)
|
||||||
|
except httpx.RequestError as exc:
|
||||||
|
await mark_webhook_sent(payment.payment_hash, -1)
|
||||||
|
logger.warning(f"Could not send balance_notify to {url}")
|
||||||
|
logger.warning(exc)
|
||||||
|
|
||||||
|
# dispatch push notification
|
||||||
|
await send_payment_push_notification(payment)
|
||||||
|
|
||||||
|
|
||||||
async def dispatch_api_invoice_listeners(payment: Payment):
|
async def dispatch_api_invoice_listeners(payment: Payment):
|
||||||
@@ -126,10 +117,12 @@ async def dispatch_api_invoice_listeners(payment: Payment):
|
|||||||
"""
|
"""
|
||||||
for chan_name, send_channel in api_invoice_listeners.items():
|
for chan_name, send_channel in api_invoice_listeners.items():
|
||||||
try:
|
try:
|
||||||
logger.debug(f"sending invoice paid event to {chan_name}")
|
logger.debug(f"api invoice listener: sending paid event to {chan_name}")
|
||||||
send_channel.put_nowait(payment)
|
send_channel.put_nowait(payment)
|
||||||
except asyncio.QueueFull:
|
except asyncio.QueueFull:
|
||||||
logger.error(f"removing sse listener {send_channel}:{chan_name}")
|
logger.error(
|
||||||
|
f"api invoice listener: QueueFull, removing {send_channel}:{chan_name}"
|
||||||
|
)
|
||||||
api_invoice_listeners.pop(chan_name)
|
api_invoice_listeners.pop(chan_name)
|
||||||
|
|
||||||
|
|
||||||
@@ -140,22 +133,44 @@ async def dispatch_webhook(payment: Payment):
|
|||||||
logger.debug("sending webhook", payment.webhook)
|
logger.debug("sending webhook", payment.webhook)
|
||||||
|
|
||||||
if not payment.webhook:
|
if not payment.webhook:
|
||||||
return await mark_webhook_sent(payment, -1)
|
return await mark_webhook_sent(payment.payment_hash, -1)
|
||||||
|
|
||||||
async with httpx.AsyncClient() as client:
|
headers = {"User-Agent": settings.user_agent}
|
||||||
|
async with httpx.AsyncClient(headers=headers) as client:
|
||||||
data = payment.dict()
|
data = payment.dict()
|
||||||
try:
|
try:
|
||||||
r = await client.post(payment.webhook, json=data, timeout=40)
|
r = await client.post(payment.webhook, json=data, timeout=40)
|
||||||
await mark_webhook_sent(payment, r.status_code)
|
r.raise_for_status()
|
||||||
except (httpx.ConnectError, httpx.RequestError):
|
await mark_webhook_sent(payment.payment_hash, r.status_code)
|
||||||
await mark_webhook_sent(payment, -1)
|
except httpx.HTTPStatusError as exc:
|
||||||
|
await mark_webhook_sent(payment.payment_hash, exc.response.status_code)
|
||||||
|
logger.warning(
|
||||||
async def mark_webhook_sent(payment: Payment, status: int) -> None:
|
f"webhook returned a bad status_code: {exc.response.status_code} "
|
||||||
await db.execute(
|
f"while requesting {exc.request.url!r}."
|
||||||
"""
|
|
||||||
UPDATE apipayments SET webhook_status = ?
|
|
||||||
WHERE hash = ?
|
|
||||||
""",
|
|
||||||
(status, payment.payment_hash),
|
|
||||||
)
|
)
|
||||||
|
except httpx.RequestError:
|
||||||
|
await mark_webhook_sent(payment.payment_hash, -1)
|
||||||
|
logger.warning(f"Could not send webhook to {payment.webhook}")
|
||||||
|
|
||||||
|
|
||||||
|
async def send_payment_push_notification(payment: Payment):
|
||||||
|
wallet = await get_wallet(payment.wallet_id)
|
||||||
|
|
||||||
|
if wallet:
|
||||||
|
subscriptions = await get_webpush_subscriptions_for_user(wallet.user)
|
||||||
|
|
||||||
|
amount = int(payment.amount / 1000)
|
||||||
|
|
||||||
|
title = f"LNbits: {wallet.name}"
|
||||||
|
body = f"You just received {amount} sat{'s'[:amount^1]}!"
|
||||||
|
|
||||||
|
if payment.memo:
|
||||||
|
body += f"\r\n{payment.memo}"
|
||||||
|
|
||||||
|
for subscription in subscriptions:
|
||||||
|
# todo: review permissions when user-id-only not allowed
|
||||||
|
# todo: replace all this logic with websockets?
|
||||||
|
url = (
|
||||||
|
f"https://{subscription.host}/wallet?usr={wallet.user}&wal={wallet.id}"
|
||||||
|
)
|
||||||
|
await send_push_notification(subscription, title, body, url)
|
||||||
|
|||||||
@@ -7,41 +7,47 @@
|
|||||||
<div class="col">
|
<div class="col">
|
||||||
<p>Funding Source Info</p>
|
<p>Funding Source Info</p>
|
||||||
<ul>
|
<ul>
|
||||||
{%raw%}
|
<li
|
||||||
<li>Funding Source: {{settings.lnbits_backend_wallet_class}}</li>
|
v-text="'Funding Source: '+ settings.lnbits_backend_wallet_class"
|
||||||
<li>
|
></li>
|
||||||
Node Balance: {{(auditData.node_balance_msats /
|
<li
|
||||||
1000).toLocaleString()}} sats
|
v-text="'Node Balance: ' + (auditData.node_balance_msats /
|
||||||
</li>
|
1000).toLocaleString() + ' sats'"
|
||||||
<li>
|
></li>
|
||||||
LNbits Balance: {{(auditData.lnbits_balance_msats /
|
<li
|
||||||
1000).toLocaleString()}} sats
|
v-text="'LNbits Balance: ' + (auditData.lnbits_balance_msats /
|
||||||
</li>
|
1000).toLocaleString() + ' sats'"
|
||||||
<li>
|
></li>
|
||||||
Reserve Percent: {{(auditData.node_balance_msats /
|
<li
|
||||||
auditData.lnbits_balance_msats * 100).toFixed(2)}} %
|
v-text="'Reserve Percent: ' + (auditData.node_balance_msats /
|
||||||
</li>
|
auditData.lnbits_balance_msats * 100).toFixed(2) + ' %'"
|
||||||
{%endraw%}
|
></li>
|
||||||
</ul>
|
</ul>
|
||||||
<br />
|
<br />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="col">
|
||||||
<div class="row q-col-gutter-md">
|
{% if LNBITS_NODE_UI_AVAILABLE %}
|
||||||
<div class="col-12 col-md-4">
|
<p>Node Management</p>
|
||||||
<div class="row">
|
<q-toggle
|
||||||
<div class="col-12">
|
label="Node UI"
|
||||||
<p>Active Funding<small> (Requires server restart)</small></p>
|
v-model="formData.lnbits_node_ui"
|
||||||
<q-select
|
></q-toggle>
|
||||||
:disable="!isSuperUser"
|
<q-toggle
|
||||||
filled
|
v-if="formData.lnbits_node_ui"
|
||||||
v-model="formData.lnbits_backend_wallet_class"
|
label="Public node UI"
|
||||||
hint="Select the active funding wallet"
|
v-model="formData.lnbits_public_node_ui"
|
||||||
:options="settings.lnbits_allowed_funding_sources"
|
></q-toggle>
|
||||||
></q-select>
|
<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>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="col-12 col-md-8">
|
|
||||||
<div class="row q-col-gutter-md">
|
<div class="row q-col-gutter-md">
|
||||||
<div class="col-12 col-md-4">
|
<div class="col-12 col-md-4">
|
||||||
<p>Invoice Expiry</p>
|
<p>Invoice Expiry</p>
|
||||||
@@ -79,40 +85,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-if="isSuperUser">
|
<div v-if="isSuperUser">
|
||||||
<p class="q-my-md">
|
<lnbits-funding-sources
|
||||||
Funding Sources<small> (Requires server restart)</small>
|
:form-data="formData"
|
||||||
</p>
|
:allowed-funding-sources="settings.lnbits_allowed_funding_sources"
|
||||||
<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"
|
|
||||||
>
|
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
|
|||||||
@@ -1,7 +1,122 @@
|
|||||||
<q-tab-panel name="security">
|
<q-tab-panel name="security">
|
||||||
<q-card-section class="q-pa-none">
|
<q-card-section class="q-pa-none">
|
||||||
<h6 class="q-my-none" v-text="$t('security_tools')"></h6>
|
<h6 class="q-my-none q-mb-sm">Authentication</h6>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 col-sm-12 q-pr-sm">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
v-model="formData.auth_token_expire_minutes"
|
||||||
|
type="number"
|
||||||
|
label="Token expire minutes"
|
||||||
|
hint="Time in minutes until the token expires"
|
||||||
|
>
|
||||||
|
</q-input>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 col-sm-12 q-pr-sm">
|
||||||
|
<q-select
|
||||||
|
filled
|
||||||
|
v-model="formData.auth_allowed_methods"
|
||||||
|
multiple
|
||||||
|
hint="Allowed authorization methods"
|
||||||
|
label="Select authorization methods"
|
||||||
|
:options="formData.auth_all_methods"
|
||||||
|
></q-select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-section
|
||||||
|
v-if="formData.auth_allowed_methods?.includes('google-auth')"
|
||||||
|
class="q-pl-xl"
|
||||||
|
>
|
||||||
|
<strong class="q-my-none q-mb-sm">Google Auth</strong>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 col-sm-12 q-pr-sm">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
v-model="formData.google_client_id"
|
||||||
|
label="Google Client ID"
|
||||||
|
hint="Make sure thant the authorized redirect URIs contain https://{domain}/api/v1/auth/google/token"
|
||||||
|
>
|
||||||
|
</q-input>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 col-sm-12">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
v-model="formData.google_client_secret"
|
||||||
|
type="password"
|
||||||
|
label="Google Client Secret"
|
||||||
|
>
|
||||||
|
</q-input>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-section
|
||||||
|
v-if="formData.auth_allowed_methods?.includes('github-auth')"
|
||||||
|
class="q-pl-xl"
|
||||||
|
>
|
||||||
|
<strong class="q-my-none q-mb-sm">GitHub Auth</strong>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 col-sm-12 q-pr-sm">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
v-model="formData.github_client_id"
|
||||||
|
label="GitHub Client ID"
|
||||||
|
hint="Make sure thant the authorization callback URL is set to https://{domain}/api/v1/auth/github/token"
|
||||||
|
>
|
||||||
|
</q-input>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 col-sm-12">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
v-model="formData.github_client_secret"
|
||||||
|
type="password"
|
||||||
|
label="GitHub Client Secret"
|
||||||
|
>
|
||||||
|
</q-input>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-section
|
||||||
|
v-if="formData.auth_allowed_methods?.includes('keycloak-auth')"
|
||||||
|
class="q-pl-xl"
|
||||||
|
>
|
||||||
|
<strong class="q-my-none q-mb-sm">Keycloak Auth</strong>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-4 col-sm-12 q-pr-sm">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
v-model="formData.keycloak_discovery_url"
|
||||||
|
label="Keycloak Discovey URL"
|
||||||
|
>
|
||||||
|
</q-input>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 col-sm-12 q-pr-sm">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
v-model="formData.keycloak_client_id"
|
||||||
|
label="Keycloak Client ID"
|
||||||
|
hint="Make sure thant the authorization callback URL is set to https://{domain}/api/v1/auth/keycloak/token"
|
||||||
|
>
|
||||||
|
</q-input>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 col-sm-12">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
v-model="formData.keycloak_client_secret"
|
||||||
|
type="password"
|
||||||
|
label="Keycloak Client Secret"
|
||||||
|
>
|
||||||
|
</q-input>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
<q-separator></q-separator>
|
||||||
|
<q-card-section class="q-pa-none">
|
||||||
<br />
|
<br />
|
||||||
|
<h6 class="q-my-none" v-text="$t('security_tools')"></h6>
|
||||||
<div>
|
<div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div v-if="serverlogEnabled" class="column" style="width: 100%">
|
<div v-if="serverlogEnabled" class="column" style="width: 100%">
|
||||||
@@ -16,7 +131,7 @@
|
|||||||
style="padding: 10px; color: #fafafa; height: 320px"
|
style="padding: 10px; color: #fafafa; height: 320px"
|
||||||
>
|
>
|
||||||
<small v-for="log in logs"
|
<small v-for="log in logs"
|
||||||
>{% raw %}{{ log }}{% endraw %}<br
|
><span v-text="log"></span><br
|
||||||
/></small>
|
/></small>
|
||||||
</q-scroll-area>
|
</q-scroll-area>
|
||||||
</div>
|
</div>
|
||||||
@@ -51,7 +166,6 @@
|
|||||||
></q-btn>
|
></q-btn>
|
||||||
</q-input>
|
</q-input>
|
||||||
<div>
|
<div>
|
||||||
{%raw%}
|
|
||||||
<q-chip
|
<q-chip
|
||||||
v-for="blocked_ip in formData.lnbits_blocked_ips"
|
v-for="blocked_ip in formData.lnbits_blocked_ips"
|
||||||
:key="blocked_ip"
|
:key="blocked_ip"
|
||||||
@@ -59,10 +173,8 @@
|
|||||||
@remove="removeBlockedIPs(blocked_ip)"
|
@remove="removeBlockedIPs(blocked_ip)"
|
||||||
color="primary"
|
color="primary"
|
||||||
text-color="white"
|
text-color="white"
|
||||||
>
|
v-text="blocked_ip"
|
||||||
{{ blocked_ip }}
|
></q-chip>
|
||||||
</q-chip>
|
|
||||||
{%endraw%}
|
|
||||||
</div>
|
</div>
|
||||||
<br />
|
<br />
|
||||||
</div>
|
</div>
|
||||||
@@ -83,7 +195,6 @@
|
|||||||
></q-btn>
|
></q-btn>
|
||||||
</q-input>
|
</q-input>
|
||||||
<div>
|
<div>
|
||||||
{%raw%}
|
|
||||||
<q-chip
|
<q-chip
|
||||||
v-for="allowed_ip in formData.lnbits_allowed_ips"
|
v-for="allowed_ip in formData.lnbits_allowed_ips"
|
||||||
:key="allowed_ip"
|
:key="allowed_ip"
|
||||||
@@ -91,15 +202,14 @@
|
|||||||
@remove="removeAllowedIPs(allowed_ip)"
|
@remove="removeAllowedIPs(allowed_ip)"
|
||||||
color="primary"
|
color="primary"
|
||||||
text-color="white"
|
text-color="white"
|
||||||
>
|
v-text="allowed_ip"
|
||||||
{{ allowed_ip }}
|
></q-chip>
|
||||||
</q-chip>
|
|
||||||
{%endraw%}
|
|
||||||
</div>
|
</div>
|
||||||
<br />
|
<br />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-12 col-md-12">
|
<div class="col-12 col-md-12">
|
||||||
<p v-text="$t('rate_limiter')"></p>
|
<p v-text="$t('rate_limiter')"></p>
|
||||||
<div class="row q-col-gutter-md">
|
<div class="row q-col-gutter-md">
|
||||||
@@ -121,6 +231,36 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 col-md-12">
|
||||||
|
<p v-text="$t('wallet_limiter')"></p>
|
||||||
|
<div class="row q-col-gutter-md">
|
||||||
|
<div class="col-4">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
type="number"
|
||||||
|
v-model.number="formData.lnbits_wallet_limit_max_balance"
|
||||||
|
:label="$t('wallet_max_ballance')"
|
||||||
|
></q-input>
|
||||||
|
</div>
|
||||||
|
<div class="col-4">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
type="number"
|
||||||
|
v-model.number="formData.lnbits_wallet_limit_daily_max_withdraw"
|
||||||
|
:label="$t('wallet_limit_max_withdraw_per_day')"
|
||||||
|
></q-input>
|
||||||
|
</div>
|
||||||
|
<div class="col-4">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
type="number"
|
||||||
|
v-model.number="formData.lnbits_wallet_limit_secs_between_trans"
|
||||||
|
:label="$t('wallet_limit_secs_between_trans')"
|
||||||
|
></q-input>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<br />
|
<br />
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
{% raw %}
|
|
||||||
<q-banner v-if="updateAvailable" class="bg-primary text-white">
|
<q-banner v-if="updateAvailable" class="bg-primary text-white">
|
||||||
<q-icon size="28px" name="update"></q-icon>
|
<q-icon size="28px" name="update"></q-icon>
|
||||||
|
|
||||||
@@ -8,13 +7,14 @@
|
|||||||
class="q-btn"
|
class="q-btn"
|
||||||
color="white"
|
color="white"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
href="https://github.com/lnbits/lnbits/releases"
|
href="https://github.com/lnbits/lnbits/releases"
|
||||||
v-text="$t('releases')"
|
v-text="$t('releases')"
|
||||||
></a>
|
></a>
|
||||||
</template>
|
</template>
|
||||||
</q-banner>
|
</q-banner>
|
||||||
<q-banner v-if="!updateAvailable" class="bg-green text-white">
|
<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>
|
<span v-text="$t('latest_update', {version: statusData.version})"></span>
|
||||||
</q-banner>
|
</q-banner>
|
||||||
<q-card>
|
<q-card>
|
||||||
@@ -29,9 +29,12 @@
|
|||||||
<template v-slot:header="props">
|
<template v-slot:header="props">
|
||||||
<q-tr :props="props">
|
<q-tr :props="props">
|
||||||
<q-th auto-width> </q-th>
|
<q-th auto-width> </q-th>
|
||||||
<q-th v-for="col in props.cols" :key="col.name" :props="props"
|
<q-th
|
||||||
>{{ col.label }}</q-th
|
v-for="col in props.cols"
|
||||||
>
|
:key="col.name"
|
||||||
|
:props="props"
|
||||||
|
v-text="col.label"
|
||||||
|
></q-th>
|
||||||
</q-tr>
|
</q-tr>
|
||||||
</template>
|
</template>
|
||||||
<template v-slot:body="props">
|
<template v-slot:body="props">
|
||||||
@@ -50,14 +53,19 @@
|
|||||||
color="red"
|
color="red"
|
||||||
></q-icon>
|
></q-icon>
|
||||||
</q-td>
|
</q-td>
|
||||||
<q-td auto-width key="date" :props="props">
|
<q-td
|
||||||
{{ formatDate(props.row.date) }}
|
auto-width
|
||||||
|
key="date"
|
||||||
|
:props="props"
|
||||||
|
v-text="formatDate(props.row.date)"
|
||||||
|
>
|
||||||
</q-td>
|
</q-td>
|
||||||
<q-td key="message" :props="props"
|
<q-td key="message" :props="props"
|
||||||
>{{ props.row.message }}
|
><span v-text="props.row.message"></span
|
||||||
<a
|
><a
|
||||||
v-if="props.row.link"
|
v-if="props.row.link"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
:href="props.row.link"
|
:href="props.row.link"
|
||||||
v-text="$t('more')"
|
v-text="$t('more')"
|
||||||
></a
|
></a
|
||||||
@@ -67,4 +75,3 @@
|
|||||||
</q-table>
|
</q-table>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-card>
|
</q-card>
|
||||||
{% endraw %}
|
|
||||||
|
|||||||
@@ -7,53 +7,18 @@
|
|||||||
<div class="col">
|
<div class="col">
|
||||||
<p>Server Info</p>
|
<p>Server Info</p>
|
||||||
<ul>
|
<ul>
|
||||||
{%raw%}
|
<li
|
||||||
<li v-if="settings.lnbits_data_folder">
|
v-if="settings.lnbits_data_folder"
|
||||||
SQlite: {{settings.lnbits_data_folder}}
|
v-text="'SQlite: ' + settings.lnbits_data_folder"
|
||||||
</li>
|
></li>
|
||||||
<li v-if="settings.lnbits_database_url">
|
<li
|
||||||
Postgres: {{settings.lnbits_database_url}}
|
v-if="settings.lnbits_database_url"
|
||||||
</li>
|
v-text="'Postgres: ' + settings.lnbits_database_url"
|
||||||
{%endraw%}
|
></li>
|
||||||
</ul>
|
</ul>
|
||||||
<br />
|
<br />
|
||||||
</div>
|
</div>
|
||||||
</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="row q-col-gutter-md">
|
||||||
<div class="col-12 col-md-6">
|
<div class="col-12 col-md-6">
|
||||||
<p>Allowed currencies</p>
|
<p>Allowed currencies</p>
|
||||||
@@ -75,7 +40,7 @@
|
|||||||
clearable
|
clearable
|
||||||
hint="Default currency for accounting"
|
hint="Default currency for accounting"
|
||||||
label="Currency"
|
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>
|
></q-select>
|
||||||
<br />
|
<br />
|
||||||
</div>
|
</div>
|
||||||
@@ -93,8 +58,89 @@
|
|||||||
></q-select>
|
></q-select>
|
||||||
<br />
|
<br />
|
||||||
</div>
|
</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>
|
||||||
|
<br />
|
||||||
|
<h6 class="q-my-none">Service Fee</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-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>
|
||||||
|
|
||||||
|
<h6 class="q-my-none">Extensions</h6>
|
||||||
<div>
|
<div>
|
||||||
<p>Extension Sources</p>
|
<p>Extension Sources</p>
|
||||||
<q-input
|
<q-input
|
||||||
@@ -108,7 +154,6 @@
|
|||||||
<q-btn @click="addExtensionsManifest" dense flat icon="add"></q-btn>
|
<q-btn @click="addExtensionsManifest" dense flat icon="add"></q-btn>
|
||||||
</q-input>
|
</q-input>
|
||||||
<div>
|
<div>
|
||||||
{%raw%}
|
|
||||||
<q-chip
|
<q-chip
|
||||||
v-for="manifestUrl in formData.lnbits_extensions_manifests"
|
v-for="manifestUrl in formData.lnbits_extensions_manifests"
|
||||||
:key="manifestUrl"
|
:key="manifestUrl"
|
||||||
@@ -116,10 +161,8 @@
|
|||||||
@remove="removeExtensionsManifest(manifestUrl)"
|
@remove="removeExtensionsManifest(manifestUrl)"
|
||||||
color="primary"
|
color="primary"
|
||||||
text-color="white"
|
text-color="white"
|
||||||
>
|
v-text="manifestUrl"
|
||||||
{{ manifestUrl }}
|
></q-chip>
|
||||||
</q-chip>
|
|
||||||
{%endraw%}
|
|
||||||
</div>
|
</div>
|
||||||
<br />
|
<br />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -31,12 +31,12 @@
|
|||||||
v-model="formData.lnbits_site_description"
|
v-model="formData.lnbits_site_description"
|
||||||
filled
|
filled
|
||||||
type="textarea"
|
type="textarea"
|
||||||
hint="Use plain text or raw HTML"
|
hint="Use plain text, Markdown, or raw HTML"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<br />
|
<br />
|
||||||
<div class="row q-col-gutter-md">
|
<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>
|
<p>Default Wallet Name</p>
|
||||||
<q-input
|
<q-input
|
||||||
filled
|
filled
|
||||||
@@ -46,7 +46,7 @@
|
|||||||
></q-input>
|
></q-input>
|
||||||
<br />
|
<br />
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12 col-md-6">
|
<div class="col-12 col-md-4">
|
||||||
<p>Denomination</p>
|
<p>Denomination</p>
|
||||||
<q-input
|
<q-input
|
||||||
filled
|
filled
|
||||||
@@ -57,6 +57,17 @@
|
|||||||
></q-input>
|
></q-input>
|
||||||
<br />
|
<br />
|
||||||
</div>
|
</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>
|
||||||
<div class="row q-col-gutter-md">
|
<div class="row q-col-gutter-md">
|
||||||
<div class="col-12 col-md-6">
|
<div class="col-12 col-md-6">
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
<q-tab-panel name="users">
|
<q-tab-panel name="users">
|
||||||
<q-card-section class="q-pa-none">
|
<q-card-section class="q-pa-none">
|
||||||
<h6 class="q-my-none">User Management</h6>
|
<h6 class="q-my-none q-mb-sm">User Management</h6>
|
||||||
<br />
|
|
||||||
<div>
|
<div class="row">
|
||||||
|
<div class="col-md-6 col-sm-12 q-pr-sm">
|
||||||
<p>Admin Users</p>
|
<p>Admin Users</p>
|
||||||
<q-input
|
<q-input
|
||||||
filled
|
filled
|
||||||
@@ -15,7 +16,6 @@
|
|||||||
<q-btn @click="addAdminUser" dense flat icon="add"></q-btn>
|
<q-btn @click="addAdminUser" dense flat icon="add"></q-btn>
|
||||||
</q-input>
|
</q-input>
|
||||||
<div>
|
<div>
|
||||||
{%raw%}
|
|
||||||
<q-chip
|
<q-chip
|
||||||
v-for="user in formData.lnbits_admin_users"
|
v-for="user in formData.lnbits_admin_users"
|
||||||
:key="user"
|
:key="user"
|
||||||
@@ -24,13 +24,12 @@
|
|||||||
color="primary"
|
color="primary"
|
||||||
text-color="white"
|
text-color="white"
|
||||||
>
|
>
|
||||||
{{ user }}
|
<span v-text="user" />
|
||||||
</q-chip>
|
</q-chip>
|
||||||
{%endraw%}
|
|
||||||
</div>
|
</div>
|
||||||
<br />
|
<br />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="col-md-6 col-sm-12">
|
||||||
<p>Allowed Users</p>
|
<p>Allowed Users</p>
|
||||||
<q-input
|
<q-input
|
||||||
filled
|
filled
|
||||||
@@ -43,7 +42,6 @@
|
|||||||
<q-btn @click="addAllowedUser" dense flat icon="add"></q-btn>
|
<q-btn @click="addAllowedUser" dense flat icon="add"></q-btn>
|
||||||
</q-input>
|
</q-input>
|
||||||
<div>
|
<div>
|
||||||
{% raw %}
|
|
||||||
<q-chip
|
<q-chip
|
||||||
v-for="user in formData.lnbits_allowed_users"
|
v-for="user in formData.lnbits_allowed_users"
|
||||||
:key="user"
|
:key="user"
|
||||||
@@ -52,11 +50,29 @@
|
|||||||
color="primary"
|
color="primary"
|
||||||
text-color="white"
|
text-color="white"
|
||||||
>
|
>
|
||||||
{{ user }}
|
<span v-text="user" />
|
||||||
</q-chip>
|
</q-chip>
|
||||||
{% endraw %}
|
|
||||||
</div>
|
</div>
|
||||||
<br />
|
<br />
|
||||||
|
<q-item tag="label" v-ripple>
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label>Allow creation of new users</q-item-label>
|
||||||
|
<q-item-label caption
|
||||||
|
>Allow creation of new users on the index page</q-item-label
|
||||||
|
>
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section avatar>
|
||||||
|
<q-toggle
|
||||||
|
size="md"
|
||||||
|
v-model="formData.lnbits_allow_new_accounts"
|
||||||
|
checked-icon="check"
|
||||||
|
color="green"
|
||||||
|
unchecked-icon="clear"
|
||||||
|
/>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
<br />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-tab-panel>
|
</q-tab-panel>
|
||||||
|
|||||||
@@ -8,9 +8,9 @@
|
|||||||
@click="updateSettings"
|
@click="updateSettings"
|
||||||
:disabled="!checkChanges"
|
:disabled="!checkChanges"
|
||||||
>
|
>
|
||||||
<q-tooltip v-if="checkChanges"
|
<q-tooltip v-if="checkChanges">
|
||||||
>{%raw%}{{ $t('save_tooltip') }}{%endraw%}</q-tooltip
|
<span v-text="$t('save_tooltip')"></span>
|
||||||
>
|
</q-tooltip>
|
||||||
|
|
||||||
<q-badge
|
<q-badge
|
||||||
v-if="checkChanges"
|
v-if="checkChanges"
|
||||||
@@ -27,9 +27,9 @@
|
|||||||
color="primary"
|
color="primary"
|
||||||
@click="restartServer"
|
@click="restartServer"
|
||||||
>
|
>
|
||||||
<q-tooltip v-if="needsRestart"
|
<q-tooltip v-if="needsRestart">
|
||||||
>{%raw%}{{ $t('restart_tooltip') }}{%endraw%}</q-tooltip
|
<span v-text="$t('restart_tooltip')"></span>
|
||||||
>
|
</q-tooltip>
|
||||||
|
|
||||||
<q-badge
|
<q-badge
|
||||||
v-if="needsRestart"
|
v-if="needsRestart"
|
||||||
@@ -46,7 +46,9 @@
|
|||||||
color="primary"
|
color="primary"
|
||||||
@click="topUpDialog.show = true"
|
@click="topUpDialog.show = true"
|
||||||
>
|
>
|
||||||
<q-tooltip>{%raw%}{{ $t('add_funds_tooltip') }}{%endraw%}</q-tooltip>
|
<q-tooltip>
|
||||||
|
<span v-text="$t('add_funds_tooltip')"></span>
|
||||||
|
</q-tooltip>
|
||||||
</q-btn>
|
</q-btn>
|
||||||
|
|
||||||
<q-btn :label="$t('download_backup')" flat @click="downloadBackup"></q-btn>
|
<q-btn :label="$t('download_backup')" flat @click="downloadBackup"></q-btn>
|
||||||
@@ -59,7 +61,9 @@
|
|||||||
@click="deleteSettings"
|
@click="deleteSettings"
|
||||||
class="float-right"
|
class="float-right"
|
||||||
>
|
>
|
||||||
<q-tooltip>{%raw%}{{ $t('reset_defaults_tooltip') }}{%endraw%}</q-tooltip>
|
<q-tooltip>
|
||||||
|
<span v-text="$t('reset_defaults_tooltip')"></span>
|
||||||
|
</q-tooltip>
|
||||||
</q-btn>
|
</q-btn>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -164,522 +168,5 @@
|
|||||||
</q-card>
|
</q-card>
|
||||||
</q-dialog>
|
</q-dialog>
|
||||||
{% endblock %} {% block scripts %} {{ window_vars(user) }}
|
{% endblock %} {% block scripts %} {{ window_vars(user) }}
|
||||||
<script>
|
<script src="{{ static_url_for('static', 'js/admin.js') }}"></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>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
<q-expansion-item
|
<q-expansion-item
|
||||||
group="extras"
|
group="extras"
|
||||||
icon="swap_vertical_circle"
|
icon="vpn_key"
|
||||||
:label="$t('api_docs')"
|
:label="$t('api_keys_api_docs')"
|
||||||
:content-inset-level="0.5"
|
:content-inset-level="0.5"
|
||||||
>
|
>
|
||||||
<q-card-section>
|
<q-card-section>
|
||||||
|
<strong>Node URL: </strong><em v-text="origin"></em><br />
|
||||||
<strong>Wallet ID: </strong><em>{{ wallet.id }}</em><br />
|
<strong>Wallet ID: </strong><em>{{ wallet.id }}</em><br />
|
||||||
<strong>Admin key: </strong><em>{{ wallet.adminkey }}</em><br />
|
<strong>Admin key: </strong><em>{{ wallet.adminkey }}</em><br />
|
||||||
<strong>Invoice/read key: </strong><em>{{ wallet.inkey }}</em>
|
<strong>Invoice/read key: </strong><em>{{ wallet.inkey }}</em>
|
||||||
|
|||||||
@@ -0,0 +1,436 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
<!---->
|
||||||
|
{% from "macros.jinja" import window_vars with context %}
|
||||||
|
<!---->
|
||||||
|
{% block scripts %} {{ window_vars(user) }}
|
||||||
|
<script src="{{ static_url_for('static', 'js/account.js') }}"></script>
|
||||||
|
{% endblock %} {% block page %}
|
||||||
|
|
||||||
|
<div class="row q-col-gutter-md">
|
||||||
|
<div v-if="user" class="col-12 col-md-6 q-gutter-y-md">
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col">
|
||||||
|
<q-tabs v-model="tab" align="justify">
|
||||||
|
<q-tab
|
||||||
|
name="user"
|
||||||
|
:label="$t('account_settings')"
|
||||||
|
@update="val => tab = val.name"
|
||||||
|
></q-tab>
|
||||||
|
<q-tab
|
||||||
|
name="theme"
|
||||||
|
:label="$t('look_and_feel')"
|
||||||
|
@update="val => tab = val.name"
|
||||||
|
></q-tab>
|
||||||
|
</q-tabs>
|
||||||
|
<q-tab-panels v-model="tab">
|
||||||
|
<q-tab-panel name="user">
|
||||||
|
<div v-if="passwordData.show">
|
||||||
|
<q-card-section>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col">
|
||||||
|
<h4 class="q-my-none">
|
||||||
|
<span v-text="$t('password_config')"></span>
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<q-img
|
||||||
|
v-if="user.config.picture"
|
||||||
|
style="max-width: 100px"
|
||||||
|
:src="user.config.picture"
|
||||||
|
class="float-right"
|
||||||
|
></q-img>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
<q-separator></q-separator>
|
||||||
|
<q-card-section>
|
||||||
|
<q-input
|
||||||
|
v-if="user.has_password"
|
||||||
|
v-model="passwordData.oldPassword"
|
||||||
|
type="password"
|
||||||
|
autocomplete="off"
|
||||||
|
label="Old Password"
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password')]"
|
||||||
|
></q-input>
|
||||||
|
<q-input
|
||||||
|
v-model="passwordData.newPassword"
|
||||||
|
type="password"
|
||||||
|
autocomplete="off"
|
||||||
|
:label="$t('password')"
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password')]"
|
||||||
|
></q-input>
|
||||||
|
<q-input
|
||||||
|
v-model="passwordData.newPasswordRepeat"
|
||||||
|
type="password"
|
||||||
|
autocomplete="off"
|
||||||
|
:label="$t('password_repeat')"
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
class="q-mb-md"
|
||||||
|
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password')]"
|
||||||
|
></q-input>
|
||||||
|
</q-card-section>
|
||||||
|
<q-separator></q-separator>
|
||||||
|
<q-card-section class="q-pb-lg">
|
||||||
|
<q-btn
|
||||||
|
@click="updatePassword"
|
||||||
|
:disable="(!passwordData.newPassword || !passwordData.newPasswordRepeat) || passwordData.newPassword !== passwordData.newPasswordRepeat"
|
||||||
|
unelevated
|
||||||
|
color="primary"
|
||||||
|
:label="$t('change_password')"
|
||||||
|
>
|
||||||
|
</q-btn>
|
||||||
|
<q-btn
|
||||||
|
@click="passwordData.show = false"
|
||||||
|
:label="$t('back')"
|
||||||
|
outline
|
||||||
|
unelevated
|
||||||
|
color="grey"
|
||||||
|
class="float-right"
|
||||||
|
></q-btn>
|
||||||
|
</q-card-section>
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<q-card-section>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col">
|
||||||
|
<q-img
|
||||||
|
v-if="user.config.picture"
|
||||||
|
style="max-width: 100px"
|
||||||
|
:src="user.config.picture"
|
||||||
|
class="float-right"
|
||||||
|
></q-img>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
<q-separator></q-separator>
|
||||||
|
|
||||||
|
<q-card-section>
|
||||||
|
<q-input
|
||||||
|
v-model="user.id"
|
||||||
|
:label="$t('user_id')"
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
readonly
|
||||||
|
:type="showUserId ? 'text': 'password'"
|
||||||
|
class="q-mb-md"
|
||||||
|
><q-btn
|
||||||
|
@click="showUserId = !showUserId"
|
||||||
|
dense
|
||||||
|
flat
|
||||||
|
:icon="showUserId ? 'visibility_off' : 'visibility'"
|
||||||
|
color="grey"
|
||||||
|
></q-btn>
|
||||||
|
</q-input>
|
||||||
|
<q-input
|
||||||
|
v-model="user.username"
|
||||||
|
:label="$t('username')"
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
:readonly="hasUsername"
|
||||||
|
class="q-mb-md"
|
||||||
|
>
|
||||||
|
</q-input>
|
||||||
|
<q-input
|
||||||
|
v-model="user.email"
|
||||||
|
:label="$t('email')"
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
readonly
|
||||||
|
class="q-mb-md"
|
||||||
|
>
|
||||||
|
</q-input>
|
||||||
|
<div v-if="!user.email" class="row"></div>
|
||||||
|
<div v-if="!user.email" class="row">
|
||||||
|
{% if "google-auth" in LNBITS_AUTH_METHODS or
|
||||||
|
"github-auth" in LNBITS_AUTH_METHODS %}
|
||||||
|
<div class="col q-pa-sm text-h6">
|
||||||
|
<span v-text="$t('verify_email')"></span>:
|
||||||
|
</div>
|
||||||
|
{%endif%} {% if "google-auth" in LNBITS_AUTH_METHODS %}
|
||||||
|
<div class="col q-pa-sm">
|
||||||
|
<q-btn
|
||||||
|
:href="`/api/v1/auth/google?user_id=${user.id}`"
|
||||||
|
type="a"
|
||||||
|
outline
|
||||||
|
no-caps
|
||||||
|
rounded
|
||||||
|
color="grey"
|
||||||
|
class="full-width"
|
||||||
|
>
|
||||||
|
<q-avatar size="32px" class="q-mr-md">
|
||||||
|
<q-img
|
||||||
|
:src="'{{ static_url_for('static', 'images/google-logo.png') }}'"
|
||||||
|
></q-img>
|
||||||
|
</q-avatar>
|
||||||
|
<div>Google</div>
|
||||||
|
</q-btn>
|
||||||
|
</div>
|
||||||
|
{%endif%} {% if "github-auth" in LNBITS_AUTH_METHODS %}
|
||||||
|
<div class="col q-pa-sm">
|
||||||
|
<q-btn
|
||||||
|
:href="`/api/v1/auth/github?user_id=${user.id}`"
|
||||||
|
type="a"
|
||||||
|
outline
|
||||||
|
no-caps
|
||||||
|
color="grey"
|
||||||
|
rounded
|
||||||
|
class="full-width"
|
||||||
|
>
|
||||||
|
<q-avatar size="32px" class="q-mr-md">
|
||||||
|
<q-img
|
||||||
|
:src="'{{ static_url_for('static', 'images/github-logo.png') }}'"
|
||||||
|
></q-img>
|
||||||
|
</q-avatar>
|
||||||
|
<div>GitHub</div>
|
||||||
|
</q-btn>
|
||||||
|
</div>
|
||||||
|
{%endif%}
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<q-card-section v-if="user.config">
|
||||||
|
<q-input
|
||||||
|
v-model="user.config.first_name"
|
||||||
|
:label="$t('first_name')"
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
class="q-mb-md"
|
||||||
|
>
|
||||||
|
</q-input>
|
||||||
|
<q-input
|
||||||
|
v-model="user.config.last_name"
|
||||||
|
:label="$t('last_name')"
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
class="q-mb-md"
|
||||||
|
>
|
||||||
|
</q-input>
|
||||||
|
<q-input
|
||||||
|
v-model="user.config.provider"
|
||||||
|
:label="$t('auth_provider')"
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
readonly
|
||||||
|
class="q-mb-md"
|
||||||
|
>
|
||||||
|
</q-input>
|
||||||
|
<q-input
|
||||||
|
v-model="user.config.picture"
|
||||||
|
:label="$t('picture')"
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
class="q-mb-md"
|
||||||
|
>
|
||||||
|
</q-input>
|
||||||
|
</q-card-section>
|
||||||
|
<q-separator></q-separator>
|
||||||
|
<q-card-section>
|
||||||
|
<q-btn @click="updateAccount" unelevated color="primary">
|
||||||
|
<span v-text="$t('update_account')"></span>
|
||||||
|
</q-btn>
|
||||||
|
<q-btn
|
||||||
|
@click="showChangePassword()"
|
||||||
|
:label="user.has_password ? $t('change_password'): $t('set_password')"
|
||||||
|
outline
|
||||||
|
unelevated
|
||||||
|
color="grey"
|
||||||
|
class="float-right"
|
||||||
|
></q-btn>
|
||||||
|
</q-card-section>
|
||||||
|
</div>
|
||||||
|
</q-tab-panel>
|
||||||
|
<q-tab-panel name="theme">
|
||||||
|
<div class="row q-mb-md">
|
||||||
|
<div class="col-4"><span v-text="$t('language')"></span></div>
|
||||||
|
<div class="col-8">
|
||||||
|
<q-btn-dropdown
|
||||||
|
dense
|
||||||
|
flat
|
||||||
|
round
|
||||||
|
size="sm"
|
||||||
|
icon="language"
|
||||||
|
class="q-pl-md"
|
||||||
|
>
|
||||||
|
<q-list v-for="(lang, index) in g.langs" :key="index">
|
||||||
|
<q-item
|
||||||
|
clickable
|
||||||
|
v-close-popup
|
||||||
|
:active="activeLanguage(lang.value)"
|
||||||
|
@click="changeLanguage(lang.value)"
|
||||||
|
><q-item-section>
|
||||||
|
<q-item-label
|
||||||
|
v-text="lang.display ?? lang.value.toUpperCase()"
|
||||||
|
></q-item-label>
|
||||||
|
<q-tooltip
|
||||||
|
><span v-text="lang.label"></span
|
||||||
|
></q-tooltip>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</q-list>
|
||||||
|
</q-btn-dropdown>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row q-mb-md">
|
||||||
|
<div class="col-4">
|
||||||
|
<span v-text="$t('color_scheme')"></span>
|
||||||
|
</div>
|
||||||
|
<div class="col-8">
|
||||||
|
<q-btn
|
||||||
|
v-if="g.allowedThemes.includes('classic')"
|
||||||
|
dense
|
||||||
|
flat
|
||||||
|
@click="changeColor('classic')"
|
||||||
|
icon="circle"
|
||||||
|
color="deep-purple"
|
||||||
|
size="md"
|
||||||
|
><q-tooltip>classic</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
<q-btn
|
||||||
|
v-if="g.allowedThemes.includes('bitcoin')"
|
||||||
|
dense
|
||||||
|
flat
|
||||||
|
@click="changeColor('bitcoin')"
|
||||||
|
icon="circle"
|
||||||
|
color="orange"
|
||||||
|
size="md"
|
||||||
|
><q-tooltip>bitcoin</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
<q-btn
|
||||||
|
v-if="g.allowedThemes.includes('mint')"
|
||||||
|
dense
|
||||||
|
flat
|
||||||
|
@click="changeColor('mint')"
|
||||||
|
icon="circle"
|
||||||
|
color="green"
|
||||||
|
size="md"
|
||||||
|
><q-tooltip>mint</q-tooltip> </q-btn
|
||||||
|
><q-btn
|
||||||
|
v-if="g.allowedThemes.includes('autumn')"
|
||||||
|
dense
|
||||||
|
flat
|
||||||
|
@click="changeColor('autumn')"
|
||||||
|
icon="circle"
|
||||||
|
color="brown"
|
||||||
|
size="md"
|
||||||
|
><q-tooltip>autumn</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
<q-btn
|
||||||
|
v-if="g.allowedThemes.includes('monochrome')"
|
||||||
|
dense
|
||||||
|
flat
|
||||||
|
@click="changeColor('monochrome')"
|
||||||
|
icon="circle"
|
||||||
|
color="grey"
|
||||||
|
size="md"
|
||||||
|
><q-tooltip>monochrome</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
<q-btn
|
||||||
|
v-if="g.allowedThemes.includes('salvador')"
|
||||||
|
dense
|
||||||
|
flat
|
||||||
|
@click="changeColor('salvador')"
|
||||||
|
icon="circle"
|
||||||
|
color="blue-10"
|
||||||
|
size="md"
|
||||||
|
><q-tooltip>elSalvador</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
<q-btn
|
||||||
|
v-if="g.allowedThemes.includes('freedom')"
|
||||||
|
dense
|
||||||
|
flat
|
||||||
|
@click="changeColor('freedom')"
|
||||||
|
icon="circle"
|
||||||
|
color="pink-13"
|
||||||
|
size="md"
|
||||||
|
><q-tooltip>freedom</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
<q-btn
|
||||||
|
v-if="g.allowedThemes.includes('cyber')"
|
||||||
|
dense
|
||||||
|
flat
|
||||||
|
@click="changeColor('cyber')"
|
||||||
|
icon="circle"
|
||||||
|
color="light-green-9"
|
||||||
|
size="md"
|
||||||
|
><q-tooltip>cyber</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
<q-btn
|
||||||
|
v-if="g.allowedThemes.includes('flamingo')"
|
||||||
|
dense
|
||||||
|
flat
|
||||||
|
@click="changeColor('flamingo')"
|
||||||
|
icon="circle"
|
||||||
|
color="pink-3"
|
||||||
|
size="md"
|
||||||
|
><q-tooltip>flamingo</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row q-mb-md">
|
||||||
|
<div class="col-4">
|
||||||
|
<span v-text="$t('toggle_darkmode')"></span>
|
||||||
|
</div>
|
||||||
|
<div class="col-8">
|
||||||
|
<q-btn
|
||||||
|
dense
|
||||||
|
flat
|
||||||
|
round
|
||||||
|
@click="toggleDarkMode"
|
||||||
|
:icon="($q.dark.isActive) ? 'brightness_3' : 'wb_sunny'"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<q-tooltip
|
||||||
|
><span v-text="$t('toggle_darkmode')"></span
|
||||||
|
></q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row q-mb-md">
|
||||||
|
<div class="col-4">Notifications</div>
|
||||||
|
<div class="col-8">
|
||||||
|
<lnbits-notifications-btn
|
||||||
|
v-if="g.user"
|
||||||
|
pubkey="{{ WEBPUSH_PUBKEY }}"
|
||||||
|
></lnbits-notifications-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row q-mb-md">
|
||||||
|
<div class="col-4">
|
||||||
|
<span v-text="$t('payment_reactions')"></span>
|
||||||
|
</div>
|
||||||
|
<div class="col-8">
|
||||||
|
<q-select
|
||||||
|
v-model="reactionChoice"
|
||||||
|
:options="reactionOptions"
|
||||||
|
label="Reactions"
|
||||||
|
@input="reactionChoiceFunc"
|
||||||
|
>
|
||||||
|
<q-tooltip
|
||||||
|
><span v-text="$t('payment_reactions')"></span
|
||||||
|
></q-tooltip>
|
||||||
|
</q-select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-tab-panel>
|
||||||
|
</q-tab-panels>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</div>
|
||||||
|
<div v-else class="col-12 col-md-6 q-gutter-y-md">
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<h4 class="q-my-none"><span v-text="$t('account')"></span></h4>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
%} {{ window_vars(user, extensions) }}{% block page %}
|
%} {{ window_vars(user, extensions) }}{% block page %}
|
||||||
<div class="row q-col-gutter-md q-mb-md">
|
<div class="row q-col-gutter-md q-mb-md">
|
||||||
<div class="col-sm-9 col-xs-12">
|
<div class="col-sm-9 col-xs-12">
|
||||||
<p class="text-h4 gt-sm">{%raw%}{{ $t('extensions') }}{%endraw%}</p>
|
<p class="text-h4 gt-sm" v-text="$t('extensions')"></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-sm-3 col-xs-12 q-ml-auto">
|
<div class="col-sm-3 col-xs-12 q-ml-auto">
|
||||||
@@ -43,9 +43,10 @@
|
|||||||
:label="$t('featured')"
|
:label="$t('featured')"
|
||||||
@update="val => tab = val.name"
|
@update="val => tab = val.name"
|
||||||
></q-tab>
|
></q-tab>
|
||||||
<i v-if="!g.user.admin && tab != 'installed'"
|
<i
|
||||||
>{%raw%}{{ $t('only_admins_can_install') }}{%endraw%}</i
|
v-if="!g.user.admin && tab != 'installed'"
|
||||||
>
|
v-text="$t('only_admins_can_install')"
|
||||||
|
></i>
|
||||||
</q-tabs>
|
</q-tabs>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -89,45 +90,42 @@
|
|||||||
color="green"
|
color="green"
|
||||||
class="float-right"
|
class="float-right"
|
||||||
>
|
>
|
||||||
<small>{%raw%}{{ $t('new_version') }}{%endraw%}</small>
|
<small v-text="$t('new_version')"></small>
|
||||||
<q-tooltip
|
<q-tooltip
|
||||||
><span v-text="extension.latestRelease.version"></span
|
><span v-text="extension.latestRelease.version"></span
|
||||||
></q-tooltip>
|
></q-tooltip>
|
||||||
</q-badge>
|
</q-badge>
|
||||||
{% raw %}
|
<div
|
||||||
<div class="text-h5 gt-sm q-mt-sm q-mb-xs gt-sm">
|
class="text-h5 gt-sm q-mt-sm q-mb-xs gt-sm"
|
||||||
{{ extension.name }}
|
v-text="extension.name"
|
||||||
</div>
|
></div>
|
||||||
<div
|
<div
|
||||||
class="text-h5 gt-sm q-mt-sm q-mb-xs lt-md"
|
class="text-h5 gt-sm q-mt-sm q-mb-xs lt-md"
|
||||||
style="min-height: 60px"
|
style="min-height: 60px"
|
||||||
>
|
v-text="extension.name"
|
||||||
{{ extension.name }}
|
></div>
|
||||||
</div>
|
|
||||||
<div
|
<div
|
||||||
class="text-subtitle2 gt-sm"
|
class="text-subtitle2 gt-sm"
|
||||||
style="font-size: 11px; height: 34px"
|
style="font-size: 11px; height: 34px"
|
||||||
>
|
v-text="extension.shortDescription || extension.installedRelease?.description"
|
||||||
{{ extension.shortDescription ||
|
></div>
|
||||||
extension.installedRelease?.description }}
|
<div
|
||||||
</div>
|
class="text-subtitle1 lt-md q-mt-sm q-mb-xs"
|
||||||
<div class="text-subtitle1 lt-md q-mt-sm q-mb-xs">
|
v-text="extension.name"
|
||||||
{{ extension.name }}
|
></div>
|
||||||
</div>
|
|
||||||
<div
|
<div
|
||||||
class="text-subtitle2 lt-md"
|
class="text-subtitle2 lt-md"
|
||||||
style="font-size: 9px; height: 34px"
|
style="font-size: 9px; height: 34px"
|
||||||
>
|
v-text="extension.shortDescription"
|
||||||
{{ extension.shortDescription }}
|
></div>
|
||||||
</div>
|
|
||||||
{% endraw %}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row q-pt-sm">
|
<div class="row q-pt-sm">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<small v-if="extension.dependencies?.length"
|
<small
|
||||||
>{%raw%}{{ $t('extension_depends_on') }}{%endraw%}</small
|
v-if="extension.dependencies?.length"
|
||||||
>
|
v-text="$t('extension_depends_on')"
|
||||||
|
></small>
|
||||||
<small v-else> </small>
|
<small v-else> </small>
|
||||||
<q-badge
|
<q-badge
|
||||||
v-for="dep in extension.dependencies"
|
v-for="dep in extension.dependencies"
|
||||||
@@ -148,32 +146,30 @@
|
|||||||
size="1.5em"
|
size="1.5em"
|
||||||
:max="5"
|
:max="5"
|
||||||
color="primary"
|
color="primary"
|
||||||
><q-tooltip
|
><q-tooltip>
|
||||||
>{%raw%}{{ $t('extension_rating_soon') }}{%endraw%}</q-tooltip
|
<span v-text="$t('extension_rating_soon')"></span> </q-tooltip
|
||||||
></q-rating
|
></q-rating>
|
||||||
>
|
|
||||||
<q-rating
|
<q-rating
|
||||||
v-model="maxStars"
|
v-model="maxStars"
|
||||||
class="lt-md"
|
class="lt-md"
|
||||||
size="1.5em"
|
size="1.5em"
|
||||||
:max="5"
|
:max="5"
|
||||||
color="primary"
|
color="primary"
|
||||||
><q-tooltip
|
><q-tooltip>
|
||||||
>{%raw%}{{ $t('extension_rating_soon') }}{%endraw%}</q-tooltip
|
<span v-text="$t('extension_rating_soon')"></span> </q-tooltip
|
||||||
></q-rating
|
></q-rating>
|
||||||
>
|
|
||||||
<q-toggle
|
<q-toggle
|
||||||
v-if="extension.isAvailable && extension.isInstalled && g.user.admin"
|
v-if="extension.isAvailable && extension.isInstalled && g.user.admin"
|
||||||
:label="extension.isActive ? $t('activated'): $t('deactivated') "
|
:label="extension.isActive ? $t('activated'): $t('deactivated') "
|
||||||
color="secodary"
|
color="secondary"
|
||||||
style="max-height: 21px"
|
style="max-height: 21px"
|
||||||
v-model="extension.isActive"
|
v-model="extension.isActive"
|
||||||
@input="toggleExtension(extension)"
|
@input="toggleExtension(extension)"
|
||||||
><q-tooltip
|
><q-tooltip>
|
||||||
>{%raw%}{{ $t('activate_extension_details')
|
<span
|
||||||
}}{%endraw%}</q-tooltip
|
v-text="$t('activate_extension_details')"
|
||||||
></q-toggle
|
></span> </q-tooltip
|
||||||
>
|
></q-toggle>
|
||||||
</div>
|
</div>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
<q-separator></q-separator>
|
<q-separator></q-separator>
|
||||||
@@ -185,9 +181,9 @@
|
|||||||
flat
|
flat
|
||||||
color="primary"
|
color="primary"
|
||||||
type="a"
|
type="a"
|
||||||
:href="[extension.id, '/?usr=', g.user.id].join('')"
|
:href="extension.id + '/'"
|
||||||
>{%raw%}{{ $t('open') }}{%endraw%}</q-btn
|
:label="$t('open')"
|
||||||
>
|
></q-btn>
|
||||||
<q-btn
|
<q-btn
|
||||||
v-if="user.extensions.includes(extension.id) && extension.isActive && extension.isInstalled"
|
v-if="user.extensions.includes(extension.id) && extension.isActive && extension.isInstalled"
|
||||||
flat
|
flat
|
||||||
@@ -195,12 +191,13 @@
|
|||||||
type="a"
|
type="a"
|
||||||
:href="['{{
|
:href="['{{
|
||||||
url_for('install.extensions')
|
url_for('install.extensions')
|
||||||
}}', '?usr=', g.user.id, '&disable=', extension.id].join('')"
|
}}', '?disable=', extension.id].join('')"
|
||||||
|
:label="$t('disable')"
|
||||||
|
></q-btn>
|
||||||
|
<q-badge
|
||||||
|
v-if="extension.isAdminOnly && !user.admin"
|
||||||
|
v-text="$t('admin_only')"
|
||||||
>
|
>
|
||||||
{%raw%}{{ $t('disable') }}{%endraw%}</q-btn
|
|
||||||
>
|
|
||||||
<q-badge v-if="extension.isAdminOnly && !user.admin">
|
|
||||||
{%raw%}{{ $t('admin_only') }}{%endraw%}
|
|
||||||
</q-badge>
|
</q-badge>
|
||||||
<q-btn
|
<q-btn
|
||||||
v-else-if="extension.isInstalled && extension.isActive && !user.extensions.includes(extension.id)"
|
v-else-if="extension.isInstalled && extension.isActive && !user.extensions.includes(extension.id)"
|
||||||
@@ -209,9 +206,9 @@
|
|||||||
type="a"
|
type="a"
|
||||||
:href="['{{
|
:href="['{{
|
||||||
url_for('install.extensions')
|
url_for('install.extensions')
|
||||||
}}', '?usr=', g.user.id, '&enable=', extension.id].join('')"
|
}}', '?enable=', extension.id].join('')"
|
||||||
|
:label="$t('enable')"
|
||||||
>
|
>
|
||||||
{%raw%}{{ $t('enable') }}{%endraw%}
|
|
||||||
<q-tooltip>
|
<q-tooltip>
|
||||||
<span v-text="$t('enable_extension_details')">
|
<span v-text="$t('enable_extension_details')">
|
||||||
</span> </q-tooltip
|
</span> </q-tooltip
|
||||||
@@ -222,12 +219,11 @@
|
|||||||
flat
|
flat
|
||||||
color="primary"
|
color="primary"
|
||||||
v-if="g.user.admin"
|
v-if="g.user.admin"
|
||||||
>
|
:label="$t('manage')"
|
||||||
{%raw%}{{ $t('manage') }}{%endraw%}<q-tooltip
|
><q-tooltip
|
||||||
>{%raw%}{{ $t('manage_extension_details')
|
><span v-text="$t('manage_extension_details')"></span
|
||||||
}}{%endraw%}</q-tooltip
|
></q-tooltip>
|
||||||
></q-btn
|
</q-btn>
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
<div v-else>
|
<div v-else>
|
||||||
<q-spinner color="primary" size="2.55em"></q-spinner>
|
<q-spinner color="primary" size="2.55em"></q-spinner>
|
||||||
@@ -240,11 +236,10 @@
|
|||||||
class="float-right"
|
class="float-right"
|
||||||
>
|
>
|
||||||
<q-badge>
|
<q-badge>
|
||||||
{% raw %}{{ extension.installedRelease.version }}{% endraw
|
<span v-text="extension.installedRelease.version"></span>
|
||||||
%}<q-tooltip
|
<q-tooltip>
|
||||||
>{%raw%}{{ $t('extension_installed_version')
|
<span v-text="$t('extension_installed_version')"></span>
|
||||||
}}{%endraw%}</q-tooltip
|
</q-tooltip>
|
||||||
>
|
|
||||||
</q-badge>
|
</q-badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -253,10 +248,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<q-dialog v-model="showUninstallDialog">
|
<q-dialog v-model="showUninstallDialog">
|
||||||
<q-card class="q-pa-lg">
|
<q-card class="q-pa-lg">
|
||||||
<h6 class="q-my-md text-primary">{%raw%}{{ $t('warning') }}{%endraw%}</h6>
|
<h6 class="q-my-md text-primary" v-text="$t('warning')"></h6>
|
||||||
<p>
|
<p>
|
||||||
{%raw%}{{ $t('extension_uninstall_warning') }}{%endraw%} <br />
|
<span v-text="$t('extension_uninstall_warning')"></span><br />
|
||||||
{%raw%}{{ $t('confirm_continue') }}{%endraw%}
|
<span v-text="$t('confirm_continue')"></span>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="row q-mt-lg">
|
<div class="row q-mt-lg">
|
||||||
@@ -265,32 +260,39 @@
|
|||||||
value="false"
|
value="false"
|
||||||
label="Cleanup database tables"
|
label="Cleanup database tables"
|
||||||
>
|
>
|
||||||
<q-tooltip class="bg-grey-8" anchor="bottom left" self="top left">
|
<q-tooltip class="bg-grey-8" anchor="bottom left" self="top left"
|
||||||
{%raw%}{{ $t('extension_db_drop_info') }}{%endraw%}
|
><span v-text="$t('extension_db_drop_info')"></span>
|
||||||
</q-tooltip>
|
</q-tooltip>
|
||||||
</q-checkbox>
|
</q-checkbox>
|
||||||
</div>
|
</div>
|
||||||
<div class="row q-mt-lg">
|
<div class="row q-mt-lg">
|
||||||
<q-btn outline color="grey" @click="uninstallExtension()"
|
<q-btn
|
||||||
>{%raw%}{{ $t('uninstall_confirm') }}{%endraw%}</q-btn
|
outline
|
||||||
>
|
color="grey"
|
||||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
|
@click="uninstallExtension()"
|
||||||
>{%raw%}{{ $t('cancel') }}{%endraw%}</q-btn
|
v-text="$t('uninstall_confirm')"
|
||||||
>
|
></q-btn>
|
||||||
|
<q-btn
|
||||||
|
v-close-popup
|
||||||
|
flat
|
||||||
|
color="grey"
|
||||||
|
class="q-ml-auto"
|
||||||
|
v-text="$t('cancel')"
|
||||||
|
></q-btn>
|
||||||
</div>
|
</div>
|
||||||
</q-card>
|
</q-card>
|
||||||
</q-dialog>
|
</q-dialog>
|
||||||
|
|
||||||
<q-dialog v-model="showDropDbDialog">
|
<q-dialog v-model="showDropDbDialog">
|
||||||
<q-card v-if="selectedExtension" class="q-pa-lg">
|
<q-card v-if="selectedExtension" class="q-pa-lg">
|
||||||
<h6 class="q-my-md text-primary">{%raw%}{{ $t('warning') }}{%endraw%}</h6>
|
<h6 class="q-my-md text-primary" v-text="$t('warning')"></h6>
|
||||||
<p>{%raw%}{{ $t('extension_db_drop_warning') }}{%endraw%} <br /></p>
|
<p><span v-text="$t('extension_db_drop_warning')"></span><br /></p>
|
||||||
<q-input
|
<q-input
|
||||||
v-model="dropDbExtensionId"
|
v-model="dropDbExtensionId"
|
||||||
:label="selectedExtension.id"
|
:label="selectedExtension.id"
|
||||||
></q-input>
|
></q-input>
|
||||||
<br />
|
<br />
|
||||||
<p>{%raw%}{{ $t('confirm_continue') }}{%endraw%}</p>
|
<p v-text="$t('confirm_continue')"></p>
|
||||||
|
|
||||||
<div class="row q-mt-lg">
|
<div class="row q-mt-lg">
|
||||||
<q-btn
|
<q-btn
|
||||||
@@ -298,17 +300,58 @@
|
|||||||
outline
|
outline
|
||||||
color="red"
|
color="red"
|
||||||
@click="dropExtensionDb()"
|
@click="dropExtensionDb()"
|
||||||
>{%raw%}{{ $t('confirm') }}{%endraw%}</q-btn
|
v-text="$t('confirm')"
|
||||||
>
|
></q-btn>
|
||||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
|
<q-btn
|
||||||
>{%raw%}{{ $t('cancel') }}{%endraw%}</q-btn
|
v-close-popup
|
||||||
>
|
flat
|
||||||
|
color="grey"
|
||||||
|
class="q-ml-auto"
|
||||||
|
v-text="$t('cancel')"
|
||||||
|
></q-btn>
|
||||||
</div>
|
</div>
|
||||||
</q-card>
|
</q-card>
|
||||||
</q-dialog>
|
</q-dialog>
|
||||||
|
|
||||||
<q-dialog v-model="showUpgradeDialog">
|
<q-dialog v-model="showUpgradeDialog">
|
||||||
<q-card class="q-pa-lg lnbits__dialog-card">
|
<q-card v-if="selectedRelease" class="q-pa-lg lnbits__dialog-card">
|
||||||
|
<q-card-section>
|
||||||
|
<div v-if="selectedRelease.paymentRequest">
|
||||||
|
<a :href="'lightning:' + selectedRelease.paymentRequest">
|
||||||
|
<q-responsive :ratio="1" class="q-mx-xl">
|
||||||
|
<lnbits-qrcode
|
||||||
|
:value="'lightning:' + selectedRelease.paymentRequest.toUpperCase()"
|
||||||
|
></lnbits-qrcode>
|
||||||
|
</q-responsive>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<q-spinner color="primary" size="2.55em"></q-spinner>
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<div class="row q-mt-lg">
|
||||||
|
<div class="col">
|
||||||
|
<q-btn
|
||||||
|
v-if="selectedRelease.paymentRequest"
|
||||||
|
outline
|
||||||
|
color="grey"
|
||||||
|
@click="copyText(selectedRelease.paymentRequest)"
|
||||||
|
:label="$t('copy_invoice')"
|
||||||
|
></q-btn>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<q-btn
|
||||||
|
v-close-popup
|
||||||
|
flat
|
||||||
|
color="grey"
|
||||||
|
class="float-right q-ml-lg"
|
||||||
|
v-text="$t('close')"
|
||||||
|
></q-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-card>
|
||||||
|
<q-card v-else class="q-pa-lg lnbits__dialog-card">
|
||||||
<q-card-section>
|
<q-card-section>
|
||||||
<div class="text-h6" v-text="selectedExtension?.name"></div>
|
<div class="text-h6" v-text="selectedExtension?.name"></div>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
@@ -338,7 +381,7 @@
|
|||||||
<q-item-section>
|
<q-item-section>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-10">
|
<div class="col-10">
|
||||||
{%raw%}{{ $t('repository') }}{%endraw%}
|
<span v-text="$t('repository')"></span>
|
||||||
<br />
|
<br />
|
||||||
<small v-text="repoName"></small>
|
<small v-text="repoName"></small>
|
||||||
<q-tooltip
|
<q-tooltip
|
||||||
@@ -377,30 +420,116 @@
|
|||||||
color="pink"
|
color="pink"
|
||||||
size="70px"
|
size="70px"
|
||||||
></q-icon>
|
></q-icon>
|
||||||
Cannot get the release details.
|
<span v-text="$t('release_details_error')"></span>
|
||||||
</div>
|
</div>
|
||||||
<q-card v-else>
|
<q-card v-else>
|
||||||
<q-card-section v-if="release.is_version_compatible">
|
<q-card-section v-if="release.is_version_compatible">
|
||||||
|
<span
|
||||||
|
v-if="release.requiresPayment && !release.paid_sats"
|
||||||
|
v-text="$t('extension_cost', {cost: release.cost_sats})"
|
||||||
|
class="q-mb-lg"
|
||||||
|
></span>
|
||||||
|
<span
|
||||||
|
v-if="release.requiresPayment && release.paid_sats"
|
||||||
|
class="q-mb-lg"
|
||||||
|
v-text="$t('extension_paid_sats', {paid_sats: release.paid_sats})"
|
||||||
|
></span>
|
||||||
|
<div
|
||||||
|
v-if="!release.requiresPayment || (release.requiresPayment && release.paid_sats)"
|
||||||
|
>
|
||||||
<q-btn
|
<q-btn
|
||||||
v-if="!release.isInstalled"
|
v-if="!release.isInstalled"
|
||||||
@click="installExtension(release)"
|
@click="installExtension(release)"
|
||||||
color="primary unelevated mt-lg pt-lg"
|
color="primary unelevated mt-lg pt-lg"
|
||||||
>{%raw%}{{ $t('install') }}{%endraw%}</q-btn
|
:label="$t('install')"
|
||||||
|
></q-btn>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="release.requiresPayment && !release.paid_sats">
|
||||||
|
<div v-if="!release.payment_hash">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.number="release.paidAmount"
|
||||||
|
type="number"
|
||||||
|
:min="release.cost_sats"
|
||||||
|
suffix="sat"
|
||||||
|
class="q-mt-sm"
|
||||||
>
|
>
|
||||||
<q-btn v-else @click="showUninstall()" flat color="red">
|
</q-input>
|
||||||
{%raw%}{{ $t('uninstall') }}{%endraw%}</q-btn
|
<q-select
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
emit-value
|
||||||
|
v-model="release.wallet"
|
||||||
|
:options="g.user.walletOptions"
|
||||||
|
label="Wallet *"
|
||||||
|
class="q-mt-sm"
|
||||||
>
|
>
|
||||||
|
</q-select>
|
||||||
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
color="primary"
|
||||||
|
@click="payAndInstall(release)"
|
||||||
|
:disabled="!release.wallet"
|
||||||
|
class="q-mt-sm"
|
||||||
|
:label="$t('pay_from_wallet')"
|
||||||
|
></q-btn>
|
||||||
|
|
||||||
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
color="primary"
|
||||||
|
@click="showQRCode(release)"
|
||||||
|
class="q-mt-sm float-right"
|
||||||
|
:label="$t('show_qr')"
|
||||||
|
></q-btn>
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<br />
|
||||||
|
<span
|
||||||
|
class="q-mb-lg q-mt-lg"
|
||||||
|
v-text="'There is a previous pending invoice for this release.'"
|
||||||
|
></span>
|
||||||
|
<br />
|
||||||
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
@click="installExtension(release)"
|
||||||
|
color="primary"
|
||||||
|
class="q-mt-sm"
|
||||||
|
:label="$t('retry_install')"
|
||||||
|
></q-btn>
|
||||||
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
@click="clearHangingInvoice(release)"
|
||||||
|
color="primary"
|
||||||
|
class="q-mt-sm float-right"
|
||||||
|
:label="$t('new_payment')"
|
||||||
|
></q-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<q-btn
|
||||||
|
v-if="release.isInstalled"
|
||||||
|
@click="showUninstall()"
|
||||||
|
:label="$t('uninstall')"
|
||||||
|
flat
|
||||||
|
color="red"
|
||||||
|
></q-btn>
|
||||||
|
</div>
|
||||||
|
|
||||||
<a
|
<a
|
||||||
v-if="release.html_url"
|
v-if="release.html_url"
|
||||||
class="text-secondary float-right"
|
class="text-secondary float-right"
|
||||||
:href="release.html_url"
|
:href="release.html_url"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
style="color: inherit"
|
style="color: inherit"
|
||||||
>{%raw%}{{ $t('release_notes') }}{%endraw%}</a
|
v-text="$t('release_notes')"
|
||||||
>
|
></a>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
<q-card-section v-else>
|
<q-card-section v-else>
|
||||||
{%raw%}{{ $t('extension_min_lnbits_version') }}{%endraw%}
|
<span v-text="$t('extension_min_lnbits_version')"></span>
|
||||||
<strong>
|
<strong>
|
||||||
<span v-text="release.min_lnbits_version"></span>
|
<span v-text="release.min_lnbits_version"></span>
|
||||||
</strong>
|
</strong>
|
||||||
@@ -408,8 +537,11 @@
|
|||||||
<q-card v-if="release.warning">
|
<q-card v-if="release.warning">
|
||||||
<q-card-section>
|
<q-card-section>
|
||||||
<div class="text-h6">
|
<div class="text-h6">
|
||||||
<q-badge color="yellow" text-color="black">
|
<q-badge
|
||||||
{%raw%}{{ $t('warning') }}{%endraw%}
|
color="yellow"
|
||||||
|
text-color="black"
|
||||||
|
v-text="$t('warning')"
|
||||||
|
>
|
||||||
</q-badge>
|
</q-badge>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-subtitle2">
|
<div class="text-subtitle2">
|
||||||
@@ -432,9 +564,8 @@
|
|||||||
@click="showUninstall()"
|
@click="showUninstall()"
|
||||||
flat
|
flat
|
||||||
color="red"
|
color="red"
|
||||||
>
|
v-text="$t('uninstall')"
|
||||||
{%raw%}{{ $t('uninstall') }}{%endraw%}</q-btn
|
></q-btn>
|
||||||
>
|
|
||||||
<q-btn
|
<q-btn
|
||||||
v-else-if="selectedExtension?.hasDatabaseTables"
|
v-else-if="selectedExtension?.hasDatabaseTables"
|
||||||
@click="showDropDb()"
|
@click="showDropDb()"
|
||||||
@@ -442,9 +573,13 @@
|
|||||||
color="red"
|
color="red"
|
||||||
:label="$t('drop_db')"
|
:label="$t('drop_db')"
|
||||||
></q-btn>
|
></q-btn>
|
||||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto">
|
<q-btn
|
||||||
{%raw%}{{ $t('close') }}{%endraw%}</q-btn
|
v-close-popup
|
||||||
>
|
flat
|
||||||
|
color="grey"
|
||||||
|
class="q-ml-auto"
|
||||||
|
v-text="$t('close')"
|
||||||
|
></q-btn>
|
||||||
</div>
|
</div>
|
||||||
</q-card>
|
</q-card>
|
||||||
</q-dialog>
|
</q-dialog>
|
||||||
@@ -464,8 +599,10 @@
|
|||||||
dropDbExtensionId: '',
|
dropDbExtensionId: '',
|
||||||
selectedExtension: null,
|
selectedExtension: null,
|
||||||
selectedExtensionRepos: null,
|
selectedExtensionRepos: null,
|
||||||
|
selectedRelease: null,
|
||||||
uninstallAndDropDb: false,
|
uninstallAndDropDb: false,
|
||||||
maxStars: 5,
|
maxStars: 5,
|
||||||
|
paylinkWebsocket: null,
|
||||||
user: null
|
user: null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -504,19 +641,29 @@
|
|||||||
.filter(extensionNameContains(term))
|
.filter(extensionNameContains(term))
|
||||||
this.tab = tab
|
this.tab = tab
|
||||||
},
|
},
|
||||||
|
|
||||||
installExtension: async function (release) {
|
installExtension: async function (release) {
|
||||||
|
// no longer required to check if the invoice was paid
|
||||||
|
// the install logic has been triggered one way or another
|
||||||
|
this.unsubscribeFromPaylinkWs()
|
||||||
|
|
||||||
const extension = this.selectedExtension
|
const extension = this.selectedExtension
|
||||||
extension.inProgress = true
|
extension.inProgress = true
|
||||||
this.showUpgradeDialog = false
|
this.showUpgradeDialog = false
|
||||||
|
release.payment_hash =
|
||||||
|
release.payment_hash || this.getPaylinkHash(release.pay_link)
|
||||||
|
|
||||||
LNbits.api
|
LNbits.api
|
||||||
.request(
|
.request(
|
||||||
'POST',
|
'POST',
|
||||||
`/api/v1/extension?usr=${this.g.user.id}`,
|
`/api/v1/extension`,
|
||||||
this.g.user.wallets[0].adminkey,
|
this.g.user.wallets[0].adminkey,
|
||||||
{
|
{
|
||||||
ext_id: extension.id,
|
ext_id: extension.id,
|
||||||
archive: release.archive,
|
archive: release.archive,
|
||||||
source_repo: release.source_repo
|
source_repo: release.source_repo,
|
||||||
|
payment_hash: release.payment_hash,
|
||||||
|
version: release.version
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.then(response => {
|
.then(response => {
|
||||||
@@ -530,8 +677,9 @@
|
|||||||
this.tab = 'installed'
|
this.tab = 'installed'
|
||||||
})
|
})
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
LNbits.utils.notifyApiError(err)
|
console.warn(err)
|
||||||
extension.inProgress = false
|
extension.inProgress = false
|
||||||
|
LNbits.utils.notifyApiError(err)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
uninstallExtension: async function () {
|
uninstallExtension: async function () {
|
||||||
@@ -542,7 +690,7 @@
|
|||||||
LNbits.api
|
LNbits.api
|
||||||
.request(
|
.request(
|
||||||
'DELETE',
|
'DELETE',
|
||||||
`/api/v1/extension/${extension.id}?usr=${this.g.user.id}`,
|
`/api/v1/extension/${extension.id}`,
|
||||||
this.g.user.wallets[0].adminkey
|
this.g.user.wallets[0].adminkey
|
||||||
)
|
)
|
||||||
.then(response => {
|
.then(response => {
|
||||||
@@ -576,7 +724,7 @@
|
|||||||
LNbits.api
|
LNbits.api
|
||||||
.request(
|
.request(
|
||||||
'DELETE',
|
'DELETE',
|
||||||
`/api/v1/extension/${extension.id}/db?usr=${this.g.user.id}`,
|
`/api/v1/extension/${extension.id}/db`,
|
||||||
this.g.user.wallets[0].adminkey
|
this.g.user.wallets[0].adminkey
|
||||||
)
|
)
|
||||||
.then(response => {
|
.then(response => {
|
||||||
@@ -598,9 +746,8 @@
|
|||||||
LNbits.api
|
LNbits.api
|
||||||
.request(
|
.request(
|
||||||
'GET',
|
'GET',
|
||||||
"{{ url_for('install.extensions') }}?usr=" +
|
"{{ url_for('install.extensions') }}" +
|
||||||
this.g.user.id +
|
'?' +
|
||||||
'&' +
|
|
||||||
action +
|
action +
|
||||||
'=' +
|
'=' +
|
||||||
extension.id
|
extension.id
|
||||||
@@ -624,12 +771,14 @@
|
|||||||
|
|
||||||
showUpgrade: async function (extension) {
|
showUpgrade: async function (extension) {
|
||||||
this.selectedExtension = extension
|
this.selectedExtension = extension
|
||||||
this.showUpgradeDialog = true
|
this.selectedRelease = null
|
||||||
this.selectedExtensionRepos = null
|
this.selectedExtensionRepos = null
|
||||||
|
this.showUpgradeDialog = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const {data} = await LNbits.api.request(
|
const {data} = await LNbits.api.request(
|
||||||
'GET',
|
'GET',
|
||||||
`/api/v1/extension/${extension.id}/releases?usr=${this.g.user.id}`,
|
`/api/v1/extension/${extension.id}/releases`,
|
||||||
this.g.user.wallets[0].adminkey
|
this.g.user.wallets[0].adminkey
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -649,6 +798,12 @@
|
|||||||
if (release.isInstalled) {
|
if (release.isInstalled) {
|
||||||
repos[release.source_repo].isInstalled = true
|
repos[release.source_repo].isInstalled = true
|
||||||
}
|
}
|
||||||
|
if (release.pay_link) {
|
||||||
|
release.requiresPayment = true
|
||||||
|
release.paidAmount = release.cost_sats
|
||||||
|
release.payment_hash = this.getPaylinkHash(release.pay_link)
|
||||||
|
}
|
||||||
|
|
||||||
repos[release.source_repo].releases.push(release)
|
repos[release.source_repo].releases.push(release)
|
||||||
return repos
|
return repos
|
||||||
}, {})
|
}, {})
|
||||||
@@ -657,6 +812,122 @@
|
|||||||
extension.inProgress = false
|
extension.inProgress = false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async payAndInstall(release) {
|
||||||
|
try {
|
||||||
|
this.selectedExtension.inProgress = true
|
||||||
|
this.showUpgradeDialog = false
|
||||||
|
const paymentInfo = await this.requestPayment(
|
||||||
|
this.selectedExtension.id,
|
||||||
|
release
|
||||||
|
)
|
||||||
|
this.rememberPaylinkHash(release.pay_link, paymentInfo.payment_hash)
|
||||||
|
const wallet = this.g.user.wallets.find(w => w.id === release.wallet)
|
||||||
|
const {data} = await LNbits.api.payInvoice(
|
||||||
|
wallet,
|
||||||
|
paymentInfo.payment_request
|
||||||
|
)
|
||||||
|
|
||||||
|
release.payment_hash = data.payment_hash
|
||||||
|
|
||||||
|
await this.installExtension(release)
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(err)
|
||||||
|
LNbits.utils.notifyApiError(err)
|
||||||
|
} finally {
|
||||||
|
this.selectedExtension.inProgress = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async showQRCode(release) {
|
||||||
|
this.selectedRelease = release
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await this.requestPayment(
|
||||||
|
this.selectedExtension.id,
|
||||||
|
release
|
||||||
|
)
|
||||||
|
|
||||||
|
this.selectedRelease.paymentRequest = data.payment_request
|
||||||
|
this.selectedRelease.payment_hash = data.payment_hash
|
||||||
|
this.selectedRelease = _.clone(this.selectedRelease)
|
||||||
|
this.rememberPaylinkHash(
|
||||||
|
this.selectedRelease.pay_link,
|
||||||
|
this.selectedRelease.payment_hash
|
||||||
|
)
|
||||||
|
|
||||||
|
this.subscribeToPaylinkWs(
|
||||||
|
this.selectedRelease.pay_link,
|
||||||
|
data.payment_hash
|
||||||
|
)
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(err)
|
||||||
|
LNbits.utils.notifyApiError(err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async requestPayment(extId, release) {
|
||||||
|
const {data} = await LNbits.api.request(
|
||||||
|
'PUT',
|
||||||
|
`/api/v1/extension/invoice`,
|
||||||
|
this.g.user.wallets[0].adminkey,
|
||||||
|
{
|
||||||
|
ext_id: extId,
|
||||||
|
archive: release.archive,
|
||||||
|
source_repo: release.source_repo,
|
||||||
|
cost_sats: release.paidAmount,
|
||||||
|
version: release.version
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
|
||||||
|
clearHangingInvoice(release) {
|
||||||
|
this.forgetPaylinkHash(release.pay_link)
|
||||||
|
release.payment_hash = null
|
||||||
|
},
|
||||||
|
|
||||||
|
rememberPaylinkHash(pay_link, payment_hash) {
|
||||||
|
this.$q.localStorage.set(
|
||||||
|
`lnbits.extensions.paylink.${pay_link}`,
|
||||||
|
payment_hash
|
||||||
|
)
|
||||||
|
},
|
||||||
|
getPaylinkHash(pay_link) {
|
||||||
|
return this.$q.localStorage.getItem(
|
||||||
|
`lnbits.extensions.paylink.${pay_link}`
|
||||||
|
)
|
||||||
|
},
|
||||||
|
forgetPaylinkHash(pay_link) {
|
||||||
|
this.$q.localStorage.remove(`lnbits.extensions.paylink.${pay_link}`)
|
||||||
|
},
|
||||||
|
subscribeToPaylinkWs(pay_link, payment_hash) {
|
||||||
|
const url = new URL(`${pay_link}/${payment_hash}`)
|
||||||
|
url.protocol = url.protocol === 'https:' ? 'wss' : 'ws'
|
||||||
|
this.paylinkWebsocket = new WebSocket(url)
|
||||||
|
this.paylinkWebsocket.addEventListener('message', async ({data}) => {
|
||||||
|
const resp = JSON.parse(data)
|
||||||
|
if (resp.paid) {
|
||||||
|
this.$q.notify({
|
||||||
|
type: 'positive',
|
||||||
|
message: 'Invoice Paid!'
|
||||||
|
})
|
||||||
|
this.installExtension(this.selectedRelease)
|
||||||
|
} else {
|
||||||
|
this.$q.notify({
|
||||||
|
type: 'warning',
|
||||||
|
message: 'Invoice tracking lost!'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
unsubscribeFromPaylinkWs() {
|
||||||
|
try {
|
||||||
|
this.paylinkWebsocket && this.paylinkWebsocket.close()
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
hasNewVersion: function (extension) {
|
hasNewVersion: function (extension) {
|
||||||
if (extension.installedRelease && extension.latestRelease) {
|
if (extension.installedRelease && extension.latestRelease) {
|
||||||
return (
|
return (
|
||||||
@@ -694,7 +965,7 @@
|
|||||||
try {
|
try {
|
||||||
const {data} = await LNbits.api.request(
|
const {data} = await LNbits.api.request(
|
||||||
'GET',
|
'GET',
|
||||||
`/api/v1/extension/release/${org}/${repo}/${release.version}?usr=${this.g.user.id}`,
|
`/api/v1/extension/release/${org}/${repo}/${release.version}`,
|
||||||
this.g.user.wallets[0].adminkey
|
this.g.user.wallets[0].adminkey
|
||||||
)
|
)
|
||||||
release.loaded = true
|
release.loaded = true
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
{% extends "public.html" %} {% block page %}
|
||||||
|
<div class="row q-col-gutter-md justify-center main">
|
||||||
|
<div class="col-10 col-md-8 col-lg-6 q-gutter-y-md">
|
||||||
|
<q-card>
|
||||||
|
<q-card-section class="grid">
|
||||||
|
<div>
|
||||||
|
<h6 class="q-my-none text-center">
|
||||||
|
<strong>Welcome to LNbits</strong>
|
||||||
|
<p>Set up the Superuser account below.</p>
|
||||||
|
</h6>
|
||||||
|
<br />
|
||||||
|
<q-form class="q-gutter-md">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
v-model="loginData.username"
|
||||||
|
:label="$t('username')"
|
||||||
|
></q-input>
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
v-model.trim="loginData.password"
|
||||||
|
:type="loginData.isPwd ? 'password' : 'text'"
|
||||||
|
autocomplete="off"
|
||||||
|
:label="$t('password')"
|
||||||
|
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password')]"
|
||||||
|
><template v-slot:append>
|
||||||
|
<q-icon
|
||||||
|
:name="loginData.isPwd ? 'visibility_off' : 'visibility'"
|
||||||
|
class="cursor-pointer"
|
||||||
|
@click="loginData.isPwd = !loginData.isPwd"
|
||||||
|
/> </template
|
||||||
|
></q-input>
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
v-model.trim="loginData.passwordRepeat"
|
||||||
|
:type="loginData.isPwdRepeat ? 'password' : 'text'"
|
||||||
|
autocomplete="off"
|
||||||
|
:label="$t('password_repeat')"
|
||||||
|
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password'), (val) => val === loginData.password || 'Passwords_dont_match']"
|
||||||
|
><template v-slot:append>
|
||||||
|
<q-icon
|
||||||
|
:name="loginData.isPwdRepeat ? 'visibility_off' : 'visibility'"
|
||||||
|
class="cursor-pointer"
|
||||||
|
@click="loginData.isPwdRepeat = !loginData.isPwdRepeat"
|
||||||
|
/> </template
|
||||||
|
></q-input>
|
||||||
|
<q-btn
|
||||||
|
@click="setPassword()"
|
||||||
|
unelevated
|
||||||
|
color="primary"
|
||||||
|
:label="$t('login')"
|
||||||
|
:disable="checkPasswordsMatch || !loginData.username || !loginData.password || !loginData.passwordRepeat"
|
||||||
|
></q-btn>
|
||||||
|
</q-form>
|
||||||
|
</div>
|
||||||
|
<div class="hero-wrapper">
|
||||||
|
<div class="hero q-mx-auto"></div>
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %} {% block scripts %}
|
||||||
|
<style>
|
||||||
|
main {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.grid {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.hero-wrapper {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.hero {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
max-width: 250px;
|
||||||
|
background-image: url("{{ static_url_for('static', 'images/logos/lnbits.svg') }}");
|
||||||
|
background-position: center;
|
||||||
|
background-size: contain;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 992px) {
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
grid-gap: 1rem;
|
||||||
|
}
|
||||||
|
.hero-wrapper {
|
||||||
|
display: block;
|
||||||
|
position: relative;
|
||||||
|
height: 100%;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<script>
|
||||||
|
new Vue({
|
||||||
|
el: '#vue',
|
||||||
|
mixins: [windowMixin],
|
||||||
|
data: function () {
|
||||||
|
return {
|
||||||
|
loginData: {
|
||||||
|
isPwd: true,
|
||||||
|
isPwdRepeat: true,
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
passwordRepeat: ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created() {
|
||||||
|
this.hasAdminUI = '{{ LNBITS_ADMIN_UI | tojson}}'
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
checkPasswordsMatch() {
|
||||||
|
return this.loginData.password !== this.loginData.passwordRepeat
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async setPassword() {
|
||||||
|
try {
|
||||||
|
await LNbits.api.request('PUT', '/api/v1/auth/first_install', null, {
|
||||||
|
username: this.loginData.username,
|
||||||
|
password: this.loginData.password,
|
||||||
|
password_repeat: this.loginData.passwordRepeat
|
||||||
|
})
|
||||||
|
|
||||||
|
window.location.href = '/admin'
|
||||||
|
} catch (e) {
|
||||||
|
LNbits.utils.notifyApiError(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -1,11 +1,26 @@
|
|||||||
{% extends "public.html" %} {% block scripts %}
|
{% 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 %}
|
{% endblock %} {% block page %}
|
||||||
<div class="row q-col-gutter-md justify-center">
|
<div class="row q-col-gutter-md justify-center">
|
||||||
<div class="col-12 col-md-7 col-lg-6 q-gutter-y-md">
|
<div
|
||||||
|
v-if="isUserAuthorized"
|
||||||
|
class="col-12 col-md-6 col-lg-6 q-gutter-y-md"
|
||||||
|
></div>
|
||||||
|
<div v-else class="col-12 col-md-4 col-lg-4 q-gutter-y-md">
|
||||||
|
<div class="gt-sm">
|
||||||
|
<h3 class="q-my-none" v-if="'{{SITE_TITLE}}' == 'LNbits'">
|
||||||
|
{{SITE_TITLE}}
|
||||||
|
</h3>
|
||||||
|
<h5 class="q-my-md" v-if="'{{SITE_TITLE}}' == 'LNbits'">
|
||||||
|
{{SITE_TAGLINE}}
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
{% if lnurl and LNBITS_NEW_ACCOUNTS_ALLOWED and ("user-id-only" in
|
||||||
|
LNBITS_AUTH_METHODS)%}
|
||||||
|
<div class="row q-mt-xl">
|
||||||
|
<div class="col-12 col-md-8 col-lg-7 q-gutter-y-md">
|
||||||
<q-card>
|
<q-card>
|
||||||
<q-card-section>
|
<q-card-section>
|
||||||
{% if lnurl %}
|
|
||||||
<q-btn
|
<q-btn
|
||||||
unelevated
|
unelevated
|
||||||
color="primary"
|
color="primary"
|
||||||
@@ -13,8 +28,160 @@
|
|||||||
type="a"
|
type="a"
|
||||||
href="{{ url_for('core.lnurlwallet') }}?lightning={{ lnurl }}"
|
href="{{ url_for('core.lnurlwallet') }}?lightning={{ lnurl }}"
|
||||||
v-text="$t('press_to_claim')"
|
v-text="$t('press_to_claim')"
|
||||||
|
class="full-width"
|
||||||
></q-btn>
|
></q-btn>
|
||||||
{% else %}
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{%else%} {% endif %}
|
||||||
|
|
||||||
|
<div class="row q-mt-md">
|
||||||
|
<div class="col-12 col-md-10 col-lg-10 q-gutter-y-md">
|
||||||
|
<q-badge v-if="isAccessTokenExpired" color="primary" rounded>
|
||||||
|
<div class="text-h5">
|
||||||
|
<span v-text="$t('session_has_expired')"></span>
|
||||||
|
</div>
|
||||||
|
</q-badge>
|
||||||
|
<q-card class="shadow-12">
|
||||||
|
{% if "user-id-only" in LNBITS_AUTH_METHODS %}
|
||||||
|
<q-card-section>
|
||||||
|
<div class="text-h6">
|
||||||
|
<span v-text="$t('instant_access_question')"></span>
|
||||||
|
<br />
|
||||||
|
<q-badge
|
||||||
|
@click="showLogin('user-id-only')"
|
||||||
|
color="primary"
|
||||||
|
class="cursor-pointer"
|
||||||
|
rounded
|
||||||
|
>
|
||||||
|
<strong>
|
||||||
|
<q-icon name="account_circle" size="xs"></q-icon>
|
||||||
|
<span v-text="$t('login_with_user_id')"></span> </strong
|
||||||
|
></q-badge>
|
||||||
|
{% if LNBITS_NEW_ACCOUNTS_ALLOWED %}
|
||||||
|
<span v-text="$t('or')"></span>
|
||||||
|
<q-badge
|
||||||
|
@click="showRegister('user-id-only')"
|
||||||
|
color="primary"
|
||||||
|
class="cursor-pointer"
|
||||||
|
rounded
|
||||||
|
>
|
||||||
|
<strong>
|
||||||
|
<q-icon name="add" size="xs"></q-icon>
|
||||||
|
<span v-text="$t('create_new_wallet')"></span>
|
||||||
|
</strong>
|
||||||
|
</q-badge>
|
||||||
|
{%endif%}
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
<q-separator></q-separator>
|
||||||
|
<q-card-section
|
||||||
|
v-if="authAction === 'login' && authMethod === 'user-id-only'"
|
||||||
|
>
|
||||||
|
<b> <span v-text="$t('login_with_user_id')"></span> </b><br /><br />
|
||||||
|
<q-form @submit="loginUsr" class="q-gutter-md">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model="usr"
|
||||||
|
label="usr"
|
||||||
|
type="password"
|
||||||
|
></q-input>
|
||||||
|
<div>
|
||||||
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
color="primary"
|
||||||
|
:disable="usr == ''"
|
||||||
|
type="submit"
|
||||||
|
label="Login"
|
||||||
|
class="full-width"
|
||||||
|
></q-btn>
|
||||||
|
</div>
|
||||||
|
</q-form>
|
||||||
|
</q-card-section>
|
||||||
|
{%endif%} {% if "username-password" in LNBITS_AUTH_METHODS %}
|
||||||
|
<q-card-section
|
||||||
|
v-if="authAction === 'login' && authMethod === 'username-password'"
|
||||||
|
>
|
||||||
|
<div class="q-mb-lg">
|
||||||
|
<strong>
|
||||||
|
<span v-text="$t('login_to_account')"></span>
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<q-form @submit="login" class="q-gutter-md">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model="username"
|
||||||
|
name="username"
|
||||||
|
:label="$t('username_or_email') + ' *'"
|
||||||
|
></q-input>
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model="password"
|
||||||
|
name="password"
|
||||||
|
:label="$t('password') + ' *'"
|
||||||
|
type="password"
|
||||||
|
></q-input>
|
||||||
|
<div>
|
||||||
|
<q-btn
|
||||||
|
:disable="!username || !password"
|
||||||
|
color="primary"
|
||||||
|
type="submit"
|
||||||
|
:label="$t('login')"
|
||||||
|
class="full-width"
|
||||||
|
></q-btn>
|
||||||
|
</div>
|
||||||
|
</q-form>
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-section
|
||||||
|
v-if="authAction === 'register' && authMethod === 'username-password'"
|
||||||
|
>
|
||||||
|
<b> <span v-text="$t('create_account')"></span> </b><br /><br />
|
||||||
|
<q-form @submit="register" class="q-gutter-md">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
required
|
||||||
|
v-model="username"
|
||||||
|
:label="$t('username') + ' *'"
|
||||||
|
:rules="[(val) => validateUsername(val) || $t('invalid_username')]"
|
||||||
|
></q-input>
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model="password"
|
||||||
|
:label="$t('password') + ' *'"
|
||||||
|
type="password"
|
||||||
|
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password')]"
|
||||||
|
></q-input>
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model="passwordRepeat"
|
||||||
|
:label="$t('password_repeat') + ' *'"
|
||||||
|
type="password"
|
||||||
|
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password')]"
|
||||||
|
></q-input>
|
||||||
|
<div>
|
||||||
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
color="primary"
|
||||||
|
:disable="!password || !passwordRepeat|| !username || (password !== passwordRepeat)"
|
||||||
|
type="submit"
|
||||||
|
class="full-width"
|
||||||
|
:label="$t('create_account')"
|
||||||
|
></q-btn>
|
||||||
|
</div>
|
||||||
|
</q-form>
|
||||||
|
</q-card-section>
|
||||||
|
{%endif%} {% if LNBITS_NEW_ACCOUNTS_ALLOWED %}
|
||||||
|
<q-card-section
|
||||||
|
v-if="authAction === 'register' && authMethod === 'user-id-only'"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
<q-form @submit="createWallet" class="q-gutter-md">
|
<q-form @submit="createWallet" class="q-gutter-md">
|
||||||
<q-input
|
<q-input
|
||||||
filled
|
filled
|
||||||
@@ -22,33 +189,143 @@
|
|||||||
v-model="walletName"
|
v-model="walletName"
|
||||||
:label='$t("name_your_wallet", { name: "{{ SITE_TITLE }} *" })'
|
:label='$t("name_your_wallet", { name: "{{ SITE_TITLE }} *" })'
|
||||||
></q-input>
|
></q-input>
|
||||||
|
<div>
|
||||||
<q-btn
|
<q-btn
|
||||||
unelevated
|
|
||||||
color="primary"
|
color="primary"
|
||||||
:disable="walletName == ''"
|
:disable="walletName == ''"
|
||||||
type="submit"
|
type="submit"
|
||||||
:label="$t('add_wallet')"
|
:label="$t('add_wallet')"
|
||||||
|
class="full-width"
|
||||||
></q-btn>
|
></q-btn>
|
||||||
|
</div>
|
||||||
</q-form>
|
</q-form>
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
{% if "username-password" in LNBITS_AUTH_METHODS %}
|
||||||
|
<q-card-section
|
||||||
|
v-if="authAction === 'login' && authMethod === 'username-password'"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<q-btn
|
||||||
|
color="grey"
|
||||||
|
outline
|
||||||
|
:label="$t('register')"
|
||||||
|
class="full-width"
|
||||||
|
@click="showRegister('username-password')"
|
||||||
|
></q-btn>
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
{%endif%}
|
{%endif%}
|
||||||
|
<q-separator></q-separator>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<q-card-section
|
||||||
|
v-if="authAction === 'login' && authMethod === 'username-password'"
|
||||||
|
>
|
||||||
|
<div class="row">
|
||||||
|
{% if "google-auth" in LNBITS_AUTH_METHODS %}
|
||||||
|
<div class="col-12 full-width q-pa-sm">
|
||||||
|
<q-btn
|
||||||
|
href="/api/v1/auth/google"
|
||||||
|
type="a"
|
||||||
|
outline
|
||||||
|
no-caps
|
||||||
|
rounded
|
||||||
|
color="grey"
|
||||||
|
class="full-width"
|
||||||
|
>
|
||||||
|
<q-avatar size="32px" class="q-mr-md">
|
||||||
|
<q-img
|
||||||
|
:src="'{{ static_url_for('static', 'images/google-logo.png') }}'"
|
||||||
|
></q-img>
|
||||||
|
</q-avatar>
|
||||||
|
<div>
|
||||||
|
<span v-text="$t('signin_with_google')"></span>
|
||||||
|
</div>
|
||||||
|
</q-btn>
|
||||||
|
</div>
|
||||||
|
{%endif%} {% if "github-auth" in LNBITS_AUTH_METHODS %}
|
||||||
|
<div class="col-12 full-width q-pa-sm">
|
||||||
|
<q-btn
|
||||||
|
href="/api/v1/auth/github"
|
||||||
|
type="a"
|
||||||
|
outline
|
||||||
|
no-caps
|
||||||
|
color="grey"
|
||||||
|
rounded
|
||||||
|
class="full-width"
|
||||||
|
>
|
||||||
|
<q-avatar size="32px" class="q-mr-md">
|
||||||
|
<q-img
|
||||||
|
:src="'{{ static_url_for('static', 'images/github-logo.png') }}'"
|
||||||
|
></q-img>
|
||||||
|
</q-avatar>
|
||||||
|
<div><span v-text="$t('signin_with_github')"></span></div>
|
||||||
|
</q-btn>
|
||||||
|
</div>
|
||||||
|
{%endif%} {% if "keycloak-auth" in LNBITS_AUTH_METHODS %}
|
||||||
|
<div class="col-12 full-width q-pa-sm">
|
||||||
|
<q-btn
|
||||||
|
href="/api/v1/auth/keycloak"
|
||||||
|
type="a"
|
||||||
|
outline
|
||||||
|
no-caps
|
||||||
|
color="grey"
|
||||||
|
rounded
|
||||||
|
class="full-width"
|
||||||
|
>
|
||||||
|
<q-avatar size="32px" class="q-mr-md">
|
||||||
|
<q-img
|
||||||
|
:src="'{{ static_url_for('static', 'images/keycloak-logo.png') }}'"
|
||||||
|
></q-img>
|
||||||
|
</q-avatar>
|
||||||
|
<div><span v-text="$t('signin_with_keycloak')"></span></div>
|
||||||
|
</q-btn>
|
||||||
|
</div>
|
||||||
|
{%endif%}
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-section v-else>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col q-pa-sm">
|
||||||
|
<q-btn
|
||||||
|
@click="showLogin('username-password')"
|
||||||
|
:label="$t('back')"
|
||||||
|
outline
|
||||||
|
rounded
|
||||||
|
color="grey"
|
||||||
|
class="full-width"
|
||||||
|
>
|
||||||
|
</q-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-card>
|
</q-card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<q-card>
|
<div
|
||||||
<q-card-section>
|
v-if="'{{SITE_TITLE}}' != 'LNbits'"
|
||||||
|
class="col-12 col-md-5 col-lg-5 q-pt-xl"
|
||||||
|
>
|
||||||
<h3 class="q-my-none">{{SITE_TITLE}}</h3>
|
<h3 class="q-my-none">{{SITE_TITLE}}</h3>
|
||||||
<h5 class="q-my-md">{{SITE_TAGLINE}}</h5>
|
<h5 class="q-my-md">{{SITE_TAGLINE}}</h5>
|
||||||
<div v-if="'{{SITE_TITLE}}' == 'LNbits'">
|
<div v-html="formatDescription"></div>
|
||||||
<p v-text="$t('lnbits_description')"></p>
|
</div>
|
||||||
<div class="row q-mt-md q-gutter-sm">
|
|
||||||
|
<div class="col-12 col-md-3 col-lg-3 gt-sm" v-else>
|
||||||
|
<div class="row q-col-gutter-lg justify-center">
|
||||||
|
<div class="col-6 col-sm-4 col-md-8 q-gutter-y-sm">
|
||||||
<q-btn
|
<q-btn
|
||||||
outline
|
outline
|
||||||
color="grey"
|
color="grey"
|
||||||
type="a"
|
type="a"
|
||||||
href="https://github.com/lnbits/lnbits"
|
href="https://github.com/lnbits/lnbits"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener"
|
rel="noopener noreferrer"
|
||||||
:label="$t('view_github')"
|
:label="$t('view_github')"
|
||||||
|
class="full-width"
|
||||||
></q-btn>
|
></q-btn>
|
||||||
<q-btn
|
<q-btn
|
||||||
outline
|
outline
|
||||||
@@ -56,19 +333,11 @@
|
|||||||
type="a"
|
type="a"
|
||||||
href="https://legend.lnbits.com/paywall/GAqKguK5S8f6w5VNjS9DfK"
|
href="https://legend.lnbits.com/paywall/GAqKguK5S8f6w5VNjS9DfK"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener"
|
rel="noopener noreferrer"
|
||||||
:label="$t('donate')"
|
:label="$t('donate')"
|
||||||
|
class="full-width q-mb-lg"
|
||||||
></q-btn>
|
></q-btn>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<p v-else>{{SITE_DESCRIPTION | safe}}</p>
|
|
||||||
</q-card-section>
|
|
||||||
</q-card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Ads -->
|
|
||||||
<div class="col-12 col-md-3 col-lg-3" v-if="'{{SITE_TITLE}}' == 'LNbits'">
|
|
||||||
<div class="row q-col-gutter-lg justify-center">
|
|
||||||
<div class="col-6 col-sm-4 col-md-8 q-gutter-y-sm">
|
<div class="col-6 col-sm-4 col-md-8 q-gutter-y-sm">
|
||||||
<q-btn
|
<q-btn
|
||||||
flat
|
flat
|
||||||
@@ -78,18 +347,26 @@
|
|||||||
></q-btn>
|
></q-btn>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<a href="https://github.com/ElementsProject/lightning">
|
<a
|
||||||
|
href="https://github.com/ElementsProject/lightning"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
<q-img
|
<q-img
|
||||||
contain
|
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>
|
></q-img>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="col q-pl-md">
|
<div class="col q-pl-md">
|
||||||
<a href="https://github.com/lightningnetwork/lnd">
|
<a
|
||||||
|
href="https://github.com/lightningnetwork/lnd"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
<q-img
|
<q-img
|
||||||
contain
|
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>
|
></q-img>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -97,18 +374,26 @@
|
|||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<a href="https://opennode.com">
|
<a
|
||||||
|
href="https://opennode.com"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
<q-img
|
<q-img
|
||||||
contain
|
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>
|
></q-img>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="col q-pl-md">
|
<div class="col q-pl-md">
|
||||||
<a href="https://lnpay.co/">
|
<a
|
||||||
|
href="https://lnpay.co/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
<q-img
|
<q-img
|
||||||
contain
|
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>
|
></q-img>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -116,77 +401,125 @@
|
|||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<a href="https://github.com/rootzoll/raspiblitz">
|
<a
|
||||||
|
href="https://github.com/rootzoll/raspiblitz"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
<q-img
|
<q-img
|
||||||
contain
|
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>
|
></q-img>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="col q-pl-md">
|
<div class="col q-pl-md">
|
||||||
<a href="https://start9.com/">
|
<a
|
||||||
|
href="https://start9.com/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
<q-img
|
<q-img
|
||||||
contain
|
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>
|
></q-img>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<a href="https://getumbrel.com/">
|
<a
|
||||||
|
href="https://getumbrel.com/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
<q-img
|
<q-img
|
||||||
contain
|
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>
|
></q-img>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="col q-pl-md">
|
<div class="col q-pl-md">
|
||||||
<a href="https://mynodebtc.com">
|
<a
|
||||||
|
href="https://mynodebtc.com"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
<q-img
|
<q-img
|
||||||
contain
|
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>
|
></q-img>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<a href="https://github.com/shesek/spark-wallet">
|
<a
|
||||||
|
href="https://github.com/shesek/spark-wallet"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
<q-img
|
<q-img
|
||||||
contain
|
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>
|
></q-img>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="col q-pl-md">
|
<div class="col q-pl-md">
|
||||||
<a href="https://voltage.cloud">
|
<a
|
||||||
|
href="https://voltage.cloud"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
<q-img
|
<q-img
|
||||||
contain
|
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>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col">
|
||||||
|
<a
|
||||||
|
href="https://getalby.com"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<q-img
|
||||||
|
contain
|
||||||
|
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/alby.png') }}' : '{{ static_url_for('static', 'images/albyl.png') }}'"
|
||||||
|
></q-img>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="col q-pl-md">
|
||||||
|
<a href="https://zbd.gg" target="_blank" rel="noopener noreferrer">
|
||||||
|
<q-img
|
||||||
|
contain
|
||||||
|
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/zbd.png') }}' : '{{ static_url_for('static', 'images/zbdl.png') }}'"
|
||||||
></q-img>
|
></q-img>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{% if AD_SPACE %} {% for ADS in AD_SPACE %} {% set AD = ADS.split(';') %}
|
{% if AD_SPACE %} {% for ADS in AD_SPACE %} {% set AD = ADS.split(';') %}
|
||||||
<div class="col-6 col-sm-4 col-md-8 q-gutter-y-sm">
|
<div class="col-6 col-sm-4 col-md-8 q-gutter-y-sm">
|
||||||
<q-btn flat color="secondary" class="full-width q-mb-md"
|
<q-btn flat color="secondary" class="full-width q-mb-md"
|
||||||
>{{ AD_TITLE }}</q-btn
|
>{{ AD_SPACE_TITLE }}</q-btn
|
||||||
>
|
>
|
||||||
|
|
||||||
<a href="{{ AD[0] }}" class="q-ma-md">
|
<a href="{{ AD[0] }}" class="q-ma-md">
|
||||||
<img
|
<img v-if="($q.dark.isActive)" src="{{ AD[1] }}" style="max-width: 90%" />
|
||||||
v-if="($q.dark.isActive)"
|
|
||||||
src="{{ AD[1] }}"
|
|
||||||
style="max-width: 90%"
|
|
||||||
/>
|
|
||||||
<img v-else src="{{ AD[2] }}" style="max-width: 90%" />
|
<img v-else src="{{ AD[2] }}" style="max-width: 90%" />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %} {% endif %}
|
{% endfor %} {% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="'{{SITE_TITLE}}' == 'LNbits'" class="row gt-sm q-mt-xl">
|
||||||
|
<div class="col-1"></div>
|
||||||
|
<div class="col-10 q-pl-xl">
|
||||||
|
<span v-text="$t('lnbits_description')"></span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="col-1"></div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
{% from "macros.jinja" import window_vars with context %}
|
{% from "macros.jinja" import window_vars with context %}
|
||||||
<!---->
|
<!---->
|
||||||
{% block scripts %} {{ window_vars(user, wallet) }}
|
{% 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 %}
|
{% endblock %}
|
||||||
<!---->
|
<!---->
|
||||||
{% block title %} {{ wallet.name }} - {{ SITE_TITLE }} {% endblock %}
|
{% block title %} {{ wallet.name }} - {{ SITE_TITLE }} {% endblock %}
|
||||||
@@ -15,46 +15,58 @@
|
|||||||
{% elif HIDE_API %}
|
{% elif HIDE_API %}
|
||||||
<div class="col-12 q-gutter-y-md">
|
<div class="col-12 q-gutter-y-md">
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="col-12 col-md-7 q-gutter-y-md">
|
<div
|
||||||
|
class="col-12 col-md-7 q-gutter-y-md"
|
||||||
|
:style="$q.screen.lt.md ? {
|
||||||
|
position: mobileSimple ? 'fixed !important': ''
|
||||||
|
, top: mobileSimple ? '50% !important': ''
|
||||||
|
, left: mobileSimple ? '50% !important': ''
|
||||||
|
, transform: mobileSimple ? 'translate(-50%, -50%) !important': ''
|
||||||
|
} : ''"
|
||||||
|
>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<q-card>
|
<q-card
|
||||||
|
:style="$q.screen.lt.md ? {
|
||||||
|
background: $q.screen.lt.md ? 'none !important': ''
|
||||||
|
, boxShadow: $q.screen.lt.md ? 'none !important': ''
|
||||||
|
, margin: $q.screen.lt.md && mobileSimple ? 'auto !important': ''
|
||||||
|
, width: $q.screen.lt.md && mobileSimple ? '90% !important': ''
|
||||||
|
} : ''"
|
||||||
|
>
|
||||||
<q-card-section>
|
<q-card-section>
|
||||||
<h3 class="q-my-none">
|
<h3 class="q-my-none text-no-wrap">
|
||||||
<strong>{% raw %}{{ formattedBalance }} {% endraw %}</strong>
|
<strong v-text="formattedBalance"></strong>
|
||||||
{{LNBITS_DENOMINATION}}
|
<small>{{LNBITS_DENOMINATION}}</small>
|
||||||
<q-btn
|
<lnbits-update-balance
|
||||||
v-if="'{{user.super_user}}' == 'True'"
|
v-if="'{{user.super_user}}' == 'True'"
|
||||||
|
:wallet_id="this.g.wallet.id"
|
||||||
flat
|
flat
|
||||||
|
:callback="updateBalanceCallback"
|
||||||
round
|
round
|
||||||
color="primary"
|
/>
|
||||||
icon="add"
|
|
||||||
size="md"
|
|
||||||
>
|
|
||||||
<q-popup-edit
|
|
||||||
class="bg-accent text-white"
|
|
||||||
v-slot="scope"
|
|
||||||
v-model="credit"
|
|
||||||
>
|
|
||||||
<q-input
|
|
||||||
filled
|
|
||||||
:label='$t("credit_label", { denomination: "{{LNBITS_DENOMINATION}}"})'
|
|
||||||
:hint="$t('credit_hint')"
|
|
||||||
v-model="scope.value"
|
|
||||||
dense
|
|
||||||
autofocus
|
|
||||||
:mask="'{{LNBITS_DENOMINATION}}' != 'sats' ? '#.##' : '#'"
|
|
||||||
fill-mask="0"
|
|
||||||
reverse-fill-mask
|
|
||||||
:step="'{{LNBITS_DENOMINATION}}' != 'sats' ? '0.01' : '1'"
|
|
||||||
@keyup.enter="updateBalance(scope.value)"
|
|
||||||
>
|
|
||||||
<template v-slot:append>
|
|
||||||
<q-icon name="edit" />
|
|
||||||
</template>
|
|
||||||
</q-input>
|
|
||||||
</q-popup-edit>
|
|
||||||
</q-btn>
|
|
||||||
</h3>
|
</h3>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col">
|
||||||
|
<div v-if="g.wallet.currency">
|
||||||
|
<span
|
||||||
|
class="text-h5 text-italic"
|
||||||
|
v-text="formattedFiatBalance"
|
||||||
|
style="opacity: 0.75"
|
||||||
|
></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<q-btn
|
||||||
|
@click="mobileSimple = !mobileSimple"
|
||||||
|
color="primary"
|
||||||
|
class="float-right lt-md"
|
||||||
|
size="sm"
|
||||||
|
flat
|
||||||
|
:icon="mobileSimple ? 'unfold_more' : 'unfold_less'"
|
||||||
|
:label="mobileSimple ? $t('more') : $t('less')"
|
||||||
|
></q-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
<div class="row q-pb-md q-px-md q-col-gutter-md gt-sm">
|
<div class="row q-pb-md q-px-md q-col-gutter-md gt-sm">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
@@ -84,14 +96,20 @@
|
|||||||
:label="$t('scan')"
|
:label="$t('scan')"
|
||||||
>
|
>
|
||||||
<q-tooltip
|
<q-tooltip
|
||||||
>{% raw %}{{$t('camera_tooltip')}}{% endraw %}</q-tooltip
|
><span v-text="$t('camera_tooltip')"></span
|
||||||
>
|
></q-tooltip>
|
||||||
</q-btn>
|
</q-btn>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</q-card>
|
</q-card>
|
||||||
|
|
||||||
<q-card>
|
<q-card
|
||||||
|
:style="$q.screen.lt.md ? {
|
||||||
|
background: $q.screen.lt.md ? 'none !important': ''
|
||||||
|
, boxShadow: $q.screen.lt.md ? 'none !important': ''
|
||||||
|
, marginTop: $q.screen.lt.md ? '0px !important': ''
|
||||||
|
} : ''"
|
||||||
|
>
|
||||||
<q-card-section>
|
<q-card-section>
|
||||||
<div class="row items-center no-wrap q-mb-sm">
|
<div class="row items-center no-wrap q-mb-sm">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
@@ -100,16 +118,13 @@
|
|||||||
:v-text="$t('transactions')"
|
:v-text="$t('transactions')"
|
||||||
></h5>
|
></h5>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-auto">
|
<div class="gt-sm col-auto">
|
||||||
<q-btn
|
<q-btn
|
||||||
flat
|
flat
|
||||||
color="grey"
|
color="grey"
|
||||||
@click="exportCSV"
|
@click="exportCSV"
|
||||||
:label="$t('export_csv')"
|
:label="$t('export_csv')"
|
||||||
></q-btn>
|
></q-btn>
|
||||||
<!--<q-btn v-if="pendingPaymentsExist" dense flat round icon="update" color="grey" @click="checkPendingPayments">
|
|
||||||
<q-tooltip>Check pending</q-tooltip>
|
|
||||||
</q-btn>-->
|
|
||||||
<q-btn
|
<q-btn
|
||||||
dense
|
dense
|
||||||
flat
|
flat
|
||||||
@@ -118,17 +133,20 @@
|
|||||||
color="grey"
|
color="grey"
|
||||||
@click="showChart"
|
@click="showChart"
|
||||||
>
|
>
|
||||||
<q-tooltip
|
<q-tooltip>
|
||||||
>{% raw %}{{$t('chart_tooltip')}}{% endraw %}</q-tooltip
|
<span v-text="$t('chart_tooltip')"></span
|
||||||
>
|
></q-tooltip>
|
||||||
</q-btn>
|
</q-btn>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<q-input
|
<q-input
|
||||||
|
:style="$q.screen.lt.md ? {
|
||||||
|
display: mobileSimple ? 'none !important': ''
|
||||||
|
} : ''"
|
||||||
filled
|
filled
|
||||||
dense
|
dense
|
||||||
clearable
|
clearable
|
||||||
v-model="paymentsTable.filter"
|
v-model="paymentsTable.search"
|
||||||
debounce="300"
|
debounce="300"
|
||||||
:placeholder="$t('search_by_tag_memo_amount')"
|
:placeholder="$t('search_by_tag_memo_amount')"
|
||||||
class="q-mb-md"
|
class="q-mb-md"
|
||||||
@@ -137,22 +155,26 @@
|
|||||||
<q-table
|
<q-table
|
||||||
dense
|
dense
|
||||||
flat
|
flat
|
||||||
:data="payments"
|
:data="paymentsOmitter"
|
||||||
:row-key="paymentTableRowKey"
|
:row-key="paymentTableRowKey"
|
||||||
:columns="paymentsTable.columns"
|
:columns="paymentsTable.columns"
|
||||||
:pagination.sync="paymentsTable.pagination"
|
:pagination.sync="paymentsTable.pagination"
|
||||||
:no-data-label="$t('no_transactions')"
|
:no-data-label="$t('no_transactions')"
|
||||||
:filter="paymentsTable.filter"
|
:filter="paymentsTable.search"
|
||||||
:loading="paymentsTable.loading"
|
:loading="paymentsTable.loading"
|
||||||
|
:hide-header="mobileSimple"
|
||||||
|
:hide-bottom="mobileSimple"
|
||||||
@request="fetchPayments"
|
@request="fetchPayments"
|
||||||
>
|
>
|
||||||
{% raw %}
|
|
||||||
<template v-slot:header="props">
|
<template v-slot:header="props">
|
||||||
<q-tr :props="props">
|
<q-tr :props="props">
|
||||||
<q-th auto-width></q-th>
|
<q-th auto-width></q-th>
|
||||||
<q-th v-for="col in props.cols" :key="col.name" :props="props"
|
<q-th
|
||||||
>{{ col.label }}</q-th
|
v-for="col in props.cols"
|
||||||
>
|
:key="col.name"
|
||||||
|
:props="props"
|
||||||
|
v-text="col.label"
|
||||||
|
></q-th>
|
||||||
</q-tr>
|
</q-tr>
|
||||||
</template>
|
</template>
|
||||||
<template v-slot:body="props">
|
<template v-slot:body="props">
|
||||||
@@ -171,7 +193,9 @@
|
|||||||
color="grey"
|
color="grey"
|
||||||
@click="props.expand = !props.expand"
|
@click="props.expand = !props.expand"
|
||||||
>
|
>
|
||||||
<q-tooltip>{{$t('pending')}}</q-tooltip>
|
<q-tooltip
|
||||||
|
><span v-text="$t('pending')"></span
|
||||||
|
></q-tooltip>
|
||||||
</q-icon>
|
</q-icon>
|
||||||
</q-td>
|
</q-td>
|
||||||
<q-td
|
<q-td
|
||||||
@@ -185,47 +209,47 @@
|
|||||||
text-color="black"
|
text-color="black"
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
|
v-text="'#'+props.row.tag"
|
||||||
class="inherit"
|
class="inherit"
|
||||||
:href="['/', props.row.tag, '/?usr=', user.id].join('')"
|
:href="['/', props.row.tag].join('')"
|
||||||
>
|
></a>
|
||||||
#{{ props.row.tag }}
|
|
||||||
</a>
|
|
||||||
</q-badge>
|
</q-badge>
|
||||||
{{ props.row.memo }}
|
<span v-text="props.row.memo"></span>
|
||||||
<br />
|
<br />
|
||||||
|
|
||||||
<i>
|
<i>
|
||||||
{{ props.row.dateFrom }}<q-tooltip
|
<span v-text="props.row.dateFrom"></span>
|
||||||
>{{ props.row.date }}</q-tooltip
|
<q-tooltip
|
||||||
></i
|
><span v-text="props.row.date"></span
|
||||||
>
|
></q-tooltip>
|
||||||
|
</i>
|
||||||
</q-td>
|
</q-td>
|
||||||
|
|
||||||
{% endraw %}
|
|
||||||
<q-td
|
<q-td
|
||||||
auto-width
|
auto-width
|
||||||
key="amount"
|
key="amount"
|
||||||
v-if="'{{LNBITS_DENOMINATION}}' != 'sats'"
|
v-if="'{{LNBITS_DENOMINATION}}' != 'sats'"
|
||||||
:props="props"
|
:props="props"
|
||||||
>{% raw %} {{
|
v-text="parseFloat(String(props.row.fsat).replaceAll(',', '')) / 100"
|
||||||
parseFloat(String(props.row.fsat).replaceAll(",", "")) / 100
|
>
|
||||||
}}
|
|
||||||
</q-td>
|
</q-td>
|
||||||
<q-td auto-width key="amount" v-else :props="props">
|
<q-td auto-width key="amount" v-else :props="props">
|
||||||
{{ props.row.fsat }}<br />
|
<span v-text="props.row.fsat"></span>
|
||||||
|
<br />
|
||||||
<i v-if="props.row.extra.wallet_fiat_currency">
|
<i v-if="props.row.extra.wallet_fiat_currency">
|
||||||
{{ props.row.extra.wallet_fiat_currency }} {{
|
<span
|
||||||
props.row.extra.wallet_fiat_amount }}
|
v-text="formatFiat(props.row.extra.wallet_fiat_currency, props.row.extra.wallet_fiat_amount)"
|
||||||
|
></span>
|
||||||
<br />
|
<br />
|
||||||
</i>
|
</i>
|
||||||
<i v-if="props.row.extra.fiat_currency">
|
<i v-if="props.row.extra.fiat_currency">
|
||||||
{{ props.row.extra.fiat_currency }} {{
|
<span
|
||||||
props.row.extra.fiat_amount }}
|
v-text="formatFiat(props.row.extra.fiat_currency, props.row.extra.fiat_amount)"
|
||||||
|
></span>
|
||||||
</i>
|
</i>
|
||||||
</q-td>
|
</q-td>
|
||||||
</q-tr>
|
</q-tr>
|
||||||
|
|
||||||
<q-dialog v-model="props.expand" :props="props">
|
<q-dialog v-model="props.expand" :props="props" position="top">
|
||||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||||
<div class="text-center q-mb-lg">
|
<div class="text-center q-mb-lg">
|
||||||
<div v-if="props.row.isIn && props.row.pending">
|
<div v-if="props.row.isIn && props.row.pending">
|
||||||
@@ -240,11 +264,9 @@
|
|||||||
>
|
>
|
||||||
<a :href="'lightning:' + props.row.bolt11">
|
<a :href="'lightning:' + props.row.bolt11">
|
||||||
<q-responsive :ratio="1" class="q-mx-xl">
|
<q-responsive :ratio="1" class="q-mx-xl">
|
||||||
<qrcode
|
<lnbits-qrcode
|
||||||
:value="'lightning:' + props.row.bolt11.toUpperCase()"
|
:value="'lightning:' + props.row.bolt11.toUpperCase()"
|
||||||
:options="{width: 340}"
|
></lnbits-qrcode>
|
||||||
class="rounded-borders"
|
|
||||||
></qrcode>
|
|
||||||
</q-responsive>
|
</q-responsive>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -297,16 +319,17 @@
|
|||||||
</q-card>
|
</q-card>
|
||||||
</q-dialog>
|
</q-dialog>
|
||||||
</template>
|
</template>
|
||||||
{% endraw %}
|
|
||||||
</q-table>
|
</q-table>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-card>
|
</q-card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if HIDE_API %}
|
{% if HIDE_API %}
|
||||||
<div class="col-12 col-md-4 q-gutter-y-md">
|
<div class="col-12 col-md-4 q-gutter-y-md">
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="col-12 col-md-5 q-gutter-y-md">
|
<div
|
||||||
|
v-if="!mobileSimple || $q.screen.gt.sm"
|
||||||
|
class="col-12 col-md-5 q-gutter-y-md"
|
||||||
|
>
|
||||||
<q-card>
|
<q-card>
|
||||||
<q-card-section>
|
<q-card-section>
|
||||||
<h6 class="text-subtitle1 q-mt-none q-mb-sm">
|
<h6 class="text-subtitle1 q-mt-none q-mb-sm">
|
||||||
@@ -318,9 +341,6 @@
|
|||||||
<q-separator></q-separator>
|
<q-separator></q-separator>
|
||||||
|
|
||||||
<q-list>
|
<q-list>
|
||||||
{% include "core/_api_docs.html" %}
|
|
||||||
<q-separator></q-separator>
|
|
||||||
|
|
||||||
{% if wallet.lnurlwithdraw_full %}
|
{% if wallet.lnurlwithdraw_full %}
|
||||||
<q-expansion-item
|
<q-expansion-item
|
||||||
group="extras"
|
group="extras"
|
||||||
@@ -330,12 +350,11 @@
|
|||||||
<q-card>
|
<q-card>
|
||||||
<q-card-section class="text-center">
|
<q-card-section class="text-center">
|
||||||
<a href="lightning:{{wallet.lnurlwithdraw_full}}">
|
<a href="lightning:{{wallet.lnurlwithdraw_full}}">
|
||||||
<qrcode
|
<lnbits-qrcode
|
||||||
value="lightning:{{wallet.lnurlwithdraw_full}}"
|
:value="lightning:{{wallet.lnurlwithdraw_full}}"
|
||||||
:options="{width:240}"
|
></lnbits-qrcode>
|
||||||
></qrcode>
|
|
||||||
</a>
|
</a>
|
||||||
<p v-text="$('drain_funds_desc')"></p>
|
<p v-text="$t('drain_funds_desc')"></p>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-card>
|
</q-card>
|
||||||
</q-expansion-item>
|
</q-expansion-item>
|
||||||
@@ -351,7 +370,7 @@
|
|||||||
<q-card-section class="text-center">
|
<q-card-section class="text-center">
|
||||||
<p v-text="$t('export_to_phone_desc')"></p>
|
<p v-text="$t('export_to_phone_desc')"></p>
|
||||||
<qrcode
|
<qrcode
|
||||||
:value="'{{request.base_url}}' +'wallet?usr={{user.id}}&wal={{wallet.id}}'"
|
:value="'{{request.base_url}}wallet?usr={{user.id}}&wal={{wallet.id}}'"
|
||||||
:options="{width:240}"
|
:options="{width:240}"
|
||||||
></qrcode>
|
></qrcode>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
@@ -428,12 +447,15 @@
|
|||||||
<q-btn
|
<q-btn
|
||||||
unelevated
|
unelevated
|
||||||
color="red-10"
|
color="red-10"
|
||||||
@click="deleteWallet('{{ wallet.id }}', '{{ user.id }}')"
|
@click="deleteWallet()"
|
||||||
:label="$t('delete_wallet')"
|
:label="$t('delete_wallet')"
|
||||||
></q-btn>
|
></q-btn>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-card>
|
</q-card>
|
||||||
</q-expansion-item>
|
</q-expansion-item>
|
||||||
|
|
||||||
|
<q-separator></q-separator>
|
||||||
|
{% include "core/_api_docs.html" %}
|
||||||
</q-list>
|
</q-list>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-card>
|
</q-card>
|
||||||
@@ -466,22 +488,21 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<q-dialog v-model="receive.show">
|
<q-dialog v-model="receive.show" position="top">
|
||||||
{% raw %}
|
|
||||||
<q-card
|
<q-card
|
||||||
v-if="!receive.paymentReq"
|
v-if="!receive.paymentReq"
|
||||||
class="q-pa-lg q-pt-xl lnbits__dialog-card"
|
class="q-pa-lg q-pt-xl lnbits__dialog-card"
|
||||||
>
|
>
|
||||||
<q-form @submit="createInvoice" class="q-gutter-md">
|
<q-form @submit="createInvoice" class="q-gutter-md">
|
||||||
<p v-if="receive.lnurl" class="text-h6 text-center q-my-none">
|
<p v-if="receive.lnurl" class="text-h6 text-center q-my-none">
|
||||||
<b>{{receive.lnurl.domain}}</b> is requesting an invoice:
|
<b v-text="receive.lnurl.domain"></b> is requesting an invoice:
|
||||||
</p>
|
</p>
|
||||||
{% endraw %} {% if LNBITS_DENOMINATION != 'sats' %}
|
{% if LNBITS_DENOMINATION != 'sats' %}
|
||||||
<q-input
|
<q-input
|
||||||
filled
|
filled
|
||||||
dense
|
dense
|
||||||
v-model.number="receive.data.amount"
|
v-model.number="receive.data.amount"
|
||||||
:label="$('amount')+' ({{LNBITS_DENOMINATION}}) *'"
|
:label="$t('amount')+' ({{LNBITS_DENOMINATION}}) *'"
|
||||||
mask="#.##"
|
mask="#.##"
|
||||||
fill-mask="0"
|
fill-mask="0"
|
||||||
reverse-fill-mask
|
reverse-fill-mask
|
||||||
@@ -520,7 +541,6 @@
|
|||||||
v-model.trim="receive.data.memo"
|
v-model.trim="receive.data.memo"
|
||||||
:label="$t('memo')"
|
:label="$t('memo')"
|
||||||
></q-input>
|
></q-input>
|
||||||
{% raw %}
|
|
||||||
<div v-if="receive.status == 'pending'" class="row q-mt-lg">
|
<div v-if="receive.status == 'pending'" class="row q-mt-lg">
|
||||||
<q-btn
|
<q-btn
|
||||||
unelevated
|
unelevated
|
||||||
@@ -528,9 +548,10 @@
|
|||||||
:disable="receive.data.amount == null || receive.data.amount <= 0"
|
:disable="receive.data.amount == null || receive.data.amount <= 0"
|
||||||
type="submit"
|
type="submit"
|
||||||
>
|
>
|
||||||
<span v-if="receive.lnurl">
|
<span
|
||||||
{{$t('withdraw_from')}} {{receive.lnurl.domain}}
|
v-if="receive.lnurl"
|
||||||
</span>
|
v-text="$t('withdraw_from') + receive.lnurl.domain"
|
||||||
|
></span>
|
||||||
<span v-else v-text="$t('create_invoice')"></span>
|
<span v-else v-text="$t('create_invoice')"></span>
|
||||||
</q-btn>
|
</q-btn>
|
||||||
<q-btn
|
<q-btn
|
||||||
@@ -548,15 +569,16 @@
|
|||||||
></q-spinner>
|
></q-spinner>
|
||||||
</q-form>
|
</q-form>
|
||||||
</q-card>
|
</q-card>
|
||||||
<q-card v-else class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
<q-card
|
||||||
|
v-else-if="receive.paymentReq && receive.lnurl == null"
|
||||||
|
class="q-pa-lg q-pt-xl lnbits__dialog-card"
|
||||||
|
>
|
||||||
<div class="text-center q-mb-lg">
|
<div class="text-center q-mb-lg">
|
||||||
<a :href="'lightning:' + receive.paymentReq">
|
<a :href="'lightning:' + receive.paymentReq">
|
||||||
<q-responsive :ratio="1" class="q-mx-xl">
|
<q-responsive :ratio="1" class="q-mx-xl">
|
||||||
<qrcode
|
<lnbits-qrcode
|
||||||
:value="'lightning:' + receive.paymentReq.toUpperCase()"
|
:value="'lightning:' + receive.paymentReq.toUpperCase()"
|
||||||
:options="{width: 340}"
|
></lnbits-qrcode>
|
||||||
class="rounded-borders"
|
|
||||||
></qrcode>
|
|
||||||
</q-responsive>
|
</q-responsive>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -576,28 +598,32 @@
|
|||||||
></q-btn>
|
></q-btn>
|
||||||
</div>
|
</div>
|
||||||
</q-card>
|
</q-card>
|
||||||
{% endraw %}
|
|
||||||
</q-dialog>
|
</q-dialog>
|
||||||
|
|
||||||
<q-dialog v-model="parse.show" @hide="closeParseDialog">
|
<q-dialog v-model="parse.show" @hide="closeParseDialog" position="top">
|
||||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||||
<div v-if="parse.invoice">
|
<div v-if="parse.invoice">
|
||||||
<h6 v-if="'{{LNBITS_DENOMINATION}}' != 'sats'" class="q-my-none">
|
<h6
|
||||||
{% raw %} {{ parseFloat(String(parse.invoice.fsat).replaceAll(",",
|
v-if="'{{LNBITS_DENOMINATION}}' != 'sats'"
|
||||||
"")) / 100 }} {% endraw %} {{LNBITS_DENOMINATION}} {% raw %}
|
class="q-my-none"
|
||||||
</h6>
|
v-text="parseFloat(String(parse.invoice.fsat).replaceAll(',', '')) / 100 + '{{LNBITS_DENOMINATION}}'"
|
||||||
<h6 v-else class="q-my-none">
|
></h6>
|
||||||
{{ parse.invoice.fsat }}{% endraw %} {{LNBITS_DENOMINATION}} {%
|
<h6
|
||||||
raw %}
|
v-else
|
||||||
</h6>
|
class="q-my-none"
|
||||||
|
v-text="parse.invoice.fsat + '{{LNBITS_DENOMINATION}}'"
|
||||||
|
></h6>
|
||||||
<q-separator class="q-my-sm"></q-separator>
|
<q-separator class="q-my-sm"></q-separator>
|
||||||
<p class="text-wrap">
|
<p class="text-wrap">
|
||||||
<strong v-text="$t('memo')">:</strong> {{
|
<strong v-text="$t('memo') + ': '"></strong>
|
||||||
parse.invoice.description }}<br />
|
<span v-text="parse.invoice.description"></span>
|
||||||
<strong>Expire date:</strong> {{ parse.invoice.expireDate }}<br />
|
<br />
|
||||||
<strong>Hash:</strong> {{ parse.invoice.hash }}
|
<strong>Expire date: </strong>
|
||||||
|
<span v-text="parse.invoice.expireDate"></span>
|
||||||
|
<br />
|
||||||
|
<strong>Hash: </strong>
|
||||||
|
<span v-text="parse.invoice.hash"></span>
|
||||||
</p>
|
</p>
|
||||||
{% endraw %}
|
|
||||||
<div v-if="canPay" class="row q-mt-lg">
|
<div v-if="canPay" class="row q-mt-lg">
|
||||||
<q-btn
|
<q-btn
|
||||||
unelevated
|
unelevated
|
||||||
@@ -631,24 +657,31 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="parse.lnurlauth">
|
<div v-else-if="parse.lnurlauth">
|
||||||
{% raw %}
|
|
||||||
<q-form @submit="authLnurl" class="q-gutter-md">
|
<q-form @submit="authLnurl" class="q-gutter-md">
|
||||||
<p class="q-my-none text-h6">
|
<p class="q-my-none text-h6">
|
||||||
Authenticate with <b>{{ parse.lnurlauth.domain }}</b>?
|
Authenticate with <b v-text="parse.lnurlauth.domain"></b>?
|
||||||
</p>
|
</p>
|
||||||
<q-separator class="q-my-sm"></q-separator>
|
<q-separator class="q-my-sm"></q-separator>
|
||||||
<p>
|
<p>
|
||||||
For every website and for every LNbits wallet, a new keypair
|
For every website and for every LNbits wallet, a new keypair
|
||||||
will be deterministically generated so your identity can't be
|
will be deterministically generated so your identity can't be
|
||||||
tied to your LNbits wallet or linked across websites. No other
|
tied to your LNbits wallet or linked across websites. No other
|
||||||
data will be shared with {{ parse.lnurlauth.domain }}.
|
data will be shared with
|
||||||
|
<span v-text="parse.lnurlauth.domain"></span>.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Your public key for <b v-text="parse.lnurlauth.domain"></b> is:
|
||||||
</p>
|
</p>
|
||||||
<p>Your public key for <b>{{ parse.lnurlauth.domain }}</b> is:</p>
|
|
||||||
<p class="q-mx-xl">
|
<p class="q-mx-xl">
|
||||||
<code class="text-wrap"> {{ parse.lnurlauth.pubkey }} </code>
|
<code class="text-wrap" v-text="parse.lnurlauth.pubkey"></code>
|
||||||
</p>
|
</p>
|
||||||
<div class="row q-mt-lg">
|
<div class="row q-mt-lg">
|
||||||
<q-btn unelevated color="primary" type="submit">Login</q-btn>
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
color="primary"
|
||||||
|
type="submit"
|
||||||
|
:label="$t('login')"
|
||||||
|
></q-btn>
|
||||||
<q-btn
|
<q-btn
|
||||||
:label="$t('cancel')"
|
:label="$t('cancel')"
|
||||||
v-close-popup
|
v-close-popup
|
||||||
@@ -658,55 +691,74 @@
|
|||||||
></q-btn>
|
></q-btn>
|
||||||
</div>
|
</div>
|
||||||
</q-form>
|
</q-form>
|
||||||
{% endraw %}
|
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="parse.lnurlpay">
|
<div v-else-if="parse.lnurlpay">
|
||||||
{% raw %}
|
|
||||||
<q-form @submit="payLnurl" class="q-gutter-md">
|
<q-form @submit="payLnurl" class="q-gutter-md">
|
||||||
<p v-if="parse.lnurlpay.fixed" class="q-my-none text-h6">
|
<p v-if="parse.lnurlpay.fixed" class="q-my-none text-h6">
|
||||||
<b>{{ parse.lnurlpay.domain }}</b> is requesting {{
|
<b v-text="parse.lnurlpay.domain"></b> is requesting
|
||||||
parse.lnurlpay.maxSendable | msatoshiFormat }}
|
<span
|
||||||
{{LNBITS_DENOMINATION}}
|
v-text="msatoshiFormat(parse.lnurlpay.maxSendable)"
|
||||||
|
></span>
|
||||||
|
<span v-text="'{{LNBITS_DENOMINATION}}'"></span>
|
||||||
<span v-if="parse.lnurlpay.commentAllowed > 0">
|
<span v-if="parse.lnurlpay.commentAllowed > 0">
|
||||||
<br />
|
<br />
|
||||||
and a {{parse.lnurlpay.commentAllowed}}-char comment
|
and a
|
||||||
|
<span v-text="parse.lnurlpay.commentAllowed"></span>-char
|
||||||
|
comment
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
<p v-else class="q-my-none text-h6 text-center">
|
<p v-else class="q-my-none text-h6 text-center">
|
||||||
<b>{{ parse.lnurlpay.targetUser || parse.lnurlpay.domain }}</b>
|
<b
|
||||||
|
v-text="parse.lnurlpay.targetUser || parse.lnurlpay.domain"
|
||||||
|
></b>
|
||||||
is requesting <br />
|
is requesting <br />
|
||||||
between
|
between
|
||||||
<b>{{ parse.lnurlpay.minSendable | msatoshiFormat }}</b> and
|
<b v-text="msatoshiFormat(parse.lnurlpay.minSendable)"></b> and
|
||||||
<b>{{ parse.lnurlpay.maxSendable | msatoshiFormat }}</b>
|
<b v-text="msatoshiFormat(parse.lnurlpay.maxSendable)"></b>
|
||||||
{% endraw %} {{LNBITS_DENOMINATION}} {% raw %}
|
<span v-text="'{{LNBITS_DENOMINATION}}'"></span>
|
||||||
<span v-if="parse.lnurlpay.commentAllowed > 0">
|
<span v-if="parse.lnurlpay.commentAllowed > 0">
|
||||||
<br />
|
<br />
|
||||||
and a {{parse.lnurlpay.commentAllowed}}-char comment
|
and a
|
||||||
|
<span v-text="parse.lnurlpay.commentAllowed"></span>-char
|
||||||
|
comment
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
<q-separator class="q-my-sm"></q-separator>
|
<q-separator class="q-my-sm"></q-separator>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<p class="col text-justify text-italic">
|
<p
|
||||||
{{ parse.lnurlpay.description }}
|
class="col text-justify text-italic"
|
||||||
</p>
|
v-text="parse.lnurlpay.description"
|
||||||
|
></p>
|
||||||
<p class="col-4 q-pl-md" v-if="parse.lnurlpay.image">
|
<p class="col-4 q-pl-md" v-if="parse.lnurlpay.image">
|
||||||
<q-img :src="parse.lnurlpay.image" />
|
<q-img :src="parse.lnurlpay.image" />
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
{% endraw %}
|
<q-select
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-if="!parse.lnurlpay.fixed"
|
||||||
|
v-model="parse.data.unit"
|
||||||
|
type="text"
|
||||||
|
:label="$t('unit')"
|
||||||
|
:options="receive.units"
|
||||||
|
></q-select>
|
||||||
|
<br />
|
||||||
<q-input
|
<q-input
|
||||||
|
ref="setAmount"
|
||||||
filled
|
filled
|
||||||
dense
|
dense
|
||||||
v-model.number="parse.data.amount"
|
v-model.number="parse.data.amount"
|
||||||
type="number"
|
:label="$t('amount') + ' (' + parse.data.unit + ') *'"
|
||||||
label="Amount ({{LNBITS_DENOMINATION}}) *"
|
:mask="parse.data.unit != 'sat' ? '#.##' : '#'"
|
||||||
|
:step="parse.data.unit != 'sat' ? '0.01' : '1'"
|
||||||
|
fill-mask="0"
|
||||||
|
reverse-fill-mask
|
||||||
:min="parse.lnurlpay.minSendable / 1000"
|
:min="parse.lnurlpay.minSendable / 1000"
|
||||||
:max="parse.lnurlpay.maxSendable / 1000"
|
:max="parse.lnurlpay.maxSendable / 1000"
|
||||||
:readonly="parse.lnurlpay.fixed"
|
:readonly="parse.lnurlpay && parse.lnurlpay.fixed"
|
||||||
></q-input>
|
></q-input>
|
||||||
{% raw %}
|
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="col-8 q-pl-md"
|
class="col-8 q-pl-md"
|
||||||
@@ -723,9 +775,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row q-mt-lg">
|
<div class="row q-mt-lg">
|
||||||
<q-btn unelevated color="primary" type="submit"
|
<q-btn unelevated color="primary" type="submit">Send</q-btn>
|
||||||
>Send {{LNBITS_DENOMINATION}}</q-btn
|
|
||||||
>
|
|
||||||
<q-btn
|
<q-btn
|
||||||
:label="$t('cancel')"
|
:label="$t('cancel')"
|
||||||
v-close-popup
|
v-close-popup
|
||||||
@@ -735,7 +785,6 @@
|
|||||||
></q-btn>
|
></q-btn>
|
||||||
</div>
|
</div>
|
||||||
</q-form>
|
</q-form>
|
||||||
{% endraw %}
|
|
||||||
</div>
|
</div>
|
||||||
<div v-else>
|
<div v-else>
|
||||||
<q-form
|
<q-form
|
||||||
@@ -766,11 +815,11 @@
|
|||||||
class="q-mt-xs q-ml-sm q-mr-auto"
|
class="q-mt-xs q-ml-sm q-mr-auto"
|
||||||
v-if="parse.copy.show"
|
v-if="parse.copy.show"
|
||||||
@click="pasteToTextArea"
|
@click="pasteToTextArea"
|
||||||
><q-tooltip
|
|
||||||
>{% raw %}{{$t('paste_from_clipboard')}}{% endraw
|
|
||||||
%}</q-tooltip
|
|
||||||
></q-icon
|
|
||||||
>
|
>
|
||||||
|
<q-tooltip>
|
||||||
|
<span v-text="$t('paste_from_clipboard')"></span>
|
||||||
|
</q-tooltip>
|
||||||
|
</q-icon>
|
||||||
<q-btn
|
<q-btn
|
||||||
v-close-popup
|
v-close-popup
|
||||||
flat
|
flat
|
||||||
@@ -803,7 +852,7 @@
|
|||||||
</q-card>
|
</q-card>
|
||||||
</q-dialog>
|
</q-dialog>
|
||||||
|
|
||||||
<q-dialog v-model="parse.camera.show">
|
<q-dialog v-model="parse.camera.show" position="top">
|
||||||
<q-card class="q-pa-lg q-pt-xl">
|
<q-card class="q-pa-lg q-pt-xl">
|
||||||
<div class="text-center q-mb-lg">
|
<div class="text-center q-mb-lg">
|
||||||
<qrcode-stream
|
<qrcode-stream
|
||||||
@@ -824,9 +873,22 @@
|
|||||||
</q-card>
|
</q-card>
|
||||||
</q-dialog>
|
</q-dialog>
|
||||||
|
|
||||||
<q-dialog v-model="paymentsChart.show">
|
<q-dialog v-model="paymentsChart.show" position="top">
|
||||||
<q-card class="q-pa-sm" style="width: 800px; max-width: unset">
|
<q-card class="q-pa-sm" style="width: 800px; max-width: unset">
|
||||||
<q-card-section>
|
<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>
|
<canvas ref="canvas" width="600" height="400"></canvas>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-card>
|
</q-card>
|
||||||
@@ -858,16 +920,20 @@
|
|||||||
</q-btn>
|
</q-btn>
|
||||||
</q-tabs>
|
</q-tabs>
|
||||||
|
|
||||||
<q-dialog v-model="disclaimerDialog.show">
|
<q-dialog v-model="disclaimerDialog.show" position="top">
|
||||||
<q-card class="q-pa-lg">
|
<q-card class="q-pa-lg">
|
||||||
<h6 class="q-my-md text-primary">Warning</h6>
|
<h6
|
||||||
<p v-text="$t('disclaimer_dialog')"></p>
|
class="q-my-md text-primary"
|
||||||
|
v-text="$t('disclaimer_dialog_title')"
|
||||||
|
></h6>
|
||||||
|
<p class="whitespace-pre-line" v-text="$t('disclaimer_dialog')"></p>
|
||||||
<div class="row q-mt-lg">
|
<div class="row q-mt-lg">
|
||||||
<q-btn
|
<q-btn
|
||||||
outline
|
outline
|
||||||
color="grey"
|
color="grey"
|
||||||
@click="copyText(disclaimerDialog.location.href)"
|
type="a"
|
||||||
:label="$t('copy_wallet_url')"
|
href="/account"
|
||||||
|
:label="$t('my_account')"
|
||||||
></q-btn>
|
></q-btn>
|
||||||
<q-btn
|
<q-btn
|
||||||
v-close-popup
|
v-close-popup
|
||||||
|
|||||||
@@ -0,0 +1,308 @@
|
|||||||
|
<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>
|
||||||
|
<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"
|
||||||
|
v-text="props.row.name"
|
||||||
|
></div>
|
||||||
|
<div class="text-caption" v-if="props.row.short_id">
|
||||||
|
<span v-text="props.row.short_id"></span>
|
||||||
|
<q-btn
|
||||||
|
size="xs"
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
icon="content_paste"
|
||||||
|
@click="copyText(props.row.short_id)"
|
||||||
|
></q-btn>
|
||||||
|
</div>
|
||||||
|
<q-badge
|
||||||
|
rounded
|
||||||
|
:color="states.find(s => s.value == props.row.state)?.color"
|
||||||
|
v-text="states.find(s => s.value == props.row.state)?.label"
|
||||||
|
>
|
||||||
|
</q-badge>
|
||||||
|
<q-btn
|
||||||
|
:disable='props.row.state !== "active"'
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
size="md"
|
||||||
|
@click="showCloseChannelDialog(props.row)"
|
||||||
|
icon="cancel"
|
||||||
|
color="pink"
|
||||||
|
></q-btn>
|
||||||
|
</div>
|
||||||
|
<lnbits-channel-balance
|
||||||
|
:balance="props.row.balance"
|
||||||
|
:color="props.row.color"
|
||||||
|
></lnbits-channel-balance>
|
||||||
|
</div>
|
||||||
|
</q-tr>
|
||||||
|
</template>
|
||||||
|
</q-table>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-xl-6">
|
||||||
|
<q-card class="full-height">
|
||||||
|
<q-card-section class="column q-gutter-y-sm">
|
||||||
|
<div
|
||||||
|
class="row items-center q-mt-none justify-between q-gutter-x-md no-wrap"
|
||||||
|
>
|
||||||
|
<div class="col-grow text-h6 q-my-none">Peers</div>
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
clearable
|
||||||
|
v-model="peers.filter"
|
||||||
|
placeholder="Search..."
|
||||||
|
class="col-auto"
|
||||||
|
></q-input>
|
||||||
|
<q-btn
|
||||||
|
class="col-auto"
|
||||||
|
color="primary"
|
||||||
|
@click="connectPeerDialog.show = true"
|
||||||
|
>
|
||||||
|
Connect Peer
|
||||||
|
</q-btn>
|
||||||
|
</div>
|
||||||
|
<q-separator></q-separator>
|
||||||
|
<q-table
|
||||||
|
dense
|
||||||
|
flat
|
||||||
|
: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"
|
||||||
|
v-text="props.row.alias"
|
||||||
|
></div>
|
||||||
|
<div class="row items-center q-gutter-sm">
|
||||||
|
<q-badge
|
||||||
|
:style="`background-color: #${props.row.color}`"
|
||||||
|
class="text-bold"
|
||||||
|
v-text="'#'+props.row.color"
|
||||||
|
>
|
||||||
|
</q-badge>
|
||||||
|
<div
|
||||||
|
class="text-bold"
|
||||||
|
v-text="shortenNodeId(props.row.id)"
|
||||||
|
></div>
|
||||||
|
<q-btn
|
||||||
|
size="xs"
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
icon="content_paste"
|
||||||
|
@click="copyText(props.row.id)"
|
||||||
|
></q-btn>
|
||||||
|
<q-btn
|
||||||
|
size="xs"
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
icon="qr_code"
|
||||||
|
@click="showNodeInfoDialog(props.row)"
|
||||||
|
></q-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
color="primary"
|
||||||
|
@click="showOpenChannelDialog(props.row.id)"
|
||||||
|
>
|
||||||
|
Open channel
|
||||||
|
</q-btn>
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
size="md"
|
||||||
|
@click="disconnectPeer(props.row.id)"
|
||||||
|
icon="cancel"
|
||||||
|
color="pink"
|
||||||
|
></q-btn>
|
||||||
|
</div>
|
||||||
|
</q-tr>
|
||||||
|
</template>
|
||||||
|
</q-table>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
</q-tab-panel>
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
<q-tab-panel name="dashboard">
|
||||||
|
<q-card-section class="q-pa-none">
|
||||||
|
<lnbits-node-info :info="this.info"></lnbits-node-info>
|
||||||
|
<div class="row q-col-gutter-lg q-mt-sm">
|
||||||
|
<div class="col-12 col-md-8 q-gutter-y-md">
|
||||||
|
<div class="row q-col-gutter-md q-pb-lg">
|
||||||
|
<div class="col-12 col-md-6 col-xl-4 q-gutter-y-md">
|
||||||
|
<lnbits-stat
|
||||||
|
:title="$t('total_capacity')"
|
||||||
|
:msat="this.channel_stats.total_capacity"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-6 col-xl-4 q-gutter-y-md">
|
||||||
|
<lnbits-stat title="Balance" :msat="this.info.balance_msat" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 col-md-6 col-xl-4 q-gutter-y-md">
|
||||||
|
<lnbits-stat
|
||||||
|
title="Fees collected"
|
||||||
|
:msat="this.info.fees?.total_msat"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 col-md-6 col-xl-4 q-gutter-y-md">
|
||||||
|
<lnbits-stat
|
||||||
|
title="Onchain Balance"
|
||||||
|
:btc="this.info.onchain_balance_sat / 100000000"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 col-md-6 col-xl-4 q-gutter-y-md">
|
||||||
|
<lnbits-stat
|
||||||
|
title="Onchain Confirmed"
|
||||||
|
:btc="this.info.onchain_confirmed_sat / 100000000"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-6 col-xl-4 q-gutter-y-md">
|
||||||
|
<lnbits-stat title="Peers" :amount="this.info.num_peers" />
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-6 col-xl-4 q-gutter-y-md">
|
||||||
|
<lnbits-stat
|
||||||
|
:title="$t('avg_channel_size')"
|
||||||
|
:msat="this.channel_stats.avg_size"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-6 col-xl-4 q-gutter-y-md">
|
||||||
|
<lnbits-stat
|
||||||
|
:title="$t('biggest_channel_size')"
|
||||||
|
:msat="this.channel_stats.biggest_size"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-6 col-xl-4 q-gutter-y-md">
|
||||||
|
<lnbits-stat
|
||||||
|
:title="$t('smallest_channel_size')"
|
||||||
|
:msat="this.channel_stats.smallest_size"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="column col-12 col-md-4 q-gutter-y-md">
|
||||||
|
<lnbits-node-ranks :ranks="this.ranks"></lnbits-node-ranks>
|
||||||
|
<lnbits-channel-stats
|
||||||
|
:stats="this.channel_stats"
|
||||||
|
></lnbits-channel-stats>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
</q-tab-panel>
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
<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>
|
||||||
|
<div class="text-center q-mb-lg">
|
||||||
|
<div
|
||||||
|
v-if="transactionDetailsDialog.data.isIn && transactionDetailsDialog.data.pending"
|
||||||
|
>
|
||||||
|
<q-icon
|
||||||
|
size="18px"
|
||||||
|
:name="'call_received'"
|
||||||
|
:color="'green'"
|
||||||
|
></q-icon>
|
||||||
|
<span v-text="$t('payment_received')"></span>
|
||||||
|
</div>
|
||||||
|
<div class="row q-my-md">
|
||||||
|
<div class="col-3"><b v-text="$t('payment_hash')"></b>:</div>
|
||||||
|
<div class="col-9 text-wrap mono">
|
||||||
|
<span
|
||||||
|
v-text="transactionDetailsDialog.data.payment_hash"
|
||||||
|
></span>
|
||||||
|
<q-icon
|
||||||
|
name="content_copy"
|
||||||
|
@click="copyText(transactionDetailsDialog.data.payment_hash)"
|
||||||
|
size="1em"
|
||||||
|
color="grey"
|
||||||
|
class="q-mb-xs cursor-pointer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="row"
|
||||||
|
v-if="transactionDetailsDialog.data.preimage && !transactionDetailsDialog.data.pending"
|
||||||
|
>
|
||||||
|
<div class="col-3"><b v-text="$t('payment_proof')"></b>:</div>
|
||||||
|
<div class="col-9 text-wrap mono">
|
||||||
|
<span v-text="transactionDetailsDialog.data.preimage"></span>
|
||||||
|
<q-icon
|
||||||
|
name="content_copy"
|
||||||
|
@click="copyText(transactionDetailsDialog.data.preimage)"
|
||||||
|
size="1em"
|
||||||
|
color="grey"
|
||||||
|
class="q-mb-xs cursor-pointer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="transactionDetailsDialog.data.bolt11"
|
||||||
|
class="text-center q-mb-lg"
|
||||||
|
>
|
||||||
|
<a :href="'lightning:' + transactionDetailsDialog.data.bolt11">
|
||||||
|
<q-responsive :ratio="1" class="q-mx-xl">
|
||||||
|
<qrcode
|
||||||
|
: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>
|
||||||
|
</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"
|
||||||
|
>
|
||||||
|
<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"
|
||||||
|
v-text="props.row.destination?.alias"
|
||||||
|
></q-badge>
|
||||||
|
<div>
|
||||||
|
<q-btn
|
||||||
|
size="xs"
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
icon="content_paste"
|
||||||
|
@click="copyText(info.id)"
|
||||||
|
></q-btn>
|
||||||
|
<q-btn
|
||||||
|
size="xs"
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
icon="qr_code"
|
||||||
|
@click="showNodeInfoDialog(props.row.destination)"
|
||||||
|
></q-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-td>
|
||||||
|
</template>
|
||||||
|
</q-table>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-lg-6 q-gutter-y-md">
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<div class="row items-center no-wrap q-mb-sm">
|
||||||
|
<div class="col text-h6 q-my-none">Invoices</div>
|
||||||
|
<q-input
|
||||||
|
v-if="payments.length > 10"
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
clearable
|
||||||
|
v-model="paymentsTable.filter"
|
||||||
|
debounce="300"
|
||||||
|
placeholder="Search by tag, memo, amount"
|
||||||
|
class="q-mb-md"
|
||||||
|
>
|
||||||
|
</q-input>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<q-table
|
||||||
|
dense
|
||||||
|
flat
|
||||||
|
:data="invoiceTable.data"
|
||||||
|
:columns="invoiceTable.columns"
|
||||||
|
:pagination.sync="invoiceTable.pagination"
|
||||||
|
no-data-label="No transactions made yet"
|
||||||
|
:filter="invoiceTable.filter"
|
||||||
|
@request="getInvoices"
|
||||||
|
>
|
||||||
|
<template v-slot:body-cell-pending="props">
|
||||||
|
<q-td auto-width class="text-center">
|
||||||
|
<q-icon
|
||||||
|
v-if="!props.row.pending"
|
||||||
|
size="xs"
|
||||||
|
name="call_received"
|
||||||
|
color="green"
|
||||||
|
@click="showTransactionDetailsDialog(props.row)"
|
||||||
|
></q-icon>
|
||||||
|
<q-icon
|
||||||
|
v-else
|
||||||
|
size="xs"
|
||||||
|
name="settings_ethernet"
|
||||||
|
color="grey"
|
||||||
|
@click="showTransactionDetailsDialog(props.row)"
|
||||||
|
>
|
||||||
|
<q-tooltip>Pending</q-tooltip>
|
||||||
|
</q-icon>
|
||||||
|
</q-td>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-slot:body-cell-paid_at="props">
|
||||||
|
<q-td auto-width :props="props">
|
||||||
|
<lnbits-date
|
||||||
|
v-if="props.row.paid_at"
|
||||||
|
:ts="props.row.paid_at"
|
||||||
|
></lnbits-date>
|
||||||
|
</q-td>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-slot:body-cell-expiry="props">
|
||||||
|
<q-td auto-width :props="props">
|
||||||
|
<lnbits-date
|
||||||
|
v-if="props.row.expiry"
|
||||||
|
:ts="props.row.expiry"
|
||||||
|
></lnbits-date>
|
||||||
|
</q-td>
|
||||||
|
</template>
|
||||||
|
</q-table>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
</q-tab-panel>
|
||||||
@@ -0,0 +1,376 @@
|
|||||||
|
{% 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)
|
||||||
|
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="$t('total_capacity')"
|
||||||
|
:msat="this.channel_stats.total_capacity"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 col-md-6 q-gutter-y-md">
|
||||||
|
<lnbits-stat title="Peers" :amount="this.info.num_peers" />
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-6 q-gutter-y-md">
|
||||||
|
<lnbits-stat
|
||||||
|
:title="$t('avg_channel_size')"
|
||||||
|
:msat="this.channel_stats.avg_size"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-6 q-gutter-y-md">
|
||||||
|
<lnbits-stat
|
||||||
|
:title="$t('biggest_channel_size')"
|
||||||
|
:msat="this.channel_stats.biggest_size"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-6 q-gutter-y-md">
|
||||||
|
<lnbits-stat
|
||||||
|
:title="$t('smallest_channel_size')"
|
||||||
|
:msat="this.channel_stats.smallest_size"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-6 q-gutter-y-md">
|
||||||
|
<lnbits-stat
|
||||||
|
:title="$t('smallest_channel_size')"
|
||||||
|
:msat="this.channel_stats.smallest_size"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="column col-12 col-md-4 q-gutter-y-md">
|
||||||
|
<lnbits-node-ranks :ranks="this.ranks"></lnbits-node-ranks>
|
||||||
|
<lnbits-channel-stats :stats="this.channel_stats"></lnbits-channel-stats>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %} {% block scripts %} {{ window_vars(user) }}
|
||||||
|
<script src="{{ static_url_for('static', 'js/node.js') }}"></script>
|
||||||
|
<script>
|
||||||
|
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 %}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
// update cache version every time there is a new deployment
|
// update cache version every time there is a new deployment
|
||||||
// so the service worker reinitializes the cache
|
// so the service worker reinitializes the cache
|
||||||
const CACHE_VERSION = 53
|
const CURRENT_CACHE = 'lnbits-{{ cache_version }}-'
|
||||||
const CURRENT_CACHE = `lnbits-${CACHE_VERSION}-`
|
|
||||||
|
|
||||||
const getApiKey = request => {
|
const getApiKey = request => {
|
||||||
let api_key = request.headers.get('X-Api-Key')
|
let api_key = request.headers.get('X-Api-Key')
|
||||||
@@ -17,8 +16,7 @@ self.addEventListener('activate', evt =>
|
|||||||
caches.keys().then(cacheNames => {
|
caches.keys().then(cacheNames => {
|
||||||
return Promise.all(
|
return Promise.all(
|
||||||
cacheNames.map(cacheName => {
|
cacheNames.map(cacheName => {
|
||||||
const currentCacheVersion = cacheName.split('-').slice(-2, 2)
|
if (!cacheName.startsWith(CURRENT_CACHE)) {
|
||||||
if (currentCacheVersion !== CACHE_VERSION) {
|
|
||||||
return caches.delete(cacheName)
|
return caches.delete(cacheName)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -56,3 +54,32 @@ self.addEventListener('fetch', event => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Handle and show incoming push notifications
|
||||||
|
self.addEventListener('push', function (event) {
|
||||||
|
if (!(self.Notification && self.Notification.permission === 'granted')) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let data = event.data.json()
|
||||||
|
const title = data.title
|
||||||
|
const body = data.body
|
||||||
|
const url = data.url
|
||||||
|
|
||||||
|
event.waitUntil(
|
||||||
|
self.registration.showNotification(title, {
|
||||||
|
body: body,
|
||||||
|
icon: '/favicon.ico',
|
||||||
|
data: {
|
||||||
|
url: url
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
// User can click on the notification message to open wallet
|
||||||
|
// Installed app will open when `url_handlers` in web app manifest is supported
|
||||||
|
self.addEventListener('notificationclick', function (event) {
|
||||||
|
event.notification.close()
|
||||||
|
event.waitUntil(clients.openWindow(event.notification.data.url))
|
||||||
|
})
|
||||||
@@ -6,7 +6,7 @@ from subprocess import Popen
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from fastapi import Depends
|
from fastapi import APIRouter, Depends
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from starlette.exceptions import HTTPException
|
from starlette.exceptions import HTTPException
|
||||||
|
|
||||||
@@ -17,15 +17,19 @@ from lnbits.core.services import (
|
|||||||
update_cached_settings,
|
update_cached_settings,
|
||||||
update_wallet_balance,
|
update_wallet_balance,
|
||||||
)
|
)
|
||||||
|
from lnbits.core.tasks import api_invoice_listeners
|
||||||
from lnbits.decorators import check_admin, check_super_user
|
from lnbits.decorators import check_admin, check_super_user
|
||||||
from lnbits.server import server_restart
|
from lnbits.server import server_restart
|
||||||
from lnbits.settings import AdminSettings, EditableSettings, settings
|
from lnbits.settings import AdminSettings, UpdateSettings, settings
|
||||||
|
from lnbits.tasks import invoice_listeners
|
||||||
|
|
||||||
from .. import core_app, core_app_extra
|
from .. import core_app_extra
|
||||||
from ..crud import delete_admin_settings, get_admin_settings, update_admin_settings
|
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",
|
"/admin/api/v1/audit",
|
||||||
name="Audit",
|
name="Audit",
|
||||||
description="show the current balance of the node and the LNbits database",
|
description="show the current balance of the node and the LNbits database",
|
||||||
@@ -46,7 +50,20 @@ async def api_auditor():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@core_app.get("/admin/api/v1/settings/")
|
@admin_router.get(
|
||||||
|
"/admin/api/v1/monitor",
|
||||||
|
name="Monitor",
|
||||||
|
description="show the current listeners and other monitoring data",
|
||||||
|
dependencies=[Depends(check_admin)],
|
||||||
|
)
|
||||||
|
async def api_monitor():
|
||||||
|
return {
|
||||||
|
"invoice_listeners": list(invoice_listeners.keys()),
|
||||||
|
"api_invoice_listeners": list(api_invoice_listeners.keys()),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@admin_router.get("/admin/api/v1/settings/", response_model=Optional[AdminSettings])
|
||||||
async def api_get_settings(
|
async def api_get_settings(
|
||||||
user: User = Depends(check_admin),
|
user: User = Depends(check_admin),
|
||||||
) -> Optional[AdminSettings]:
|
) -> Optional[AdminSettings]:
|
||||||
@@ -54,13 +71,11 @@ async def api_get_settings(
|
|||||||
return admin_settings
|
return admin_settings
|
||||||
|
|
||||||
|
|
||||||
@core_app.put(
|
@admin_router.put(
|
||||||
"/admin/api/v1/settings/",
|
"/admin/api/v1/settings/",
|
||||||
status_code=HTTPStatus.OK,
|
status_code=HTTPStatus.OK,
|
||||||
)
|
)
|
||||||
async def api_update_settings(
|
async def api_update_settings(data: UpdateSettings, user: User = Depends(check_admin)):
|
||||||
data: EditableSettings, user: User = Depends(check_admin)
|
|
||||||
):
|
|
||||||
await update_admin_settings(data)
|
await update_admin_settings(data)
|
||||||
admin_settings = await get_admin_settings(user.super_user)
|
admin_settings = await get_admin_settings(user.super_user)
|
||||||
assert admin_settings, "Updated admin settings not found."
|
assert admin_settings, "Updated admin settings not found."
|
||||||
@@ -69,7 +84,7 @@ async def api_update_settings(
|
|||||||
return {"status": "Success"}
|
return {"status": "Success"}
|
||||||
|
|
||||||
|
|
||||||
@core_app.delete(
|
@admin_router.delete(
|
||||||
"/admin/api/v1/settings/",
|
"/admin/api/v1/settings/",
|
||||||
status_code=HTTPStatus.OK,
|
status_code=HTTPStatus.OK,
|
||||||
dependencies=[Depends(check_super_user)],
|
dependencies=[Depends(check_super_user)],
|
||||||
@@ -79,7 +94,7 @@ async def api_delete_settings() -> None:
|
|||||||
server_restart.set()
|
server_restart.set()
|
||||||
|
|
||||||
|
|
||||||
@core_app.get(
|
@admin_router.get(
|
||||||
"/admin/api/v1/restart/",
|
"/admin/api/v1/restart/",
|
||||||
status_code=HTTPStatus.OK,
|
status_code=HTTPStatus.OK,
|
||||||
dependencies=[Depends(check_super_user)],
|
dependencies=[Depends(check_super_user)],
|
||||||
@@ -89,7 +104,7 @@ async def api_restart_server() -> dict[str, str]:
|
|||||||
return {"status": "Success"}
|
return {"status": "Success"}
|
||||||
|
|
||||||
|
|
||||||
@core_app.put(
|
@admin_router.put(
|
||||||
"/admin/api/v1/topup/",
|
"/admin/api/v1/topup/",
|
||||||
name="Topup",
|
name="Topup",
|
||||||
status_code=HTTPStatus.OK,
|
status_code=HTTPStatus.OK,
|
||||||
@@ -113,7 +128,7 @@ async def api_topup_balance(data: CreateTopup) -> dict[str, str]:
|
|||||||
return {"status": "Success"}
|
return {"status": "Success"}
|
||||||
|
|
||||||
|
|
||||||
@core_app.get(
|
@admin_router.get(
|
||||||
"/admin/api/v1/backup/",
|
"/admin/api/v1/backup/",
|
||||||
status_code=HTTPStatus.OK,
|
status_code=HTTPStatus.OK,
|
||||||
dependencies=[Depends(check_super_user)],
|
dependencies=[Depends(check_super_user)],
|
||||||
|
|||||||
@@ -4,38 +4,45 @@ import json
|
|||||||
import uuid
|
import uuid
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
|
from math import ceil
|
||||||
from typing import Dict, List, Optional, Union
|
from typing import Dict, List, Optional, Union
|
||||||
from urllib.parse import ParseResult, parse_qs, urlencode, urlparse, urlunparse
|
from urllib.parse import ParseResult, parse_qs, urlencode, urlparse, urlunparse
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import pyqrcode
|
import pyqrcode
|
||||||
from fastapi import (
|
from fastapi import (
|
||||||
|
APIRouter,
|
||||||
Body,
|
Body,
|
||||||
Depends,
|
Depends,
|
||||||
Header,
|
Header,
|
||||||
Request,
|
Request,
|
||||||
Response,
|
|
||||||
WebSocket,
|
WebSocket,
|
||||||
WebSocketDisconnect,
|
WebSocketDisconnect,
|
||||||
)
|
)
|
||||||
from fastapi.exceptions import HTTPException
|
from fastapi.exceptions import HTTPException
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from sse_starlette.sse import EventSourceResponse
|
from sse_starlette.sse import EventSourceResponse
|
||||||
from starlette.responses import RedirectResponse, StreamingResponse
|
from starlette.responses import StreamingResponse
|
||||||
|
|
||||||
from lnbits import bolt11, lnurl
|
from lnbits import bolt11
|
||||||
|
from lnbits.core.db import core_app_extra, db
|
||||||
from lnbits.core.helpers import (
|
from lnbits.core.helpers import (
|
||||||
migrate_extension_database,
|
migrate_extension_database,
|
||||||
stop_extension_background_work,
|
stop_extension_background_work,
|
||||||
)
|
)
|
||||||
from lnbits.core.models import (
|
from lnbits.core.models import (
|
||||||
|
BaseWallet,
|
||||||
ConversionData,
|
ConversionData,
|
||||||
CreateInvoice,
|
CreateInvoice,
|
||||||
CreateLnurl,
|
CreateLnurl,
|
||||||
CreateLnurlAuth,
|
CreateLnurlAuth,
|
||||||
|
CreateWallet,
|
||||||
DecodePayment,
|
DecodePayment,
|
||||||
Payment,
|
Payment,
|
||||||
PaymentFilters,
|
PaymentFilters,
|
||||||
|
PaymentHistoryPoint,
|
||||||
|
Query,
|
||||||
User,
|
User,
|
||||||
Wallet,
|
Wallet,
|
||||||
WalletType,
|
WalletType,
|
||||||
@@ -43,7 +50,9 @@ from lnbits.core.models import (
|
|||||||
from lnbits.db import Filters, Page
|
from lnbits.db import Filters, Page
|
||||||
from lnbits.decorators import (
|
from lnbits.decorators import (
|
||||||
WalletTypeInfo,
|
WalletTypeInfo,
|
||||||
|
check_access_token,
|
||||||
check_admin,
|
check_admin,
|
||||||
|
check_user_exists,
|
||||||
get_key_type,
|
get_key_type,
|
||||||
parse_filters,
|
parse_filters,
|
||||||
require_admin_key,
|
require_admin_key,
|
||||||
@@ -55,32 +64,36 @@ from lnbits.extension_manager import (
|
|||||||
ExtensionRelease,
|
ExtensionRelease,
|
||||||
InstallableExtension,
|
InstallableExtension,
|
||||||
fetch_github_release_config,
|
fetch_github_release_config,
|
||||||
|
fetch_release_payment_info,
|
||||||
get_valid_extensions,
|
get_valid_extensions,
|
||||||
)
|
)
|
||||||
from lnbits.helpers import generate_filter_params_openapi, url_for
|
from lnbits.helpers import generate_filter_params_openapi, url_for
|
||||||
|
from lnbits.lnurl import decode as lnurl_decode
|
||||||
from lnbits.settings import settings
|
from lnbits.settings import settings
|
||||||
from lnbits.utils.exchange_rates import (
|
from lnbits.utils.exchange_rates import (
|
||||||
currencies,
|
allowed_currencies,
|
||||||
fiat_amount_as_satoshis,
|
fiat_amount_as_satoshis,
|
||||||
satoshis_amount_as_fiat,
|
satoshis_amount_as_fiat,
|
||||||
)
|
)
|
||||||
|
|
||||||
from .. import core_app, core_app_extra, db
|
|
||||||
from ..crud import (
|
from ..crud import (
|
||||||
|
DateTrunc,
|
||||||
add_installed_extension,
|
add_installed_extension,
|
||||||
create_tinyurl,
|
create_account,
|
||||||
|
create_wallet,
|
||||||
delete_dbversion,
|
delete_dbversion,
|
||||||
delete_installed_extension,
|
delete_installed_extension,
|
||||||
delete_tinyurl,
|
delete_wallet,
|
||||||
drop_extension_db,
|
drop_extension_db,
|
||||||
get_dbversions,
|
get_dbversions,
|
||||||
|
get_installed_extension,
|
||||||
get_payments,
|
get_payments,
|
||||||
|
get_payments_history,
|
||||||
get_payments_paginated,
|
get_payments_paginated,
|
||||||
get_standalone_payment,
|
get_standalone_payment,
|
||||||
get_tinyurl,
|
|
||||||
get_tinyurl_by_url,
|
|
||||||
get_wallet_for_key,
|
get_wallet_for_key,
|
||||||
save_balance_check,
|
save_balance_check,
|
||||||
|
update_pending_payments,
|
||||||
update_wallet,
|
update_wallet,
|
||||||
)
|
)
|
||||||
from ..services import (
|
from ..services import (
|
||||||
@@ -88,6 +101,7 @@ from ..services import (
|
|||||||
PaymentFailure,
|
PaymentFailure,
|
||||||
check_transaction_status,
|
check_transaction_status,
|
||||||
create_invoice,
|
create_invoice,
|
||||||
|
fee_reserve_total,
|
||||||
pay_invoice,
|
pay_invoice,
|
||||||
perform_lnurlauth,
|
perform_lnurlauth,
|
||||||
websocketManager,
|
websocketManager,
|
||||||
@@ -95,13 +109,15 @@ from ..services import (
|
|||||||
)
|
)
|
||||||
from ..tasks import api_invoice_listeners
|
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():
|
async def health():
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
||||||
@core_app.get("/api/v1/wallet")
|
@api_router.get("/api/v1/wallet")
|
||||||
async def api_wallet(wallet: WalletTypeInfo = Depends(get_key_type)):
|
async def api_wallet(wallet: WalletTypeInfo = Depends(get_key_type)):
|
||||||
if wallet.wallet_type == WalletType.admin:
|
if wallet.wallet_type == WalletType.admin:
|
||||||
return {
|
return {
|
||||||
@@ -113,7 +129,16 @@ async def api_wallet(wallet: WalletTypeInfo = Depends(get_key_type)):
|
|||||||
return {"name": wallet.wallet.name, "balance": wallet.wallet.balance_msat}
|
return {"name": wallet.wallet.name, "balance": wallet.wallet.balance_msat}
|
||||||
|
|
||||||
|
|
||||||
@core_app.put("/api/v1/wallet/{new_name}")
|
@api_router.get(
|
||||||
|
"/api/v1/wallets",
|
||||||
|
name="Wallets",
|
||||||
|
description="Get basic info for all of user's wallets.",
|
||||||
|
)
|
||||||
|
async def api_wallets(user: User = Depends(check_user_exists)) -> List[BaseWallet]:
|
||||||
|
return [BaseWallet(**w.dict()) for w in user.wallets]
|
||||||
|
|
||||||
|
|
||||||
|
@api_router.put("/api/v1/wallet/{new_name}")
|
||||||
async def api_update_wallet_name(
|
async def api_update_wallet_name(
|
||||||
new_name: str, wallet: WalletTypeInfo = Depends(require_admin_key)
|
new_name: str, wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||||
):
|
):
|
||||||
@@ -125,7 +150,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(
|
async def api_update_wallet(
|
||||||
name: Optional[str] = Body(None),
|
name: Optional[str] = Body(None),
|
||||||
currency: Optional[str] = Body(None),
|
currency: Optional[str] = Body(None),
|
||||||
@@ -134,7 +159,36 @@ async def api_update_wallet(
|
|||||||
return await update_wallet(wallet.wallet.id, name, currency)
|
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",
|
"/api/v1/payments",
|
||||||
name="Payment List",
|
name="Payment List",
|
||||||
summary="get list of payments",
|
summary="get list of payments",
|
||||||
@@ -146,16 +200,7 @@ async def api_payments(
|
|||||||
wallet: WalletTypeInfo = Depends(get_key_type),
|
wallet: WalletTypeInfo = Depends(get_key_type),
|
||||||
filters: Filters = Depends(parse_filters(PaymentFilters)),
|
filters: Filters = Depends(parse_filters(PaymentFilters)),
|
||||||
):
|
):
|
||||||
pending_payments = await get_payments(
|
await update_pending_payments(wallet.wallet.id)
|
||||||
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
|
|
||||||
)
|
|
||||||
return await get_payments(
|
return await get_payments(
|
||||||
wallet_id=wallet.wallet.id,
|
wallet_id=wallet.wallet.id,
|
||||||
pending=True,
|
pending=True,
|
||||||
@@ -164,7 +209,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",
|
"/api/v1/payments/paginated",
|
||||||
name="Payment List",
|
name="Payment List",
|
||||||
summary="get paginated list of payments",
|
summary="get paginated list of payments",
|
||||||
@@ -176,16 +236,7 @@ async def api_payments_paginated(
|
|||||||
wallet: WalletTypeInfo = Depends(get_key_type),
|
wallet: WalletTypeInfo = Depends(get_key_type),
|
||||||
filters: Filters = Depends(parse_filters(PaymentFilters)),
|
filters: Filters = Depends(parse_filters(PaymentFilters)),
|
||||||
):
|
):
|
||||||
pending = await get_payments_paginated(
|
await update_pending_payments(wallet.wallet.id)
|
||||||
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
|
|
||||||
)
|
|
||||||
page = await get_payments_paginated(
|
page = await get_payments_paginated(
|
||||||
wallet_id=wallet.wallet.id,
|
wallet_id=wallet.wallet.id,
|
||||||
pending=True,
|
pending=True,
|
||||||
@@ -252,7 +303,8 @@ async def api_payments_create_invoice(data: CreateInvoice, wallet: Wallet):
|
|||||||
if data.lnurl_balance_check is not None:
|
if data.lnurl_balance_check is not None:
|
||||||
await save_balance_check(wallet.id, data.lnurl_balance_check)
|
await save_balance_check(wallet.id, data.lnurl_balance_check)
|
||||||
|
|
||||||
async with httpx.AsyncClient() as client:
|
headers = {"User-Agent": settings.user_agent}
|
||||||
|
async with httpx.AsyncClient(headers=headers) as client:
|
||||||
try:
|
try:
|
||||||
r = await client.get(
|
r = await client.get(
|
||||||
data.lnurl_callback,
|
data.lnurl_callback,
|
||||||
@@ -287,9 +339,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:
|
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:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e))
|
raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e))
|
||||||
except PermissionError as e:
|
except PermissionError as e:
|
||||||
@@ -306,7 +362,7 @@ async def api_payments_pay_invoice(bolt11: str, wallet: Wallet):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@core_app.post(
|
@api_router.post(
|
||||||
"/api/v1/payments",
|
"/api/v1/payments",
|
||||||
summary="Create or pay an invoice",
|
summary="Create or pay an invoice",
|
||||||
description="""
|
description="""
|
||||||
@@ -330,7 +386,7 @@ async def api_payments_create(
|
|||||||
detail="BOLT11 string is invalid or not given",
|
detail="BOLT11 string is invalid or not given",
|
||||||
)
|
)
|
||||||
return await api_payments_pay_invoice(
|
return await api_payments_pay_invoice(
|
||||||
invoiceData.bolt11, wallet.wallet
|
invoiceData.bolt11, wallet.wallet, invoiceData.extra
|
||||||
) # admin key
|
) # admin key
|
||||||
elif not invoiceData.out:
|
elif not invoiceData.out:
|
||||||
# invoice key
|
# invoice key
|
||||||
@@ -342,21 +398,44 @@ 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(
|
async def api_payments_pay_lnurl(
|
||||||
data: CreateLnurl, wallet: WalletTypeInfo = Depends(require_admin_key)
|
data: CreateLnurl, wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||||
):
|
):
|
||||||
domain = urlparse(data.callback).netloc
|
domain = urlparse(data.callback).netloc
|
||||||
|
|
||||||
async with httpx.AsyncClient() as client:
|
headers = {"User-Agent": settings.user_agent}
|
||||||
|
async with httpx.AsyncClient(headers=headers, follow_redirects=True) as client:
|
||||||
try:
|
try:
|
||||||
|
if data.unit and data.unit != "sat":
|
||||||
|
amount_msat = await fiat_amount_as_satoshis(data.amount, data.unit)
|
||||||
|
# no msat precision
|
||||||
|
amount_msat = ceil(amount_msat // 1000) * 1000
|
||||||
|
else:
|
||||||
|
amount_msat = data.amount
|
||||||
r = await client.get(
|
r = await client.get(
|
||||||
data.callback,
|
data.callback,
|
||||||
params={"amount": data.amount, "comment": data.comment},
|
params={"amount": amount_msat, "comment": data.comment},
|
||||||
timeout=40,
|
timeout=40,
|
||||||
)
|
)
|
||||||
if r.is_error:
|
if r.is_error:
|
||||||
raise httpx.ConnectError("LNURL callback connection error")
|
raise httpx.ConnectError("LNURL callback connection error")
|
||||||
|
r.raise_for_status()
|
||||||
except (httpx.ConnectError, httpx.RequestError):
|
except (httpx.ConnectError, httpx.RequestError):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.BAD_REQUEST,
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
@@ -377,24 +456,13 @@ async def api_payments_pay_lnurl(
|
|||||||
)
|
)
|
||||||
|
|
||||||
invoice = bolt11.decode(params["pr"])
|
invoice = bolt11.decode(params["pr"])
|
||||||
if invoice.amount_msat != data.amount:
|
if invoice.amount_msat != amount_msat:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.BAD_REQUEST,
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
detail=(
|
detail=(
|
||||||
(
|
(
|
||||||
f"{domain} returned an invalid invoice. Expected"
|
f"{domain} returned an invalid invoice. Expected"
|
||||||
f" {data.amount} msat, got {invoice.amount_msat}."
|
f" {amount_msat} msat, got {invoice.amount_msat}."
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
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}."
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -405,6 +473,9 @@ async def api_payments_pay_lnurl(
|
|||||||
extra["success_action"] = params["successAction"]
|
extra["success_action"] = params["successAction"]
|
||||||
if data.comment:
|
if data.comment:
|
||||||
extra["comment"] = data.comment
|
extra["comment"] = data.comment
|
||||||
|
if data.unit and data.unit != "sat":
|
||||||
|
extra["fiat_currency"] = data.unit
|
||||||
|
extra["fiat_amount"] = data.amount / 1000
|
||||||
assert data.description is not None, "description is required"
|
assert data.description is not None, "description is required"
|
||||||
payment_hash = await pay_invoice(
|
payment_hash = await pay_invoice(
|
||||||
wallet_id=wallet.wallet.id,
|
wallet_id=wallet.wallet.id,
|
||||||
@@ -445,11 +516,13 @@ async def subscribe_wallet_invoices(request: Request, wallet: Wallet):
|
|||||||
yield dict(data=payment.json(), event="payment-received")
|
yield dict(data=payment.json(), event="payment-received")
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
logger.debug(f"removing listener for wallet {uid}")
|
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)
|
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(
|
async def api_payments_sse(
|
||||||
request: Request, wallet: WalletTypeInfo = Depends(get_key_type)
|
request: Request, wallet: WalletTypeInfo = Depends(get_key_type)
|
||||||
):
|
):
|
||||||
@@ -461,15 +534,12 @@ async def api_payments_sse(
|
|||||||
|
|
||||||
|
|
||||||
# TODO: refactor this route into a public and admin one
|
# 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)):
|
async def api_payment(payment_hash, X_Api_Key: Optional[str] = Header(None)):
|
||||||
# We use X_Api_Key here because we want this call to work with and without keys
|
# We use X_Api_Key here because we want this call to work with and without keys
|
||||||
# If a valid key is given, we also return the field "details", otherwise not
|
# If a valid key is given, we also return the field "details", otherwise not
|
||||||
wallet = await get_wallet_for_key(X_Api_Key) if isinstance(X_Api_Key, str) else None
|
wallet = await get_wallet_for_key(X_Api_Key) if isinstance(X_Api_Key, str) else None
|
||||||
|
|
||||||
# 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
|
|
||||||
payment = await get_standalone_payment(
|
payment = await get_standalone_payment(
|
||||||
payment_hash, wallet_id=wallet.id if wallet else None
|
payment_hash, wallet_id=wallet.id if wallet else None
|
||||||
)
|
)
|
||||||
@@ -506,10 +576,10 @@ async def api_payment(payment_hash, X_Api_Key: Optional[str] = Header(None)):
|
|||||||
return {"paid": not payment.pending, "preimage": payment.preimage}
|
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)):
|
async def api_lnurlscan(code: str, wallet: WalletTypeInfo = Depends(get_key_type)):
|
||||||
try:
|
try:
|
||||||
url = lnurl.decode(code)
|
url = str(lnurl_decode(code))
|
||||||
domain = urlparse(url).netloc
|
domain = urlparse(url).netloc
|
||||||
except Exception:
|
except Exception:
|
||||||
# parse internet identifier (user@domain.com)
|
# parse internet identifier (user@domain.com)
|
||||||
@@ -539,7 +609,8 @@ async def api_lnurlscan(code: str, wallet: WalletTypeInfo = Depends(get_key_type
|
|||||||
assert lnurlauth_key.verifying_key
|
assert lnurlauth_key.verifying_key
|
||||||
params.update(pubkey=lnurlauth_key.verifying_key.to_string("compressed").hex())
|
params.update(pubkey=lnurlauth_key.verifying_key.to_string("compressed").hex())
|
||||||
else:
|
else:
|
||||||
async with httpx.AsyncClient(follow_redirects=True) as client:
|
headers = {"User-Agent": settings.user_agent}
|
||||||
|
async with httpx.AsyncClient(headers=headers, follow_redirects=True) as client:
|
||||||
r = await client.get(url, timeout=5)
|
r = await client.get(url, timeout=5)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
if r.is_error:
|
if r.is_error:
|
||||||
@@ -621,33 +692,24 @@ async def api_lnurlscan(code: str, wallet: WalletTypeInfo = Depends(get_key_type
|
|||||||
return params
|
return params
|
||||||
|
|
||||||
|
|
||||||
@core_app.post("/api/v1/payments/decode", status_code=HTTPStatus.OK)
|
@api_router.post("/api/v1/payments/decode", status_code=HTTPStatus.OK)
|
||||||
async def api_payments_decode(data: DecodePayment, response: Response):
|
async def api_payments_decode(data: DecodePayment) -> JSONResponse:
|
||||||
payment_str = data.data
|
payment_str = data.data
|
||||||
try:
|
try:
|
||||||
if payment_str[:5] == "LNURL":
|
if payment_str[:5] == "LNURL":
|
||||||
url = lnurl.decode(payment_str)
|
url = str(lnurl_decode(payment_str))
|
||||||
return {"domain": url}
|
return JSONResponse({"domain": url})
|
||||||
else:
|
else:
|
||||||
invoice = bolt11.decode(payment_str)
|
invoice = bolt11.decode(payment_str)
|
||||||
return {
|
return JSONResponse(invoice.data)
|
||||||
"payment_hash": invoice.payment_hash,
|
except Exception as exc:
|
||||||
"amount_msat": invoice.amount_msat,
|
return JSONResponse(
|
||||||
"description": invoice.description,
|
{"message": f"Failed to decode: {str(exc)}"},
|
||||||
"description_hash": invoice.description_hash,
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
"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"}
|
|
||||||
|
|
||||||
|
|
||||||
@core_app.post("/api/v1/lnurlauth")
|
@api_router.post("/api/v1/lnurlauth")
|
||||||
async def api_perform_lnurlauth(
|
async def api_perform_lnurlauth(
|
||||||
data: CreateLnurlAuth, wallet: WalletTypeInfo = Depends(require_admin_key)
|
data: CreateLnurlAuth, wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||||
):
|
):
|
||||||
@@ -659,18 +721,12 @@ async def api_perform_lnurlauth(
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
@core_app.get("/api/v1/currencies")
|
@api_router.get("/api/v1/currencies")
|
||||||
async def api_list_currencies_available():
|
async def api_list_currencies_available() -> List[str]:
|
||||||
if len(settings.lnbits_allowed_currencies) > 0:
|
return allowed_currencies()
|
||||||
return [
|
|
||||||
item
|
|
||||||
for item in currencies.keys()
|
|
||||||
if item.upper() in settings.lnbits_allowed_currencies
|
|
||||||
]
|
|
||||||
return list(currencies.keys())
|
|
||||||
|
|
||||||
|
|
||||||
@core_app.post("/api/v1/conversion")
|
@api_router.post("/api/v1/conversion")
|
||||||
async def api_fiat_as_sats(data: ConversionData):
|
async def api_fiat_as_sats(data: ConversionData):
|
||||||
output = {}
|
output = {}
|
||||||
if data.from_ == "sat":
|
if data.from_ == "sat":
|
||||||
@@ -688,7 +744,7 @@ async def api_fiat_as_sats(data: ConversionData):
|
|||||||
return output
|
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):
|
async def img(data):
|
||||||
qr = pyqrcode.create(data)
|
qr = pyqrcode.create(data)
|
||||||
stream = BytesIO()
|
stream = BytesIO()
|
||||||
@@ -709,7 +765,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):
|
async def websocket_connect(websocket: WebSocket, item_id: str):
|
||||||
await websocketManager.connect(websocket, item_id)
|
await websocketManager.connect(websocket, item_id)
|
||||||
try:
|
try:
|
||||||
@@ -719,7 +775,7 @@ async def websocket_connect(websocket: WebSocket, item_id: str):
|
|||||||
websocketManager.disconnect(websocket)
|
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):
|
async def websocket_update_post(item_id: str, data: str):
|
||||||
try:
|
try:
|
||||||
await websocketUpdater(item_id, data)
|
await websocketUpdater(item_id, data)
|
||||||
@@ -728,7 +784,7 @@ async def websocket_update_post(item_id: str, data: str):
|
|||||||
return {"sent": False, "data": data}
|
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):
|
async def websocket_update_get(item_id: str, data: str):
|
||||||
try:
|
try:
|
||||||
await websocketUpdater(item_id, data)
|
await websocketUpdater(item_id, data)
|
||||||
@@ -737,12 +793,14 @@ async def websocket_update_get(item_id: str, data: str):
|
|||||||
return {"sent": False, "data": data}
|
return {"sent": False, "data": data}
|
||||||
|
|
||||||
|
|
||||||
@core_app.post("/api/v1/extension")
|
@api_router.post("/api/v1/extension")
|
||||||
async def api_install_extension(
|
async def api_install_extension(
|
||||||
data: CreateExtension, user: User = Depends(check_admin)
|
data: CreateExtension,
|
||||||
|
user: User = Depends(check_admin),
|
||||||
|
access_token: Optional[str] = Depends(check_access_token),
|
||||||
):
|
):
|
||||||
release = await InstallableExtension.get_extension_release(
|
release = await InstallableExtension.get_extension_release(
|
||||||
data.ext_id, data.source_repo, data.archive
|
data.ext_id, data.source_repo, data.archive, data.version
|
||||||
)
|
)
|
||||||
if not release:
|
if not release:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -754,13 +812,17 @@ async def api_install_extension(
|
|||||||
status_code=HTTPStatus.BAD_REQUEST, detail="Incompatible extension version"
|
status_code=HTTPStatus.BAD_REQUEST, detail="Incompatible extension version"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
release.payment_hash = data.payment_hash
|
||||||
ext_info = InstallableExtension(
|
ext_info = InstallableExtension(
|
||||||
id=data.ext_id, name=data.ext_id, installed_release=release, icon=release.icon
|
id=data.ext_id, name=data.ext_id, installed_release=release, icon=release.icon
|
||||||
)
|
)
|
||||||
|
|
||||||
ext_info.download_archive()
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
installed_ext = await get_installed_extension(data.ext_id)
|
||||||
|
ext_info.payments = installed_ext.payments if installed_ext else []
|
||||||
|
|
||||||
|
await ext_info.download_archive()
|
||||||
|
|
||||||
ext_info.extract_archive()
|
ext_info.extract_archive()
|
||||||
|
|
||||||
extension = Extension.from_installable_ext(ext_info)
|
extension = Extension.from_installable_ext(ext_info)
|
||||||
@@ -770,8 +832,9 @@ async def api_install_extension(
|
|||||||
|
|
||||||
await add_installed_extension(ext_info)
|
await add_installed_extension(ext_info)
|
||||||
|
|
||||||
|
if extension.is_upgrade_extension:
|
||||||
# call stop while the old routes are still active
|
# call stop while the old routes are still active
|
||||||
await stop_extension_background_work(data.ext_id, user.id)
|
await stop_extension_background_work(data.ext_id, user.id, access_token)
|
||||||
|
|
||||||
if data.ext_id not in settings.lnbits_deactivated_extensions:
|
if data.ext_id not in settings.lnbits_deactivated_extensions:
|
||||||
settings.lnbits_deactivated_extensions += [data.ext_id]
|
settings.lnbits_deactivated_extensions += [data.ext_id]
|
||||||
@@ -783,7 +846,8 @@ async def api_install_extension(
|
|||||||
ext_info.nofiy_upgrade()
|
ext_info.nofiy_upgrade()
|
||||||
|
|
||||||
return extension
|
return extension
|
||||||
|
except AssertionError as e:
|
||||||
|
raise HTTPException(HTTPStatus.BAD_REQUEST, str(e))
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
logger.warning(ex)
|
logger.warning(ex)
|
||||||
ext_info.clean_extension_files()
|
ext_info.clean_extension_files()
|
||||||
@@ -796,8 +860,12 @@ 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)):
|
async def api_uninstall_extension(
|
||||||
|
ext_id: str,
|
||||||
|
user: User = Depends(check_admin),
|
||||||
|
access_token: Optional[str] = Depends(check_access_token),
|
||||||
|
):
|
||||||
installable_extensions = await InstallableExtension.get_installable_extensions()
|
installable_extensions = await InstallableExtension.get_installable_extensions()
|
||||||
|
|
||||||
extensions = [e for e in installable_extensions if e.id == ext_id]
|
extensions = [e for e in installable_extensions if e.id == ext_id]
|
||||||
@@ -823,7 +891,7 @@ async def api_uninstall_extension(ext_id: str, user: User = Depends(check_admin)
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
# call stop while the old routes are still active
|
# call stop while the old routes are still active
|
||||||
await stop_extension_background_work(ext_id, user.id)
|
await stop_extension_background_work(ext_id, user.id, access_token)
|
||||||
|
|
||||||
if ext_id not in settings.lnbits_deactivated_extensions:
|
if ext_id not in settings.lnbits_deactivated_extensions:
|
||||||
settings.lnbits_deactivated_extensions += [ext_id]
|
settings.lnbits_deactivated_extensions += [ext_id]
|
||||||
@@ -839,14 +907,23 @@ 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)]
|
"/api/v1/extension/{ext_id}/releases", dependencies=[Depends(check_admin)]
|
||||||
)
|
)
|
||||||
async def get_extension_releases(ext_id: str):
|
async def get_extension_releases(ext_id: str):
|
||||||
try:
|
try:
|
||||||
extension_releases: List[
|
extension_releases: List[ExtensionRelease] = (
|
||||||
ExtensionRelease
|
await InstallableExtension.get_extension_releases(ext_id)
|
||||||
] = await InstallableExtension.get_extension_releases(ext_id)
|
)
|
||||||
|
|
||||||
|
installed_ext = await get_installed_extension(ext_id)
|
||||||
|
if not installed_ext:
|
||||||
|
return extension_releases
|
||||||
|
|
||||||
|
for release in extension_releases:
|
||||||
|
payment_info = installed_ext.find_existing_payment(release.pay_link)
|
||||||
|
if payment_info:
|
||||||
|
release.paid_sats = payment_info.amount
|
||||||
|
|
||||||
return extension_releases
|
return extension_releases
|
||||||
|
|
||||||
@@ -856,7 +933,41 @@ async def get_extension_releases(ext_id: str):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@core_app.get(
|
@api_router.put("/api/v1/extension/invoice", dependencies=[Depends(check_admin)])
|
||||||
|
async def get_extension_invoice(data: CreateExtension):
|
||||||
|
try:
|
||||||
|
assert data.cost_sats, "A non-zero amount must be specified"
|
||||||
|
release = await InstallableExtension.get_extension_release(
|
||||||
|
data.ext_id, data.source_repo, data.archive, data.version
|
||||||
|
)
|
||||||
|
assert release, "Release not found"
|
||||||
|
assert release.pay_link, "Pay link not found for release"
|
||||||
|
|
||||||
|
payment_info = await fetch_release_payment_info(
|
||||||
|
release.pay_link, data.cost_sats
|
||||||
|
)
|
||||||
|
assert payment_info and payment_info.payment_request, "Cannot request invoice"
|
||||||
|
invoice = bolt11.decode(payment_info.payment_request)
|
||||||
|
|
||||||
|
assert invoice.amount_msat is not None, "Invoic amount is missing"
|
||||||
|
invoice_amount = int(invoice.amount_msat / 1000)
|
||||||
|
assert (
|
||||||
|
invoice_amount == data.cost_sats
|
||||||
|
), f"Wrong invoice amount: {invoice_amount}."
|
||||||
|
assert (
|
||||||
|
payment_info.payment_hash == invoice.payment_hash
|
||||||
|
), "Wroong invoice payment hash"
|
||||||
|
|
||||||
|
return payment_info
|
||||||
|
|
||||||
|
except AssertionError as e:
|
||||||
|
raise HTTPException(HTTPStatus.BAD_REQUEST, str(e))
|
||||||
|
except Exception as ex:
|
||||||
|
logger.warning(ex)
|
||||||
|
raise HTTPException(HTTPStatus.INTERNAL_SERVER_ERROR, "Cannot request invoice")
|
||||||
|
|
||||||
|
|
||||||
|
@api_router.get(
|
||||||
"/api/v1/extension/release/{org}/{repo}/{tag_name}",
|
"/api/v1/extension/release/{org}/{repo}/{tag_name}",
|
||||||
dependencies=[Depends(check_admin)],
|
dependencies=[Depends(check_admin)],
|
||||||
)
|
)
|
||||||
@@ -877,7 +988,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",
|
"/api/v1/extension/{ext_id}/db",
|
||||||
dependencies=[Depends(check_admin)],
|
dependencies=[Depends(check_admin)],
|
||||||
)
|
)
|
||||||
@@ -901,88 +1012,3 @@ async def delete_extension_db(ext_id: str):
|
|||||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||||
detail=f"Cannot delete data for extension '{ext_id}'",
|
detail=f"Cannot delete data for extension '{ext_id}'",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# TINYURL
|
|
||||||
|
|
||||||
|
|
||||||
@core_app.post(
|
|
||||||
"/api/v1/tinyurl",
|
|
||||||
name="Tinyurl",
|
|
||||||
description="creates a tinyurl",
|
|
||||||
)
|
|
||||||
async def api_create_tinyurl(
|
|
||||||
url: str, endless: bool = False, wallet: WalletTypeInfo = Depends(get_key_type)
|
|
||||||
):
|
|
||||||
tinyurls = await get_tinyurl_by_url(url)
|
|
||||||
try:
|
|
||||||
for tinyurl in tinyurls:
|
|
||||||
if tinyurl:
|
|
||||||
if tinyurl.wallet == wallet.wallet.inkey:
|
|
||||||
return tinyurl
|
|
||||||
return await create_tinyurl(url, endless, wallet.wallet.inkey)
|
|
||||||
except Exception:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=HTTPStatus.BAD_REQUEST, detail="Unable to create tinyurl"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@core_app.get(
|
|
||||||
"/api/v1/tinyurl/{tinyurl_id}",
|
|
||||||
name="Tinyurl",
|
|
||||||
description="get a tinyurl by id",
|
|
||||||
)
|
|
||||||
async def api_get_tinyurl(
|
|
||||||
tinyurl_id: str, wallet: WalletTypeInfo = Depends(get_key_type)
|
|
||||||
):
|
|
||||||
try:
|
|
||||||
tinyurl = await get_tinyurl(tinyurl_id)
|
|
||||||
if tinyurl:
|
|
||||||
if tinyurl.wallet == wallet.wallet.inkey:
|
|
||||||
return tinyurl
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=HTTPStatus.FORBIDDEN, detail="Wrong key provided."
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=HTTPStatus.NOT_FOUND, detail="Unable to fetch tinyurl"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@core_app.delete(
|
|
||||||
"/api/v1/tinyurl/{tinyurl_id}",
|
|
||||||
name="Tinyurl",
|
|
||||||
description="delete a tinyurl by id",
|
|
||||||
)
|
|
||||||
async def api_delete_tinyurl(
|
|
||||||
tinyurl_id: str, wallet: WalletTypeInfo = Depends(get_key_type)
|
|
||||||
):
|
|
||||||
try:
|
|
||||||
tinyurl = await get_tinyurl(tinyurl_id)
|
|
||||||
if tinyurl:
|
|
||||||
if tinyurl.wallet == wallet.wallet.inkey:
|
|
||||||
await delete_tinyurl(tinyurl_id)
|
|
||||||
return {"deleted": True}
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=HTTPStatus.FORBIDDEN, detail="Wrong key provided."
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=HTTPStatus.BAD_REQUEST, detail="Unable to delete"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@core_app.get(
|
|
||||||
"/t/{tinyurl_id}",
|
|
||||||
name="Tinyurl",
|
|
||||||
description="redirects a tinyurl by id",
|
|
||||||
)
|
|
||||||
async def api_tinyurl(tinyurl_id: str):
|
|
||||||
tinyurl = await get_tinyurl(tinyurl_id)
|
|
||||||
if tinyurl:
|
|
||||||
response = RedirectResponse(url=tinyurl.url)
|
|
||||||
return response
|
|
||||||
else:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=HTTPStatus.NOT_FOUND, detail="unable to find tinyurl"
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -0,0 +1,346 @@
|
|||||||
|
import importlib
|
||||||
|
from typing import Callable, Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||||
|
from fastapi.responses import JSONResponse, RedirectResponse
|
||||||
|
from fastapi_sso.sso.base import OpenID, SSOBase
|
||||||
|
from loguru import logger
|
||||||
|
from starlette.status import (
|
||||||
|
HTTP_400_BAD_REQUEST,
|
||||||
|
HTTP_401_UNAUTHORIZED,
|
||||||
|
HTTP_403_FORBIDDEN,
|
||||||
|
HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
)
|
||||||
|
|
||||||
|
from lnbits.decorators import check_user_exists
|
||||||
|
from lnbits.helpers import (
|
||||||
|
create_access_token,
|
||||||
|
decrypt_internal_message,
|
||||||
|
encrypt_internal_message,
|
||||||
|
is_valid_email_address,
|
||||||
|
is_valid_username,
|
||||||
|
)
|
||||||
|
from lnbits.settings import AuthMethods, settings
|
||||||
|
|
||||||
|
from ..crud import (
|
||||||
|
create_account,
|
||||||
|
create_user,
|
||||||
|
get_account,
|
||||||
|
get_account_by_email,
|
||||||
|
get_account_by_username_or_email,
|
||||||
|
get_user,
|
||||||
|
update_account,
|
||||||
|
update_user_password,
|
||||||
|
verify_user_password,
|
||||||
|
)
|
||||||
|
from ..models import (
|
||||||
|
CreateUser,
|
||||||
|
LoginUsernamePassword,
|
||||||
|
LoginUsr,
|
||||||
|
UpdateSuperuserPassword,
|
||||||
|
UpdateUser,
|
||||||
|
UpdateUserPassword,
|
||||||
|
User,
|
||||||
|
UserConfig,
|
||||||
|
)
|
||||||
|
|
||||||
|
auth_router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@auth_router.get("/api/v1/auth", description="Get the authenticated user")
|
||||||
|
async def get_auth_user(user: User = Depends(check_user_exists)) -> User:
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@auth_router.post("/api/v1/auth", description="Login via the username and password")
|
||||||
|
async def login(data: LoginUsernamePassword) -> JSONResponse:
|
||||||
|
if not settings.is_auth_method_allowed(AuthMethods.username_and_password):
|
||||||
|
raise HTTPException(
|
||||||
|
HTTP_401_UNAUTHORIZED, "Login by 'Username and Password' not allowed."
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
user = await get_account_by_username_or_email(data.username)
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(HTTP_401_UNAUTHORIZED, "Invalid credentials.")
|
||||||
|
if not await verify_user_password(user.id, data.password):
|
||||||
|
raise HTTPException(HTTP_401_UNAUTHORIZED, "Invalid credentials.")
|
||||||
|
|
||||||
|
return _auth_success_response(user.username, user.id)
|
||||||
|
except HTTPException as e:
|
||||||
|
raise e
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(e)
|
||||||
|
raise HTTPException(HTTP_500_INTERNAL_SERVER_ERROR, "Cannot login.")
|
||||||
|
|
||||||
|
|
||||||
|
@auth_router.post("/api/v1/auth/usr", description="Login via the User ID")
|
||||||
|
async def login_usr(data: LoginUsr) -> JSONResponse:
|
||||||
|
if not settings.is_auth_method_allowed(AuthMethods.user_id_only):
|
||||||
|
raise HTTPException(HTTP_401_UNAUTHORIZED, "Login by 'User ID' not allowed.")
|
||||||
|
|
||||||
|
try:
|
||||||
|
user = await get_user(data.usr)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(HTTP_401_UNAUTHORIZED, "User ID does not exist.")
|
||||||
|
|
||||||
|
return _auth_success_response(user.username or "", user.id)
|
||||||
|
except HTTPException as e:
|
||||||
|
raise e
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(e)
|
||||||
|
raise HTTPException(HTTP_500_INTERNAL_SERVER_ERROR, "Cannot login.")
|
||||||
|
|
||||||
|
|
||||||
|
@auth_router.get("/api/v1/auth/{provider}", description="SSO Provider")
|
||||||
|
async def login_with_sso_provider(
|
||||||
|
request: Request, provider: str, user_id: Optional[str] = None
|
||||||
|
):
|
||||||
|
provider_sso = _new_sso(provider)
|
||||||
|
if not provider_sso:
|
||||||
|
raise HTTPException(
|
||||||
|
HTTP_401_UNAUTHORIZED, f"Login by '{provider}' not allowed."
|
||||||
|
)
|
||||||
|
|
||||||
|
provider_sso.redirect_uri = str(request.base_url) + f"api/v1/auth/{provider}/token"
|
||||||
|
with provider_sso:
|
||||||
|
state = encrypt_internal_message(user_id)
|
||||||
|
return await provider_sso.get_login_redirect(state=state)
|
||||||
|
|
||||||
|
|
||||||
|
@auth_router.get("/api/v1/auth/{provider}/token", description="Handle OAuth callback")
|
||||||
|
async def handle_oauth_token(request: Request, provider: str) -> RedirectResponse:
|
||||||
|
provider_sso = _new_sso(provider)
|
||||||
|
if not provider_sso:
|
||||||
|
raise HTTPException(
|
||||||
|
HTTP_401_UNAUTHORIZED, f"Login by '{provider}' not allowed."
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with provider_sso:
|
||||||
|
userinfo = await provider_sso.verify_and_process(request)
|
||||||
|
assert userinfo is not None
|
||||||
|
user_id = decrypt_internal_message(provider_sso.state)
|
||||||
|
request.session.pop("user", None)
|
||||||
|
return await _handle_sso_login(userinfo, user_id)
|
||||||
|
except HTTPException as e:
|
||||||
|
raise e
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(HTTP_403_FORBIDDEN, str(e))
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(e)
|
||||||
|
raise HTTPException(
|
||||||
|
HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
f"Cannot authenticate user with {provider} Auth.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@auth_router.post("/api/v1/auth/logout")
|
||||||
|
async def logout() -> JSONResponse:
|
||||||
|
response = JSONResponse({"status": "success"}, status_code=status.HTTP_200_OK)
|
||||||
|
response.delete_cookie("cookie_access_token")
|
||||||
|
response.delete_cookie("is_lnbits_user_authorized")
|
||||||
|
response.delete_cookie("is_access_token_expired")
|
||||||
|
response.delete_cookie("lnbits_last_active_wallet")
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@auth_router.post("/api/v1/auth/register")
|
||||||
|
async def register(data: CreateUser) -> JSONResponse:
|
||||||
|
if not settings.is_auth_method_allowed(AuthMethods.username_and_password):
|
||||||
|
raise HTTPException(
|
||||||
|
HTTP_401_UNAUTHORIZED, "Register by 'Username and Password' not allowed."
|
||||||
|
)
|
||||||
|
|
||||||
|
if data.password != data.password_repeat:
|
||||||
|
raise HTTPException(HTTP_400_BAD_REQUEST, "Passwords do not match.")
|
||||||
|
|
||||||
|
if not data.username:
|
||||||
|
raise HTTPException(HTTP_400_BAD_REQUEST, "Missing username.")
|
||||||
|
if not is_valid_username(data.username):
|
||||||
|
raise HTTPException(HTTP_400_BAD_REQUEST, "Invalid username.")
|
||||||
|
|
||||||
|
if data.email and not is_valid_email_address(data.email):
|
||||||
|
raise HTTPException(HTTP_400_BAD_REQUEST, "Invalid email.")
|
||||||
|
|
||||||
|
try:
|
||||||
|
user = await create_user(data)
|
||||||
|
return _auth_success_response(user.username)
|
||||||
|
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(HTTP_403_FORBIDDEN, str(e))
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(e)
|
||||||
|
raise HTTPException(HTTP_500_INTERNAL_SERVER_ERROR, "Cannot create user.")
|
||||||
|
|
||||||
|
|
||||||
|
@auth_router.put("/api/v1/auth/password")
|
||||||
|
async def update_password(
|
||||||
|
data: UpdateUserPassword, user: User = Depends(check_user_exists)
|
||||||
|
) -> Optional[User]:
|
||||||
|
if not settings.is_auth_method_allowed(AuthMethods.username_and_password):
|
||||||
|
raise HTTPException(
|
||||||
|
HTTP_401_UNAUTHORIZED, "Auth by 'Username and Password' not allowed."
|
||||||
|
)
|
||||||
|
if data.user_id != user.id:
|
||||||
|
raise HTTPException(HTTP_400_BAD_REQUEST, "Invalid user ID.")
|
||||||
|
|
||||||
|
try:
|
||||||
|
return await update_user_password(data)
|
||||||
|
except AssertionError as e:
|
||||||
|
raise HTTPException(HTTP_403_FORBIDDEN, str(e))
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(e)
|
||||||
|
raise HTTPException(
|
||||||
|
HTTP_500_INTERNAL_SERVER_ERROR, "Cannot update user password."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@auth_router.put("/api/v1/auth/update")
|
||||||
|
async def update(
|
||||||
|
data: UpdateUser, user: User = Depends(check_user_exists)
|
||||||
|
) -> Optional[User]:
|
||||||
|
if data.user_id != user.id:
|
||||||
|
raise HTTPException(HTTP_400_BAD_REQUEST, "Invalid user ID.")
|
||||||
|
if data.username and not is_valid_username(data.username):
|
||||||
|
raise HTTPException(HTTP_400_BAD_REQUEST, "Invalid username.")
|
||||||
|
if data.email != user.email:
|
||||||
|
raise HTTPException(HTTP_400_BAD_REQUEST, "Email mismatch.")
|
||||||
|
|
||||||
|
try:
|
||||||
|
return await update_account(user.id, data.username, None, data.config)
|
||||||
|
except AssertionError as e:
|
||||||
|
raise HTTPException(HTTP_403_FORBIDDEN, str(e))
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(e)
|
||||||
|
raise HTTPException(HTTP_500_INTERNAL_SERVER_ERROR, "Cannot update user.")
|
||||||
|
|
||||||
|
|
||||||
|
@auth_router.put("/api/v1/auth/first_install")
|
||||||
|
async def first_install(data: UpdateSuperuserPassword) -> JSONResponse:
|
||||||
|
if not settings.first_install:
|
||||||
|
raise HTTPException(HTTP_401_UNAUTHORIZED, "This is not your first install")
|
||||||
|
try:
|
||||||
|
await update_account(
|
||||||
|
user_id=settings.super_user,
|
||||||
|
username=data.username,
|
||||||
|
user_config=UserConfig(provider="lnbits"),
|
||||||
|
)
|
||||||
|
super_user = UpdateUserPassword(
|
||||||
|
user_id=settings.super_user,
|
||||||
|
password=data.password,
|
||||||
|
password_repeat=data.password_repeat,
|
||||||
|
username=data.username,
|
||||||
|
)
|
||||||
|
await update_user_password(super_user)
|
||||||
|
settings.first_install = False
|
||||||
|
return _auth_success_response(username=super_user.username)
|
||||||
|
except AssertionError as e:
|
||||||
|
raise HTTPException(HTTP_403_FORBIDDEN, str(e))
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(e)
|
||||||
|
raise HTTPException(
|
||||||
|
HTTP_500_INTERNAL_SERVER_ERROR, "Cannot update user password."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _handle_sso_login(userinfo: OpenID, verified_user_id: Optional[str] = None):
|
||||||
|
email = userinfo.email
|
||||||
|
if not email or not is_valid_email_address(email):
|
||||||
|
raise HTTPException(HTTP_400_BAD_REQUEST, "Invalid email.")
|
||||||
|
|
||||||
|
redirect_path = "/wallet"
|
||||||
|
user_config = UserConfig(**dict(userinfo))
|
||||||
|
user_config.email_verified = True
|
||||||
|
|
||||||
|
account = await get_account_by_email(email)
|
||||||
|
|
||||||
|
if verified_user_id:
|
||||||
|
if account:
|
||||||
|
raise HTTPException(HTTP_401_UNAUTHORIZED, "Email already used.")
|
||||||
|
account = await get_account(verified_user_id)
|
||||||
|
if not account:
|
||||||
|
raise HTTPException(HTTP_401_UNAUTHORIZED, "Cannot verify user email.")
|
||||||
|
redirect_path = "/account"
|
||||||
|
|
||||||
|
if account:
|
||||||
|
user = await update_account(account.id, email=email, user_config=user_config)
|
||||||
|
else:
|
||||||
|
if not settings.new_accounts_allowed:
|
||||||
|
raise HTTPException(HTTP_400_BAD_REQUEST, "Account creation is disabled.")
|
||||||
|
user = await create_account(email=email, user_config=user_config)
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(HTTP_401_UNAUTHORIZED, "User not found.")
|
||||||
|
|
||||||
|
return _auth_redirect_response(redirect_path, email)
|
||||||
|
|
||||||
|
|
||||||
|
def _auth_success_response(
|
||||||
|
username: Optional[str] = None,
|
||||||
|
user_id: Optional[str] = None,
|
||||||
|
email: Optional[str] = None,
|
||||||
|
) -> JSONResponse:
|
||||||
|
access_token = create_access_token(
|
||||||
|
data={"sub": username or "", "usr": user_id, "email": email}
|
||||||
|
)
|
||||||
|
response = JSONResponse({"access_token": access_token, "token_type": "bearer"})
|
||||||
|
response.set_cookie("cookie_access_token", access_token, httponly=True)
|
||||||
|
response.set_cookie("is_lnbits_user_authorized", "true")
|
||||||
|
response.delete_cookie("is_access_token_expired")
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
def _auth_redirect_response(path: str, email: str) -> RedirectResponse:
|
||||||
|
access_token = create_access_token(data={"sub": "" or "", "email": email})
|
||||||
|
response = RedirectResponse(path)
|
||||||
|
response.set_cookie("cookie_access_token", access_token, httponly=True)
|
||||||
|
response.set_cookie("is_lnbits_user_authorized", "true")
|
||||||
|
response.delete_cookie("is_access_token_expired")
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
def _new_sso(provider: str) -> Optional[SSOBase]:
|
||||||
|
try:
|
||||||
|
if not settings.is_auth_method_allowed(AuthMethods(f"{provider}-auth")):
|
||||||
|
return None
|
||||||
|
|
||||||
|
client_id = getattr(settings, f"{provider}_client_id", None)
|
||||||
|
client_secret = getattr(settings, f"{provider}_client_secret", None)
|
||||||
|
discovery_url = getattr(settings, f"{provider}_discovery_url", None)
|
||||||
|
|
||||||
|
if not client_id or not client_secret:
|
||||||
|
logger.warning(f"{provider} auth allowed but not configured.")
|
||||||
|
return None
|
||||||
|
|
||||||
|
SSOProviderClass = _find_auth_provider_class(provider)
|
||||||
|
ssoProvider = SSOProviderClass(
|
||||||
|
client_id, client_secret, None, allow_insecure_http=True
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
discovery_url
|
||||||
|
and getattr(ssoProvider, "discovery_url", discovery_url) != discovery_url
|
||||||
|
):
|
||||||
|
ssoProvider.discovery_url = discovery_url
|
||||||
|
return ssoProvider
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(e)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _find_auth_provider_class(provider: str) -> Callable:
|
||||||
|
sso_modules = ["lnbits.core.sso", "fastapi_sso.sso"]
|
||||||
|
for module in sso_modules:
|
||||||
|
try:
|
||||||
|
provider_module = importlib.import_module(f"{module}.{provider}")
|
||||||
|
ProviderClass = getattr(provider_module, f"{provider.title()}SSO")
|
||||||
|
if ProviderClass:
|
||||||
|
return ProviderClass
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
raise ValueError(f"No SSO provider found for '{provider}'.")
|
||||||
@@ -1,16 +1,18 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
import sys
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
from typing import List, Optional
|
from pathlib import Path
|
||||||
|
from typing import Annotated, List, Optional, Union
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from fastapi import Depends, Query, Request, status
|
from fastapi import Cookie, Depends, Query, Request, status
|
||||||
from fastapi.exceptions import HTTPException
|
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 fastapi.routing import APIRouter
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from pydantic.types import UUID4
|
from pydantic.types import UUID4
|
||||||
from starlette.responses import HTMLResponse, JSONResponse
|
|
||||||
|
|
||||||
from lnbits.core import db
|
from lnbits.core.db import core_app_extra, db
|
||||||
from lnbits.core.helpers import to_valid_user_id
|
from lnbits.core.helpers import to_valid_user_id
|
||||||
from lnbits.core.models import User
|
from lnbits.core.models import User
|
||||||
from lnbits.decorators import check_admin, check_user_exists
|
from lnbits.decorators import check_admin, check_user_exists
|
||||||
@@ -19,11 +21,10 @@ from lnbits.settings import settings
|
|||||||
from lnbits.wallets import get_wallet_class
|
from lnbits.wallets import get_wallet_class
|
||||||
|
|
||||||
from ...extension_manager import InstallableExtension, get_valid_extensions
|
from ...extension_manager import InstallableExtension, get_valid_extensions
|
||||||
from ...utils.exchange_rates import currencies
|
from ...utils.exchange_rates import allowed_currencies, currencies
|
||||||
from ..crud import (
|
from ..crud import (
|
||||||
create_account,
|
create_account,
|
||||||
create_wallet,
|
create_wallet,
|
||||||
delete_wallet,
|
|
||||||
get_balance_check,
|
get_balance_check,
|
||||||
get_dbversions,
|
get_dbversions,
|
||||||
get_inactive_extensions,
|
get_inactive_extensions,
|
||||||
@@ -35,24 +36,40 @@ from ..crud import (
|
|||||||
)
|
)
|
||||||
from ..services import pay_invoice, redeem_lnurl_withdraw
|
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
|
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():
|
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 = ""):
|
async def home(request: Request, lightning: str = ""):
|
||||||
return template_renderer().TemplateResponse(
|
return template_renderer().TemplateResponse(
|
||||||
"core/index.html", {"request": request, "lnurl": lightning}
|
"core/index.html", {"request": request, "lnurl": lightning}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@core_html_routes.get("/robots.txt", response_class=HTMLResponse)
|
@generic_router.get("/first_install", response_class=HTMLResponse)
|
||||||
|
async def first_install(request: Request):
|
||||||
|
if not settings.first_install:
|
||||||
|
return template_renderer().TemplateResponse(
|
||||||
|
"error.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"err": "Super user account has already been configured.",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return template_renderer().TemplateResponse(
|
||||||
|
"core/first_install.html",
|
||||||
|
{"request": request},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@generic_router.get("/robots.txt", response_class=HTMLResponse)
|
||||||
async def robots():
|
async def robots():
|
||||||
data = """
|
data = """
|
||||||
User-agent: *
|
User-agent: *
|
||||||
@@ -61,7 +78,7 @@ async def robots():
|
|||||||
return HTMLResponse(content=data, media_type="text/plain")
|
return HTMLResponse(content=data, media_type="text/plain")
|
||||||
|
|
||||||
|
|
||||||
@core_html_routes.get(
|
@generic_router.get(
|
||||||
"/extensions", name="install.extensions", response_class=HTMLResponse
|
"/extensions", name="install.extensions", response_class=HTMLResponse
|
||||||
)
|
)
|
||||||
async def extensions_install(
|
async def extensions_install(
|
||||||
@@ -74,18 +91,14 @@ async def extensions_install(
|
|||||||
):
|
):
|
||||||
await toggle_extension(enable, disable, user.id)
|
await toggle_extension(enable, disable, user.id)
|
||||||
|
|
||||||
# Update user as his extensions have been updated
|
|
||||||
if enable or disable:
|
|
||||||
user = await get_user(user.id) # type: ignore
|
|
||||||
try:
|
try:
|
||||||
installed_exts: List["InstallableExtension"] = await get_installed_extensions()
|
installed_exts: List["InstallableExtension"] = await get_installed_extensions()
|
||||||
installed_exts_ids = [e.id for e in installed_exts]
|
installed_exts_ids = [e.id for e in installed_exts]
|
||||||
|
|
||||||
installable_exts: List[
|
installable_exts = await InstallableExtension.get_installable_extensions()
|
||||||
InstallableExtension
|
installable_exts_ids = [e.id for e in installable_exts]
|
||||||
] = await InstallableExtension.get_installable_extensions()
|
|
||||||
installable_exts += [
|
installable_exts += [
|
||||||
e for e in installed_exts if e.id not in installed_exts_ids
|
e for e in installed_exts if e.id not in installable_exts_ids
|
||||||
]
|
]
|
||||||
|
|
||||||
for e in installable_exts:
|
for e in installable_exts:
|
||||||
@@ -103,20 +116,28 @@ async def extensions_install(
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
ext_id = activate or deactivate
|
ext_id = activate or deactivate
|
||||||
|
all_extensions = get_valid_extensions()
|
||||||
|
ext = next((e for e in all_extensions if e.code == ext_id), None)
|
||||||
if ext_id and user.admin:
|
if ext_id and user.admin:
|
||||||
if deactivate and deactivate not in settings.lnbits_deactivated_extensions:
|
if deactivate and deactivate not in settings.lnbits_deactivated_extensions:
|
||||||
settings.lnbits_deactivated_extensions += [deactivate]
|
settings.lnbits_deactivated_extensions += [deactivate]
|
||||||
elif activate:
|
elif activate:
|
||||||
|
# if extension never loaded (was deactivated on server startup)
|
||||||
|
if ext_id not in sys.modules.keys():
|
||||||
|
# run extension start-up routine
|
||||||
|
core_app_extra.register_new_ext_routes(ext)
|
||||||
|
|
||||||
settings.lnbits_deactivated_extensions = list(
|
settings.lnbits_deactivated_extensions = list(
|
||||||
filter(
|
filter(
|
||||||
lambda e: e != activate, settings.lnbits_deactivated_extensions
|
lambda e: e != activate, settings.lnbits_deactivated_extensions
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
await update_installed_extension_state(
|
await update_installed_extension_state(
|
||||||
ext_id=ext_id, active=activate is not None
|
ext_id=ext_id, active=activate is not None
|
||||||
)
|
)
|
||||||
|
|
||||||
all_extensions = list(map(lambda e: e.code, get_valid_extensions()))
|
all_ext_ids = list(map(lambda e: e.code, all_extensions))
|
||||||
inactive_extensions = await get_inactive_extensions()
|
inactive_extensions = await get_inactive_extensions()
|
||||||
db_version = await get_dbversions()
|
db_version = await get_dbversions()
|
||||||
extensions = list(
|
extensions = list(
|
||||||
@@ -131,7 +152,7 @@ async def extensions_install(
|
|||||||
"dependencies": ext.dependencies,
|
"dependencies": ext.dependencies,
|
||||||
"isInstalled": ext.id in installed_exts_ids,
|
"isInstalled": ext.id in installed_exts_ids,
|
||||||
"hasDatabaseTables": ext.id in db_version,
|
"hasDatabaseTables": ext.id in db_version,
|
||||||
"isAvailable": ext.id in all_extensions,
|
"isAvailable": ext.id in all_ext_ids,
|
||||||
"isAdminOnly": ext.id in settings.lnbits_admin_extensions,
|
"isAdminOnly": ext.id in settings.lnbits_admin_extensions,
|
||||||
"isActive": ext.id not in inactive_extensions,
|
"isActive": ext.id not in inactive_extensions,
|
||||||
"latestRelease": (
|
"latestRelease": (
|
||||||
@@ -145,6 +166,9 @@ async def extensions_install(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# refresh user state. Eg: enabled extensions.
|
||||||
|
user = await get_user(user.id) or user
|
||||||
|
|
||||||
return template_renderer().TemplateResponse(
|
return template_renderer().TemplateResponse(
|
||||||
"core/extensions.html",
|
"core/extensions.html",
|
||||||
{
|
{
|
||||||
@@ -158,99 +182,83 @@ async def extensions_install(
|
|||||||
raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e))
|
raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@core_html_routes.get(
|
@generic_router.get(
|
||||||
"/wallet",
|
"/wallet",
|
||||||
response_class=HTMLResponse,
|
response_class=HTMLResponse,
|
||||||
description="""
|
description="show wallet page",
|
||||||
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
|
|
||||||
""",
|
|
||||||
)
|
)
|
||||||
async def wallet(
|
async def wallet(
|
||||||
request: Request = Query(None),
|
request: Request,
|
||||||
nme: Optional[str] = Query(None),
|
lnbits_last_active_wallet: Annotated[Union[str, None], Cookie()] = None,
|
||||||
usr: Optional[UUID4] = Query(None),
|
user: User = Depends(check_user_exists),
|
||||||
wal: Optional[UUID4] = Query(None),
|
wal: Optional[UUID4] = Query(None),
|
||||||
):
|
):
|
||||||
user_id = usr.hex if usr else None
|
if wal:
|
||||||
wallet_id = wal.hex if wal else None
|
wallet_id = wal.hex
|
||||||
wallet_name = nme
|
elif len(user.wallets) == 0:
|
||||||
|
wallet = await create_wallet(user_id=user.id)
|
||||||
if not user_id:
|
user = await get_user(user_id=user.id) or user
|
||||||
new_user = await create_account()
|
wallet_id = wallet.id
|
||||||
user = await get_user(new_user.id)
|
elif lnbits_last_active_wallet and user.get_wallet(lnbits_last_active_wallet):
|
||||||
assert user, "Newly created user has to exist."
|
wallet_id = lnbits_last_active_wallet
|
||||||
logger.info(f"Create user {user.id}")
|
|
||||||
else:
|
else:
|
||||||
user = await get_user(user_id)
|
wallet_id = user.wallets[0].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:
|
user_wallet = user.get_wallet(wallet_id)
|
||||||
if user.wallets and not wallet_name:
|
if not user_wallet or user_wallet.deleted:
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.debug(
|
|
||||||
"Access "
|
|
||||||
f"{'user '+ user.id + ' ' if user else ''} "
|
|
||||||
f"{'wallet ' + wallet_name if wallet_name else ''}"
|
|
||||||
)
|
|
||||||
userwallet = user.get_wallet(wallet_id)
|
|
||||||
if not userwallet:
|
|
||||||
return template_renderer().TemplateResponse(
|
return template_renderer().TemplateResponse(
|
||||||
"error.html", {"request": request, "err": "Wallet not found"}
|
"error.html", {"request": request, "err": "Wallet not found"}
|
||||||
)
|
)
|
||||||
|
|
||||||
return template_renderer().TemplateResponse(
|
resp = template_renderer().TemplateResponse(
|
||||||
"core/wallet.html",
|
"core/wallet.html",
|
||||||
{
|
{
|
||||||
"request": request,
|
"request": request,
|
||||||
"user": user.dict(),
|
"user": user.dict(),
|
||||||
"wallet": userwallet.dict(),
|
"wallet": user_wallet.dict(),
|
||||||
|
"currencies": allowed_currencies(),
|
||||||
"service_fee": settings.lnbits_service_fee,
|
"service_fee": settings.lnbits_service_fee,
|
||||||
|
"service_fee_max": settings.lnbits_service_fee_max,
|
||||||
"web_manifest": f"/manifest/{user.id}.webmanifest",
|
"web_manifest": f"/manifest/{user.id}.webmanifest",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
resp.set_cookie("lnbits_last_active_wallet", wallet_id)
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
@generic_router.get(
|
||||||
|
"/account",
|
||||||
|
response_class=HTMLResponse,
|
||||||
|
description="show account page",
|
||||||
|
)
|
||||||
|
async def account(
|
||||||
|
request: Request,
|
||||||
|
user: User = Depends(check_user_exists),
|
||||||
|
):
|
||||||
|
return template_renderer().TemplateResponse(
|
||||||
|
"core/account.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"user": user.dict(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@core_html_routes.get("/withdraw", response_class=JSONResponse)
|
@generic_router.get("/withdraw", response_class=JSONResponse)
|
||||||
async def lnurl_full_withdraw(request: Request):
|
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:
|
if not user:
|
||||||
return {"status": "ERROR", "reason": "User does not exist."}
|
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:
|
if not wallet:
|
||||||
return {"status": "ERROR", "reason": "Wallet does not exist."}
|
return {"status": "ERROR", "reason": "Wallet does not exist."}
|
||||||
|
|
||||||
@@ -267,17 +275,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):
|
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:
|
if not user:
|
||||||
return {"status": "ERROR", "reason": "User does not exist."}
|
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:
|
if not wallet:
|
||||||
return {"status": "ERROR", "reason": "Wallet does not exist."}
|
return {"status": "ERROR", "reason": "Wallet does not exist."}
|
||||||
|
|
||||||
pr = request.query_params.get("pr")
|
pr = request.query_params.get("pr")
|
||||||
|
if not pr:
|
||||||
|
return {"status": "ERROR", "reason": "payment_request not provided."}
|
||||||
|
|
||||||
async def pay():
|
async def pay():
|
||||||
try:
|
try:
|
||||||
@@ -294,40 +312,18 @@ async def lnurl_full_withdraw_callback(request: Request):
|
|||||||
return {"status": "OK"}
|
return {"status": "OK"}
|
||||||
|
|
||||||
|
|
||||||
@core_html_routes.get("/deletewallet", response_class=RedirectResponse)
|
@generic_router.get("/withdraw/notify/{service}")
|
||||||
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}")
|
|
||||||
async def lnurl_balance_notify(request: Request, service: str):
|
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:
|
if bc:
|
||||||
await redeem_lnurl_withdraw(bc.wallet, bc.url)
|
await redeem_lnurl_withdraw(bc.wallet, bc.url)
|
||||||
|
|
||||||
|
|
||||||
@core_html_routes.get(
|
@generic_router.get(
|
||||||
"/lnurlwallet", response_class=RedirectResponse, name="core.lnurlwallet"
|
"/lnurlwallet", response_class=RedirectResponse, name="core.lnurlwallet"
|
||||||
)
|
)
|
||||||
async def lnurlwallet(request: Request):
|
async def lnurlwallet(request: Request):
|
||||||
@@ -337,10 +333,14 @@ async def lnurlwallet(request: Request):
|
|||||||
assert user, "Newly created user not found."
|
assert user, "Newly created user not found."
|
||||||
wallet = await create_wallet(user_id=user.id, conn=conn)
|
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(
|
asyncio.create_task(
|
||||||
redeem_lnurl_withdraw(
|
redeem_lnurl_withdraw(
|
||||||
wallet.id,
|
wallet.id,
|
||||||
request.query_params.get("lightning"),
|
lightning_param,
|
||||||
"LNbits initial funding: voucher redeem.",
|
"LNbits initial funding: voucher redeem.",
|
||||||
{"tag": "lnurlwallet"},
|
{"tag": "lnurlwallet"},
|
||||||
5, # wait 5 seconds before sending the invoice to the service
|
5, # wait 5 seconds before sending the invoice to the service
|
||||||
@@ -353,13 +353,22 @@ async def lnurlwallet(request: Request):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@core_html_routes.get("/service-worker.js", response_class=FileResponse)
|
@generic_router.get("/service-worker.js")
|
||||||
async def service_worker():
|
async def service_worker(request: Request):
|
||||||
return FileResponse("lnbits/core/static/js/service-worker.js")
|
return template_renderer().TemplateResponse(
|
||||||
|
"service-worker.js",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"cache_version": settings.server_startup_time,
|
||||||
|
},
|
||||||
|
media_type="text/javascript",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@core_html_routes.get("/manifest/{usr}.webmanifest")
|
@generic_router.get("/manifest/{usr}.webmanifest")
|
||||||
async def manifest(usr: str):
|
async def manifest(request: Request, usr: str):
|
||||||
|
host = urlparse(str(request.url)).netloc
|
||||||
|
|
||||||
user = await get_user(usr)
|
user = await get_user(usr)
|
||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(status_code=HTTPStatus.NOT_FOUND)
|
raise HTTPException(status_code=HTTPStatus.NOT_FOUND)
|
||||||
@@ -372,11 +381,46 @@ async def manifest(usr: str):
|
|||||||
"src": (
|
"src": (
|
||||||
settings.lnbits_custom_logo
|
settings.lnbits_custom_logo
|
||||||
if 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"
|
||||||
),
|
),
|
||||||
|
"sizes": "512x512",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"sizes": "900x900",
|
},
|
||||||
}
|
{"src": "/static/favicon.ico", "sizes": "32x32", "type": "image/x-icon"},
|
||||||
|
{
|
||||||
|
"src": "/static/images/maskable_icon_x192.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"purpose": "maskable",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/static/images/maskable_icon_x512.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"purpose": "maskable",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/static/images/maskable_icon.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "1024x1024",
|
||||||
|
"purpose": "maskable",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"screenshots": [
|
||||||
|
{
|
||||||
|
"src": "/static/images/screenshot_desktop.png",
|
||||||
|
"sizes": "2394x1314",
|
||||||
|
"type": "image/png",
|
||||||
|
"form_factor": "wide",
|
||||||
|
"label": "LNbits - Desktop screenshot",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/static/images/screenshot_phone.png",
|
||||||
|
"sizes": "1080x1739",
|
||||||
|
"type": "image/png",
|
||||||
|
"form_factor": "narrow",
|
||||||
|
"label": "LNbits - Phone screenshot",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
"start_url": f"/wallet?usr={usr}&wal={user.wallets[0].id}",
|
"start_url": f"/wallet?usr={usr}&wal={user.wallets[0].id}",
|
||||||
"background_color": "#1F2234",
|
"background_color": "#1F2234",
|
||||||
@@ -390,13 +434,59 @@ async def manifest(usr: str):
|
|||||||
"short_name": wallet.name,
|
"short_name": wallet.name,
|
||||||
"description": wallet.name,
|
"description": wallet.name,
|
||||||
"url": f"/wallet?usr={usr}&wal={wallet.id}",
|
"url": f"/wallet?usr={usr}&wal={wallet.id}",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/static/images/maskable_icon_x96.png",
|
||||||
|
"sizes": "96x96",
|
||||||
|
"type": "image/png",
|
||||||
|
}
|
||||||
|
],
|
||||||
}
|
}
|
||||||
for wallet in user.wallets
|
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)):
|
async def index(request: Request, user: User = Depends(check_admin)):
|
||||||
if not settings.lnbits_admin_ui:
|
if not settings.lnbits_admin_ui:
|
||||||
raise HTTPException(status_code=HTTPStatus.NOT_FOUND)
|
raise HTTPException(status_code=HTTPStatus.NOT_FOUND)
|
||||||
@@ -416,7 +506,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):
|
async def hex_to_uuid4(hex_value: str):
|
||||||
try:
|
try:
|
||||||
user_id = to_valid_user_id(hex_value).hex
|
user_id = to_valid_user_id(hex_value).hex
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
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()
|
||||||
|
headers = {"User-Agent": settings.user_agent}
|
||||||
|
async with httpx.AsyncClient(headers=headers) 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 asyncio
|
||||||
import datetime
|
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from lnbits import bolt11
|
from lnbits import bolt11
|
||||||
|
|
||||||
from .. import core_app
|
|
||||||
from ..crud import get_standalone_payment
|
from ..crud import get_standalone_payment
|
||||||
from ..tasks import api_invoice_listeners
|
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):
|
async def api_public_payment_longpolling(payment_hash):
|
||||||
payment = await get_standalone_payment(payment_hash)
|
payment = await get_standalone_payment(payment_hash)
|
||||||
|
|
||||||
@@ -25,8 +25,7 @@ async def api_public_payment_longpolling(payment_hash):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
invoice = bolt11.decode(payment.bolt11)
|
invoice = bolt11.decode(payment.bolt11)
|
||||||
expiration = datetime.datetime.fromtimestamp(invoice.date + invoice.expiry)
|
if invoice.has_expired():
|
||||||
if expiration < datetime.datetime.now():
|
|
||||||
return {"status": "expired"}
|
return {"status": "expired"}
|
||||||
except Exception:
|
except Exception:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
from http import HTTPStatus
|
||||||
|
|
||||||
|
from fastapi import (
|
||||||
|
APIRouter,
|
||||||
|
Depends,
|
||||||
|
HTTPException,
|
||||||
|
)
|
||||||
|
from starlette.responses import RedirectResponse
|
||||||
|
|
||||||
|
from lnbits.decorators import (
|
||||||
|
WalletTypeInfo,
|
||||||
|
require_admin_key,
|
||||||
|
require_invoice_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
from ..crud import (
|
||||||
|
create_tinyurl,
|
||||||
|
delete_tinyurl,
|
||||||
|
get_tinyurl,
|
||||||
|
get_tinyurl_by_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
tinyurl_router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@tinyurl_router.post(
|
||||||
|
"/api/v1/tinyurl",
|
||||||
|
name="Tinyurl",
|
||||||
|
description="creates a tinyurl",
|
||||||
|
)
|
||||||
|
async def api_create_tinyurl(
|
||||||
|
url: str, endless: bool = False, wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||||
|
):
|
||||||
|
tinyurls = await get_tinyurl_by_url(url)
|
||||||
|
try:
|
||||||
|
for tinyurl in tinyurls:
|
||||||
|
if tinyurl:
|
||||||
|
if tinyurl.wallet == wallet.wallet.id:
|
||||||
|
return tinyurl
|
||||||
|
return await create_tinyurl(url, endless, wallet.wallet.id)
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.BAD_REQUEST, detail="Unable to create tinyurl"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@tinyurl_router.get(
|
||||||
|
"/api/v1/tinyurl/{tinyurl_id}",
|
||||||
|
name="Tinyurl",
|
||||||
|
description="get a tinyurl by id",
|
||||||
|
)
|
||||||
|
async def api_get_tinyurl(
|
||||||
|
tinyurl_id: str, wallet: WalletTypeInfo = Depends(require_invoice_key)
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
tinyurl = await get_tinyurl(tinyurl_id)
|
||||||
|
if tinyurl:
|
||||||
|
if tinyurl.wallet == wallet.wallet.id:
|
||||||
|
return tinyurl
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.FORBIDDEN, detail="Wrong key provided."
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.NOT_FOUND, detail="Unable to fetch tinyurl"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@tinyurl_router.delete(
|
||||||
|
"/api/v1/tinyurl/{tinyurl_id}",
|
||||||
|
name="Tinyurl",
|
||||||
|
description="delete a tinyurl by id",
|
||||||
|
)
|
||||||
|
async def api_delete_tinyurl(
|
||||||
|
tinyurl_id: str, wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
tinyurl = await get_tinyurl(tinyurl_id)
|
||||||
|
if tinyurl:
|
||||||
|
if tinyurl.wallet == wallet.wallet.id:
|
||||||
|
await delete_tinyurl(tinyurl_id)
|
||||||
|
return {"deleted": True}
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.FORBIDDEN, detail="Wrong key provided."
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.BAD_REQUEST, detail="Unable to delete"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@tinyurl_router.get(
|
||||||
|
"/t/{tinyurl_id}",
|
||||||
|
name="Tinyurl",
|
||||||
|
description="redirects a tinyurl by id",
|
||||||
|
)
|
||||||
|
async def api_tinyurl(tinyurl_id: str):
|
||||||
|
tinyurl = await get_tinyurl(tinyurl_id)
|
||||||
|
if tinyurl:
|
||||||
|
response = RedirectResponse(url=tinyurl.url)
|
||||||
|
return response
|
||||||
|
else:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.NOT_FOUND, detail="unable to find tinyurl"
|
||||||
|
)
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import base64
|
||||||
|
import json
|
||||||
|
from http import HTTPStatus
|
||||||
|
from urllib.parse import unquote, urlparse
|
||||||
|
|
||||||
|
from fastapi import (
|
||||||
|
APIRouter,
|
||||||
|
Depends,
|
||||||
|
Request,
|
||||||
|
)
|
||||||
|
|
||||||
|
from lnbits.core.models import (
|
||||||
|
CreateWebPushSubscription,
|
||||||
|
WebPushSubscription,
|
||||||
|
)
|
||||||
|
from lnbits.decorators import (
|
||||||
|
WalletTypeInfo,
|
||||||
|
require_admin_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
from ..crud import (
|
||||||
|
create_webpush_subscription,
|
||||||
|
delete_webpush_subscription,
|
||||||
|
get_webpush_subscription,
|
||||||
|
)
|
||||||
|
|
||||||
|
webpush_router = APIRouter(prefix="/api/v1/webpush", tags=["webpush"])
|
||||||
|
|
||||||
|
|
||||||
|
@webpush_router.post("/", 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,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@webpush_router.delete("/", 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)
|
||||||
@@ -50,13 +50,10 @@ if settings.lnbits_database_url:
|
|||||||
|
|
||||||
register_type(new_type((1184, 1114), "TIMESTAMP2INT", _parse_timestamp))
|
register_type(new_type((1184, 1114), "TIMESTAMP2INT", _parse_timestamp))
|
||||||
else:
|
else:
|
||||||
if os.path.isdir(settings.lnbits_data_folder):
|
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
|
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"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def compat_timestamp_placeholder():
|
def compat_timestamp_placeholder():
|
||||||
@@ -94,6 +91,12 @@ class Compat:
|
|||||||
return "(strftime('%s', 'now'))"
|
return "(strftime('%s', 'now'))"
|
||||||
return "<nothing>"
|
return "<nothing>"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def timestamp_column_default(self) -> str:
|
||||||
|
if self.type in {POSTGRES, COCKROACH}:
|
||||||
|
return self.timestamp_now
|
||||||
|
return "NULL"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def serial_primary_key(self) -> str:
|
def serial_primary_key(self) -> str:
|
||||||
if self.type in {POSTGRES, COCKROACH}:
|
if self.type in {POSTGRES, COCKROACH}:
|
||||||
@@ -176,16 +179,27 @@ class Connection(Compat):
|
|||||||
values: Optional[List[str]] = None,
|
values: Optional[List[str]] = None,
|
||||||
filters: Optional[Filters] = None,
|
filters: Optional[Filters] = None,
|
||||||
model: Optional[Type[TRowModel]] = None,
|
model: Optional[Type[TRowModel]] = None,
|
||||||
|
group_by: Optional[List[str]] = None,
|
||||||
) -> Page[TRowModel]:
|
) -> Page[TRowModel]:
|
||||||
if not filters:
|
if not filters:
|
||||||
filters = Filters()
|
filters = Filters()
|
||||||
clause = filters.where(where)
|
clause = filters.where(where)
|
||||||
parsed_values = filters.values(values)
|
parsed_values = filters.values(values)
|
||||||
|
|
||||||
|
group_by_string = ""
|
||||||
|
if group_by:
|
||||||
|
for field in group_by:
|
||||||
|
if not re.fullmatch(
|
||||||
|
r"[a-zA-Z_][a-zA-Z0-9_]*(\.[a-zA-Z_][a-zA-Z0-9_]*)?", field
|
||||||
|
):
|
||||||
|
raise ValueError("Value for GROUP BY is invalid")
|
||||||
|
group_by_string = f"GROUP BY {', '.join(group_by)}"
|
||||||
|
|
||||||
rows = await self.fetchall(
|
rows = await self.fetchall(
|
||||||
f"""
|
f"""
|
||||||
{query}
|
{query}
|
||||||
{clause}
|
{clause}
|
||||||
|
{group_by_string}
|
||||||
{filters.order_by()}
|
{filters.order_by()}
|
||||||
{filters.pagination()}
|
{filters.pagination()}
|
||||||
""",
|
""",
|
||||||
@@ -199,6 +213,7 @@ class Connection(Compat):
|
|||||||
SELECT COUNT(*) FROM (
|
SELECT COUNT(*) FROM (
|
||||||
{query}
|
{query}
|
||||||
{clause}
|
{clause}
|
||||||
|
{group_by_string}
|
||||||
) as count
|
) as count
|
||||||
""",
|
""",
|
||||||
parsed_values,
|
parsed_values,
|
||||||
@@ -239,7 +254,9 @@ class Database(Compat):
|
|||||||
else:
|
else:
|
||||||
self.schema = None
|
self.schema = None
|
||||||
|
|
||||||
self.engine = create_engine(database_uri, strategy=ASYNCIO_STRATEGY)
|
self.engine = create_engine(
|
||||||
|
database_uri, strategy=ASYNCIO_STRATEGY, echo=settings.debug_database
|
||||||
|
)
|
||||||
self.lock = asyncio.Lock()
|
self.lock = asyncio.Lock()
|
||||||
|
|
||||||
logger.trace(f"database {self.type} added for {self.name}")
|
logger.trace(f"database {self.type} added for {self.name}")
|
||||||
@@ -285,9 +302,10 @@ class Database(Compat):
|
|||||||
values: Optional[List[str]] = None,
|
values: Optional[List[str]] = None,
|
||||||
filters: Optional[Filters] = None,
|
filters: Optional[Filters] = None,
|
||||||
model: Optional[Type[TRowModel]] = None,
|
model: Optional[Type[TRowModel]] = None,
|
||||||
|
group_by: Optional[List[str]] = None,
|
||||||
) -> Page[TRowModel]:
|
) -> Page[TRowModel]:
|
||||||
async with self.connect() as conn:
|
async with self.connect() as conn:
|
||||||
return await conn.fetch_page(query, where, values, filters, model)
|
return await conn.fetch_page(query, where, values, filters, model, group_by)
|
||||||
|
|
||||||
async def execute(self, query: str, values: tuple = ()):
|
async def execute(self, query: str, values: tuple = ()):
|
||||||
async with self.connect() as conn:
|
async with self.connect() as conn:
|
||||||
|
|||||||
@@ -1,21 +1,29 @@
|
|||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
from typing import Literal, Optional, Type
|
from typing import Annotated, Literal, Optional, Type, Union
|
||||||
|
|
||||||
from fastapi import Query, Request, Security
|
from fastapi import Cookie, Depends, Query, Request, Security
|
||||||
from fastapi.exceptions import HTTPException
|
from fastapi.exceptions import HTTPException
|
||||||
from fastapi.openapi.models import APIKey, APIKeyIn
|
from fastapi.openapi.models import APIKey, APIKeyIn, SecuritySchemeType
|
||||||
from fastapi.security import APIKeyHeader, APIKeyQuery
|
from fastapi.security import APIKeyHeader, APIKeyQuery, OAuth2PasswordBearer
|
||||||
from fastapi.security.base import SecurityBase
|
from fastapi.security.base import SecurityBase
|
||||||
|
from jose import ExpiredSignatureError, JWTError, jwt
|
||||||
|
from loguru import logger
|
||||||
from pydantic.types import UUID4
|
from pydantic.types import UUID4
|
||||||
|
|
||||||
from lnbits.core.crud import get_user, get_wallet_for_key
|
from lnbits.core.crud import (
|
||||||
from lnbits.core.models import User, WalletType, WalletTypeInfo
|
get_account,
|
||||||
|
get_account_by_email,
|
||||||
|
get_account_by_username,
|
||||||
|
get_user,
|
||||||
|
get_wallet_for_key,
|
||||||
|
)
|
||||||
|
from lnbits.core.models import User, Wallet, WalletType, WalletTypeInfo
|
||||||
from lnbits.db import Filter, Filters, TFilterModel
|
from lnbits.db import Filter, Filters, TFilterModel
|
||||||
from lnbits.requestvars import g
|
from lnbits.settings import AuthMethods, settings
|
||||||
from lnbits.settings import settings
|
|
||||||
|
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="api/v1/auth", auto_error=False)
|
||||||
|
|
||||||
|
|
||||||
# TODO: fix type ignores
|
|
||||||
class KeyChecker(SecurityBase):
|
class KeyChecker(SecurityBase):
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -24,23 +32,25 @@ class KeyChecker(SecurityBase):
|
|||||||
api_key: Optional[str] = None,
|
api_key: Optional[str] = None,
|
||||||
):
|
):
|
||||||
self.scheme_name = scheme_name or self.__class__.__name__
|
self.scheme_name = scheme_name or self.__class__.__name__
|
||||||
self.auto_error = auto_error
|
self.auto_error: bool = auto_error
|
||||||
self._key_type = WalletType.invoice
|
self._key_type: WalletType = WalletType.invoice
|
||||||
self._api_key = api_key
|
self._api_key = api_key
|
||||||
if api_key:
|
if api_key:
|
||||||
key = APIKey(
|
openapi_model = APIKey(
|
||||||
**{"in": APIKeyIn.query}, # type: ignore
|
**{"in": APIKeyIn.query},
|
||||||
|
type=SecuritySchemeType.apiKey,
|
||||||
name="X-API-KEY",
|
name="X-API-KEY",
|
||||||
description="Wallet API Key - QUERY",
|
description="Wallet API Key - QUERY",
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
key = APIKey(
|
openapi_model = APIKey(
|
||||||
**{"in": APIKeyIn.header}, # type: ignore
|
**{"in": APIKeyIn.header},
|
||||||
|
type=SecuritySchemeType.apiKey,
|
||||||
name="X-API-KEY",
|
name="X-API-KEY",
|
||||||
description="Wallet API Key - HEADER",
|
description="Wallet API Key - HEADER",
|
||||||
)
|
)
|
||||||
self.wallet = None
|
self.wallet: Optional[Wallet] = None
|
||||||
self.model: APIKey = key
|
self.model: APIKey = openapi_model
|
||||||
|
|
||||||
async def __call__(self, request: Request):
|
async def __call__(self, request: Request):
|
||||||
try:
|
try:
|
||||||
@@ -53,12 +63,12 @@ class KeyChecker(SecurityBase):
|
|||||||
# avoided here. Also, we should not return the wallet here - thats
|
# avoided here. Also, we should not return the wallet here - thats
|
||||||
# silly. Possibly store it in a Redis DB
|
# silly. Possibly store it in a Redis DB
|
||||||
wallet = await get_wallet_for_key(key_value, self._key_type)
|
wallet = await get_wallet_for_key(key_value, self._key_type)
|
||||||
self.wallet = wallet # type: ignore
|
|
||||||
if not wallet:
|
if not wallet:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.UNAUTHORIZED,
|
status_code=HTTPStatus.UNAUTHORIZED,
|
||||||
detail="Invalid key or expired key.",
|
detail="Invalid key or wallet.",
|
||||||
)
|
)
|
||||||
|
self.wallet = wallet
|
||||||
except KeyError:
|
except KeyError:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.BAD_REQUEST, detail="`X-API-KEY` header missing."
|
status_code=HTTPStatus.BAD_REQUEST, detail="`X-API-KEY` header missing."
|
||||||
@@ -220,44 +230,50 @@ async def require_invoice_key(
|
|||||||
return wallet
|
return wallet
|
||||||
|
|
||||||
|
|
||||||
async def check_user_exists(usr: UUID4) -> User:
|
async def check_access_token(
|
||||||
g().user = await get_user(usr.hex)
|
header_access_token: Annotated[Union[str, None], Depends(oauth2_scheme)],
|
||||||
|
cookie_access_token: Annotated[Union[str, None], Cookie()] = None,
|
||||||
if not g().user:
|
) -> Optional[str]:
|
||||||
raise HTTPException(
|
return header_access_token or cookie_access_token
|
||||||
status_code=HTTPStatus.NOT_FOUND, detail="User does not exist."
|
|
||||||
)
|
|
||||||
|
|
||||||
if (
|
|
||||||
len(settings.lnbits_allowed_users) > 0
|
|
||||||
and g().user.id not in settings.lnbits_allowed_users
|
|
||||||
and g().user.id not in settings.lnbits_admin_users
|
|
||||||
and g().user.id != settings.super_user
|
|
||||||
):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=HTTPStatus.UNAUTHORIZED, detail="User not authorized."
|
|
||||||
)
|
|
||||||
|
|
||||||
return g().user
|
|
||||||
|
|
||||||
|
|
||||||
async def check_admin(usr: UUID4) -> User:
|
async def check_user_exists(
|
||||||
user = await check_user_exists(usr)
|
r: Request,
|
||||||
|
access_token: Annotated[Optional[str], Depends(check_access_token)],
|
||||||
|
usr: Optional[UUID4] = None,
|
||||||
|
) -> User:
|
||||||
|
if access_token:
|
||||||
|
account = await _get_account_from_token(access_token)
|
||||||
|
elif usr and settings.is_auth_method_allowed(AuthMethods.user_id_only):
|
||||||
|
account = await get_account(usr.hex)
|
||||||
|
else:
|
||||||
|
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Missing user ID or access token.")
|
||||||
|
|
||||||
|
if not account or not settings.is_user_allowed(account.id):
|
||||||
|
raise HTTPException(HTTPStatus.UNAUTHORIZED, "User not allowed.")
|
||||||
|
|
||||||
|
user = await get_user(account.id)
|
||||||
|
assert user, "User not found for account."
|
||||||
|
|
||||||
|
if not user.admin and r["path"].split("/")[1] in settings.lnbits_admin_extensions:
|
||||||
|
raise HTTPException(HTTPStatus.FORBIDDEN, "User not authorized for extension.")
|
||||||
|
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
async def check_admin(user: Annotated[User, Depends(check_user_exists)]) -> User:
|
||||||
if user.id != settings.super_user and user.id not in settings.lnbits_admin_users:
|
if user.id != settings.super_user and user.id not in settings.lnbits_admin_users:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.UNAUTHORIZED,
|
HTTPStatus.UNAUTHORIZED, "User not authorized. No admin privileges."
|
||||||
detail="User not authorized. No admin privileges.",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
async def check_super_user(usr: UUID4) -> User:
|
async def check_super_user(user: Annotated[User, Depends(check_user_exists)]) -> User:
|
||||||
user = await check_admin(usr)
|
|
||||||
if user.id != settings.super_user:
|
if user.id != settings.super_user:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.UNAUTHORIZED,
|
HTTPStatus.UNAUTHORIZED, "User not authorized. No super user privileges."
|
||||||
detail="User not authorized. No super user privileges.",
|
|
||||||
)
|
)
|
||||||
return user
|
return user
|
||||||
|
|
||||||
@@ -295,3 +311,23 @@ def parse_filters(model: Type[TFilterModel]):
|
|||||||
)
|
)
|
||||||
|
|
||||||
return dependency
|
return dependency
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_account_from_token(access_token):
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(access_token, settings.auth_secret_key, "HS256")
|
||||||
|
if "sub" in payload and payload.get("sub"):
|
||||||
|
return await get_account_by_username(str(payload.get("sub")))
|
||||||
|
if "usr" in payload and payload.get("usr"):
|
||||||
|
return await get_account(str(payload.get("usr")))
|
||||||
|
if "email" in payload and payload.get("email"):
|
||||||
|
return await get_account_by_email(str(payload.get("email")))
|
||||||
|
|
||||||
|
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Data missing for access token.")
|
||||||
|
except ExpiredSignatureError:
|
||||||
|
raise HTTPException(
|
||||||
|
HTTPStatus.UNAUTHORIZED, "Session expired.", {"token-expired": "true"}
|
||||||
|
)
|
||||||
|
except JWTError as e:
|
||||||
|
logger.debug(e)
|
||||||
|
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid access token.")
|
||||||
|
|||||||
@@ -1,16 +1,15 @@
|
|||||||
|
import asyncio
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import sys
|
import sys
|
||||||
import zipfile
|
import zipfile
|
||||||
from http import HTTPStatus
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, List, NamedTuple, Optional, Tuple
|
from typing import Any, List, NamedTuple, Optional, Tuple
|
||||||
from urllib import request
|
from urllib import request
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import HTTPException
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from packaging import version
|
from packaging import version
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
@@ -33,11 +32,12 @@ class ExplicitRelease(BaseModel):
|
|||||||
warning: Optional[str]
|
warning: Optional[str]
|
||||||
info_notification: Optional[str]
|
info_notification: Optional[str]
|
||||||
critical_notification: Optional[str]
|
critical_notification: Optional[str]
|
||||||
|
pay_link: Optional[str]
|
||||||
|
|
||||||
def is_version_compatible(self):
|
def is_version_compatible(self):
|
||||||
if not self.min_lnbits_version:
|
if not self.min_lnbits_version:
|
||||||
return True
|
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):
|
class GitHubRelease(BaseModel):
|
||||||
@@ -75,11 +75,18 @@ class ExtensionConfig(BaseModel):
|
|||||||
def is_version_compatible(self):
|
def is_version_compatible(self):
|
||||||
if not self.min_lnbits_version:
|
if not self.min_lnbits_version:
|
||||||
return True
|
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 ReleasePaymentInfo(BaseModel):
|
||||||
|
amount: Optional[int] = None
|
||||||
|
pay_link: Optional[str] = None
|
||||||
|
payment_hash: Optional[str] = None
|
||||||
|
payment_request: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
def download_url(url, save_path):
|
def download_url(url, save_path):
|
||||||
with request.urlopen(url) as dl_file:
|
with request.urlopen(url, timeout=60) as dl_file:
|
||||||
with open(save_path, "wb") as out_file:
|
with open(save_path, "wb") as out_file:
|
||||||
out_file.write(dl_file.read())
|
out_file.write(dl_file.read())
|
||||||
|
|
||||||
@@ -144,22 +151,32 @@ async def fetch_github_release_config(
|
|||||||
|
|
||||||
|
|
||||||
async def github_api_get(url: str, error_msg: Optional[str]) -> Any:
|
async def github_api_get(url: str, error_msg: Optional[str]) -> Any:
|
||||||
async with httpx.AsyncClient() as client:
|
headers = {"User-Agent": settings.user_agent}
|
||||||
headers = (
|
if settings.lnbits_ext_github_token:
|
||||||
{"Authorization": "Bearer " + settings.lnbits_ext_github_token}
|
headers["Authorization"] = f"Bearer {settings.lnbits_ext_github_token}"
|
||||||
if settings.lnbits_ext_github_token
|
async with httpx.AsyncClient(headers=headers) as client:
|
||||||
else None
|
resp = await client.get(url)
|
||||||
)
|
|
||||||
resp = await client.get(
|
|
||||||
url,
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
if resp.status_code != 200:
|
if resp.status_code != 200:
|
||||||
logger.warning(f"{error_msg} ({url}): {resp.text}")
|
logger.warning(f"{error_msg} ({url}): {resp.text}")
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
return resp.json()
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_release_payment_info(
|
||||||
|
url: str, amount: Optional[int] = None
|
||||||
|
) -> Optional[ReleasePaymentInfo]:
|
||||||
|
if amount:
|
||||||
|
url = f"{url}?amount={amount}"
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
resp = await client.get(url)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return ReleasePaymentInfo(**resp.json())
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def icon_to_github_url(source_repo: str, path: Optional[str]) -> str:
|
def icon_to_github_url(source_repo: str, path: Optional[str]) -> str:
|
||||||
if not path:
|
if not path:
|
||||||
return ""
|
return ""
|
||||||
@@ -182,12 +199,19 @@ class Extension(NamedTuple):
|
|||||||
upgrade_hash: Optional[str] = ""
|
upgrade_hash: Optional[str] = ""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def module_name(self):
|
def module_name(self) -> str:
|
||||||
return (
|
if self.is_upgrade_extension:
|
||||||
f"lnbits.extensions.{self.code}"
|
if settings.has_default_extension_path:
|
||||||
if self.upgrade_hash == ""
|
return f"lnbits.upgrades.{self.code}-{self.upgrade_hash}"
|
||||||
else f"lnbits.upgrades.{self.code}-{self.upgrade_hash}.{self.code}"
|
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
|
@classmethod
|
||||||
def from_installable_ext(cls, ext_info: "InstallableExtension") -> "Extension":
|
def from_installable_ext(cls, ext_info: "InstallableExtension") -> "Extension":
|
||||||
@@ -205,7 +229,7 @@ class Extension(NamedTuple):
|
|||||||
|
|
||||||
class ExtensionManager:
|
class ExtensionManager:
|
||||||
def __init__(self) -> None:
|
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)
|
Path(p).mkdir(parents=True, exist_ok=True)
|
||||||
self._extension_folders: List[Path] = [f for f in p.iterdir() if f.is_dir()]
|
self._extension_folders: List[Path] = [f for f in p.iterdir() if f.is_dir()]
|
||||||
|
|
||||||
@@ -258,6 +282,26 @@ class ExtensionRelease(BaseModel):
|
|||||||
repo: Optional[str] = None
|
repo: Optional[str] = None
|
||||||
icon: Optional[str] = None
|
icon: Optional[str] = None
|
||||||
|
|
||||||
|
pay_link: Optional[str] = None
|
||||||
|
cost_sats: Optional[int] = None
|
||||||
|
paid_sats: Optional[int] = 0
|
||||||
|
payment_hash: Optional[str] = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def archive_url(self) -> str:
|
||||||
|
if not self.pay_link:
|
||||||
|
return self.archive
|
||||||
|
return (
|
||||||
|
f"{self.archive}?version=v{self.version}&payment_hash={self.payment_hash}"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def check_payment_requirements(self):
|
||||||
|
if not self.pay_link:
|
||||||
|
return
|
||||||
|
|
||||||
|
payment_info = await fetch_release_payment_info(self.pay_link)
|
||||||
|
self.cost_sats = payment_info.amount if payment_info else None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_github_release(
|
def from_github_release(
|
||||||
cls, source_repo: str, r: "GitHubRepoRelease"
|
cls, source_repo: str, r: "GitHubRepoRelease"
|
||||||
@@ -288,12 +332,13 @@ class ExtensionRelease(BaseModel):
|
|||||||
is_version_compatible=e.is_version_compatible(),
|
is_version_compatible=e.is_version_compatible(),
|
||||||
warning=e.warning,
|
warning=e.warning,
|
||||||
html_url=e.html_url,
|
html_url=e.html_url,
|
||||||
|
pay_link=e.pay_link,
|
||||||
repo=e.repo,
|
repo=e.repo,
|
||||||
icon=e.icon,
|
icon=e.icon,
|
||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def all_releases(cls, org: str, repo: str) -> List["ExtensionRelease"]:
|
async def get_github_releases(cls, org: str, repo: str) -> List["ExtensionRelease"]:
|
||||||
try:
|
try:
|
||||||
github_releases = await fetch_github_releases(org, repo)
|
github_releases = await fetch_github_releases(org, repo)
|
||||||
return [
|
return [
|
||||||
@@ -316,6 +361,7 @@ class InstallableExtension(BaseModel):
|
|||||||
featured = False
|
featured = False
|
||||||
latest_release: Optional[ExtensionRelease] = None
|
latest_release: Optional[ExtensionRelease] = None
|
||||||
installed_release: Optional[ExtensionRelease] = None
|
installed_release: Optional[ExtensionRelease] = None
|
||||||
|
payments: List[ReleasePaymentInfo] = []
|
||||||
archive: Optional[str] = None
|
archive: Optional[str] = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -330,21 +376,25 @@ class InstallableExtension(BaseModel):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def zip_path(self) -> Path:
|
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)
|
Path(extensions_data_dir).mkdir(parents=True, exist_ok=True)
|
||||||
return Path(extensions_data_dir, f"{self.id}.zip")
|
return Path(extensions_data_dir, f"{self.id}.zip")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def ext_dir(self) -> Path:
|
def ext_dir(self) -> Path:
|
||||||
return Path(settings.lnbits_path, "extensions", self.id)
|
return Path(settings.lnbits_extensions_path, "extensions", self.id)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def ext_upgrade_dir(self) -> Path:
|
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
|
@property
|
||||||
def module_name(self) -> str:
|
def module_name(self) -> str:
|
||||||
|
if settings.has_default_extension_path:
|
||||||
return f"lnbits.extensions.{self.id}"
|
return f"lnbits.extensions.{self.id}"
|
||||||
|
return self.id
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def module_installed(self) -> bool:
|
def module_installed(self) -> bool:
|
||||||
@@ -362,48 +412,55 @@ class InstallableExtension(BaseModel):
|
|||||||
return self.installed_release.version
|
return self.installed_release.version
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
def download_archive(self):
|
async def download_archive(self):
|
||||||
logger.info(f"Downloading extension {self.name} ({self.installed_version}).")
|
logger.info(f"Downloading extension {self.name} ({self.installed_version}).")
|
||||||
ext_zip_file = self.zip_path
|
ext_zip_file = self.zip_path
|
||||||
if ext_zip_file.is_file():
|
if ext_zip_file.is_file():
|
||||||
os.remove(ext_zip_file)
|
os.remove(ext_zip_file)
|
||||||
try:
|
try:
|
||||||
assert self.installed_release, "installed_release is none."
|
assert self.installed_release, "installed_release is none."
|
||||||
download_url(self.installed_release.archive, ext_zip_file)
|
|
||||||
|
self._restore_payment_info()
|
||||||
|
|
||||||
|
await asyncio.to_thread(
|
||||||
|
download_url, self.installed_release.archive_url, ext_zip_file
|
||||||
|
)
|
||||||
|
|
||||||
|
self._remember_payment_info()
|
||||||
|
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
logger.warning(ex)
|
logger.warning(ex)
|
||||||
raise HTTPException(
|
raise AssertionError("Cannot fetch extension archive file")
|
||||||
status_code=HTTPStatus.NOT_FOUND,
|
|
||||||
detail="Cannot fetch extension archive file",
|
|
||||||
)
|
|
||||||
|
|
||||||
archive_hash = file_hash(ext_zip_file)
|
archive_hash = file_hash(ext_zip_file)
|
||||||
if self.installed_release.hash and self.installed_release.hash != archive_hash:
|
if self.installed_release.hash and self.installed_release.hash != archive_hash:
|
||||||
# remove downloaded archive
|
# remove downloaded archive
|
||||||
if ext_zip_file.is_file():
|
if ext_zip_file.is_file():
|
||||||
os.remove(ext_zip_file)
|
os.remove(ext_zip_file)
|
||||||
raise HTTPException(
|
raise AssertionError("File hash missmatch. Will not install.")
|
||||||
status_code=HTTPStatus.NOT_FOUND,
|
|
||||||
detail="File hash missmatch. Will not install.",
|
|
||||||
)
|
|
||||||
|
|
||||||
def extract_archive(self):
|
def extract_archive(self):
|
||||||
logger.info(f"Extracting extension {self.name} ({self.installed_version}).")
|
logger.info(f"Extracting extension {self.name} ({self.installed_version}).")
|
||||||
Path("lnbits", "upgrades").mkdir(parents=True, exist_ok=True)
|
Path(settings.lnbits_extensions_path, "upgrades").mkdir(
|
||||||
shutil.rmtree(self.ext_upgrade_dir, True)
|
parents=True, exist_ok=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),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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
|
# Pre-packed extensions can be upgraded
|
||||||
# Mark the extension as installed so we know it is not the pre-packed version
|
# Mark the extension as installed so we know it is not the pre-packed version
|
||||||
with open(
|
with open(Path(self.ext_upgrade_dir, "config.json"), "r+") as json_file:
|
||||||
Path(self.ext_upgrade_dir, self.id, "config.json"), "r+"
|
|
||||||
) as json_file:
|
|
||||||
config_json = json.load(json_file)
|
config_json = json.load(json_file)
|
||||||
|
|
||||||
self.name = config_json.get("name")
|
self.name = config_json.get("name")
|
||||||
@@ -419,10 +476,7 @@ class InstallableExtension(BaseModel):
|
|||||||
)
|
)
|
||||||
|
|
||||||
shutil.rmtree(self.ext_dir, True)
|
shutil.rmtree(self.ext_dir, True)
|
||||||
shutil.copytree(
|
shutil.copytree(Path(self.ext_upgrade_dir), Path(self.ext_dir))
|
||||||
Path(self.ext_upgrade_dir, self.id),
|
|
||||||
Path(settings.lnbits_path, "extensions", self.id),
|
|
||||||
)
|
|
||||||
logger.success(f"Extension {self.name} ({self.installed_version}) installed.")
|
logger.success(f"Extension {self.name} ({self.installed_version}) installed.")
|
||||||
|
|
||||||
def nofiy_upgrade(self) -> None:
|
def nofiy_upgrade(self) -> None:
|
||||||
@@ -457,17 +511,57 @@ class InstallableExtension(BaseModel):
|
|||||||
if not self.latest_release:
|
if not self.latest_release:
|
||||||
self.latest_release = release
|
self.latest_release = release
|
||||||
return
|
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
|
self.latest_release = release
|
||||||
|
|
||||||
|
def find_existing_payment(
|
||||||
|
self, pay_link: Optional[str]
|
||||||
|
) -> Optional[ReleasePaymentInfo]:
|
||||||
|
if not pay_link:
|
||||||
|
return None
|
||||||
|
return next(
|
||||||
|
(p for p in self.payments if p.pay_link == pay_link),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _restore_payment_info(self):
|
||||||
|
if not self.installed_release:
|
||||||
|
return
|
||||||
|
if not self.installed_release.pay_link:
|
||||||
|
return
|
||||||
|
if self.installed_release.payment_hash:
|
||||||
|
return
|
||||||
|
payment_info = self.find_existing_payment(self.installed_release.pay_link)
|
||||||
|
if payment_info:
|
||||||
|
self.installed_release.payment_hash = payment_info.payment_hash
|
||||||
|
|
||||||
|
def _remember_payment_info(self):
|
||||||
|
if not self.installed_release or not self.installed_release.pay_link:
|
||||||
|
return
|
||||||
|
payment_info = ReleasePaymentInfo(
|
||||||
|
amount=self.installed_release.cost_sats,
|
||||||
|
pay_link=self.installed_release.pay_link,
|
||||||
|
payment_hash=self.installed_release.payment_hash,
|
||||||
|
)
|
||||||
|
self.payments = [
|
||||||
|
p for p in self.payments if p.pay_link != payment_info.pay_link
|
||||||
|
]
|
||||||
|
self.payments.append(payment_info)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_row(cls, data: dict) -> "InstallableExtension":
|
def from_row(cls, data: dict) -> "InstallableExtension":
|
||||||
meta = json.loads(data["meta"])
|
meta = json.loads(data["meta"])
|
||||||
ext = InstallableExtension(**data)
|
ext = InstallableExtension(**data)
|
||||||
if "installed_release" in meta:
|
if "installed_release" in meta:
|
||||||
ext.installed_release = ExtensionRelease(**meta["installed_release"])
|
ext.installed_release = ExtensionRelease(**meta["installed_release"])
|
||||||
|
if meta.get("payments"):
|
||||||
|
ext.payments = [ReleasePaymentInfo(**p) for p in meta["payments"]]
|
||||||
return ext
|
return ext
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_rows(cls, rows: List[Any] = []) -> List["InstallableExtension"]:
|
||||||
|
return [InstallableExtension.from_row(row) for row in rows]
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def from_github_release(
|
async def from_github_release(
|
||||||
cls, github_release: GitHubRelease
|
cls, github_release: GitHubRelease
|
||||||
@@ -557,17 +651,19 @@ class InstallableExtension(BaseModel):
|
|||||||
try:
|
try:
|
||||||
manifest = await fetch_manifest(url)
|
manifest = await fetch_manifest(url)
|
||||||
for r in manifest.repos:
|
for r in manifest.repos:
|
||||||
if r.id == ext_id:
|
if r.id != ext_id:
|
||||||
repo_releases = await ExtensionRelease.all_releases(
|
continue
|
||||||
|
repo_releases = await ExtensionRelease.get_github_releases(
|
||||||
r.organisation, r.repository
|
r.organisation, r.repository
|
||||||
)
|
)
|
||||||
extension_releases += repo_releases
|
extension_releases += repo_releases
|
||||||
|
|
||||||
for e in manifest.extensions:
|
for e in manifest.extensions:
|
||||||
if e.id == ext_id:
|
if e.id != ext_id:
|
||||||
extension_releases += [
|
continue
|
||||||
ExtensionRelease.from_explicit_release(url, e)
|
explicit_release = ExtensionRelease.from_explicit_release(url, e)
|
||||||
]
|
await explicit_release.check_payment_requirements()
|
||||||
|
extension_releases.append(explicit_release)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Manifest {url} failed with '{str(e)}'")
|
logger.warning(f"Manifest {url} failed with '{str(e)}'")
|
||||||
@@ -576,15 +672,17 @@ class InstallableExtension(BaseModel):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def get_extension_release(
|
async def get_extension_release(
|
||||||
cls, ext_id: str, source_repo: str, archive: str
|
cls, ext_id: str, source_repo: str, archive: str, version: str
|
||||||
) -> Optional["ExtensionRelease"]:
|
) -> Optional["ExtensionRelease"]:
|
||||||
all_releases: List[
|
all_releases: List[ExtensionRelease] = (
|
||||||
ExtensionRelease
|
await InstallableExtension.get_extension_releases(ext_id)
|
||||||
] = await InstallableExtension.get_extension_releases(ext_id)
|
)
|
||||||
selected_release = [
|
selected_release = [
|
||||||
r
|
r
|
||||||
for r in all_releases
|
for r in all_releases
|
||||||
if r.archive == archive and r.source_repo == source_repo
|
if r.archive == archive
|
||||||
|
and r.source_repo == source_repo
|
||||||
|
and r.version == version
|
||||||
]
|
]
|
||||||
|
|
||||||
return selected_release[0] if len(selected_release) != 0 else None
|
return selected_release[0] if len(selected_release) != 0 else None
|
||||||
@@ -594,9 +692,35 @@ class CreateExtension(BaseModel):
|
|||||||
ext_id: str
|
ext_id: str
|
||||||
archive: str
|
archive: str
|
||||||
source_repo: str
|
source_repo: str
|
||||||
|
version: str
|
||||||
|
cost_sats: Optional[int] = 0
|
||||||
|
payment_hash: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
def get_valid_extensions() -> List[Extension]:
|
def get_valid_extensions(include_deactivated: Optional[bool] = True) -> List[Extension]:
|
||||||
return [
|
valid_extensions = [
|
||||||
extension for extension in ExtensionManager().extensions if extension.is_valid
|
extension for extension in ExtensionManager().extensions if extension.is_valid
|
||||||
]
|
]
|
||||||
|
|
||||||
|
if include_deactivated:
|
||||||
|
return valid_extensions
|
||||||
|
|
||||||
|
if settings.lnbits_extensions_deactivate_all:
|
||||||
|
return []
|
||||||
|
|
||||||
|
return [
|
||||||
|
e
|
||||||
|
for e in valid_extensions
|
||||||
|
if e.code not in settings.lnbits_deactivated_extensions
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
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")
|
||||||
|
|||||||
@@ -1,14 +1,20 @@
|
|||||||
import json
|
import json
|
||||||
|
import re
|
||||||
|
from datetime import datetime, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, List, Optional, Type
|
from typing import Any, List, Optional, Type
|
||||||
|
|
||||||
import jinja2
|
import jinja2
|
||||||
import shortuuid
|
import shortuuid
|
||||||
|
from jose import jwt
|
||||||
|
from pydantic import BaseModel
|
||||||
from pydantic.schema import field_schema
|
from pydantic.schema import field_schema
|
||||||
|
|
||||||
from lnbits.jinja2_templating import Jinja2Templates
|
from lnbits.jinja2_templating import Jinja2Templates
|
||||||
|
from lnbits.nodes import get_node_class
|
||||||
from lnbits.requestvars import g
|
from lnbits.requestvars import g
|
||||||
from lnbits.settings import settings
|
from lnbits.settings import settings
|
||||||
|
from lnbits.utils.crypto import AESCipher
|
||||||
|
|
||||||
from .db import FilterModel
|
from .db import FilterModel
|
||||||
from .extension_manager import get_valid_extensions
|
from .extension_manager import get_valid_extensions
|
||||||
@@ -27,11 +33,20 @@ def url_for(endpoint: str, external: Optional[bool] = False, **params: Any) -> s
|
|||||||
return url
|
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:
|
def template_renderer(additional_folders: Optional[List] = None) -> Jinja2Templates:
|
||||||
folders = ["lnbits/templates", "lnbits/core/templates"]
|
folders = ["lnbits/templates", "lnbits/core/templates"]
|
||||||
if additional_folders:
|
if additional_folders:
|
||||||
|
additional_folders += [
|
||||||
|
Path(settings.lnbits_extensions_path, "extensions", f)
|
||||||
|
for f in additional_folders
|
||||||
|
]
|
||||||
folders.extend(additional_folders)
|
folders.extend(additional_folders)
|
||||||
t = Jinja2Templates(loader=jinja2.FileSystemLoader(folders))
|
t = Jinja2Templates(loader=jinja2.FileSystemLoader(folders))
|
||||||
|
t.env.globals["static_url_for"] = static_url_for
|
||||||
|
|
||||||
if settings.lnbits_ad_space_enabled:
|
if settings.lnbits_ad_space_enabled:
|
||||||
t.env.globals["AD_SPACE"] = settings.lnbits_ad_space.split(",")
|
t.env.globals["AD_SPACE"] = settings.lnbits_ad_space.split(",")
|
||||||
@@ -44,20 +59,25 @@ def template_renderer(additional_folders: Optional[List] = None) -> Jinja2Templa
|
|||||||
t.env.globals["SITE_TAGLINE"] = settings.lnbits_site_tagline
|
t.env.globals["SITE_TAGLINE"] = settings.lnbits_site_tagline
|
||||||
t.env.globals["SITE_DESCRIPTION"] = settings.lnbits_site_description
|
t.env.globals["SITE_DESCRIPTION"] = settings.lnbits_site_description
|
||||||
t.env.globals["LNBITS_THEME_OPTIONS"] = settings.lnbits_theme_options
|
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_VERSION"] = settings.version
|
||||||
|
t.env.globals["LNBITS_NEW_ACCOUNTS_ALLOWED"] = settings.new_accounts_allowed
|
||||||
|
t.env.globals["LNBITS_AUTH_METHODS"] = settings.auth_allowed_methods
|
||||||
t.env.globals["LNBITS_ADMIN_UI"] = settings.lnbits_admin_ui
|
t.env.globals["LNBITS_ADMIN_UI"] = settings.lnbits_admin_ui
|
||||||
t.env.globals["EXTENSIONS"] = [
|
t.env.globals["LNBITS_SERVICE_FEE"] = settings.lnbits_service_fee
|
||||||
e
|
t.env.globals["LNBITS_SERVICE_FEE_MAX"] = settings.lnbits_service_fee_max
|
||||||
for e in get_valid_extensions()
|
t.env.globals["LNBITS_SERVICE_FEE_WALLET"] = settings.lnbits_service_fee_wallet
|
||||||
if e.code not in settings.lnbits_deactivated_extensions
|
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"] = get_valid_extensions(False)
|
||||||
if settings.lnbits_custom_logo:
|
if settings.lnbits_custom_logo:
|
||||||
t.env.globals["USE_CUSTOM_LOGO"] = settings.lnbits_custom_logo
|
t.env.globals["USE_CUSTOM_LOGO"] = settings.lnbits_custom_logo
|
||||||
|
|
||||||
if settings.bundle_assets:
|
if settings.bundle_assets:
|
||||||
t.env.globals["INCLUDED_JS"] = ["/static/bundle.min.js"]
|
t.env.globals["INCLUDED_JS"] = ["bundle.min.js"]
|
||||||
t.env.globals["INCLUDED_CSS"] = ["/static/bundle.min.css"]
|
t.env.globals["INCLUDED_CSS"] = ["bundle.min.css"]
|
||||||
else:
|
else:
|
||||||
vendor_filepath = Path(settings.lnbits_path, "static", "vendor.json")
|
vendor_filepath = Path(settings.lnbits_path, "static", "vendor.json")
|
||||||
with open(vendor_filepath) as vendor_file:
|
with open(vendor_filepath) as vendor_file:
|
||||||
@@ -65,11 +85,17 @@ def template_renderer(additional_folders: Optional[List] = None) -> Jinja2Templa
|
|||||||
t.env.globals["INCLUDED_JS"] = vendor_files["js"]
|
t.env.globals["INCLUDED_JS"] = vendor_files["js"]
|
||||||
t.env.globals["INCLUDED_CSS"] = vendor_files["css"]
|
t.env.globals["INCLUDED_CSS"] = vendor_files["css"]
|
||||||
|
|
||||||
|
t.env.globals["WEBPUSH_PUBKEY"] = settings.lnbits_webpush_pubkey
|
||||||
|
|
||||||
return t
|
return t
|
||||||
|
|
||||||
|
|
||||||
def get_current_extension_name() -> str:
|
def get_current_extension_name() -> str:
|
||||||
"""
|
"""
|
||||||
|
DEPRECATED: Use the repo name instead, will be removed in the future
|
||||||
|
before: `register_invoice_listener(invoice_queue, get_current_extension_name())`
|
||||||
|
after: `register_invoice_listener(invoice_queue, "my-extension")`
|
||||||
|
|
||||||
Returns the name of the extension that calls this method.
|
Returns the name of the extension that calls this method.
|
||||||
"""
|
"""
|
||||||
import inspect
|
import inspect
|
||||||
@@ -123,3 +149,56 @@ def generate_filter_params_openapi(model: Type[FilterModel], keep_optional=False
|
|||||||
return {
|
return {
|
||||||
"parameters": params,
|
"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}"
|
||||||
|
|
||||||
|
|
||||||
|
def is_valid_email_address(email: str) -> bool:
|
||||||
|
email_regex = r"[A-Za-z0-9\._%+-]+@[A-Za-z0-9\.-]+\.[A-Za-z]{2,63}"
|
||||||
|
return re.fullmatch(email_regex, email) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def is_valid_username(username: str) -> bool:
|
||||||
|
username_regex = r"(?=[a-zA-Z0-9._]{2,20}$)(?!.*[_.]{2})[^_.].*[^_.]"
|
||||||
|
return re.fullmatch(username_regex, username) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def create_access_token(data: dict):
|
||||||
|
expire = datetime.utcnow() + timedelta(minutes=settings.auth_token_expire_minutes)
|
||||||
|
to_encode = data.copy()
|
||||||
|
to_encode.update({"exp": expire})
|
||||||
|
return jwt.encode(to_encode, settings.auth_secret_key, "HS256")
|
||||||
|
|
||||||
|
|
||||||
|
def encrypt_internal_message(m: Optional[str] = None) -> Optional[str]:
|
||||||
|
"""Encrypt message with the internal secret key"""
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
return AESCipher(key=settings.auth_secret_key).encrypt(m.encode())
|
||||||
|
|
||||||
|
|
||||||
|
def decrypt_internal_message(m: Optional[str] = None) -> Optional[str]:
|
||||||
|
"""Decrypt message with the internal secret key"""
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
return AESCipher(key=settings.auth_secret_key).decrypt(m)
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ from starlette.templating import Jinja2Templates as SuperJinja2Templates
|
|||||||
|
|
||||||
class Jinja2Templates(SuperJinja2Templates):
|
class Jinja2Templates(SuperJinja2Templates):
|
||||||
def __init__(self, loader: BaseLoader) -> None:
|
def __init__(self, loader: BaseLoader) -> None:
|
||||||
super().__init__("")
|
|
||||||
self.env = self.get_environment(loader)
|
self.env = self.get_environment(loader)
|
||||||
|
super().__init__(env=self.env)
|
||||||
|
|
||||||
def get_environment(self, loader: BaseLoader) -> Environment:
|
def get_environment(self, loader: BaseLoader) -> Environment:
|
||||||
@pass_context
|
@pass_context
|
||||||
|
|||||||
@@ -1,17 +1 @@
|
|||||||
from bech32 import bech32_decode, bech32_encode, convertbits
|
from lnurl import LnurlErrorResponse, decode, encode, handle # noqa: F401
|
||||||
|
|
||||||
|
|
||||||
def decode(lnurl: str) -> str:
|
|
||||||
hrp, data = bech32_decode(lnurl)
|
|
||||||
assert hrp
|
|
||||||
assert data
|
|
||||||
bech32_data = convertbits(data, 5, 8, False)
|
|
||||||
assert bech32_data
|
|
||||||
return bytes(bech32_data).decode()
|
|
||||||
|
|
||||||
|
|
||||||
def encode(url: str) -> str:
|
|
||||||
bech32_data = convertbits(url.encode(), 8, 5, True)
|
|
||||||
assert bech32_data
|
|
||||||
lnurl = bech32_encode("lnurl", bech32_data)
|
|
||||||
return lnurl.upper()
|
|
||||||
|
|||||||
@@ -1,16 +1,15 @@
|
|||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
from typing import Any, List, Tuple, Union
|
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 fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||||
from slowapi import _rate_limit_exceeded_handler
|
from slowapi import _rate_limit_exceeded_handler
|
||||||
from slowapi.errors import RateLimitExceeded
|
from slowapi.errors import RateLimitExceeded
|
||||||
from slowapi.middleware import SlowAPIMiddleware
|
from slowapi.middleware import SlowAPIMiddleware
|
||||||
from starlette.middleware.gzip import GZipMiddleware
|
from starlette.middleware.gzip import GZipMiddleware
|
||||||
from starlette.types import ASGIApp, Receive, Scope, Send
|
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.helpers import template_renderer
|
||||||
from lnbits.settings import settings
|
from lnbits.settings import settings
|
||||||
|
|
||||||
@@ -25,67 +24,42 @@ class InstalledExtensionMiddleware:
|
|||||||
self.app = app
|
self.app = app
|
||||||
|
|
||||||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
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)
|
await self.app(scope, receive, send)
|
||||||
return
|
return
|
||||||
|
|
||||||
path_elements = scope["path"].split("/")
|
top_path, *rest = [p for p in full_path.split("/") if p]
|
||||||
if len(path_elements) > 2:
|
|
||||||
_, path_name, path_type, *rest = path_elements
|
|
||||||
else:
|
|
||||||
_, path_name = path_elements
|
|
||||||
path_type = None
|
|
||||||
rest = []
|
|
||||||
|
|
||||||
headers = scope.get("headers", [])
|
headers = scope.get("headers", [])
|
||||||
|
|
||||||
# block path for all users if the extension is disabled
|
# 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(
|
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)
|
await response(scope, receive, send)
|
||||||
return
|
return
|
||||||
|
|
||||||
if not self._user_allowed_to_extension(path_name, scope):
|
# static resources do not require redirect
|
||||||
response = self._response_by_accepted_type(
|
if rest[0:1] == ["static"]:
|
||||||
headers, "User not authorized.", HTTPStatus.FORBIDDEN
|
await self.app(scope, receive, send)
|
||||||
)
|
|
||||||
await response(scope, receive, send)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# re-route API trafic if the extension has been upgraded
|
upgrade_path = next(
|
||||||
if path_type == "api":
|
(
|
||||||
upgraded_extensions = list(
|
e
|
||||||
filter(
|
for e in settings.lnbits_upgraded_extensions
|
||||||
lambda ext: ext.endswith(f"/{path_name}"),
|
if e.endswith(f"/{top_path}")
|
||||||
settings.lnbits_upgraded_extensions,
|
),
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
)
|
# re-route all trafic if the extension has been upgraded
|
||||||
if len(upgraded_extensions) != 0:
|
if upgrade_path:
|
||||||
upgrade_path = upgraded_extensions[0]
|
|
||||||
tail = "/".join(rest)
|
tail = "/".join(rest)
|
||||||
scope["path"] = f"/upgrades/{upgrade_path}/{path_type}/{tail}"
|
scope["path"] = f"/upgrades/{upgrade_path}/{tail}"
|
||||||
|
|
||||||
await self.app(scope, receive, send)
|
await self.app(scope, receive, send)
|
||||||
|
|
||||||
def _user_allowed_to_extension(self, ext_name: str, scope) -> bool:
|
|
||||||
if ext_name not in settings.lnbits_admin_extensions:
|
|
||||||
return True
|
|
||||||
if "query_string" not in scope:
|
|
||||||
return True
|
|
||||||
|
|
||||||
# parse the URL query string into a `dict`
|
|
||||||
q = parse_qs(scope["query_string"].decode("UTF-8"))
|
|
||||||
user = q.get("usr", [""])[0]
|
|
||||||
if not user:
|
|
||||||
return True
|
|
||||||
|
|
||||||
if user == settings.super_user or user in settings.lnbits_admin_users:
|
|
||||||
return True
|
|
||||||
|
|
||||||
return False
|
|
||||||
|
|
||||||
def _response_by_accepted_type(
|
def _response_by_accepted_type(
|
||||||
self, headers: List[Any], msg: str, status_code: HTTPStatus
|
self, headers: List[Any], msg: str, status_code: HTTPStatus
|
||||||
) -> Union[HTMLResponse, JSONResponse]:
|
) -> Union[HTMLResponse, JSONResponse]:
|
||||||
@@ -213,7 +187,12 @@ class ExtensionsRedirectMiddleware:
|
|||||||
|
|
||||||
def add_ratelimit_middleware(app: FastAPI):
|
def add_ratelimit_middleware(app: FastAPI):
|
||||||
core_app_extra.register_new_ratelimiter()
|
core_app_extra.register_new_ratelimiter()
|
||||||
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
# latest https://slowapi.readthedocs.io/en/latest/
|
||||||
|
# shows this as a valid way to add the handler
|
||||||
|
app.add_exception_handler(
|
||||||
|
RateLimitExceeded,
|
||||||
|
_rate_limit_exceeded_handler, # type: ignore
|
||||||
|
)
|
||||||
app.add_middleware(SlowAPIMiddleware)
|
app.add_middleware(SlowAPIMiddleware)
|
||||||
|
|
||||||
|
|
||||||
@@ -233,15 +212,21 @@ def add_ip_block_middleware(app: FastAPI):
|
|||||||
status_code=403, # Forbidden
|
status_code=403, # Forbidden
|
||||||
content={"detail": "IP is blocked"},
|
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)
|
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
|
|
||||||
|
|
||||||
app.middleware("http")(block_allow_ip_middleware)
|
app.middleware("http")(block_allow_ip_middleware)
|
||||||
|
|
||||||
|
|
||||||
|
def add_first_install_middleware(app: FastAPI):
|
||||||
|
@app.middleware("http")
|
||||||
|
async def first_install_middleware(request: Request, call_next):
|
||||||
|
if (
|
||||||
|
settings.first_install
|
||||||
|
and request.url.path != "/api/v1/auth/first_install"
|
||||||
|
and request.url.path != "/first_install"
|
||||||
|
and not request.url.path.startswith("/static")
|
||||||
|
):
|
||||||
|
return RedirectResponse("/first_install")
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
app.middleware("http")(first_install_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,336 @@
|
|||||||
|
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=(
|
||||||
|
# normal invoice
|
||||||
|
invoice.get("amount_msat")
|
||||||
|
# keysend or paid amountless invoice
|
||||||
|
or invoice.get("amount_received_msat")
|
||||||
|
# unpaid amountless invoice
|
||||||
|
or 0
|
||||||
|
),
|
||||||
|
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,386 @@
|
|||||||
|
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=int(invoice["creation_date"]) + int(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
|
# create data dir if it does not exist
|
||||||
Path(settings.lnbits_data_folder).mkdir(parents=True, exist_ok=True)
|
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
|
# create `extensions`` dir if it does not exist
|
||||||
Path(settings.lnbits_path, "extensions").mkdir(parents=True, exist_ok=True)
|
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)
|
set_cli_settings(host=host, port=port, forwarded_allow_ips=forwarded_allow_ips)
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,19 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import importlib
|
import importlib
|
||||||
import importlib.metadata
|
import importlib.metadata
|
||||||
import inspect
|
import inspect
|
||||||
import json
|
import json
|
||||||
import subprocess
|
from enum import Enum
|
||||||
|
from hashlib import sha256
|
||||||
from os import path
|
from os import path
|
||||||
from sqlite3 import Row
|
from sqlite3 import Row
|
||||||
|
from time import time
|
||||||
from typing import Any, List, Optional
|
from typing import Any, List, Optional
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from loguru import logger
|
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):
|
def list_parse_fallback(v: str):
|
||||||
@@ -23,24 +27,22 @@ def list_parse_fallback(v: str):
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
class LNbitsSettings(BaseSettings):
|
class LNbitsSettings(BaseModel):
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate(cls, val):
|
def validate_list(cls, val):
|
||||||
if isinstance(val, str):
|
if isinstance(val, str):
|
||||||
val = val.split(",") if val else []
|
val = val.split(",") if val else []
|
||||||
return val
|
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):
|
class UsersSettings(LNbitsSettings):
|
||||||
lnbits_admin_users: List[str] = Field(default=[])
|
lnbits_admin_users: List[str] = Field(default=[])
|
||||||
lnbits_allowed_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):
|
class ExtensionsSettings(LNbitsSettings):
|
||||||
@@ -66,6 +68,16 @@ class InstalledExtensionsSettings(LNbitsSettings):
|
|||||||
# list of redirects that extensions want to perform
|
# list of redirects that extensions want to perform
|
||||||
lnbits_extensions_redirects: List[Any] = Field(default=[])
|
lnbits_extensions_redirects: List[Any] = Field(default=[])
|
||||||
|
|
||||||
|
def extension_upgrade_path(self, ext_id: str) -> Optional[str]:
|
||||||
|
return next(
|
||||||
|
(e for e in self.lnbits_upgraded_extensions if e.endswith(f"/{ext_id}")),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
def extension_upgrade_hash(self, ext_id: str) -> Optional[str]:
|
||||||
|
path = settings.extension_upgrade_path(ext_id)
|
||||||
|
return path.split("/")[0] if path else None
|
||||||
|
|
||||||
|
|
||||||
class ThemesSettings(LNbitsSettings):
|
class ThemesSettings(LNbitsSettings):
|
||||||
lnbits_site_title: str = Field(default="LNbits")
|
lnbits_site_title: str = Field(default="LNbits")
|
||||||
@@ -91,6 +103,7 @@ class ThemesSettings(LNbitsSettings):
|
|||||||
lnbits_ad_space_enabled: bool = Field(default=False)
|
lnbits_ad_space_enabled: bool = Field(default=False)
|
||||||
lnbits_allowed_currencies: List[str] = Field(default=[])
|
lnbits_allowed_currencies: List[str] = Field(default=[])
|
||||||
lnbits_default_accounting_currency: Optional[str] = Field(default=None)
|
lnbits_default_accounting_currency: Optional[str] = Field(default=None)
|
||||||
|
lnbits_qr_logo: str = Field(default="/static/images/logos/lnbits.png")
|
||||||
|
|
||||||
|
|
||||||
class OpsSettings(LNbitsSettings):
|
class OpsSettings(LNbitsSettings):
|
||||||
@@ -98,6 +111,9 @@ class OpsSettings(LNbitsSettings):
|
|||||||
lnbits_reserve_fee_min: int = Field(default=2000)
|
lnbits_reserve_fee_min: int = Field(default=2000)
|
||||||
lnbits_reserve_fee_percent: float = Field(default=1.0)
|
lnbits_reserve_fee_percent: float = Field(default=1.0)
|
||||||
lnbits_service_fee: float = Field(default=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_hide_api: bool = Field(default=False)
|
||||||
lnbits_denomination: str = Field(default="sats")
|
lnbits_denomination: str = Field(default="sats")
|
||||||
|
|
||||||
@@ -110,6 +126,9 @@ class SecuritySettings(LNbitsSettings):
|
|||||||
lnbits_notifications: bool = Field(default=False)
|
lnbits_notifications: bool = Field(default=False)
|
||||||
lnbits_killswitch: bool = Field(default=False)
|
lnbits_killswitch: bool = Field(default=False)
|
||||||
lnbits_killswitch_interval: int = Field(default=60)
|
lnbits_killswitch_interval: int = Field(default=60)
|
||||||
|
lnbits_wallet_limit_max_balance: int = Field(default=0)
|
||||||
|
lnbits_wallet_limit_daily_max_withdraw: int = Field(default=0)
|
||||||
|
lnbits_wallet_limit_secs_between_trans: int = Field(default=0)
|
||||||
lnbits_watchdog: bool = Field(default=False)
|
lnbits_watchdog: bool = Field(default=False)
|
||||||
lnbits_watchdog_interval: int = Field(default=60)
|
lnbits_watchdog_interval: int = Field(default=60)
|
||||||
lnbits_watchdog_delta: int = Field(default=1_000_000)
|
lnbits_watchdog_delta: int = Field(default=1_000_000)
|
||||||
@@ -119,6 +138,13 @@ class SecuritySettings(LNbitsSettings):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def is_wallet_max_balance_exceeded(self, amount):
|
||||||
|
return (
|
||||||
|
self.lnbits_wallet_limit_max_balance
|
||||||
|
and self.lnbits_wallet_limit_max_balance > 0
|
||||||
|
and amount > self.lnbits_wallet_limit_max_balance
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class FakeWalletFundingSource(LNbitsSettings):
|
class FakeWalletFundingSource(LNbitsSettings):
|
||||||
fake_wallet_secret: str = Field(default="ToTheMoon1")
|
fake_wallet_secret: str = Field(default="ToTheMoon1")
|
||||||
@@ -156,6 +182,7 @@ class LndRestFundingSource(LNbitsSettings):
|
|||||||
lnd_rest_cert: Optional[str] = Field(default=None)
|
lnd_rest_cert: Optional[str] = Field(default=None)
|
||||||
lnd_rest_macaroon: Optional[str] = Field(default=None)
|
lnd_rest_macaroon: Optional[str] = Field(default=None)
|
||||||
lnd_rest_macaroon_encrypted: Optional[str] = Field(default=None)
|
lnd_rest_macaroon_encrypted: Optional[str] = Field(default=None)
|
||||||
|
lnd_rest_route_hints: bool = Field(default=True)
|
||||||
lnd_cert: Optional[str] = Field(default=None)
|
lnd_cert: Optional[str] = Field(default=None)
|
||||||
lnd_admin_macaroon: Optional[str] = Field(default=None)
|
lnd_admin_macaroon: Optional[str] = Field(default=None)
|
||||||
lnd_invoice_macaroon: Optional[str] = Field(default=None)
|
lnd_invoice_macaroon: Optional[str] = Field(default=None)
|
||||||
@@ -180,6 +207,16 @@ class LnPayFundingSource(LNbitsSettings):
|
|||||||
lnpay_admin_key: Optional[str] = Field(default=None)
|
lnpay_admin_key: Optional[str] = Field(default=None)
|
||||||
|
|
||||||
|
|
||||||
|
class ZBDFundingSource(LNbitsSettings):
|
||||||
|
zbd_api_endpoint: Optional[str] = Field(default="https://api.zebedee.io/v0/")
|
||||||
|
zbd_api_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):
|
class OpenNodeFundingSource(LNbitsSettings):
|
||||||
opennode_api_endpoint: Optional[str] = Field(default=None)
|
opennode_api_endpoint: Optional[str] = Field(default=None)
|
||||||
opennode_key: Optional[str] = Field(default=None)
|
opennode_key: Optional[str] = Field(default=None)
|
||||||
@@ -199,14 +236,6 @@ class LnTipsFundingSource(LNbitsSettings):
|
|||||||
lntips_invoice_key: Optional[str] = Field(default=None)
|
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):
|
class LightningSettings(LNbitsSettings):
|
||||||
lightning_invoice_expiry: int = Field(default=3600)
|
lightning_invoice_expiry: int = Field(default=3600)
|
||||||
|
|
||||||
@@ -221,6 +250,8 @@ class FundingSourcesSettings(
|
|||||||
LndRestFundingSource,
|
LndRestFundingSource,
|
||||||
LndGrpcFundingSource,
|
LndGrpcFundingSource,
|
||||||
LnPayFundingSource,
|
LnPayFundingSource,
|
||||||
|
AlbyFundingSource,
|
||||||
|
ZBDFundingSource,
|
||||||
OpenNodeFundingSource,
|
OpenNodeFundingSource,
|
||||||
SparkFundingSource,
|
SparkFundingSource,
|
||||||
LnTipsFundingSource,
|
LnTipsFundingSource,
|
||||||
@@ -228,6 +259,59 @@ class FundingSourcesSettings(
|
|||||||
lnbits_backend_wallet_class: str = Field(default="VoidWallet")
|
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 AuthMethods(Enum):
|
||||||
|
user_id_only = "user-id-only"
|
||||||
|
username_and_password = "username-password"
|
||||||
|
google_auth = "google-auth"
|
||||||
|
github_auth = "github-auth"
|
||||||
|
keycloak_auth = "keycloak-auth"
|
||||||
|
|
||||||
|
|
||||||
|
class AuthSettings(LNbitsSettings):
|
||||||
|
auth_token_expire_minutes: int = Field(default=525600)
|
||||||
|
auth_all_methods = [a.value for a in AuthMethods]
|
||||||
|
auth_allowed_methods: List[str] = Field(
|
||||||
|
default=[
|
||||||
|
AuthMethods.user_id_only.value,
|
||||||
|
AuthMethods.username_and_password.value,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
def is_auth_method_allowed(self, method: AuthMethods):
|
||||||
|
return method.value in self.auth_allowed_methods
|
||||||
|
|
||||||
|
|
||||||
|
class GoogleAuthSettings(LNbitsSettings):
|
||||||
|
google_client_id: str = Field(default="")
|
||||||
|
google_client_secret: str = Field(default="")
|
||||||
|
|
||||||
|
|
||||||
|
class GitHubAuthSettings(LNbitsSettings):
|
||||||
|
github_client_id: str = Field(default="")
|
||||||
|
github_client_secret: str = Field(default="")
|
||||||
|
|
||||||
|
|
||||||
|
class KeycloakAuthSettings(LNbitsSettings):
|
||||||
|
keycloak_discovery_url: str = Field(default="")
|
||||||
|
keycloak_client_id: str = Field(default="")
|
||||||
|
keycloak_client_secret: str = Field(default="")
|
||||||
|
|
||||||
|
|
||||||
class EditableSettings(
|
class EditableSettings(
|
||||||
UsersSettings,
|
UsersSettings,
|
||||||
ExtensionsSettings,
|
ExtensionsSettings,
|
||||||
@@ -235,8 +319,13 @@ class EditableSettings(
|
|||||||
OpsSettings,
|
OpsSettings,
|
||||||
SecuritySettings,
|
SecuritySettings,
|
||||||
FundingSourcesSettings,
|
FundingSourcesSettings,
|
||||||
BoltzExtensionSettings,
|
|
||||||
LightningSettings,
|
LightningSettings,
|
||||||
|
WebPushSettings,
|
||||||
|
NodeUISettings,
|
||||||
|
AuthSettings,
|
||||||
|
GoogleAuthSettings,
|
||||||
|
GitHubAuthSettings,
|
||||||
|
KeycloakAuthSettings,
|
||||||
):
|
):
|
||||||
@validator(
|
@validator(
|
||||||
"lnbits_admin_users",
|
"lnbits_admin_users",
|
||||||
@@ -247,7 +336,7 @@ class EditableSettings(
|
|||||||
)
|
)
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_editable_settings(cls, val):
|
def validate_editable_settings(cls, val):
|
||||||
return super().validate(val)
|
return super().validate_list(val)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, d: dict):
|
def from_dict(cls, d: dict):
|
||||||
@@ -263,17 +352,35 @@ class EditableSettings(
|
|||||||
prop.pop("env_names", None)
|
prop.pop("env_names", None)
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateSettings(EditableSettings):
|
||||||
|
class Config:
|
||||||
|
extra = Extra.forbid
|
||||||
|
|
||||||
|
|
||||||
class EnvSettings(LNbitsSettings):
|
class EnvSettings(LNbitsSettings):
|
||||||
debug: bool = Field(default=False)
|
debug: bool = Field(default=False)
|
||||||
|
debug_database: bool = Field(default=False)
|
||||||
bundle_assets: bool = Field(default=True)
|
bundle_assets: bool = Field(default=True)
|
||||||
host: str = Field(default="127.0.0.1")
|
host: str = Field(default="127.0.0.1")
|
||||||
port: int = Field(default=5000)
|
port: int = Field(default=5000)
|
||||||
forwarded_allow_ips: str = Field(default="*")
|
forwarded_allow_ips: str = Field(default="*")
|
||||||
lnbits_title: str = Field(default="LNbits API")
|
lnbits_title: str = Field(default="LNbits API")
|
||||||
lnbits_path: str = Field(default=".")
|
lnbits_path: str = Field(default=".")
|
||||||
lnbits_commit: str = Field(default="unknown")
|
lnbits_extensions_path: str = Field(default="lnbits")
|
||||||
super_user: str = Field(default="")
|
super_user: str = Field(default="")
|
||||||
|
auth_secret_key: str = Field(default="")
|
||||||
version: str = Field(default="0.0.0")
|
version: str = Field(default="0.0.0")
|
||||||
|
user_agent: str = Field(default="")
|
||||||
|
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())
|
||||||
|
lnbits_extensions_deactivate_all: bool = Field(default=False)
|
||||||
|
cleanup_wallets_days: int = Field(default=90)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_default_extension_path(self) -> bool:
|
||||||
|
return self.lnbits_extensions_path == "lnbits"
|
||||||
|
|
||||||
|
|
||||||
class SaaSSettings(LNbitsSettings):
|
class SaaSSettings(LNbitsSettings):
|
||||||
@@ -299,6 +406,8 @@ class SuperUserSettings(LNbitsSettings):
|
|||||||
"LndWallet",
|
"LndWallet",
|
||||||
"LnTipsWallet",
|
"LnTipsWallet",
|
||||||
"LNPayWallet",
|
"LNPayWallet",
|
||||||
|
"AlbyWallet",
|
||||||
|
"ZBDWallet",
|
||||||
"LNbitsWallet",
|
"LNbitsWallet",
|
||||||
"OpenNodeWallet",
|
"OpenNodeWallet",
|
||||||
]
|
]
|
||||||
@@ -311,6 +420,7 @@ class TransientSettings(InstalledExtensionsSettings):
|
|||||||
# - are not read from a file or from the `settings` table
|
# - are not read from a file or from the `settings` table
|
||||||
# - are not persisted in the `settings` table when the settings are updated
|
# - are not persisted in the `settings` table when the settings are updated
|
||||||
# - are cleared on server restart
|
# - are cleared on server restart
|
||||||
|
first_install: bool = Field(default=False)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def readonly_fields(cls):
|
def readonly_fields(cls):
|
||||||
@@ -332,19 +442,33 @@ class ReadOnlySettings(
|
|||||||
)
|
)
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_readonly_settings(cls, val):
|
def validate_readonly_settings(cls, val):
|
||||||
return super().validate(val)
|
return super().validate_list(val)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def readonly_fields(cls):
|
def readonly_fields(cls):
|
||||||
return [f for f in inspect.signature(cls).parameters if not f.startswith("_")]
|
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
|
@classmethod
|
||||||
def from_row(cls, row: Row) -> "Settings":
|
def from_row(cls, row: Row) -> "Settings":
|
||||||
data = dict(row)
|
data = dict(row)
|
||||||
return cls(**data)
|
return cls(**data)
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
env_file = ".env"
|
||||||
|
env_file_encoding = "utf-8"
|
||||||
|
case_sensitive = False
|
||||||
|
json_loads = list_parse_fallback
|
||||||
|
|
||||||
|
def is_user_allowed(self, user_id: str):
|
||||||
|
return (
|
||||||
|
len(self.lnbits_allowed_users) == 0
|
||||||
|
or user_id in self.lnbits_allowed_users
|
||||||
|
or user_id in self.lnbits_admin_users
|
||||||
|
or user_id == self.super_user
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class SuperSettings(EditableSettings):
|
class SuperSettings(EditableSettings):
|
||||||
super_user: str
|
super_user: str
|
||||||
@@ -392,19 +516,19 @@ settings = Settings()
|
|||||||
|
|
||||||
settings.lnbits_path = str(path.dirname(path.realpath(__file__)))
|
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")
|
settings.version = importlib.metadata.version("lnbits")
|
||||||
|
settings.auth_secret_key = (
|
||||||
|
settings.auth_secret_key or sha256(settings.super_user.encode("utf-8")).hexdigest()
|
||||||
|
)
|
||||||
|
|
||||||
|
if not settings.user_agent:
|
||||||
|
settings.user_agent = f"LNbits/{settings.version}"
|
||||||
|
|
||||||
|
# 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():
|
def get_wallet_class():
|
||||||
|
|||||||
@@ -502,12 +502,6 @@ video {
|
|||||||
border-radius: 3px;
|
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 {
|
.material-icons {
|
||||||
font-family: "Material Icons";
|
font-family: "Material Icons";
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
@@ -535,3 +529,28 @@ video {
|
|||||||
.q-card code {
|
.q-card code {
|
||||||
overflow-wrap: break-word;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.whitespace-pre-line {
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 4.2 KiB |
@@ -1,8 +1,26 @@
|
|||||||
window.localisation.br = {
|
window.localisation.br = {
|
||||||
|
confirm: 'Sim',
|
||||||
server: 'Servidor',
|
server: 'Servidor',
|
||||||
theme: 'Tema',
|
theme: 'Tema',
|
||||||
funding: 'Financiamento',
|
funding: 'Financiamento',
|
||||||
users: 'Usuários',
|
users: 'Usuários',
|
||||||
|
apps: 'Aplicativos',
|
||||||
|
channels: 'Canais',
|
||||||
|
transactions: 'Transações',
|
||||||
|
dashboard: 'Painel de Controle',
|
||||||
|
node: 'Nó',
|
||||||
|
total_capacity: 'Capacidade Total',
|
||||||
|
avg_channel_size: 'Tamanho médio do canal',
|
||||||
|
biggest_channel_size: 'Maior Tamanho de Canal',
|
||||||
|
smallest_channel_size: 'Tamanho Mínimo do Canal',
|
||||||
|
number_of_channels: 'Número de Canais',
|
||||||
|
active_channels: 'Canais Ativos',
|
||||||
|
connect_peer: 'Conectar Par',
|
||||||
|
connect: 'Conectar',
|
||||||
|
open_channel: 'Canal Aberto',
|
||||||
|
open: 'Abrir',
|
||||||
|
close_channel: 'Fechar Canal',
|
||||||
|
close: 'Fechar',
|
||||||
restart: 'Reiniciar servidor',
|
restart: 'Reiniciar servidor',
|
||||||
save: 'Salvar',
|
save: 'Salvar',
|
||||||
save_tooltip: 'Salvar suas alterações',
|
save_tooltip: 'Salvar suas alterações',
|
||||||
@@ -18,7 +36,7 @@ window.localisation.br = {
|
|||||||
name_your_wallet: 'Nomeie sua carteira %{name}',
|
name_your_wallet: 'Nomeie sua carteira %{name}',
|
||||||
paste_invoice_label: 'Cole uma fatura, pedido de pagamento ou código lnurl *',
|
paste_invoice_label: 'Cole uma fatura, pedido de pagamento ou código lnurl *',
|
||||||
lnbits_description:
|
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, ZBD, 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: 'Exportar para o telefone com código QR',
|
||||||
export_to_phone_desc:
|
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.',
|
'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.',
|
||||||
@@ -29,28 +47,38 @@ window.localisation.br = {
|
|||||||
'Toda a carteira será excluída, os fundos serão IRRECUPERÁVEIS.',
|
'Toda a carteira será excluída, os fundos serão IRRECUPERÁVEIS.',
|
||||||
rename_wallet: 'Renomear carteira',
|
rename_wallet: 'Renomear carteira',
|
||||||
update_name: 'Atualizar nome',
|
update_name: 'Atualizar nome',
|
||||||
|
fiat_tracking: 'Rastreamento Fiat',
|
||||||
|
currency: 'Moeda',
|
||||||
|
update_currency: 'Atualizar moeda',
|
||||||
press_to_claim: 'Pressione para solicitar bitcoin',
|
press_to_claim: 'Pressione para solicitar bitcoin',
|
||||||
donate: 'Doar',
|
donate: 'Doar',
|
||||||
view_github: 'Ver no GitHub',
|
view_github: 'Ver no GitHub',
|
||||||
voidwallet_active: 'VoidWallet está ativo! Pagamentos desabilitados',
|
voidwallet_active: 'VoidWallet está ativo! Pagamentos desabilitados',
|
||||||
use_with_caution: 'USE COM CAUTELA - a carteira %{name} ainda está em BETA',
|
use_with_caution: 'USE COM CAUTELA - a carteira %{name} ainda está em BETA',
|
||||||
|
service_fee: 'Taxa de serviço: %{amount} % por transação',
|
||||||
|
service_fee_max:
|
||||||
|
'Taxa de serviço: %{amount} % por transação (máx %{max} sats)',
|
||||||
|
service_fee_tooltip:
|
||||||
|
'Taxa de serviço cobrada pelo administrador do servidor LNbits por transação de saída',
|
||||||
toggle_darkmode: 'Alternar modo escuro',
|
toggle_darkmode: 'Alternar modo escuro',
|
||||||
|
payment_reactions: 'Reações de Pagamento',
|
||||||
view_swagger_docs: 'Ver a documentação da API do LNbits Swagger',
|
view_swagger_docs: 'Ver a documentação da API do LNbits Swagger',
|
||||||
api_docs: 'Documentação da API',
|
api_docs: 'Documentação da API',
|
||||||
commit_version: 'Versão de commit',
|
api_keys_api_docs: 'URL do Node, chaves da API e documentação da API',
|
||||||
lnbits_version: 'Versão do LNbits',
|
lnbits_version: 'Versão do LNbits',
|
||||||
runs_on: 'Executa em',
|
runs_on: 'Executa em',
|
||||||
credit_hint: 'Pressione Enter para creditar a conta',
|
credit_hint: 'Pressione Enter para creditar a conta',
|
||||||
credit_label: '%{denomination} para creditar',
|
credit_label: '%{denomination} para creditar',
|
||||||
|
paste: 'Colar',
|
||||||
|
paste_from_clipboard: 'Cole do clipboard',
|
||||||
paste_request: 'Colar Pedido',
|
paste_request: 'Colar Pedido',
|
||||||
create_invoice: 'Criar Fatura',
|
create_invoice: 'Criar Fatura',
|
||||||
camera_tooltip: 'Usar a câmara para escanear uma fatura / QR',
|
camera_tooltip: 'Usar a câmara para escanear uma fatura / QR',
|
||||||
export_csv: 'Exportar para CSV',
|
export_csv: 'Exportar para CSV',
|
||||||
transactions: 'Transações',
|
|
||||||
chart_tooltip: 'Mostrar gráfico',
|
chart_tooltip: 'Mostrar gráfico',
|
||||||
pending: 'Pendente',
|
pending: 'Pendente',
|
||||||
copy_invoice: 'Copiar fatura',
|
copy_invoice: 'Copiar fatura',
|
||||||
close: 'Fechar',
|
withdraw_from: 'Sacar de',
|
||||||
cancel: 'Cancelar',
|
cancel: 'Cancelar',
|
||||||
scan: 'Escanear',
|
scan: 'Escanear',
|
||||||
read: 'Ler',
|
read: 'Ler',
|
||||||
@@ -74,17 +102,144 @@ window.localisation.br = {
|
|||||||
disclaimer_dialog:
|
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.',
|
'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',
|
no_transactions: 'Ainda não foram feitas transações',
|
||||||
manage_extensions: 'Gerenciar extensões',
|
manage: 'Gerenciar',
|
||||||
manage_server: 'Gerenciar servidor',
|
|
||||||
extensions: 'Extensões',
|
extensions: 'Extensões',
|
||||||
no_extensions: 'Você não possui nenhuma extensão instalada :(',
|
no_extensions: 'Você não possui nenhuma extensão instalada :(',
|
||||||
created: 'Criado',
|
created: 'Criado',
|
||||||
|
search_extensions: 'Extensões de pesquisa',
|
||||||
|
warning: 'Aviso',
|
||||||
|
repository: 'Repositório',
|
||||||
|
confirm_continue: 'Você tem certeza de que deseja continuar?',
|
||||||
|
manage_extension_details: 'Instalar/desinstalar extensão',
|
||||||
|
install: 'Instalar',
|
||||||
|
uninstall: 'Desinstalar',
|
||||||
|
drop_db: 'Remover Dados',
|
||||||
|
enable: 'Ativar',
|
||||||
|
enable_extension_details: 'Ativar extensão para o usuário atual',
|
||||||
|
disable: 'Desativar',
|
||||||
|
installed: 'Instalado',
|
||||||
|
activated: 'Ativado',
|
||||||
|
deactivated: 'Desativado',
|
||||||
|
release_notes: 'Notas de Lançamento',
|
||||||
|
activate_extension_details:
|
||||||
|
'Tornar a extensão disponível/indisponível para usuários',
|
||||||
|
featured: 'Destacado',
|
||||||
|
all: 'Tudo',
|
||||||
|
only_admins_can_install:
|
||||||
|
'Apenas contas de administrador podem instalar extensões.',
|
||||||
|
admin_only: 'Apenas para Administração',
|
||||||
|
new_version: 'Nova Versão',
|
||||||
|
extension_depends_on: 'Depende de:',
|
||||||
|
extension_rating_soon: 'Avaliações estarão disponíveis em breve',
|
||||||
|
extension_installed_version: 'Versão instalada',
|
||||||
|
extension_uninstall_warning:
|
||||||
|
'Você está prestes a remover a extensão para todos os usuários.',
|
||||||
|
uninstall_confirm: 'Sim, Desinstalar',
|
||||||
|
extension_db_drop_info:
|
||||||
|
'Todos os dados da extensão serão permanentemente excluídos. Não há como desfazer essa operação!',
|
||||||
|
extension_db_drop_warning:
|
||||||
|
'Você está prestes a remover todos os dados para a extensão. Por favor, digite o nome da extensão para continuar:',
|
||||||
|
extension_min_lnbits_version:
|
||||||
|
'Esta versão requer no mínimo a versão do LNbits',
|
||||||
payment_hash: 'Hash de pagamento',
|
payment_hash: 'Hash de pagamento',
|
||||||
fee: 'Taxa',
|
fee: 'Taxa',
|
||||||
amount: 'Quantidade',
|
amount: 'Quantidade',
|
||||||
|
tag: 'Etiqueta',
|
||||||
unit: 'Unidade',
|
unit: 'Unidade',
|
||||||
description: 'Descrição',
|
description: 'Descrição',
|
||||||
expiry: 'Validade',
|
expiry: 'Validade',
|
||||||
webhook: 'Webhook',
|
webhook: 'Webhook',
|
||||||
payment_proof: 'Comprovante de pagamento'
|
payment_proof: 'Comprovante de pagamento',
|
||||||
|
update_available: 'Atualização %{version} disponível!',
|
||||||
|
latest_update: 'Você está na versão mais recente %{version}.',
|
||||||
|
notifications: 'Notificações',
|
||||||
|
no_notifications: 'Sem notificações',
|
||||||
|
notifications_disabled:
|
||||||
|
'As notificações de status do LNbits estão desativadas.',
|
||||||
|
enable_notifications: 'Ativar notificações',
|
||||||
|
enable_notifications_desc:
|
||||||
|
'Se ativado, ele buscará as últimas atualizações de status do LNbits, como incidentes de segurança e atualizações.',
|
||||||
|
enable_killswitch: 'Ativar Killswitch',
|
||||||
|
enable_killswitch_desc:
|
||||||
|
'Se ativado, mudará sua fonte de fundos para VoidWallet automaticamente se o LNbits enviar um sinal de desativação. Você precisará ativar manualmente após uma atualização.',
|
||||||
|
killswitch_interval: 'Intervalo do Killswitch',
|
||||||
|
killswitch_interval_desc:
|
||||||
|
'Com que frequência a tarefa de fundo deve verificar o sinal de desativação do LNBits proveniente da fonte de status (em minutos).',
|
||||||
|
enable_watchdog: 'Ativar Watchdog',
|
||||||
|
enable_watchdog_desc:
|
||||||
|
'Se ativado, ele mudará automaticamente sua fonte de financiamento para VoidWallet se o seu saldo for inferior ao saldo do LNbits. Você precisará ativar manualmente após uma atualização.',
|
||||||
|
watchdog_interval: 'Intervalo do Watchdog',
|
||||||
|
watchdog_interval_desc:
|
||||||
|
'Com que frequência a tarefa de fundo deve verificar um sinal de interrupção no delta do monitor [node_balance - lnbits_balance] (em minutos).',
|
||||||
|
watchdog_delta: 'Observador Delta',
|
||||||
|
watchdog_delta_desc:
|
||||||
|
'Limite antes da mudança do mecanismo de segurança alterar a fonte de financiamento para VoidWallet [lnbits_balance - node_balance > delta]',
|
||||||
|
status: 'Estado',
|
||||||
|
notification_source: 'Fonte de Notificação',
|
||||||
|
notification_source_label:
|
||||||
|
'URL de origem (use apenas a fonte de status oficial do LNbits e fontes de confiança)',
|
||||||
|
more: 'mais',
|
||||||
|
less: 'menos',
|
||||||
|
releases: 'Lançamentos',
|
||||||
|
killswitch: 'Dispositivo de desativação',
|
||||||
|
watchdog: 'Cão de guarda',
|
||||||
|
server_logs: 'Registros do Servidor',
|
||||||
|
ip_blocker: 'Bloqueador de IP',
|
||||||
|
security: 'Segurança',
|
||||||
|
security_tools: 'Ferramentas de segurança',
|
||||||
|
block_access_hint: 'Bloquear acesso por IP',
|
||||||
|
allow_access_hint: 'Permitir acesso por IP (substituirá os IPs bloqueados)',
|
||||||
|
enter_ip: 'Digite o IP e pressione enter',
|
||||||
|
rate_limiter: 'Limitador de Taxa',
|
||||||
|
wallet_limiter: 'Limitador de Carteira',
|
||||||
|
wallet_limit_max_withdraw_per_day:
|
||||||
|
'Retirada máxima diária da carteira em sats (0 para desativar)',
|
||||||
|
wallet_max_ballance: 'Saldo máximo da carteira em sats (0 para desativar)',
|
||||||
|
wallet_limit_secs_between_trans:
|
||||||
|
'Minutos e segundos entre transações por carteira (0 para desativar)',
|
||||||
|
number_of_requests: 'Número de solicitações',
|
||||||
|
time_unit: 'Unidade de tempo',
|
||||||
|
minute: 'minuto',
|
||||||
|
second: 'segundo',
|
||||||
|
hour: 'hora',
|
||||||
|
disable_server_log: 'Desativar Log do Servidor',
|
||||||
|
enable_server_log: 'Ativar Registro do Servidor',
|
||||||
|
coming_soon: 'Funcionalidade em breve',
|
||||||
|
session_has_expired: 'Sua sessão expirou. Por favor, faça login novamente.',
|
||||||
|
instant_access_question: 'Quer acesso imediato?',
|
||||||
|
login_with_user_id: 'Faça login com ID do usuário',
|
||||||
|
or: 'ou',
|
||||||
|
create_new_wallet: 'Criar Nova Carteira',
|
||||||
|
login_to_account: 'Faça login na sua conta',
|
||||||
|
create_account: 'Criar conta',
|
||||||
|
account_settings: 'Configurações da Conta',
|
||||||
|
signin_with_google: 'Entrar com o Google',
|
||||||
|
signin_with_github: 'Entrar com GitHub',
|
||||||
|
signin_with_keycloak: 'Entrar com Keycloak',
|
||||||
|
username_or_email: 'Nome de usuário ou E-mail',
|
||||||
|
password: 'Senha',
|
||||||
|
password_config: 'Configuração de Senha',
|
||||||
|
password_repeat: 'Repetição de senha',
|
||||||
|
change_password: 'Alterar Senha',
|
||||||
|
set_password: 'Definir Senha',
|
||||||
|
invalid_password: 'A senha deve ter pelo menos 8 caracteres',
|
||||||
|
login: 'Entrar',
|
||||||
|
register: 'Registrar',
|
||||||
|
username: 'Nome de usuário',
|
||||||
|
user_id: 'ID do Usuário',
|
||||||
|
email: 'E-mail',
|
||||||
|
first_name: 'Primeiro Nome',
|
||||||
|
last_name: 'Sobrenome',
|
||||||
|
picture: 'Foto',
|
||||||
|
verify_email: 'Verifique o e-mail com',
|
||||||
|
account: 'Conta',
|
||||||
|
update_account: 'Atualizar Conta',
|
||||||
|
invalid_username: 'Nome de usuário inválido',
|
||||||
|
auth_provider: 'Provedor de Autenticação',
|
||||||
|
my_account: 'Minha Conta',
|
||||||
|
back: 'Voltar',
|
||||||
|
logout: 'Sair',
|
||||||
|
look_and_feel: 'Aparência',
|
||||||
|
language: 'Idioma',
|
||||||
|
color_scheme: 'Esquema de Cores'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,23 @@ window.localisation.cn = {
|
|||||||
theme: '主题',
|
theme: '主题',
|
||||||
funding: '资金',
|
funding: '资金',
|
||||||
users: '用户',
|
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: '重新启动服务器',
|
restart: '重新启动服务器',
|
||||||
save: '保存',
|
save: '保存',
|
||||||
save_tooltip: '保存更改',
|
save_tooltip: '保存更改',
|
||||||
@@ -18,7 +35,7 @@ window.localisation.cn = {
|
|||||||
name_your_wallet: '给你的 %{name}钱包起个名字',
|
name_your_wallet: '给你的 %{name}钱包起个名字',
|
||||||
paste_invoice_label: '粘贴发票,付款请求或lnurl*',
|
paste_invoice_label: '粘贴发票,付款请求或lnurl*',
|
||||||
lnbits_description:
|
lnbits_description:
|
||||||
'LNbits 设置简单、轻量级,可以运行在任何闪电网络的版本上,目前支持 LND、Core Lightning、OpenNode、LNPay,甚至 LNbits 本身!您可以为自己运行 LNbits,或者为他人轻松提供资金托管。每个钱包都有自己的 API 密钥,你可以创建的钱包数量没有限制。能够把资金分开管理使 LNbits 成为一款有用的资金管理和开发工具。扩展程序增加了 LNbits 的额外功能,所以你可以在闪电网络上尝试各种尖端技术。我们已经尽可能简化了开发扩展程序的过程,作为一个免费和开源的项目,我们鼓励人们开发并提交自己的扩展程序。',
|
'LNbits 设置简单、轻量级,可以运行在任何闪电网络的版本上,目前支持 LND、Core Lightning、OpenNode、Alby, ZBD, LNPay,甚至 LNbits 本身!您可以为自己运行 LNbits,或者为他人轻松提供资金托管。每个钱包都有自己的 API 密钥,你可以创建的钱包数量没有限制。能够把资金分开管理使 LNbits 成为一款有用的资金管理和开发工具。扩展程序增加了 LNbits 的额外功能,所以你可以在闪电网络上尝试各种尖端技术。我们已经尽可能简化了开发扩展程序的过程,作为一个免费和开源的项目,我们鼓励人们开发并提交自己的扩展程序。',
|
||||||
export_to_phone: '通过二维码导出到手机',
|
export_to_phone: '通过二维码导出到手机',
|
||||||
export_to_phone_desc:
|
export_to_phone_desc:
|
||||||
'这个二维码包含您钱包的URL。您可以使用手机扫描的方式打开您的钱包。',
|
'这个二维码包含您钱包的URL。您可以使用手机扫描的方式打开您的钱包。',
|
||||||
@@ -28,28 +45,36 @@ window.localisation.cn = {
|
|||||||
delete_wallet_desc: '整个钱包将被删除,资金将无法恢复',
|
delete_wallet_desc: '整个钱包将被删除,资金将无法恢复',
|
||||||
rename_wallet: '重命名钱包',
|
rename_wallet: '重命名钱包',
|
||||||
update_name: '更新名称',
|
update_name: '更新名称',
|
||||||
|
fiat_tracking: '菲亚特追踪',
|
||||||
|
currency: '货币',
|
||||||
|
update_currency: '更新货币',
|
||||||
press_to_claim: '点击领取比特币',
|
press_to_claim: '点击领取比特币',
|
||||||
donate: '捐献',
|
donate: '捐献',
|
||||||
view_github: '在GitHub上查看',
|
view_github: '在GitHub上查看',
|
||||||
voidwallet_active: 'VoidWallet 已激活!付款功能已禁用。',
|
voidwallet_active: 'VoidWallet 已激活!付款功能已禁用。',
|
||||||
use_with_caution: '请谨慎使用 - %{name}钱包还处于测试版阶段',
|
use_with_caution: '请谨慎使用 - %{name}钱包还处于测试版阶段',
|
||||||
|
service_fee: '服务费:%{amount}% 每笔交易',
|
||||||
|
service_fee_max: '服务费:%{amount}% 每笔交易(最高 %{max} sats)',
|
||||||
|
service_fee_tooltip: 'LNbits服务器管理员每笔外发交易收取的服务费',
|
||||||
toggle_darkmode: '切换暗黑模式',
|
toggle_darkmode: '切换暗黑模式',
|
||||||
|
payment_reactions: '支付反应',
|
||||||
view_swagger_docs: '查看 LNbits Swagger API 文档',
|
view_swagger_docs: '查看 LNbits Swagger API 文档',
|
||||||
api_docs: 'API文档',
|
api_docs: 'API文档',
|
||||||
commit_version: '提交版本',
|
api_keys_api_docs: '节点URL、API密钥和API文档',
|
||||||
lnbits_version: 'LNbits版本',
|
lnbits_version: 'LNbits版本',
|
||||||
runs_on: '可运行在',
|
runs_on: '可运行在',
|
||||||
credit_hint: '按 Enter 键充值账户',
|
credit_hint: '按 Enter 键充值账户',
|
||||||
credit_label: '%{denomination} 充值',
|
credit_label: '%{denomination} 充值',
|
||||||
|
paste: '粘贴',
|
||||||
|
paste_from_clipboard: '从剪贴板粘贴',
|
||||||
paste_request: '粘贴请求',
|
paste_request: '粘贴请求',
|
||||||
create_invoice: '创建发票',
|
create_invoice: '创建发票',
|
||||||
camera_tooltip: '用相机扫描发票/二维码',
|
camera_tooltip: '用相机扫描发票/二维码',
|
||||||
export_csv: '导出为CSV',
|
export_csv: '导出为CSV',
|
||||||
transactions: '交易记录',
|
|
||||||
chart_tooltip: '显示图表',
|
chart_tooltip: '显示图表',
|
||||||
pending: '待处理',
|
pending: '待处理',
|
||||||
copy_invoice: '复制发票',
|
copy_invoice: '复制发票',
|
||||||
close: '关闭',
|
withdraw_from: '从',
|
||||||
cancel: '取消',
|
cancel: '取消',
|
||||||
scan: '扫描',
|
scan: '扫描',
|
||||||
read: '读取',
|
read: '读取',
|
||||||
@@ -73,21 +98,18 @@ window.localisation.cn = {
|
|||||||
disclaimer_dialog:
|
disclaimer_dialog:
|
||||||
'登录功能将在以后的更新中发布,请将此页面加为书签,以便将来访问您的钱包!此服务处于测试阶段,我们不对资金的丢失承担任何责任。',
|
'登录功能将在以后的更新中发布,请将此页面加为书签,以便将来访问您的钱包!此服务处于测试阶段,我们不对资金的丢失承担任何责任。',
|
||||||
no_transactions: '尚未进行任何交易',
|
no_transactions: '尚未进行任何交易',
|
||||||
manage_server: '管理服务器',
|
manage: '管理',
|
||||||
extensions: '扩展程序',
|
extensions: '扩展程序',
|
||||||
no_extensions: '你没有安装任何扩展程序 :(',
|
no_extensions: '你没有安装任何扩展程序 :(',
|
||||||
created: '已创建',
|
created: '已创建',
|
||||||
|
|
||||||
search_extensions: '搜索扩展程序',
|
search_extensions: '搜索扩展程序',
|
||||||
warning: '警告',
|
warning: '警告',
|
||||||
manage: '管理',
|
|
||||||
repository: '代码库',
|
repository: '代码库',
|
||||||
confirm_continue: '你确定要继续吗?',
|
confirm_continue: '你确定要继续吗?',
|
||||||
manage_extension_details: '安装/卸载扩展程序',
|
manage_extension_details: '安装/卸载扩展程序',
|
||||||
install: '安装',
|
install: '安装',
|
||||||
uninstall: '卸载',
|
uninstall: '卸载',
|
||||||
drop_db: '删除数据',
|
drop_db: '删除数据',
|
||||||
open: '打开',
|
|
||||||
enable: '启用',
|
enable: '启用',
|
||||||
enable_extension_details: '为当前用户启用扩展程序',
|
enable_extension_details: '为当前用户启用扩展程序',
|
||||||
disable: '禁用',
|
disable: '禁用',
|
||||||
@@ -109,15 +131,103 @@ window.localisation.cn = {
|
|||||||
extension_db_drop_info: '该扩展程序的所有数据将被永久删除。此操作无法撤销!',
|
extension_db_drop_info: '该扩展程序的所有数据将被永久删除。此操作无法撤销!',
|
||||||
extension_db_drop_warning:
|
extension_db_drop_warning:
|
||||||
'您即将删除该扩展的所有数据。请继续输入扩展程序名称以确认操作:',
|
'您即将删除该扩展的所有数据。请继续输入扩展程序名称以确认操作:',
|
||||||
|
|
||||||
extension_min_lnbits_version: '此版本要求最低的 LNbits 版本为',
|
extension_min_lnbits_version: '此版本要求最低的 LNbits 版本为',
|
||||||
|
|
||||||
payment_hash: '付款哈希',
|
payment_hash: '付款哈希',
|
||||||
fee: '费',
|
fee: '费',
|
||||||
amount: '金额',
|
amount: '金额',
|
||||||
|
tag: '标签',
|
||||||
unit: '单位',
|
unit: '单位',
|
||||||
description: '详情',
|
description: '详情',
|
||||||
expiry: '过期时间',
|
expiry: '过期时间',
|
||||||
webhook: 'Webhook',
|
webhook: 'Webhook',
|
||||||
payment_proof: '付款证明'
|
payment_proof: '付款证明',
|
||||||
|
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 间隔',
|
||||||
|
killswitch_interval_desc:
|
||||||
|
'后台任务应该多久检查一次来自状态源的LNBits断路信号(以分钟为单位)。',
|
||||||
|
enable_watchdog: '启用看门狗',
|
||||||
|
enable_watchdog_desc:
|
||||||
|
'如果启用,当您的余额低于LNbits余额时,系统将自动将您的资金来源更改为VoidWallet。更新后您将需要手动启用。',
|
||||||
|
watchdog_interval: '看门狗间隔',
|
||||||
|
watchdog_interval_desc:
|
||||||
|
'后台任务应该多久检查一次看门狗增量中的 killswitch 信号 [node_balance - lnbits_balance](以分钟计)。',
|
||||||
|
watchdog_delta: '看门狗德尔塔',
|
||||||
|
watchdog_delta_desc:
|
||||||
|
'在触发紧急停止前切换资金来源至VoidWallet的限制 [lnbits_balance - node_balance > delta]',
|
||||||
|
status: '状态',
|
||||||
|
notification_source: '通知来源',
|
||||||
|
notification_source_label: '来源 URL(仅使用官方LNbits状态源和您信任的源)',
|
||||||
|
more: '更多',
|
||||||
|
less: '少',
|
||||||
|
releases: '版本',
|
||||||
|
killswitch: '杀手锏',
|
||||||
|
watchdog: '监控程序',
|
||||||
|
server_logs: '服务器日志',
|
||||||
|
ip_blocker: 'IP 阻止器',
|
||||||
|
security: '安全',
|
||||||
|
security_tools: '安全工具',
|
||||||
|
block_access_hint: '屏蔽IP访问',
|
||||||
|
allow_access_hint: '允许通过IP访问(将覆盖被屏蔽的IP)',
|
||||||
|
enter_ip: '输入IP地址并按回车键',
|
||||||
|
rate_limiter: '速率限制器',
|
||||||
|
wallet_limiter: '钱包限制器',
|
||||||
|
wallet_limit_max_withdraw_per_day:
|
||||||
|
'每日钱包最大提现额度(单位:sats)(设为0则禁用)',
|
||||||
|
wallet_max_ballance: '钱包最大余额(以sats计)(设为0则禁用)',
|
||||||
|
wallet_limit_secs_between_trans: '每个钱包交易间最少秒数(设为0则禁用)',
|
||||||
|
number_of_requests: '请求次数',
|
||||||
|
time_unit: '时间单位',
|
||||||
|
minute: '分钟',
|
||||||
|
second: '秒',
|
||||||
|
hour: '小时',
|
||||||
|
disable_server_log: '禁用服务器日志',
|
||||||
|
enable_server_log: '启用服务器日志',
|
||||||
|
coming_soon: '功能即将推出',
|
||||||
|
session_has_expired: '您的会话已过期。请重新登录。',
|
||||||
|
instant_access_question: '想要即时访问吗?',
|
||||||
|
login_with_user_id: '使用用户ID登录',
|
||||||
|
or: '或',
|
||||||
|
create_new_wallet: '创建新钱包',
|
||||||
|
login_to_account: '登录您的账户',
|
||||||
|
create_account: '创建账户',
|
||||||
|
account_settings: '账户设置',
|
||||||
|
signin_with_google: '使用谷歌账号登录',
|
||||||
|
signin_with_github: '使用GitHub登录',
|
||||||
|
signin_with_keycloak: '使用Keycloak登录',
|
||||||
|
username_or_email: '用户名或电子邮箱',
|
||||||
|
password: '密码',
|
||||||
|
password_config: '密码配置',
|
||||||
|
password_repeat: '密码重复',
|
||||||
|
change_password: '修改密码',
|
||||||
|
set_password: '设置密码',
|
||||||
|
invalid_password: '密码至少需要有8个字符',
|
||||||
|
login: '登录',
|
||||||
|
register: '注册',
|
||||||
|
username: '用户名',
|
||||||
|
user_id: '用户ID',
|
||||||
|
email: '电子邮件',
|
||||||
|
first_name: '名字',
|
||||||
|
last_name: '姓氏',
|
||||||
|
picture: '图片',
|
||||||
|
verify_email: '验证电子邮件与',
|
||||||
|
account: '账户',
|
||||||
|
update_account: '更新帐户',
|
||||||
|
invalid_username: '无效用户名',
|
||||||
|
auth_provider: '认证提供者',
|
||||||
|
my_account: '我的账户',
|
||||||
|
back: '返回',
|
||||||
|
logout: '注销',
|
||||||
|
look_and_feel: '外观和感觉',
|
||||||
|
language: '语言',
|
||||||
|
color_scheme: '配色方案'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,242 @@
|
|||||||
|
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',
|
||||||
|
node: '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, ZBD, 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Ě',
|
||||||
|
service_fee: 'Servisný poplatek: %{amount} % za transakci',
|
||||||
|
service_fee_max:
|
||||||
|
'Servisný poplatek: %{amount} % za transakci (max %{max} satoshi)',
|
||||||
|
service_fee_tooltip:
|
||||||
|
'Servisní poplatek účtovaný správcem LNbits serveru za odchozí transakci',
|
||||||
|
toggle_darkmode: 'Přepnout tmavý režim',
|
||||||
|
payment_reactions: 'Reakce na platby',
|
||||||
|
view_swagger_docs: 'Zobrazit LNbits Swagger API dokumentaci',
|
||||||
|
api_docs: 'API dokumentace',
|
||||||
|
api_keys_api_docs: 'Adresa uzlu, API klíče a 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',
|
||||||
|
chart_tooltip: 'Zobrazit graf',
|
||||||
|
pending: 'Čeká na vyřízení',
|
||||||
|
copy_invoice: 'Kopírovat fakturu',
|
||||||
|
withdraw_from: 'Vybrat z',
|
||||||
|
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',
|
||||||
|
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',
|
||||||
|
tag: 'Tag',
|
||||||
|
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',
|
||||||
|
less: 'méně',
|
||||||
|
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ů',
|
||||||
|
wallet_limiter: 'Omezení peněženky',
|
||||||
|
wallet_limit_max_withdraw_per_day:
|
||||||
|
'Maximální denní limit pro výběr z peněženky v sats (0 pro deaktivaci)',
|
||||||
|
wallet_max_ballance: 'Maximální zůstatek v peněžence v sats (0 pro zakázání)',
|
||||||
|
wallet_limit_secs_between_trans:
|
||||||
|
'Minimální počet sekund mezi transakcemi na peněženku (0 pro vypnutí)',
|
||||||
|
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á',
|
||||||
|
session_has_expired: 'Vaše relace vypršela. Prosím, přihlašte se znovu.',
|
||||||
|
instant_access_question: 'Chcete okamžitý přístup?',
|
||||||
|
login_with_user_id: 'Přihlásit se s uživatelským ID',
|
||||||
|
or: 'nebo',
|
||||||
|
create_new_wallet: 'Vytvořit novou peněženku',
|
||||||
|
login_to_account: 'Přihlaste se ke svému účtu',
|
||||||
|
create_account: 'Vytvořit účet',
|
||||||
|
account_settings: 'Nastavení účtu',
|
||||||
|
signin_with_google: 'Přihlásit se přes Google',
|
||||||
|
signin_with_github: 'Přihlásit se přes GitHub',
|
||||||
|
signin_with_keycloak: 'Přihlásit se přes Keycloak',
|
||||||
|
username_or_email: 'Uživatelské jméno nebo Email',
|
||||||
|
password: 'Heslo',
|
||||||
|
password_config: 'Konfigurace hesla',
|
||||||
|
password_repeat: 'Opakujte heslo',
|
||||||
|
change_password: 'Změnit heslo',
|
||||||
|
set_password: 'Nastavit heslo',
|
||||||
|
invalid_password: 'Heslo musí mít alespoň 8 znaků',
|
||||||
|
login: 'Přihlášení',
|
||||||
|
register: 'Registrovat',
|
||||||
|
username: 'Uživatelské jméno',
|
||||||
|
user_id: 'ID uživatele',
|
||||||
|
email: 'Email',
|
||||||
|
first_name: 'Křestní jméno',
|
||||||
|
last_name: 'Příjmení',
|
||||||
|
picture: 'Obrázek',
|
||||||
|
verify_email: 'Ověřte e-mail s',
|
||||||
|
account: 'Účet',
|
||||||
|
update_account: 'Aktualizovat účet',
|
||||||
|
invalid_username: 'Neplatné uživatelské jméno',
|
||||||
|
auth_provider: 'Poskytovatel ověření',
|
||||||
|
my_account: 'Můj účet',
|
||||||
|
back: 'Zpět',
|
||||||
|
logout: 'Odhlásit se',
|
||||||
|
look_and_feel: 'Vzhled a chování',
|
||||||
|
language: 'Jazyk',
|
||||||
|
color_scheme: 'Barevné schéma'
|
||||||
|
}
|
||||||
@@ -1,9 +1,26 @@
|
|||||||
window.localisation.de = {
|
window.localisation.de = {
|
||||||
|
confirm: 'Ja',
|
||||||
server: 'Server',
|
server: 'Server',
|
||||||
theme: 'Theme',
|
theme: 'Theme',
|
||||||
funding: 'Funding',
|
funding: 'Funding',
|
||||||
users: 'Benutzer',
|
users: 'Benutzer',
|
||||||
unit: 'Einheit',
|
apps: 'Apps',
|
||||||
|
channels: 'Kanäle',
|
||||||
|
transactions: 'Transaktionen',
|
||||||
|
dashboard: 'Armaturenbrett',
|
||||||
|
node: 'Knoten',
|
||||||
|
total_capacity: 'Gesamtkapazität',
|
||||||
|
avg_channel_size: 'Durchschn. Kanalgröße',
|
||||||
|
biggest_channel_size: 'Größte Kanalgröße',
|
||||||
|
smallest_channel_size: 'Kleinste Kanalgröße',
|
||||||
|
number_of_channels: 'Anzahl der Kanäle',
|
||||||
|
active_channels: 'Aktive Kanäle',
|
||||||
|
connect_peer: 'Peer verbinden',
|
||||||
|
connect: 'Verbinden',
|
||||||
|
open_channel: 'Offener Kanal',
|
||||||
|
open: 'Öffnen',
|
||||||
|
close_channel: 'Kanal schließen',
|
||||||
|
close: 'Schließen',
|
||||||
restart: 'Server neu starten',
|
restart: 'Server neu starten',
|
||||||
save: 'Speichern',
|
save: 'Speichern',
|
||||||
save_tooltip: 'Änderungen speichern',
|
save_tooltip: 'Änderungen speichern',
|
||||||
@@ -20,7 +37,7 @@ window.localisation.de = {
|
|||||||
paste_invoice_label:
|
paste_invoice_label:
|
||||||
'Füge eine Rechnung, Zahlungsanforderung oder LNURL ein *',
|
'Füge eine Rechnung, Zahlungsanforderung oder LNURL ein *',
|
||||||
lnbits_description:
|
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, ZBD, 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: 'Auf dem Telefon öffnen',
|
||||||
export_to_phone_desc:
|
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.',
|
'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.',
|
||||||
@@ -31,29 +48,40 @@ window.localisation.de = {
|
|||||||
'Die Wallet wird gelöscht, die hierin beinhalteten Daten hierin oder innerhalb einer Erweiterung sind UNWIEDERBRINGLICH.',
|
'Die Wallet wird gelöscht, die hierin beinhalteten Daten hierin oder innerhalb einer Erweiterung sind UNWIEDERBRINGLICH.',
|
||||||
rename_wallet: 'Wallet umbenennen',
|
rename_wallet: 'Wallet umbenennen',
|
||||||
update_name: 'Namen aktualisieren',
|
update_name: 'Namen aktualisieren',
|
||||||
|
fiat_tracking: 'Fiat-Tracking',
|
||||||
|
currency: 'Währung',
|
||||||
|
update_currency: 'Währung aktualisieren',
|
||||||
press_to_claim: 'Klicken, um Bitcoin einzufordern.',
|
press_to_claim: 'Klicken, um Bitcoin einzufordern.',
|
||||||
donate: 'Spenden',
|
donate: 'Spenden',
|
||||||
view_github: 'Auf GitHub anzeigen',
|
view_github: 'Auf GitHub anzeigen',
|
||||||
voidwallet_active: 'VoidWallet ist aktiv! Zahlungen deaktiviert',
|
voidwallet_active: 'VoidWallet ist aktiv! Zahlungen deaktiviert',
|
||||||
use_with_caution:
|
use_with_caution:
|
||||||
'BITTE MIT VORSICHT BENUTZEN - %{name} Wallet ist noch BETA',
|
'BITTE MIT VORSICHT BENUTZEN - %{name} Wallet ist noch BETA',
|
||||||
|
service_fee: 'Dienstleistungsgebühr: %{amount} % pro Transaktion',
|
||||||
|
service_fee_max:
|
||||||
|
'Servicegebühr: %{amount} % pro Transaktion (max %{max} Sats)',
|
||||||
|
service_fee_tooltip:
|
||||||
|
'Bearbeitungsgebühr, die vom LNbits Server-Administrator pro ausgehender Transaktion berechnet wird',
|
||||||
toggle_darkmode: 'Auf Dark Mode umschalten',
|
toggle_darkmode: 'Auf Dark Mode umschalten',
|
||||||
view_swagger_docs: 'LNbits Swagger API-Dokumente',
|
payment_reactions: 'Zahlungsreaktionen',
|
||||||
api_docs: 'API docs',
|
view_swagger_docs: 'LNbits Swagger API-Dokumentation',
|
||||||
commit_version: 'Commit Version',
|
api_docs: 'API-Dokumentation',
|
||||||
|
api_keys_api_docs: 'Knoten-URL, API-Schlüssel und API-Dokumentation',
|
||||||
|
lnbits_version: 'LNbits-Version',
|
||||||
runs_on: 'Läuft auf',
|
runs_on: 'Läuft auf',
|
||||||
credit_hint: 'Klicke Enter, um das Konto zu belasten',
|
credit_hint: 'Klicke Enter, um das Konto zu belasten',
|
||||||
credit_label: '%{denomination} zu belasten',
|
credit_label: '%{denomination} zu belasten',
|
||||||
|
paste: 'Einfügen',
|
||||||
|
paste_from_clipboard: 'Einfügen aus der Zwischenablage',
|
||||||
paste_request: 'Anfrage einfügen',
|
paste_request: 'Anfrage einfügen',
|
||||||
create_invoice: 'Rechnung erstellen',
|
create_invoice: 'Rechnung erstellen',
|
||||||
camera_tooltip:
|
camera_tooltip:
|
||||||
'Verwende die Kamera, um eine Rechnung oder einen QR-Code zu scannen',
|
'Verwende die Kamera, um eine Rechnung oder einen QR-Code zu scannen',
|
||||||
export_csv: 'Exportieren als CSV',
|
export_csv: 'Exportieren als CSV',
|
||||||
transactions: 'Transaktionen',
|
|
||||||
chart_tooltip: 'Diagramm anzeigen',
|
chart_tooltip: 'Diagramm anzeigen',
|
||||||
pending: 'Ausstehend',
|
pending: 'Ausstehend',
|
||||||
copy_invoice: 'Rechnung kopieren',
|
copy_invoice: 'Rechnung kopieren',
|
||||||
close: 'Schließen',
|
withdraw_from: 'Abheben von',
|
||||||
cancel: 'Stornieren',
|
cancel: 'Stornieren',
|
||||||
scan: 'Scannen',
|
scan: 'Scannen',
|
||||||
read: 'Lesen',
|
read: 'Lesen',
|
||||||
@@ -77,21 +105,18 @@ window.localisation.de = {
|
|||||||
disclaimer_dialog:
|
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.',
|
'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',
|
no_transactions: 'Keine Transaktionen',
|
||||||
manage_extensions: 'Erweiterungen verwalten',
|
manage: 'Verwalten',
|
||||||
manage_server: 'Server verwalten',
|
|
||||||
extensions: 'Erweiterungen',
|
extensions: 'Erweiterungen',
|
||||||
no_extensions: 'Du hast noch keine Erweiterungen installiert :(',
|
no_extensions: 'Du hast noch keine Erweiterungen installiert :(',
|
||||||
created: 'Erstellt',
|
created: 'Erstellt',
|
||||||
|
|
||||||
search_extensions: 'Sucherweiterungen',
|
search_extensions: 'Sucherweiterungen',
|
||||||
warning: 'Warnung',
|
warning: 'Warnung',
|
||||||
manage: 'Verwalten',
|
|
||||||
repository: 'Repository',
|
repository: 'Repository',
|
||||||
confirm_continue: 'Bist du sicher, dass du fortfahren möchtest?',
|
confirm_continue: 'Bist du sicher, dass du fortfahren möchtest?',
|
||||||
manage_extension_details: 'Erweiterung installieren/deinstallieren',
|
manage_extension_details: 'Erweiterung installieren/deinstallieren',
|
||||||
install: 'Installieren',
|
install: 'Installieren',
|
||||||
uninstall: 'Deinstallieren',
|
uninstall: 'Deinstallieren',
|
||||||
open: 'Öffnen',
|
drop_db: 'Daten löschen',
|
||||||
enable: 'Aktivieren',
|
enable: 'Aktivieren',
|
||||||
enable_extension_details: 'Erweiterung für aktuellen Benutzer aktivieren',
|
enable_extension_details: 'Erweiterung für aktuellen Benutzer aktivieren',
|
||||||
disable: 'Deaktivieren',
|
disable: 'Deaktivieren',
|
||||||
@@ -105,6 +130,7 @@ window.localisation.de = {
|
|||||||
all: 'Alle',
|
all: 'Alle',
|
||||||
only_admins_can_install:
|
only_admins_can_install:
|
||||||
'(Nur Administratorkonten können Erweiterungen installieren)',
|
'(Nur Administratorkonten können Erweiterungen installieren)',
|
||||||
|
admin_only: 'Nur für Admins',
|
||||||
new_version: 'Neue Version',
|
new_version: 'Neue Version',
|
||||||
extension_depends_on: 'Hängt ab von:',
|
extension_depends_on: 'Hängt ab von:',
|
||||||
extension_rating_soon: 'Bewertungen sind bald verfügbar',
|
extension_rating_soon: 'Bewertungen sind bald verfügbar',
|
||||||
@@ -112,14 +138,112 @@ window.localisation.de = {
|
|||||||
extension_uninstall_warning:
|
extension_uninstall_warning:
|
||||||
'Sie sind dabei, die Erweiterung für alle Benutzer zu entfernen.',
|
'Sie sind dabei, die Erweiterung für alle Benutzer zu entfernen.',
|
||||||
uninstall_confirm: 'Ja, deinstallieren',
|
uninstall_confirm: 'Ja, deinstallieren',
|
||||||
|
extension_db_drop_info:
|
||||||
|
'Alle Daten für die Erweiterung werden dauerhaft gelöscht. Es gibt keine Möglichkeit, diesen Vorgang rückgängig zu machen!',
|
||||||
|
extension_db_drop_warning:
|
||||||
|
'Sie sind dabei, alle Daten für die Erweiterung zu entfernen. Bitte geben Sie den Namen der Erweiterung ein, um fortzufahren:',
|
||||||
extension_min_lnbits_version:
|
extension_min_lnbits_version:
|
||||||
'Diese Version erfordert mindestens die LNbits-Version',
|
'Diese Version erfordert mindestens die LNbits-Version',
|
||||||
|
|
||||||
payment_hash: 'Zahlungs-Hash',
|
payment_hash: 'Zahlungs-Hash',
|
||||||
fee: 'Gebühr',
|
fee: 'Gebühr',
|
||||||
amount: 'Menge',
|
amount: 'Menge',
|
||||||
|
tag: 'Tag',
|
||||||
|
unit: 'Einheit',
|
||||||
description: 'Beschreibung',
|
description: 'Beschreibung',
|
||||||
expiry: 'Ablauf',
|
expiry: 'Ablauf',
|
||||||
webhook: 'Webhook',
|
webhook: 'Webhook',
|
||||||
payment_proof: 'Beleg'
|
payment_proof: 'Beleg',
|
||||||
|
update_available: 'Aktualisierung %{version} verfügbar!',
|
||||||
|
latest_update: 'Sie sind auf der neuesten Version %{version}.',
|
||||||
|
notifications: 'Benachrichtigungen',
|
||||||
|
no_notifications: 'Keine Benachrichtigungen',
|
||||||
|
notifications_disabled: 'LNbits Statusbenachrichtigungen sind deaktiviert.',
|
||||||
|
enable_notifications: 'Aktiviere Benachrichtigungen',
|
||||||
|
enable_notifications_desc:
|
||||||
|
'Wenn aktiviert, werden die neuesten LNbits-Statusaktualisierungen, wie Sicherheitsvorfälle und Updates, abgerufen.',
|
||||||
|
enable_killswitch: 'Aktivieren Sie den Notausschalter',
|
||||||
|
enable_killswitch_desc:
|
||||||
|
'Falls aktiviert, wird Ihre Zahlungsquelle automatisch auf VoidWallet umgestellt, wenn LNbits ein Killswitch-Signal sendet. Nach einem Update müssen Sie dies manuell wieder aktivieren.',
|
||||||
|
killswitch_interval: 'Intervall für den Notausschalter',
|
||||||
|
killswitch_interval_desc:
|
||||||
|
'Wie oft die Hintergrundaufgabe nach dem LNBits-Killswitch-Signal aus der Statusquelle suchen soll (in Minuten).',
|
||||||
|
enable_watchdog: 'Aktiviere Watchdog',
|
||||||
|
enable_watchdog_desc:
|
||||||
|
'Wenn aktiviert, wird Ihre Zahlungsquelle automatisch auf VoidWallet umgestellt, wenn Ihr Guthaben niedriger als das LNbits-Guthaben ist. Nach einem Update müssen Sie dies manuell aktivieren.',
|
||||||
|
watchdog_interval: 'Überwachungszeitintervall',
|
||||||
|
watchdog_interval_desc:
|
||||||
|
'Wie oft die Hintergrundaufgabe nach einem Abschaltsignal im Wachhund-Delta [node_balance - lnbits_balance] suchen soll (in Minuten).',
|
||||||
|
watchdog_delta: 'Watchdog Delta',
|
||||||
|
watchdog_delta_desc:
|
||||||
|
'Limit, bevor der Notausschalter die Finanzierungsquelle auf VoidWallet ändert [lnbits_balance - node_balance > delta]',
|
||||||
|
status: 'Status',
|
||||||
|
notification_source: 'Benachrichtigungsquelle',
|
||||||
|
notification_source_label:
|
||||||
|
'Quell-URL (verwenden Sie nur die offizielle LNbits-Statusquelle und Quellen, denen Sie vertrauen können)',
|
||||||
|
more: 'mehr',
|
||||||
|
less: 'weniger',
|
||||||
|
releases: 'Veröffentlichungen',
|
||||||
|
killswitch: 'Killswitch',
|
||||||
|
watchdog: 'Wachhund',
|
||||||
|
server_logs: 'Serverprotokolle',
|
||||||
|
ip_blocker: 'IP-Sperre',
|
||||||
|
security: 'Sicherheit',
|
||||||
|
security_tools: 'Sicherheitstools',
|
||||||
|
block_access_hint: 'Zugriff per IP sperren',
|
||||||
|
allow_access_hint: 'Zugriff durch IP erlauben (überschreibt blockierte IPs)',
|
||||||
|
enter_ip: 'Geben Sie die IP ein und drücken Sie die Eingabetaste',
|
||||||
|
rate_limiter: 'Ratenbegrenzer',
|
||||||
|
wallet_limiter: 'Geldbeutel-Limiter',
|
||||||
|
wallet_limit_max_withdraw_per_day:
|
||||||
|
'Maximales tägliches Wallet-Auszahlungslimit in Sats (0 zum Deaktivieren)',
|
||||||
|
wallet_max_ballance:
|
||||||
|
'Maximales Guthaben der Wallet in Sats (0 zum Deaktivieren)',
|
||||||
|
wallet_limit_secs_between_trans:
|
||||||
|
'Mindestsekunden zwischen Transaktionen pro Wallet (0 zum Deaktivieren)',
|
||||||
|
number_of_requests: 'Anzahl der Anfragen',
|
||||||
|
time_unit: 'Zeiteinheit',
|
||||||
|
minute: 'Minute',
|
||||||
|
second: 'Sekunde',
|
||||||
|
hour: 'Stunde',
|
||||||
|
disable_server_log: 'Server-Log deaktivieren',
|
||||||
|
enable_server_log: 'Serverprotokollierung aktivieren',
|
||||||
|
coming_soon: 'Funktion demnächst verfügbar',
|
||||||
|
session_has_expired:
|
||||||
|
'Ihre Sitzung ist abgelaufen. Bitte melden Sie sich erneut an.',
|
||||||
|
instant_access_question: 'Möchten Sie sofortigen Zugang?',
|
||||||
|
login_with_user_id: 'Mit Benutzer-ID anmelden',
|
||||||
|
or: 'oder',
|
||||||
|
create_new_wallet: 'Neue Geldbörse erstellen',
|
||||||
|
login_to_account: 'Melden Sie sich bei Ihrem Konto an',
|
||||||
|
create_account: 'Konto erstellen',
|
||||||
|
account_settings: 'Kontoeinstellungen',
|
||||||
|
signin_with_google: 'Mit Google anmelden',
|
||||||
|
signin_with_github: 'Anmelden mit GitHub',
|
||||||
|
signin_with_keycloak: 'Mit Keycloak anmelden',
|
||||||
|
username_or_email: 'Benutzername oder E-Mail',
|
||||||
|
password: 'Passwort',
|
||||||
|
password_config: 'Passwortkonfiguration',
|
||||||
|
password_repeat: 'Passwortwiederholung',
|
||||||
|
change_password: 'Passwort ändern',
|
||||||
|
set_password: 'Passwort festlegen',
|
||||||
|
invalid_password: 'Das Passwort muss mindestens 8 Zeichen haben.',
|
||||||
|
login: 'Anmelden',
|
||||||
|
register: 'Registrieren',
|
||||||
|
username: 'Benutzername',
|
||||||
|
user_id: 'Benutzer-ID',
|
||||||
|
email: 'E-Mail',
|
||||||
|
first_name: 'Vorname',
|
||||||
|
last_name: 'Nachname',
|
||||||
|
picture: 'Bild',
|
||||||
|
verify_email: 'E-Mail verifizieren mit',
|
||||||
|
account: 'Konto',
|
||||||
|
update_account: 'Konto aktualisieren',
|
||||||
|
invalid_username: 'Ungültiger Benutzername',
|
||||||
|
auth_provider: 'Anbieter für Authentifizierung',
|
||||||
|
my_account: 'Mein Konto',
|
||||||
|
back: 'Zurück',
|
||||||
|
logout: 'Abmelden',
|
||||||
|
look_and_feel: 'Aussehen und Verhalten',
|
||||||
|
language: 'Sprache',
|
||||||
|
color_scheme: 'Farbschema'
|
||||||
}
|
}
|
||||||
|
|||||||