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 | ||
|
|
62435b3723 | ||
|
|
6efe1a156b | ||
|
|
85b7591f6e | ||
|
|
172c277c2d | ||
|
|
2623e9247a | ||
|
|
7a37e72915 | ||
|
|
48f25488df | ||
|
|
6077edad8f | ||
|
|
e50a7fb2d1 | ||
|
|
7343d1e0a0 | ||
|
|
1efec9bb80 | ||
|
|
c54f48ee73 | ||
|
|
ac0ef07a77 | ||
|
|
39d717e34c | ||
|
|
4e6f229db2 | ||
|
|
2ab18544c3 | ||
|
|
fe88320f08 | ||
|
|
b0ad47b69b | ||
|
|
88c07acc2c | ||
|
|
c4da1dfdce | ||
|
|
3a653630f1 | ||
|
|
bb5033d225 | ||
|
|
eb0b06f98c | ||
|
|
642f9a4c00 | ||
|
|
8a6e411a0d | ||
|
|
d4de78f1e8 | ||
|
|
1fd4d9d514 | ||
|
|
65db43ace4 | ||
|
|
59acd3a2ef | ||
|
|
905afc1f5c | ||
|
|
c7cf743852 | ||
|
|
b43e7efa0b | ||
|
|
c8414829d7 | ||
|
|
5e03f8eed8 | ||
|
|
511a0fd895 | ||
|
|
2434518d21 | ||
|
|
26a0633d49 | ||
|
|
f0a66e41fb | ||
|
|
355806608b | ||
|
|
3aef1605be | ||
|
|
f096b51f70 | ||
|
|
03acf5e5ec | ||
|
|
772ae2a1ab | ||
|
|
2577ce7f81 | ||
|
|
9bc8a9db55 | ||
|
|
9f40b3cdbd | ||
|
|
1ab81f6d7e | ||
|
|
67d3a4f359 | ||
|
|
7f0c7138af | ||
|
|
dda6c1b3c1 | ||
|
|
cf0a87582c | ||
|
|
4b25714b31 | ||
|
|
0ff0abf442 | ||
|
|
6ce0387e1c | ||
|
|
427f155c76 | ||
|
|
a81a2b5a08 | ||
|
|
11fec7a889 | ||
|
|
f478c1848a | ||
|
|
2d4c9f349b | ||
|
|
befb288900 | ||
|
|
963fd5de50 | ||
|
|
a3aafe4b6f | ||
|
|
f384c5c366 | ||
|
|
153e22fb54 | ||
|
|
213be79aaa | ||
|
|
f7cbe74d77 | ||
|
|
d0d5c4d033 | ||
|
|
0adaf03de5 | ||
|
|
83a04d8f74 | ||
|
|
e13426a56b | ||
|
|
6aaa46e5a0 | ||
|
|
b9db3eafe0 | ||
|
|
ae9eb314cc | ||
|
|
fe393b83ab | ||
|
|
bc55d52ea2 | ||
|
|
9a0878de19 | ||
|
|
be7c589722 | ||
|
|
28a55b2d41 | ||
|
|
d66c83c91a | ||
|
|
7e1f43933d | ||
|
|
758a4ecaf6 | ||
|
|
339645a912 | ||
|
|
9014e73529 | ||
|
|
48fc73d9d4 | ||
|
|
150a33009d | ||
|
|
e6499104c0 | ||
|
|
49411e58cc | ||
|
|
8c0e7725de | ||
|
|
95281eba8c | ||
|
|
cd17b59a3a | ||
|
|
ec623f4861 | ||
|
|
7c479a3ebf | ||
|
|
9017cee491 | ||
|
|
2940716807 | ||
|
|
c48e2329c5 | ||
|
|
8ed2422ade | ||
|
|
af660fec14 | ||
|
|
6bbebe0cee | ||
|
|
f863a043cb | ||
|
|
2216649412 | ||
|
|
9d8c78c927 | ||
|
|
02bf2f9819 | ||
|
|
921984e52b | ||
|
|
d0599458c6 | ||
|
|
80b803913f | ||
|
|
6eab3dae01 | ||
|
|
eab19280d5 | ||
|
|
c960f718f5 | ||
|
|
7df131d3c7 | ||
|
|
cae24b233e | ||
|
|
252fd6c313 | ||
|
|
37ac630573 | ||
|
|
035d6263f9 | ||
|
|
0ab1924327 | ||
|
|
127b780af7 | ||
|
|
39066e4bc8 | ||
|
|
ca41a92bd9 | ||
|
|
f97c698917 | ||
|
|
dd1c17f86f | ||
|
|
587b763f03 | ||
|
|
e39241a9e9 | ||
|
|
b6223f0e6d | ||
|
|
6f2771e334 | ||
|
|
3fe33dfb81 | ||
|
|
4ec16b56bf | ||
|
|
662f792d4f | ||
|
|
d8ccb4dcd8 | ||
|
|
7e0beb508c | ||
|
|
95071eb87e | ||
|
|
44c5c87405 | ||
|
|
8e8093bef3 | ||
|
|
bb9ef7f88c | ||
|
|
67d8b67ee5 | ||
|
|
132d8a9320 | ||
|
|
c0f66989cb | ||
|
|
45b199a8ef | ||
|
|
159aa24a4e | ||
|
|
62812938bd |
@@ -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,57 +1,17 @@
|
||||
#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
|
||||
# FORWARDED_ALLOW_IPS="*"
|
||||
|
||||
DEBUG=false
|
||||
|
||||
# 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.
|
||||
# Enable Admin GUI, available for the first user in LNBITS_ADMIN_USERS if available.
|
||||
# Warning: Enabling this will make LNbits ignore most configurations in file. Only the
|
||||
# configurations defined in `ReadOnlySettings` will still be read from the environment variables.
|
||||
# The rest of the settings will be stored in your database and you will be able to change them
|
||||
# only through the Admin UI.
|
||||
# Disable this to make LNbits use this config file again.
|
||||
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
|
||||
|
||||
# Change theme
|
||||
LNBITS_SITE_TITLE="LNbits"
|
||||
LNBITS_SITE_TAGLINE="free and open-source lightning wallet"
|
||||
@@ -60,15 +20,22 @@ 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_CUSTOM_LOGO="https://lnbits.com/assets/images/logo/logo.svg"
|
||||
|
||||
# Choose from LNPayWallet, OpenNodeWallet, ClicheWallet,
|
||||
# LndWallet, LndRestWallet, CoreLightningWallet, EclairWallet,
|
||||
# LnTipsWallet, LNbitsWallet, SparkWallet, FakeWallet,
|
||||
HOST=127.0.0.1
|
||||
PORT=5000
|
||||
|
||||
######################################
|
||||
########## Funding Source ############
|
||||
######################################
|
||||
|
||||
# which fundingsources are allowed in the admin ui
|
||||
LNBITS_ALLOWED_FUNDING_SOURCES="VoidWallet, FakeWallet, CoreLightningWallet, CoreLightningRestWallet, LndRestWallet, EclairWallet, LndWallet, LnTipsWallet, LNPayWallet, LNbitsWallet, AlbyWallet, ZBDWallet, OpenNodeWallet"
|
||||
|
||||
LNBITS_BACKEND_WALLET_CLASS=VoidWallet
|
||||
# VoidWallet is just a fallback that works without any actual Lightning capabilities,
|
||||
# just so you can see the UI before dealing with this file.
|
||||
|
||||
# Invoice expiry for LND, CLN, Eclair, LNbits funding sources
|
||||
LIGHTNING_INVOICE_EXPIRY=600
|
||||
LIGHTNING_INVOICE_EXPIRY=3600
|
||||
|
||||
# Set one of these blocks depending on the wallet kind you chose above:
|
||||
|
||||
@@ -82,6 +49,11 @@ SPARK_TOKEN=myaccesstoken
|
||||
# CoreLightningWallet
|
||||
CORELIGHTNING_RPC="/home/bob/.lightning/bitcoin/lightning-rpc"
|
||||
|
||||
# CoreLightningRestWallet
|
||||
CORELIGHTNING_REST_URL=http://127.0.0.1:8185/
|
||||
CORELIGHTNING_REST_MACAROON="/path/to/clnrest/access.macaroon" # or BASE64/HEXSTRING
|
||||
CORELIGHTNING_REST_CERT="/path/to/clnrest/tls.cert"
|
||||
|
||||
# LnbitsWallet
|
||||
LNBITS_ENDPOINT=https://legend.lnbits.com
|
||||
LNBITS_KEY=LNBITS_ADMIN_KEY
|
||||
@@ -89,15 +61,15 @@ LNBITS_KEY=LNBITS_ADMIN_KEY
|
||||
# LndWallet
|
||||
LND_GRPC_ENDPOINT=127.0.0.1
|
||||
LND_GRPC_PORT=10009
|
||||
LND_GRPC_CERT="/home/bob/.config/Zap/lnd/bitcoin/mainnet/wallet-1/data/chain/bitcoin/mainnet/tls.cert"
|
||||
LND_GRPC_MACAROON="/home/bob/.config/Zap/lnd/bitcoin/mainnet/wallet-1/data/chain/bitcoin/mainnet/admin.macaroon or HEXSTRING"
|
||||
LND_GRPC_CERT="/home/bob/.lnd/tls.cert"
|
||||
LND_GRPC_MACAROON="/home/bob/.lnd/data/chain/bitcoin/mainnet/admin.macaroon" # or HEXSTRING
|
||||
# To use an AES-encrypted macaroon, set
|
||||
# LND_GRPC_MACAROON="eNcRyPtEdMaCaRoOn"
|
||||
|
||||
# LndRestWallet
|
||||
LND_REST_ENDPOINT=https://127.0.0.1:8080/
|
||||
LND_REST_CERT="/home/bob/.config/Zap/lnd/bitcoin/mainnet/wallet-1/data/chain/bitcoin/mainnet/tls.cert"
|
||||
LND_REST_MACAROON="/home/bob/.config/Zap/lnd/bitcoin/mainnet/wallet-1/data/chain/bitcoin/mainnet/admin.macaroon or HEXSTRING"
|
||||
LND_REST_CERT="/home/bob/.lnd/tls.cert"
|
||||
LND_REST_MACAROON="/home/bob/.lnd/data/chain/bitcoin/mainnet/admin.macaroon" # or HEXSTRING
|
||||
# To use an AES-encrypted macaroon, set
|
||||
# LND_REST_MACAROON_ENCRYPTED="eNcRyPtEdMaCaRoOn"
|
||||
|
||||
@@ -108,6 +80,14 @@ LNPAY_API_KEY=LNPAY_API_KEY
|
||||
# Wallet Admin in Wallet Access Keys
|
||||
LNPAY_WALLET_KEY=LNPAY_ADMIN_KEY
|
||||
|
||||
# AlbyWallet
|
||||
ALBY_API_ENDPOINT=https://api.getalby.com/
|
||||
ALBY_ACCESS_TOKEN=ALBY_ACCESS_TOKEN
|
||||
|
||||
# ZBDWallet
|
||||
ZBD_API_ENDPOINT=https://api.zebedee.io/v0/
|
||||
ZBD_API_KEY=ZBD_ACCESS_TOKEN
|
||||
|
||||
# OpenNodeWallet
|
||||
OPENNODE_API_ENDPOINT=https://api.opennode.com/
|
||||
OPENNODE_KEY=OPENNODE_ADMIN_KEY
|
||||
@@ -125,7 +105,136 @@ ECLAIR_PASS=eclairpw
|
||||
LNTIPS_API_KEY=LNTIPS_ADMIN_KEY
|
||||
LNTIPS_API_ENDPOINT=https://ln.tips
|
||||
|
||||
# Cashu Mint
|
||||
# Use a long-enough random (!) private key.
|
||||
# Once set, you cannot change this key as for now.
|
||||
CASHU_PRIVATE_KEY="SuperSecretPrivateKey"
|
||||
######################################
|
||||
####### Auth Configurations ##########
|
||||
######################################
|
||||
# Secret Key: will default to the hash of the super user. It is strongly recommended that you set your own value.
|
||||
AUTH_SECRET_KEY=""
|
||||
AUTH_TOKEN_EXPIRE_MINUTES=525600
|
||||
# Possible authorization methods: user-id-only, username-password, 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
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
[flake8]
|
||||
max-line-length = 150
|
||||
exclude = lnbits/wallets/lnd_grpc_files/, lnbits/extensions/
|
||||
ignore =
|
||||
# E203 whitespace before ':' black does not like it
|
||||
E203
|
||||
# E402: module level import not at top of file
|
||||
E402,
|
||||
# W503: line break before binary operator
|
||||
W503,
|
||||
# F821: undefined name - should be addressed in future PR
|
||||
F821,
|
||||
# E722 do not use bare 'except' - should be addressed in future PR
|
||||
E722,
|
||||
# flake8-requirements import checks
|
||||
I
|
||||
@@ -1,10 +1,9 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: "[BUG]"
|
||||
title: '[BUG]'
|
||||
labels: bug
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
@@ -12,6 +11,7 @@ A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
@@ -24,8 +24,9 @@ A clear and concise description of what you expected to happen.
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Desktop (please complete the following information):**
|
||||
- LNbits version: [e.g. 0.9.2 or commit hash]
|
||||
- Database [e.g. sqlite, postgres]
|
||||
|
||||
- LNbits version: [e.g. 0.9.2 or commit hash]
|
||||
- Database [e.g. sqlite, postgres]
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: "[Feature request]"
|
||||
title: '[Feature request]'
|
||||
labels: feature request
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
|
||||
@@ -4,7 +4,4 @@ about: Anything else that you need to say
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
name: prepare
|
||||
|
||||
inputs:
|
||||
python-version:
|
||||
description: "Python Version"
|
||||
required: true
|
||||
default: "3.9"
|
||||
poetry-version:
|
||||
description: "Poetry Version"
|
||||
default: "1.7.0"
|
||||
node-version:
|
||||
description: "Node Version"
|
||||
default: "18.x"
|
||||
npm:
|
||||
description: "use npm"
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Set up Python ${{ inputs.python-version }}
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: ${{ inputs.python-version }}
|
||||
# cache poetry install via pip
|
||||
cache: "pip"
|
||||
|
||||
- name: Set up Poetry ${{ inputs.poetry-version }}
|
||||
uses: abatilo/actions-poetry@v2
|
||||
with:
|
||||
poetry-version: ${{ inputs.poetry-version }}
|
||||
|
||||
- name: Setup a local virtual environment (if no poetry.toml file)
|
||||
shell: bash
|
||||
run: |
|
||||
poetry config virtualenvs.create true --local
|
||||
poetry config virtualenvs.in-project true --local
|
||||
|
||||
- uses: actions/cache@v3
|
||||
name: Define a cache for the virtual environment based on the dependencies lock file
|
||||
with:
|
||||
path: ./.venv
|
||||
key: venv-${{ hashFiles('poetry.lock') }}
|
||||
|
||||
- name: Install the project dependencies
|
||||
shell: bash
|
||||
run: poetry install
|
||||
|
||||
- name: Use Node.js ${{ inputs.node-version }}
|
||||
if: ${{ (inputs.npm == 'true') }}
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: ${{ inputs.node-version }}
|
||||
|
||||
- uses: actions/cache@v3
|
||||
if: ${{ (inputs.npm == 'true') }}
|
||||
name: Define a cache for the npm based on the dependencies lock file
|
||||
with:
|
||||
path: ./node_modules
|
||||
key: npm-${{ hashFiles('package-lock.json') }}
|
||||
|
||||
- name: Install npm packages
|
||||
if: ${{ (inputs.npm == 'true') }}
|
||||
shell: bash
|
||||
run: npm install
|
||||
@@ -0,0 +1,60 @@
|
||||
name: LNbits CI
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
pull_request:
|
||||
|
||||
|
||||
jobs:
|
||||
|
||||
lint:
|
||||
uses: ./.github/workflows/lint.yml
|
||||
|
||||
tests:
|
||||
needs: [ lint ]
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.9", "3.10"]
|
||||
db-url: ["", "postgres://lnbits:lnbits@0.0.0.0:5432/lnbits"]
|
||||
uses: ./.github/workflows/tests.yml
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
migrations:
|
||||
needs: [ lint ]
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.9"]
|
||||
uses: ./.github/workflows/tests.yml
|
||||
with:
|
||||
make: test-migration
|
||||
db-name: migration
|
||||
|
||||
openapi:
|
||||
needs: [ lint ]
|
||||
uses: ./.github/workflows/make.yml
|
||||
with:
|
||||
make: openapi
|
||||
|
||||
regtest:
|
||||
needs: [ lint ]
|
||||
uses: ./.github/workflows/regtest.yml
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.9"]
|
||||
backend-wallet-class: ["LndRestWallet", "LndWallet", "CoreLightningWallet", "CoreLightningRestWallet", "LNbitsWallet", "EclairWallet"]
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
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:
|
||||
push:
|
||||
branches: [main, ]
|
||||
branches: [main, dev]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
branches: [main, dev]
|
||||
schedule:
|
||||
- cron: '0 12 * * 5'
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
name: docker
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
default: latest
|
||||
type: string
|
||||
workflow_call:
|
||||
inputs:
|
||||
tag:
|
||||
default: latest
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
push_to_dockerhub:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- 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
|
||||
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
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ secrets.DOCKER_USERNAME }}/lnbits:${{ inputs.tag }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
cache-from: type=local,src=/tmp/.buildx-cache
|
||||
cache-to: type=local,dest=/tmp/.buildx-cache
|
||||
|
||||
- name: Build and push 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
|
||||
@@ -1,27 +0,0 @@
|
||||
name: flake8
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.9"]
|
||||
poetry-version: ["1.3.1"]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Set up Poetry ${{ matrix.poetry-version }}
|
||||
uses: abatilo/actions-poetry@v2
|
||||
with:
|
||||
poetry-version: ${{ matrix.poetry-version }}
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: "poetry"
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
poetry install
|
||||
- name: Run tests
|
||||
run: make flake8
|
||||
@@ -1,33 +0,0 @@
|
||||
name: formatting
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
checks:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.9"]
|
||||
poetry-version: ["1.3.1"]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Set up Poetry ${{ matrix.poetry-version }}
|
||||
uses: abatilo/actions-poetry@v2
|
||||
with:
|
||||
poetry-version: ${{ matrix.poetry-version }}
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: "poetry"
|
||||
- name: Install packages
|
||||
run: |
|
||||
poetry install
|
||||
npm install prettier
|
||||
- name: Check black
|
||||
run: make checkblack
|
||||
- name: Check isort
|
||||
run: make checkisort
|
||||
- uses: actions/setup-node@v3
|
||||
- name: Check prettier
|
||||
run: make checkprettier
|
||||
@@ -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/
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
name: lint
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
|
||||
black:
|
||||
uses: ./.github/workflows/make.yml
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.9", "3.10"]
|
||||
with:
|
||||
make: checkblack
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
ruff:
|
||||
uses: ./.github/workflows/make.yml
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.9", "3.10"]
|
||||
with:
|
||||
make: checkruff
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
mypy:
|
||||
uses: ./.github/workflows/make.yml
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.9", "3.10"]
|
||||
with:
|
||||
make: mypy
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
pyright:
|
||||
uses: ./.github/workflows/make.yml
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.9", "3.10"]
|
||||
with:
|
||||
make: pyright
|
||||
python-version: ${{ matrix.python-version }}
|
||||
npm: true
|
||||
|
||||
|
||||
prettier:
|
||||
uses: ./.github/workflows/make.yml
|
||||
with:
|
||||
make: checkprettier
|
||||
npm: true
|
||||
|
||||
bundle:
|
||||
uses: ./.github/workflows/make.yml
|
||||
with:
|
||||
make: checkbundle
|
||||
npm: true
|
||||
@@ -0,0 +1,34 @@
|
||||
name: make
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
make:
|
||||
description: "make command that is run"
|
||||
required: true
|
||||
type: string
|
||||
npm:
|
||||
description: "use npm install"
|
||||
default: false
|
||||
type: boolean
|
||||
python-version:
|
||||
description: "python version"
|
||||
type: string
|
||||
default: "3.10"
|
||||
|
||||
jobs:
|
||||
make:
|
||||
name: ${{ inputs.make }} (${{ inputs.python-version }})
|
||||
strategy:
|
||||
matrix:
|
||||
os-version: ["ubuntu-latest"]
|
||||
node-version: ["18.x"]
|
||||
runs-on: ${{ matrix.os-version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: ./.github/actions/prepare
|
||||
with:
|
||||
python-version: ${{ inputs.python-version }}
|
||||
node-version: ${{ matrix.node-version }}
|
||||
npm: ${{ inputs.npm }}
|
||||
- run: make ${{ inputs.make }}
|
||||
@@ -1,43 +0,0 @@
|
||||
name: migrations
|
||||
|
||||
on: [pull_request]
|
||||
|
||||
jobs:
|
||||
sqlite-to-postgres:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:latest
|
||||
env:
|
||||
POSTGRES_USER: lnbits
|
||||
POSTGRES_PASSWORD: lnbits
|
||||
POSTGRES_DB: migration
|
||||
ports:
|
||||
# maps tcp port 5432 on service container to the host
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.9"]
|
||||
poetry-version: ["1.3.1"]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Set up Poetry ${{ matrix.poetry-version }}
|
||||
uses: abatilo/actions-poetry@v2
|
||||
with:
|
||||
poetry-version: ${{ matrix.poetry-version }}
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
poetry install
|
||||
sudo apt install unzip
|
||||
- name: Run migrations
|
||||
run: |
|
||||
make test-migration
|
||||
@@ -1,27 +0,0 @@
|
||||
name: mypy
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.9"]
|
||||
poetry-version: ["1.3.1"]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Set up Poetry ${{ matrix.poetry-version }}
|
||||
uses: abatilo/actions-poetry@v2
|
||||
with:
|
||||
poetry-version: ${{ matrix.poetry-version }}
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: "poetry"
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
poetry install
|
||||
- name: Run tests
|
||||
run: make mypy
|
||||
@@ -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" ./
|
||||
@@ -1,27 +0,0 @@
|
||||
name: pylint
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.9"]
|
||||
poetry-version: ["1.3.1"]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Set up Poetry ${{ matrix.poetry-version }}
|
||||
uses: abatilo/actions-poetry@v2
|
||||
with:
|
||||
poetry-version: ${{ matrix.poetry-version }}
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: "poetry"
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
poetry install
|
||||
- name: Run tests
|
||||
run: make pylint
|
||||
@@ -1,28 +0,0 @@
|
||||
name: pyright
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.9"]
|
||||
poetry-version: ["1.3.1"]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Set up Poetry ${{ matrix.poetry-version }}
|
||||
uses: abatilo/actions-poetry@v2
|
||||
with:
|
||||
poetry-version: ${{ matrix.poetry-version }}
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: "poetry"
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
poetry install
|
||||
npm install
|
||||
- name: Run tests
|
||||
run: make pyright
|
||||
@@ -1,223 +1,81 @@
|
||||
name: regtest
|
||||
|
||||
on: [push, pull_request]
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
make:
|
||||
default: test
|
||||
type: string
|
||||
python-version:
|
||||
default: "3.9"
|
||||
type: string
|
||||
os-version:
|
||||
default: "ubuntu-latest"
|
||||
type: string
|
||||
backend-wallet-class:
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
LndRestWallet:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.9"]
|
||||
poetry-version: ["1.3.1"]
|
||||
regtest:
|
||||
runs-on: ${{ inputs.os-version }}
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Set up Poetry ${{ matrix.poetry-version }}
|
||||
uses: abatilo/actions-poetry@v2
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
if: ${{ inputs.backend-wallet-class == 'LNbitsWallet' }}
|
||||
uses: docker/setup-buildx-action@v2
|
||||
|
||||
- name: Build and push
|
||||
if: ${{ inputs.backend-wallet-class == 'LNbitsWallet' }}
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
poetry-version: ${{ matrix.poetry-version }}
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: "poetry"
|
||||
context: .
|
||||
push: false
|
||||
tags: lnbits/lnbits:latest
|
||||
cache-from: type=registry,ref=lnbits/lnbits:latest
|
||||
cache-to: type=inline
|
||||
|
||||
- name: Setup Regtest
|
||||
run: |
|
||||
docker build -t lnbitsdocker/lnbits-legend .
|
||||
git clone https://github.com/lnbits/legend-regtest-enviroment.git docker
|
||||
cd docker
|
||||
chmod +x ./tests
|
||||
./tests
|
||||
sudo chmod -R a+rwx .
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
poetry install
|
||||
- name: Run tests
|
||||
|
||||
- uses: ./.github/actions/prepare
|
||||
with:
|
||||
python-version: ${{ inputs.python-version }}
|
||||
|
||||
- name: Create fake admin
|
||||
if: ${{ inputs.backend-wallet-class == 'LNbitsWallet' }}
|
||||
run: docker exec lnbits-lnbits-1 poetry run python tools/create_fake_admin.py
|
||||
|
||||
- name: Run Tests
|
||||
env:
|
||||
PYTHONUNBUFFERED: 1
|
||||
PORT: 5123
|
||||
LNBITS_DATA_FOLDER: ./data
|
||||
LNBITS_BACKEND_WALLET_CLASS: LndRestWallet
|
||||
LNBITS_DATABASE_URL: ${{ inputs.db-url }}
|
||||
LNBITS_BACKEND_WALLET_CLASS: ${{ inputs.backend-wallet-class }}
|
||||
LND_REST_ENDPOINT: https://localhost:8081/
|
||||
LND_REST_CERT: ./docker/data/lnd-1/tls.cert
|
||||
LND_REST_MACAROON: ./docker/data/lnd-1/data/chain/bitcoin/regtest/admin.macaroon
|
||||
run: |
|
||||
sudo chmod -R a+rwx . && rm -rf ./data && mkdir -p ./data
|
||||
make test-real-wallet
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v3
|
||||
with:
|
||||
file: ./coverage.xml
|
||||
LndWallet:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.9"]
|
||||
poetry-version: ["1.3.1"]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Set up Poetry ${{ matrix.poetry-version }}
|
||||
uses: abatilo/actions-poetry@v2
|
||||
with:
|
||||
poetry-version: ${{ matrix.poetry-version }}
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: "poetry"
|
||||
- name: Setup Regtest
|
||||
run: |
|
||||
docker build -t lnbitsdocker/lnbits-legend .
|
||||
git clone https://github.com/lnbits/legend-regtest-enviroment.git docker
|
||||
cd docker
|
||||
chmod +x ./tests
|
||||
./tests
|
||||
sudo chmod -R a+rwx .
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
poetry install
|
||||
- name: Run tests
|
||||
env:
|
||||
PYTHONUNBUFFERED: 1
|
||||
PORT: 5123
|
||||
LNBITS_DATA_FOLDER: ./data
|
||||
LNBITS_BACKEND_WALLET_CLASS: LndWallet
|
||||
LND_REST_CERT: ./docker/data/lnd-3/tls.cert
|
||||
LND_REST_MACAROON: ./docker/data/lnd-3/data/chain/bitcoin/regtest/admin.macaroon
|
||||
LND_GRPC_ENDPOINT: localhost
|
||||
LND_GRPC_PORT: 10009
|
||||
LND_GRPC_CERT: docker/data/lnd-1/tls.cert
|
||||
LND_GRPC_MACAROON: docker/data/lnd-1/data/chain/bitcoin/regtest/admin.macaroon
|
||||
run: |
|
||||
sudo chmod -R a+rwx . && rm -rf ./data && mkdir -p ./data
|
||||
make test-real-wallet
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v3
|
||||
with:
|
||||
file: ./coverage.xml
|
||||
CoreLightningWallet:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.9"]
|
||||
poetry-version: ["1.3.1"]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Set up Poetry ${{ matrix.poetry-version }}
|
||||
uses: abatilo/actions-poetry@v2
|
||||
with:
|
||||
poetry-version: ${{ matrix.poetry-version }}
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: "poetry"
|
||||
- name: Setup Regtest
|
||||
run: |
|
||||
docker build -t lnbitsdocker/lnbits-legend .
|
||||
git clone https://github.com/lnbits/legend-regtest-enviroment.git docker
|
||||
cd docker
|
||||
chmod +x ./tests
|
||||
./tests
|
||||
sudo chmod -R a+rwx .
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
poetry install
|
||||
- name: Run tests
|
||||
env:
|
||||
PYTHONUNBUFFERED: 1
|
||||
PORT: 5123
|
||||
LNBITS_DATA_FOLDER: ./data
|
||||
LNBITS_BACKEND_WALLET_CLASS: CoreLightningWallet
|
||||
LND_GRPC_CERT: docker/data/lnd-3/tls.cert
|
||||
LND_GRPC_MACAROON: docker/data/lnd-3/data/chain/bitcoin/regtest/admin.macaroon
|
||||
CORELIGHTNING_RPC: ./docker/data/clightning-1/regtest/lightning-rpc
|
||||
run: |
|
||||
sudo chmod -R a+rwx . && rm -rf ./data && mkdir -p ./data
|
||||
make test-real-wallet
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v3
|
||||
with:
|
||||
file: ./coverage.xml
|
||||
LNbitsWallet:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.9"]
|
||||
poetry-version: ["1.3.1"]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Set up Poetry ${{ matrix.poetry-version }}
|
||||
uses: abatilo/actions-poetry@v2
|
||||
with:
|
||||
poetry-version: ${{ matrix.poetry-version }}
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: "poetry"
|
||||
- name: Setup Regtest
|
||||
run: |
|
||||
docker build -t lnbitsdocker/lnbits-legend .
|
||||
git clone https://github.com/lnbits/legend-regtest-enviroment.git docker
|
||||
cd docker
|
||||
chmod +x ./tests
|
||||
./tests
|
||||
sudo chmod -R a+rwx .
|
||||
docker exec lnbits-legend-lnbits-1 /bin/bash -c "poetry run python tools/create_fake_admin.py"
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
poetry install
|
||||
- name: Run tests
|
||||
env:
|
||||
PYTHONUNBUFFERED: 1
|
||||
PORT: 5123
|
||||
LNBITS_DATA_FOLDER: ./data
|
||||
LNBITS_BACKEND_WALLET_CLASS: LNbitsWallet
|
||||
CORELIGHTNING_REST_URL: https://localhost:3001
|
||||
CORELIGHTNING_REST_MACAROON: ./docker/data/clightning-2-rest/access.macaroon
|
||||
CORELIGHTNING_REST_CERT: ./docker/data/clightning-2-rest/certificate.pem
|
||||
LNBITS_ENDPOINT: http://localhost:5001
|
||||
LNBITS_KEY: "d08a3313322a4514af75d488bcc27eee"
|
||||
run: |
|
||||
sudo chmod -R a+rwx . && rm -rf ./data && mkdir -p ./data
|
||||
make test-real-wallet
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v3
|
||||
with:
|
||||
file: ./coverage.xml
|
||||
EclairWallet:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.9"]
|
||||
poetry-version: ["1.3.1"]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Set up Poetry ${{ matrix.poetry-version }}
|
||||
uses: abatilo/actions-poetry@v2
|
||||
with:
|
||||
poetry-version: ${{ matrix.poetry-version }}
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: "poetry"
|
||||
- name: Setup Regtest
|
||||
run: |
|
||||
docker build -t lnbitsdocker/lnbits-legend .
|
||||
git clone https://github.com/lnbits/legend-regtest-enviroment.git docker
|
||||
cd docker
|
||||
chmod +x ./tests
|
||||
./tests
|
||||
sudo chmod -R a+rwx .
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
poetry install
|
||||
- name: Run tests
|
||||
env:
|
||||
PYTHONUNBUFFERED: 1
|
||||
PORT: 5123
|
||||
LNBITS_DATA_FOLDER: ./data
|
||||
LNBITS_BACKEND_WALLET_CLASS: EclairWallet
|
||||
ECLAIR_URL: http://127.0.0.1:8082
|
||||
ECLAIR_PASS: lnbits
|
||||
run: |
|
||||
sudo chmod -R a+rwx . && rm -rf ./data && mkdir -p ./data
|
||||
make test-real-wallet
|
||||
run: make test-real-wallet
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v3
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
file: ./coverage.xml
|
||||
|
||||
@@ -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 }}
|
||||
@@ -1,70 +1,58 @@
|
||||
name: tests
|
||||
|
||||
on: [push, pull_request]
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
make:
|
||||
default: test
|
||||
type: string
|
||||
python-version:
|
||||
default: "3.9"
|
||||
type: string
|
||||
os-version:
|
||||
default: "ubuntu-latest"
|
||||
type: string
|
||||
db-url:
|
||||
default: ""
|
||||
type: string
|
||||
db-name:
|
||||
default: "lnbits"
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
sqlite:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.9"]
|
||||
poetry-version: ["1.3.1"]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Set up Poetry ${{ matrix.poetry-version }}
|
||||
uses: abatilo/actions-poetry@v2
|
||||
with:
|
||||
poetry-version: ${{ matrix.poetry-version }}
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: "poetry"
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
poetry install
|
||||
- name: Run tests
|
||||
run: make test
|
||||
postgres:
|
||||
runs-on: ubuntu-latest
|
||||
tests:
|
||||
runs-on: ${{ inputs.os-version }}
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:latest
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: postgres
|
||||
POSTGRES_USER: lnbits
|
||||
POSTGRES_PASSWORD: lnbits
|
||||
POSTGRES_DB: ${{ inputs.db-name }}
|
||||
ports:
|
||||
# maps tcp port 5432 on service container to the host
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.9"]
|
||||
poetry-version: ["1.3.1"]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Set up Poetry ${{ matrix.poetry-version }}
|
||||
uses: abatilo/actions-poetry@v2
|
||||
|
||||
- uses: ./.github/actions/prepare
|
||||
with:
|
||||
poetry-version: ${{ matrix.poetry-version }}
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: "poetry"
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
poetry install
|
||||
- name: Run tests
|
||||
python-version: ${{ inputs.python-version }}
|
||||
|
||||
- name: Run Tests
|
||||
env:
|
||||
LNBITS_DATABASE_URL: postgres://postgres:postgres@0.0.0.0:5432/postgres
|
||||
run: make test
|
||||
LNBITS_DATABASE_URL: ${{ inputs.db-url }}
|
||||
LNBITS_BACKEND_WALLET_CLASS: FakeWallet
|
||||
run: make ${{ inputs.make }}
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v3
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
file: ./coverage.xml
|
||||
|
||||
@@ -23,7 +23,7 @@ tests/data/*.sqlite3
|
||||
*.pyc
|
||||
*.env
|
||||
.env
|
||||
.super_user
|
||||
.pre-commit-config.yaml
|
||||
|
||||
data
|
||||
*.sqlite3
|
||||
@@ -35,6 +35,8 @@ coverage.xml
|
||||
node_modules
|
||||
lnbits/static/bundle.js
|
||||
lnbits/static/bundle.css
|
||||
lnbits/static/bundle.min.js.old
|
||||
lnbits/static/bundle.min.css.old
|
||||
docker
|
||||
|
||||
# Nix
|
||||
@@ -46,5 +48,5 @@ fly.toml
|
||||
lnbits-backup.zip
|
||||
|
||||
# Ignore extensions (post installable extension PR)
|
||||
extensions/
|
||||
extensions
|
||||
upgrades/
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
exclude: '^lnbits/static/bundle.*|^docs/.*|^lnbits/static/vendor/.*|^lnbits/extensions/.*|^lnbits/upgrades/.*|^lnbits/wallets/lnd_grpc_files/.*|^package-lock.json$'
|
||||
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v4.3.0
|
||||
hooks:
|
||||
- id: trailing-whitespace
|
||||
- id: end-of-file-fixer
|
||||
- id: check-yaml
|
||||
- id: check-added-large-files
|
||||
- id: check-docstring-first
|
||||
- id: check-json
|
||||
- id: debug-statements
|
||||
- id: mixed-line-ending
|
||||
- id: check-case-conflict
|
||||
- repo: https://github.com/psf/black
|
||||
rev: 24.2.0
|
||||
hooks:
|
||||
- id: black
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.3.2
|
||||
hooks:
|
||||
- id: ruff
|
||||
args: [ --fix, --exit-non-zero-on-fix ]
|
||||
- repo: https://github.com/pre-commit/mirrors-prettier
|
||||
rev: '50c5478ed9e10bf360335449280cf2a67f4edb7a'
|
||||
hooks:
|
||||
- id: prettier
|
||||
types_or: [css, javascript, html, json]
|
||||
args: ['lnbits']
|
||||
@@ -6,6 +6,7 @@
|
||||
*.yml
|
||||
|
||||
**/lnbits/extensions/*
|
||||
**/lnbits/upgrades/*
|
||||
|
||||
**/lnbits/static/vendor
|
||||
**/lnbits/static/bundle.*
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
FROM python:3.10-slim
|
||||
FROM python:3.10-slim-bullseye
|
||||
|
||||
RUN apt-get clean
|
||||
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 -
|
||||
ENV PATH="/root/.local/bin:$PATH"
|
||||
|
||||
# needed for backups postgresql-client version 14 (pg_dump)
|
||||
RUN apt-get install -y wget
|
||||
RUN apt-get install -y apt-utils wget
|
||||
RUN echo "deb http://apt.postgresql.org/pub/repos/apt bullseye-pgdg main" > /etc/apt/sources.list.d/pgdg.list
|
||||
RUN wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | apt-key add -
|
||||
RUN apt-get update
|
||||
@@ -20,7 +20,6 @@ COPY . .
|
||||
|
||||
RUN mkdir data
|
||||
|
||||
RUN poetry config virtualenvs.create false
|
||||
RUN poetry install --only main
|
||||
|
||||
ENV LNBITS_PORT="5000"
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
all: format check
|
||||
|
||||
format: prettier isort black
|
||||
format: prettier black ruff
|
||||
|
||||
check: mypy pyright pylint flake8 checkisort checkblack checkprettier
|
||||
check: mypy pyright checkblack checkruff checkprettier checkbundle
|
||||
|
||||
prettier:
|
||||
poetry run ./node_modules/.bin/prettier --write lnbits
|
||||
@@ -12,20 +12,17 @@ prettier:
|
||||
pyright:
|
||||
poetry run ./node_modules/.bin/pyright
|
||||
|
||||
black:
|
||||
poetry run black .
|
||||
|
||||
flake8:
|
||||
poetry run flake8
|
||||
|
||||
mypy:
|
||||
poetry run mypy
|
||||
|
||||
isort:
|
||||
poetry run isort .
|
||||
black:
|
||||
poetry run black .
|
||||
|
||||
pylint:
|
||||
poetry run pylint *.py lnbits/ tools/ tests/
|
||||
ruff:
|
||||
poetry run ruff check . --fix
|
||||
|
||||
checkruff:
|
||||
poetry run ruff check .
|
||||
|
||||
checkprettier:
|
||||
poetry run ./node_modules/.bin/prettier --check lnbits
|
||||
@@ -33,8 +30,11 @@ checkprettier:
|
||||
checkblack:
|
||||
poetry run black --check .
|
||||
|
||||
checkisort:
|
||||
poetry run isort --check-only .
|
||||
checkeditorconfig:
|
||||
editorconfig-checker
|
||||
|
||||
dev:
|
||||
poetry run lnbits --reload
|
||||
|
||||
test:
|
||||
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
|
||||
@@ -51,24 +51,34 @@ test-real-wallet:
|
||||
poetry run pytest
|
||||
|
||||
test-migration:
|
||||
rm -rf ./migration-data
|
||||
mkdir -p ./migration-data
|
||||
unzip tests/data/mock_data.zip -d ./migration-data
|
||||
LNBITS_ADMIN_UI=True \
|
||||
make test
|
||||
HOST=0.0.0.0 \
|
||||
PORT=5002 \
|
||||
LNBITS_DATA_FOLDER="./migration-data" \
|
||||
LNBITS_DATA_FOLDER="./tests/data" \
|
||||
timeout 5s poetry run lnbits --host 0.0.0.0 --port 5002 || code=$?; if [[ $code -ne 124 && $code -ne 0 ]]; then exit $code; fi
|
||||
HOST=0.0.0.0 \
|
||||
PORT=5002 \
|
||||
LNBITS_DATABASE_URL="postgres://lnbits:lnbits@localhost:5432/migration" \
|
||||
timeout 5s poetry run lnbits --host 0.0.0.0 --port 5002 || code=$?; if [[ $code -ne 124 && $code -ne 0 ]]; then exit $code; fi
|
||||
LNBITS_DATA_FOLDER="./migration-data" \
|
||||
LNBITS_DATA_FOLDER="./tests/data" \
|
||||
LNBITS_DATABASE_URL="postgres://lnbits:lnbits@localhost:5432/migration" \
|
||||
poetry run python tools/conv.py
|
||||
|
||||
migration:
|
||||
poetry run python tools/conv.py
|
||||
|
||||
openapi:
|
||||
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
|
||||
LNBITS_DATA_FOLDER="./tests/data" \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
HOST=0.0.0.0 \
|
||||
PORT=5003 \
|
||||
poetry run lnbits &
|
||||
sleep 15
|
||||
curl -s http://0.0.0.0:5003/openapi.json | poetry run openapi-spec-validator --errors=all -
|
||||
# kill -9 %1
|
||||
|
||||
bak:
|
||||
# LNBITS_DATABASE_URL=postgres://postgres:postgres@0.0.0.0:5432/postgres
|
||||
#
|
||||
@@ -86,6 +96,21 @@ bundle:
|
||||
npm run vendor_minify_css
|
||||
npm run vendor_bundle_js
|
||||
npm run vendor_minify_js
|
||||
# increment serviceworker version
|
||||
sed -i -e "s/CACHE_VERSION =.*/CACHE_VERSION = $$(awk '/CACHE_VERSION =/ { print 1+$$4 }' lnbits/core/static/js/service-worker.js)/" \
|
||||
lnbits/core/static/js/service-worker.js
|
||||
|
||||
checkbundle:
|
||||
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:
|
||||
@echo "Installing pre-commit hook to git"
|
||||
@echo "Uninstall the hook with poetry run pre-commit uninstall"
|
||||
poetry run pre-commit install
|
||||
|
||||
pre-commit:
|
||||
poetry run pre-commit run --all-files
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
LNbits
|
||||
======
|
||||
# LNbits BETA
|
||||
|
||||
[![license-badge]](LICENSE)
|
||||
[![docs-badge]][docs]
|
||||
|
||||
|
||||

|
||||
|
||||
# LNbits v0.10 BETA, free and open-source Lightning wallet accounts system
|
||||
# Free and Open-Source Lightning Wallet Accounts System
|
||||
|
||||
(Join us on [https://t.me/lnbits](https://t.me/lnbits))
|
||||
|
||||
@@ -17,15 +15,24 @@ Use [legend.lnbits.com](https://legend.lnbits.com), or run your own LNbits serve
|
||||
|
||||
LNbits is a Python server that sits on top of any funding source. It can be used as:
|
||||
|
||||
* Accounts system to mitigate the risk of exposing applications to your full balance via unique API keys for each wallet
|
||||
* Extendable platform for exploring Lightning network functionality via the LNbits extension framework
|
||||
* Part of a development stack via LNbits API
|
||||
* Fallback wallet for the LNURL scheme
|
||||
* Instant wallet for LN demonstrations
|
||||
- Accounts system to mitigate the risk of exposing applications to your full balance via unique API keys for each wallet
|
||||
- Extendable platform for exploring Lightning network functionality via the LNbits extension framework
|
||||
- Part of a development stack via LNbits API
|
||||
- Fallback wallet for the LNURL scheme
|
||||
- Instant wallet for LN demonstrations
|
||||
|
||||
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.
|
||||
|
||||
@@ -33,7 +40,7 @@ LNbits is inspired by all the great work of [opennode.com](https://www.opennode.
|
||||
|
||||
## Running LNbits
|
||||
|
||||
See the [install guide](https://github.com/lnbits/lnbits/wiki/LNbits-Documentation#install-options) for details on installation and setup.
|
||||
See the [install guide](https://github.com/lnbits/lnbits/blob/main/docs/guide/installation.md) for details on installation and setup.
|
||||
|
||||
## LNbits as an account system
|
||||
|
||||
@@ -54,13 +61,13 @@ LNURL has a fallback scheme, so if scanned by a regular QR code reader it can de
|
||||

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

|
||||
|
||||
## LNbits as an instant wallet
|
||||
|
||||
Wallets can be easily generated and given out to people at events. "Go to this website", has a lot less friction than "Download this app".
|
||||
Wallets can be easily generated and given out to people at events. "Go to this website", has a lot less friction than "Download this app".
|
||||
|
||||

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

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

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

|
||||
@@ -171,7 +171,7 @@ allow-self-payment=1
|
||||
<details><summary>ATM - deposit and withdraw in your shop or at your meetup</summary>
|
||||
<p>This is a do-it-yourself project consisting of a mini-computer (Raspberry Pi Zero), a coin acceptor, a display, a 3D printed case, and a Bitcoin Lightning wallet as a funding source. It exchanges fiat coins for valuable Bitcoin Lightning ⚡ Satoshis. The user can pick up the Satoshis via QR code (LNURL-withdraw) on a mobile phone wallet. You can get the components as individual parts and build the case yourself e.g. from <a href="https://www.Fulmo.org">Fulmo</a> who also made a <a href="https://blog.fulmo.org/the-lightningatm-pocket-edition/">guide</a> on it. The shop offers payments in Bitcoin and Lightning ⚡. The code can be found on <a href="https://github.com/21isenough/LightningATM">the ATM github project page></a>.</p>
|
||||
</details>
|
||||
|
||||
|
||||
<details><summary>POS Terminal - an offline terminal for merchants</summary>
|
||||
<p>The LNpos is a self-sufficient point of sale terminal which allows offline onchain payments and an offline Lightning ATM for withdrawals. Free and open source software, free from intermediaries, with integrated battery, WLAN, DIY. You can get the 3D print as well as the whole kit from the LNbits shop from 👇 Resources. It allows
|
||||
<li>LNPoS Online interactive Lightning payments</li>
|
||||
@@ -183,21 +183,21 @@ allow-self-payment=1
|
||||
</p>
|
||||
</p>
|
||||
</details>
|
||||
|
||||
|
||||
<details><summary>Hardware Wallet- build your own, stack harder</summary>
|
||||
<p>The hardwarewallet is a very cheap solution for builders. The projects´ <a hrel="https://github.com/lnbits/hardware-wallet">code and installation instructions for the LNbits hardware wallet can be found on github</a></p>
|
||||
<p>
|
||||
<img width="546" alt="Bildschirmfoto 2023-01-20 um 18 08 37" src="https://user-images.githubusercontent.com/63317640/213760948-38fd77b0-9247-4505-9433-f5af1b223527.png">
|
||||
</p>
|
||||
</details>
|
||||
|
||||
|
||||
<details><summary>Bitcoin Switch - turn things on with bitcoin</summary>
|
||||
<p>Candy dispenser, vending machines (online), grabbing machines, jukeboxes, bandits and <a href="https://github.com/cryptoteun/awesome-lnbits">all sorts of other things have already been build with LNbits´ tools</a>. Further info see 👇 Resources.</p>
|
||||
<p>
|
||||
<img width="549" alt="Bildschirmfoto 2023-01-20 um 18 11 55" src="https://user-images.githubusercontent.com/63317640/213761646-d25d4745-e50d-4389-98e5-f83237a8cf6b.png">
|
||||
</p>
|
||||
</details>
|
||||
|
||||
|
||||
<details><summary>Vending machine (offline)</summary>
|
||||
<p>This code works similar to the LNpos. Note that the <a href=" https://www.youtube.com/watch?v=Fg0UuuzsYXc&t=762s">setup-video for the vending machine</a> misses the new way of installing it via the new LNURLdevices extension. The <a href="https://github.com/arcbtc/LNURLVend">vending machine project code resides on github</a>.</p>
|
||||
<p>
|
||||
@@ -262,7 +262,7 @@ allow-self-payment=1
|
||||
</ul>
|
||||
|
||||
## Developing for LNbits
|
||||
<ul>
|
||||
<li><a href="http://docs.lnbits.org/devs/development.html">Making Estension / how to use Websockets / API reference</a></li>
|
||||
<ul>
|
||||
<li><a href="https://docs.lnbits.org/devs/development.html">Making extensions / How to use Websockets / API reference</a></li>
|
||||
<li><a href="https://t.me/lnbits">Telegram LNbits Support Group</a></li></ul>
|
||||
</ul>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
## Defining a route with path parameters
|
||||
**old:**
|
||||
```python
|
||||
# with <>
|
||||
# with <>
|
||||
@offlineshop_ext.route("/lnurl/<item_id>", methods=["GET"])
|
||||
```
|
||||
|
||||
@@ -35,8 +35,8 @@ async def extensions(user: User = Depends(check_user_exists)):
|
||||
```python
|
||||
return (
|
||||
{
|
||||
"id": wallet.wallet.id,
|
||||
"name": wallet.wallet.name,
|
||||
"id": wallet.wallet.id,
|
||||
"name": wallet.wallet.name,
|
||||
"balance": wallet.wallet.balance_msat
|
||||
},
|
||||
HTTPStatus.OK,
|
||||
@@ -47,8 +47,8 @@ FastAPI returns `HTTPStatus.OK` by default id no Exception is raised
|
||||
**new:**
|
||||
```python
|
||||
return {
|
||||
"id": wallet.wallet.id,
|
||||
"name": wallet.wallet.name,
|
||||
"id": wallet.wallet.id,
|
||||
"name": wallet.wallet.name,
|
||||
"balance": wallet.wallet.balance_msat
|
||||
}
|
||||
```
|
||||
@@ -119,4 +119,4 @@ Instead of hitting the database over and over again, we can store a short lived
|
||||
Example:
|
||||
* Get transactions for a wallet ID
|
||||
* User data for a user id
|
||||
* Wallet data for a Admin / Invoice key
|
||||
* Wallet data for a Admin / Invoice key
|
||||
|
||||
@@ -12,11 +12,12 @@ By default, LNbits will use SQLite as its database. You can also use PostgreSQL
|
||||
|
||||
## Option 1 (recommended): poetry
|
||||
|
||||
If you have problems installing LNbits using these instructions, please have a look at the [Troubleshooting](#troubleshooting) section.
|
||||
Mininum poetry version has is ^1.2, but it is recommended to use latest poetry. (including OSX)
|
||||
|
||||
```sh
|
||||
git clone https://github.com/lnbits/lnbits.git
|
||||
cd lnbits
|
||||
git checkout main
|
||||
|
||||
# for making sure python 3.9 is installed, skip if installed. To check your installed version: python3 --version
|
||||
sudo apt update
|
||||
@@ -60,44 +61,59 @@ poetry install --only main
|
||||
|
||||
## Option 2: Nix
|
||||
|
||||
> note: currently not supported while we make some architectural changes on the path to leave beta
|
||||
|
||||
```sh
|
||||
# Install nix. If you have installed via another manager, remove and use this install (from https://nixos.org/download)
|
||||
sh <(curl -L https://nixos.org/nix/install) --daemon
|
||||
|
||||
# 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
|
||||
cd lnbits
|
||||
# Modern debian distros usually include Nix, however you can install with:
|
||||
# 'sh <(curl -L https://nixos.org/nix/install) --daemon', or use setup here https://nixos.org/download.html#nix-verify-installation
|
||||
nix build
|
||||
|
||||
nix build .#lnbits
|
||||
mkdir data
|
||||
|
||||
```
|
||||
|
||||
#### Running the server
|
||||
|
||||
```sh
|
||||
# .env variables are currently passed when running
|
||||
LNBITS_DATA_FOLDER=data LNBITS_BACKEND_WALLET_CLASS=LNbitsWallet LNBITS_ENDPOINT=https://legend.lnbits.com LNBITS_KEY=7b1a78d6c78f48b09a202f2dcb2d22eb ./result/bin/lnbits --port 9000
|
||||
nix run
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
use latest version from docker hub
|
||||
```sh
|
||||
docker pull lnbitsdocker/lnbits-legend
|
||||
docker pull lnbits/lnbits
|
||||
wget https://raw.githubusercontent.com/lnbits/lnbits/main/.env.example -O .env
|
||||
mkdir data
|
||||
docker run --detach --publish 5000:5000 --name lnbits --volume ${PWD}/.env:/app/.env --volume ${PWD}/data/:/app/data 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
|
||||
```sh
|
||||
git clone https://github.com/lnbits/lnbits.git
|
||||
cd lnbits
|
||||
docker build -t lnbitsdocker/lnbits-legend .
|
||||
docker build -t lnbits/lnbits .
|
||||
cp .env.example .env
|
||||
mkdir data
|
||||
docker run --detach --publish 5000:5000 --name lnbits --volume ${PWD}/.env:/app/.env --volume ${PWD}/data/:/app/data 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
|
||||
@@ -190,10 +206,6 @@ sudo apt install python3.9-dev gcc build-essential
|
||||
poetry add setuptools wheel
|
||||
```
|
||||
|
||||
#### Poetry
|
||||
|
||||
If your Poetry version is older than 1.2, for `poetry install`, ignore the `--only main` flag.
|
||||
|
||||
### Optional: PostgreSQL database
|
||||
|
||||
If you want to use LNbits at scale, we recommend using PostgreSQL as the backend database. Install Postgres and setup a database for LNbits:
|
||||
|
||||
@@ -8,7 +8,7 @@ nav_order: 3
|
||||
Backend wallets
|
||||
===============
|
||||
|
||||
LNbits can run on top of many lightning-network funding sources. Currently there is support for CoreLightning, LND, LNbits, LNPay and OpenNode, with more being added regularly.
|
||||
LNbits can run on top of many lightning-network funding sources with more being added regularly.
|
||||
|
||||
A backend wallet can be configured using the following LNbits environment variables:
|
||||
|
||||
@@ -18,7 +18,14 @@ A backend wallet can be configured using the following LNbits environment variab
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: **CoreLightningWallet**
|
||||
- `CORELIGHTNING_RPC`: /file/path/lightning-rpc
|
||||
|
||||
### Spark (c-lightning)
|
||||
### CoreLightning REST
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: **CoreLightningRestWallet**
|
||||
- `CORELIGHTNING_REST_URL`: http://127.0.0.1:8185/
|
||||
- `CORELIGHTNING_REST_MACAROON`: /file/path/admin.macaroon or Base64/Hex
|
||||
- `CORELIGHTNING_REST_CERT`: /home/lightning/clnrest/tls.cert
|
||||
|
||||
### Spark (Core Lightning)
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: **SparkWallet**
|
||||
- `SPARK_URL`: http://10.147.17.230:9737/rpc
|
||||
@@ -29,7 +36,7 @@ A backend wallet can be configured using the following LNbits environment variab
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: **LndRestWallet**
|
||||
- `LND_REST_ENDPOINT`: http://10.147.17.230:8080/
|
||||
- `LND_REST_CERT`: /file/path/tls.cert
|
||||
- `LND_REST_MACAROON`: /file/path/admin.macaroon or Bech64/Hex
|
||||
- `LND_REST_MACAROON`: /file/path/admin.macaroon or Base64/Hex
|
||||
|
||||
or
|
||||
|
||||
@@ -41,7 +48,7 @@ or
|
||||
- `LND_GRPC_ENDPOINT`: ip_address
|
||||
- `LND_GRPC_PORT`: port
|
||||
- `LND_GRPC_CERT`: /file/path/tls.cert
|
||||
- `LND_GRPC_MACAROON`: /file/path/admin.macaroon or Bech64/Hex
|
||||
- `LND_GRPC_MACAROON`: /file/path/admin.macaroon or Base64/Hex
|
||||
|
||||
You can also use an AES-encrypted macaroon (more info) instead by using
|
||||
|
||||
@@ -64,7 +71,6 @@ For the invoice listener to work you have a publicly accessible URL in your LNbi
|
||||
- `LNPAY_API_KEY`: sak_apiKey
|
||||
- `LNPAY_WALLET_KEY`: waka_apiKey
|
||||
|
||||
|
||||
### OpenNode
|
||||
|
||||
For the invoice to work you must have a publicly accessible URL in your LNbits. No manual webhook setting is necessary.
|
||||
@@ -73,6 +79,21 @@ For the invoice to work you must have a publicly accessible URL in your LNbits.
|
||||
- `OPENNODE_API_ENDPOINT`: https://api.opennode.com/
|
||||
- `OPENNODE_KEY`: opennodeAdminApiKey
|
||||
|
||||
### Alby
|
||||
|
||||
For the invoice to work you must have a publicly accessible URL in your LNbits. No manual webhook setting is necessary. You can generate an alby access token here: https://getalby.com/developer/access_tokens/new
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: **AlbyWallet**
|
||||
- `ALBY_API_ENDPOINT`: https://api.getalby.com/
|
||||
- `ALBY_ACCESS_TOKEN`: AlbyAccessToken
|
||||
|
||||
### 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
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ LNbits, free and open-source lightning-network wallet/accounts system
|
||||
LNbits is a very simple Python application that sits on top of any funding source, and can be used as:
|
||||
|
||||
* Accounts system to mitigate the risk of exposing applications to your full balance, via unique API keys for each wallet
|
||||
* Extendable platform for exploring lightning-network functionality via LNbits extension framework
|
||||
* Extendable platform for exploring lightning-network functionality via LNbits extension framework
|
||||
* Part of a development stack via LNbits API
|
||||
* Fallback wallet for the LNURL scheme
|
||||
* Instant wallet for LN demonstrations
|
||||
|
||||
|
Before Width: | Height: | Size: 69 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 101.041 30.0001"><defs><path id="a" d="M33.2619 148.1667h154.2143v68.7917H33.2619z"/></defs><g fill="#1f2234" aria-label="LNbits" font-family="sans-serif" font-size=".3095" font-weight="400" letter-spacing=".0031" style="line-height:1.25;white-space:pre;shape-inside:url(#a)" transform="matrix(72.4607 0 0 72.4607 -2399.2814 -10741.3589)"><g transform="matrix(.00244 0 0 .00244 33.0708 148.1594)"><circle cx="101.2976" cy="116.4167" r="84.6667" fill="#673ab7" fill-rule="evenodd"/><path fill="#eee" d="M79.1105 71.9667v49.0613h13.3803v40.141l31.2208-53.5213h-17.8404l17.8404-35.681z"/></g><g fill="#eee" font-family="roboto"><path d="M33.6755 148.518h.0962v.0364h-.1416v-.22h.0454zM33.9796 148.5544h-.0453l-.0883-.1448v.1448h-.0453v-.22h.0453l.0884.145v-.145h.0452z" font-weight="700" style="-inkscape-font-specification:'roboto Bold'"/><path d="M34.1625 148.4744q0 .0375-.0172.0603-.0172.0227-.0462.0227-.031 0-.048-.0219l-.0014.019h-.0256v-.2322h.028v.0866q.0168-.021.0466-.021.0298 0 .0467.0225.017.0225.017.0617zm-.028-.003q0-.0286-.011-.044-.011-.0157-.0317-.0157-.0277 0-.0398.0257v.0707q.0129.0257.04.0257.0202 0 .0314-.0156.0112-.0156.0112-.0469zM34.231 148.5544h-.028v-.1635h.028zm-.0302-.2069q0-.007.0041-.0115.0042-.005.0124-.005.0082 0 .0124.005.0042.005.0042.0115 0 .007-.0042.0113-.0042.005-.0124.005-.0082 0-.0124-.005-.004-.005-.004-.0113zM34.3167 148.3513v.0396h.0305v.0216h-.0305v.1014q0 .01.004.0148.0042.005.014.005.0048 0 .0133-.002v.0227q-.011.003-.0215.003-.0187 0-.0283-.0113-.0095-.0113-.0095-.0322v-.1014h-.0297v-.0216h.0297v-.0396zM34.478 148.511q0-.0113-.0086-.0175-.0085-.006-.0298-.0109-.0211-.005-.0337-.0109-.0124-.006-.0184-.015t-.006-.0209q0-.02.017-.034.017-.0139.0435-.0139.0278 0 .045.0144.0174.0144.0174.0367h-.028q0-.0115-.0099-.0198-.0097-.008-.0245-.008-.0152 0-.0238.007-.0086.007-.0086.0174 0 .0101.008.0153.008.005.0288.01.021.005.034.0112.013.007.0192.0157.0063.009.0063.0222 0 .022-.0175.0352-.0175.0131-.0455.0131-.0197 0-.0348-.007-.015-.007-.0237-.0193-.0085-.0125-.0085-.027h.028q.0007.014.0111.0223.0106.008.0279.008.0158 0 .0253-.006.0097-.006.0097-.0172z" style="-inkscape-font-specification:roboto"/></g></g></svg>
|
||||
|
Before Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 29 KiB |
@@ -0,0 +1,11 @@
|
||||
<svg width="3032" height="902" viewBox="0 0 3032 902" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M158.566 493.857L1 901L450.49 355.202H264.831L501.791 1H187.881L36.4218 493.857H158.566Z" fill="#FF1FE1" stroke="black"/>
|
||||
<path d="M616.041 764.863V134.611H741.647V645.115H1023.41V764.863H616.041Z" fill="#F8F8F8"/>
|
||||
<path d="M1079.99 764.863V134.611H1198.81L1478.31 543.014V134.611H1600.52V764.863H1478.31L1198.81 360.241V764.863H1079.99Z" fill="#F8F8F8"/>
|
||||
<path d="M616.041 764.863V134.611H741.647V645.115H1023.41V764.863H616.041Z" stroke="black"/>
|
||||
<path d="M1079.99 764.863V134.611H1198.81L1478.31 543.014V134.611H1600.52V764.863H1478.31L1198.81 360.241V764.863H1079.99Z" stroke="black"/>
|
||||
<path d="M1693.87 768.522V148.458H1764.1V377.349H1770.05C1775.21 369.275 1782.35 358.981 1791.47 346.467C1800.8 333.751 1814.09 322.448 1831.35 312.557C1848.81 302.465 1872.41 297.419 1902.17 297.419C1940.66 297.419 1974.58 307.208 2003.94 326.787C2033.3 346.366 2056.22 374.119 2072.68 410.048C2089.15 445.976 2097.38 488.363 2097.38 537.209C2097.38 586.459 2089.15 629.149 2072.68 665.279C2056.22 701.207 2033.4 729.061 2004.24 748.842C1975.08 768.421 1941.45 778.21 1903.36 778.21C1874 778.21 1850.49 773.265 1832.84 763.375C1815.18 753.283 1801.59 741.878 1792.07 729.162C1782.55 716.244 1775.21 705.547 1770.05 697.069H1761.72V768.522H1693.87ZM1762.91 535.998C1762.91 571.119 1767.96 602.102 1778.08 628.947C1788.2 655.59 1802.98 676.481 1822.42 691.619C1841.86 706.556 1865.67 714.024 1893.84 714.024C1923.2 714.024 1947.7 706.152 1967.34 690.408C1987.18 674.463 2002.06 653.067 2011.98 626.222C2022.1 599.175 2027.15 569.1 2027.15 535.998C2027.15 503.299 2022.19 473.83 2012.28 447.59C2002.55 421.149 1987.78 400.258 1967.94 384.918C1948.3 369.376 1923.6 361.605 1893.84 361.605C1865.27 361.605 1841.27 368.972 1821.83 383.707C1802.38 398.24 1787.7 418.626 1777.78 444.866C1767.87 470.903 1762.91 501.281 1762.91 535.998Z" fill="white"/>
|
||||
<path d="M2204.81 768.522V303.474H2275.04V768.522H2204.81ZM2240.52 225.966C2226.83 225.966 2215.03 221.223 2205.11 211.736C2195.39 202.25 2190.52 190.846 2190.52 177.524C2190.52 164.202 2195.39 152.798 2205.11 143.311C2215.03 133.825 2226.83 129.081 2240.52 129.081C2254.21 129.081 2265.91 133.825 2275.63 143.311C2285.55 152.798 2290.51 164.202 2290.51 177.524C2290.51 190.846 2285.55 202.25 2275.63 211.736C2265.91 221.223 2254.21 225.966 2240.52 225.966Z" fill="white"/>
|
||||
<path d="M2602.45 303.474V364.027H2365.57V303.474H2602.45ZM2434.61 192.057H2504.84V635.305C2504.84 655.489 2507.72 670.628 2513.47 680.72C2519.42 690.61 2526.96 697.271 2536.09 700.702C2545.41 703.932 2555.23 705.547 2565.55 705.547C2573.28 705.547 2579.63 705.143 2584.59 704.335C2589.55 703.326 2593.52 702.519 2596.49 701.913L2610.78 766.1C2606.02 767.916 2599.37 769.733 2590.84 771.549C2582.31 773.568 2571.5 774.577 2558.41 774.577C2538.57 774.577 2519.12 770.237 2500.08 761.558C2481.23 752.879 2465.56 739.658 2453.06 721.896C2440.76 704.134 2434.61 681.729 2434.61 654.682V192.057Z" fill="white"/>
|
||||
<path d="M3023.67 407.625L2960.58 425.791C2956.61 415.094 2950.76 404.699 2943.02 394.607C2935.49 384.313 2925.17 375.835 2912.08 369.174C2898.98 362.513 2882.22 359.183 2861.79 359.183C2833.81 359.183 2810.5 365.743 2791.85 378.863C2773.4 391.781 2764.18 408.231 2764.18 428.214C2764.18 445.976 2770.53 460.004 2783.22 470.298C2795.92 480.592 2815.76 489.17 2842.74 496.033L2910.59 512.988C2951.46 523.08 2981.91 538.521 3001.94 559.311C3021.98 579.899 3032 606.441 3032 638.938C3032 665.581 3024.46 689.399 3009.38 710.391C2994.51 731.383 2973.67 747.934 2946.89 760.044C2920.11 772.155 2888.96 778.21 2853.45 778.21C2806.83 778.21 2768.25 767.916 2737.7 747.328C2707.14 726.74 2687.8 696.665 2679.67 657.104L2746.33 640.149C2752.67 665.178 2764.68 683.949 2782.33 696.464C2800.19 708.978 2823.5 715.235 2852.26 715.235C2885 715.235 2910.99 708.17 2930.23 694.041C2949.67 679.711 2959.39 662.554 2959.39 642.571C2959.39 626.424 2953.84 612.9 2942.73 602.001C2931.62 590.899 2914.56 582.624 2891.54 577.174L2815.36 559.008C2773.5 548.916 2742.75 533.273 2723.11 512.08C2703.67 490.684 2693.95 463.94 2693.95 431.847C2693.95 405.607 2701.19 382.395 2715.67 362.211C2730.36 342.026 2750.29 326.182 2775.49 314.676C2800.88 303.171 2829.65 297.419 2861.79 297.419C2907.02 297.419 2942.53 307.511 2968.32 327.695C2994.31 347.88 3012.76 374.523 3023.67 407.625Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 28 KiB |
@@ -1 +1,11 @@
|
||||
<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 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>
|
||||
<svg width="3032" height="902" viewBox="0 0 3032 902" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M158.566 493.857L1 901L450.49 355.202H264.831L501.791 1H187.881L36.4218 493.857H158.566Z" fill="#FF1FE1" stroke="black"/>
|
||||
<path d="M616.041 764.863V134.611H741.647V645.115H1023.41V764.863H616.041Z" fill="#1E1E1E"/>
|
||||
<path d="M1079.99 764.863V134.611H1198.81L1478.31 543.014V134.611H1600.52V764.863H1478.31L1198.81 360.241V764.863H1079.99Z" fill="#1E1E1E"/>
|
||||
<path d="M616.041 764.863V134.611H741.647V645.115H1023.41V764.863H616.041Z" stroke="black"/>
|
||||
<path d="M1079.99 764.863V134.611H1198.81L1478.31 543.014V134.611H1600.52V764.863H1478.31L1198.81 360.241V764.863H1079.99Z" stroke="black"/>
|
||||
<path d="M1693.87 768.522V148.458H1764.1V377.349H1770.05C1775.21 369.275 1782.35 358.981 1791.47 346.467C1800.8 333.751 1814.09 322.448 1831.35 312.557C1848.81 302.465 1872.41 297.419 1902.17 297.419C1940.66 297.419 1974.58 307.208 2003.94 326.787C2033.3 346.366 2056.22 374.119 2072.68 410.048C2089.15 445.976 2097.38 488.363 2097.38 537.209C2097.38 586.459 2089.15 629.149 2072.68 665.279C2056.22 701.207 2033.4 729.061 2004.24 748.842C1975.08 768.421 1941.45 778.21 1903.36 778.21C1874 778.21 1850.49 773.265 1832.84 763.375C1815.18 753.282 1801.59 741.878 1792.07 729.162C1782.55 716.244 1775.21 705.547 1770.05 697.069H1761.72V768.522H1693.87ZM1762.91 535.998C1762.91 571.119 1767.96 602.102 1778.08 628.947C1788.2 655.59 1802.98 676.481 1822.42 691.619C1841.86 706.556 1865.67 714.024 1893.84 714.024C1923.2 714.024 1947.7 706.152 1967.34 690.408C1987.18 674.463 2002.06 653.067 2011.98 626.222C2022.1 599.175 2027.15 569.1 2027.15 535.998C2027.15 503.299 2022.2 473.83 2012.28 447.59C2002.55 421.149 1987.78 400.258 1967.94 384.918C1948.3 369.376 1923.6 361.605 1893.84 361.605C1865.27 361.605 1841.27 368.972 1821.83 383.707C1802.38 398.24 1787.7 418.626 1777.78 444.866C1767.87 470.903 1762.91 501.281 1762.91 535.998Z" fill="black"/>
|
||||
<path d="M2204.81 768.522V303.474H2275.04V768.522H2204.81ZM2240.52 225.966C2226.83 225.966 2215.03 221.223 2205.11 211.736C2195.39 202.25 2190.52 190.846 2190.52 177.524C2190.52 164.202 2195.39 152.798 2205.11 143.311C2215.03 133.825 2226.83 129.081 2240.52 129.081C2254.21 129.081 2265.91 133.825 2275.63 143.311C2285.55 152.798 2290.51 164.202 2290.51 177.524C2290.51 190.846 2285.55 202.25 2275.63 211.736C2265.91 221.223 2254.21 225.966 2240.52 225.966Z" fill="black"/>
|
||||
<path d="M2602.45 303.474V364.027H2365.57V303.474H2602.45ZM2434.61 192.057H2504.84V635.305C2504.84 655.489 2507.72 670.628 2513.47 680.72C2519.42 690.61 2526.96 697.271 2536.09 700.702C2545.41 703.932 2555.23 705.546 2565.55 705.546C2573.28 705.546 2579.63 705.143 2584.59 704.335C2589.55 703.326 2593.52 702.519 2596.5 701.913L2610.78 766.1C2606.02 767.916 2599.37 769.733 2590.84 771.549C2582.31 773.568 2571.5 774.577 2558.41 774.577C2538.57 774.577 2519.12 770.237 2500.08 761.558C2481.23 752.879 2465.56 739.658 2453.06 721.896C2440.76 704.134 2434.61 681.729 2434.61 654.682V192.057Z" fill="black"/>
|
||||
<path d="M3023.67 407.625L2960.58 425.791C2956.61 415.094 2950.76 404.699 2943.02 394.607C2935.49 384.313 2925.17 375.835 2912.08 369.174C2898.98 362.513 2882.22 359.183 2861.79 359.183C2833.81 359.183 2810.5 365.743 2791.85 378.863C2773.41 391.781 2764.18 408.231 2764.18 428.213C2764.18 445.976 2770.53 460.004 2783.23 470.298C2795.92 480.592 2815.76 489.17 2842.74 496.033L2910.59 512.988C2951.46 523.08 2981.91 538.521 3001.94 559.311C3021.98 579.899 3032 606.441 3032 638.938C3032 665.581 3024.46 689.399 3009.38 710.391C2994.51 731.383 2973.67 747.934 2946.89 760.044C2920.11 772.155 2888.96 778.21 2853.45 778.21C2806.83 778.21 2768.25 767.916 2737.7 747.328C2707.14 726.74 2687.8 696.665 2679.67 657.104L2746.33 640.149C2752.67 665.178 2764.68 683.949 2782.33 696.464C2800.19 708.978 2823.5 715.235 2852.26 715.235C2885 715.235 2910.99 708.17 2930.23 694.041C2949.67 679.711 2959.39 662.554 2959.39 642.571C2959.39 626.424 2953.84 612.9 2942.73 602.001C2931.62 590.899 2914.56 582.624 2891.54 577.174L2815.36 559.008C2773.5 548.916 2742.75 533.273 2723.11 512.079C2703.67 490.684 2693.95 463.94 2693.95 431.847C2693.95 405.607 2701.19 382.395 2715.67 362.211C2730.36 342.026 2750.29 326.182 2775.49 314.676C2800.88 303.171 2829.65 297.419 2861.79 297.419C2907.02 297.419 2942.53 307.511 2968.32 327.695C2994.31 347.88 3012.76 374.523 3023.67 407.625Z" fill="black"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 15 KiB |
@@ -1 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 30 30"><g transform="translate(-2.9464 -5.625) scale(.17717)"><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></svg>
|
||||
<svg width="900" height="900" viewBox="0 0 900 900" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="450" cy="450" r="450" fill="#1E1E1E"/>
|
||||
<path d="M387.802 476.423L281.613 730.887L584.54 389.763H459.418L619.113 168.387H407.558L305.485 476.423H387.802Z" fill="#FF1FE1"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 314 B After Width: | Height: | Size: 290 B |
@@ -1,12 +1,15 @@
|
||||
{
|
||||
"nodes": {
|
||||
"flake-utils": {
|
||||
"inputs": {
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1676283394,
|
||||
"narHash": "sha256-XX2f9c3iySLCw54rJ/CZs+ZK6IQy7GXNY4nSOyu2QG4=",
|
||||
"lastModified": 1694529238,
|
||||
"narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "3db36a8b464d0c4532ba1c7dda728f4576d6d073",
|
||||
"rev": "ff7b65b44d01cf9ba6a71320833626af21126384",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -15,18 +18,39 @@
|
||||
"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": {
|
||||
"locked": {
|
||||
"lastModified": 1678470307,
|
||||
"narHash": "sha256-OEeMUr3ueLIXyW/OaFUX5jUdimyQwMg/7e+/Q0gC/QE=",
|
||||
"lastModified": 1702233072,
|
||||
"narHash": "sha256-H5G2wgbim2Ku6G6w+NSaQaauv6B6DlPhY9fMvArKqRo=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "0c4800d579af4ed98ecc47d464a5e7b0870c4b1f",
|
||||
"rev": "781e2a9797ecf0f146e81425c822dca69fe4a348",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nixos",
|
||||
"ref": "nixos-unstable",
|
||||
"ref": "nixos-23.11",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
@@ -34,16 +58,19 @@
|
||||
"poetry2nix": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils",
|
||||
"nix-github-actions": "nix-github-actions",
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
]
|
||||
],
|
||||
"systems": "systems_2",
|
||||
"treefmt-nix": "treefmt-nix"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1678513806,
|
||||
"narHash": "sha256-bEto1lp9bIQ+DlJsXQyRxN5l6B/oy3Skb7DueYRJJBo=",
|
||||
"lastModified": 1702334837,
|
||||
"narHash": "sha256-QZG6+zFshyY+L8m2tlOTm75U5m9y7z01g0josVK+8Os=",
|
||||
"owner": "nix-community",
|
||||
"repo": "poetry2nix",
|
||||
"rev": "bf239d53fafb53cf439a72b3a50e86dd6a3984a5",
|
||||
"rev": "1f4bcbf1be73abc232a972a77102a3e820485a99",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -57,6 +84,56 @@
|
||||
"nixpkgs": "nixpkgs",
|
||||
"poetry2nix": "poetry2nix"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"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",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
description = "LNbits, free and open-source Lightning wallet and accounts system";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
|
||||
nixpkgs.url = "github:nixos/nixpkgs/nixos-23.11";
|
||||
poetry2nix = {
|
||||
url = "github:nix-community/poetry2nix";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
@@ -13,28 +13,27 @@
|
||||
supportedSystems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ];
|
||||
forSystems = systems: f:
|
||||
nixpkgs.lib.genAttrs systems
|
||||
(system: f system (import nixpkgs { inherit system; overlays = [ poetry2nix.overlay self.overlays.default ]; }));
|
||||
(system: f system (import nixpkgs { inherit system; overlays = [ poetry2nix.overlays.default self.overlays.default ]; }));
|
||||
forAllSystems = forSystems supportedSystems;
|
||||
projectName = "lnbits";
|
||||
in
|
||||
{
|
||||
devShells = forAllSystems (system: pkgs: {
|
||||
default = pkgs.mkShell {
|
||||
buildInputs = with pkgs; [
|
||||
nodePackages.prettier
|
||||
poetry
|
||||
];
|
||||
};
|
||||
});
|
||||
overlays = {
|
||||
default = final: prev: {
|
||||
${projectName} = self.packages.${final.hostPlatform.system}.${projectName};
|
||||
${projectName} = self.packages.${prev.stdenv.hostPlatform.system}.${projectName};
|
||||
};
|
||||
};
|
||||
packages = forAllSystems (system: pkgs: {
|
||||
default = self.packages.${system}.${projectName};
|
||||
${projectName} = pkgs.poetry2nix.mkPoetryApplication {
|
||||
projectDir = ./.;
|
||||
meta.rev = self.dirtyRev or self.rev;
|
||||
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 = {
|
||||
|
||||
@@ -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>
|
||||
@@ -7,51 +7,75 @@ import shutil
|
||||
import signal
|
||||
import sys
|
||||
import traceback
|
||||
from hashlib import sha256
|
||||
from http import HTTPStatus
|
||||
from typing import Callable, List
|
||||
from pathlib import Path
|
||||
from typing import Callable, List, Optional
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.exceptions import HTTPException, RequestValidationError
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.middleware.gzip import GZipMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.responses import RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from loguru import logger
|
||||
from slowapi import Limiter
|
||||
from slowapi.util import get_remote_address
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
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.tasks import register_task_listeners
|
||||
from lnbits.settings import get_wallet_class, set_wallet_class, settings
|
||||
from lnbits.core.services import websocketUpdater
|
||||
from lnbits.core.tasks import ( # watchdog_task
|
||||
killswitch_task,
|
||||
wait_for_paid_invoices,
|
||||
)
|
||||
from lnbits.settings import settings
|
||||
from lnbits.tasks import (
|
||||
cancel_all_tasks,
|
||||
create_permanent_task,
|
||||
register_invoice_listener,
|
||||
)
|
||||
from lnbits.utils.cache import cache
|
||||
from lnbits.wallets import get_wallet_class, set_wallet_class
|
||||
|
||||
from .commands import db_versions, load_disabled_extension_list, migrate_databases
|
||||
from .core import (
|
||||
add_installed_extension,
|
||||
core_app,
|
||||
core_app_extra,
|
||||
update_installed_extension_state,
|
||||
from .commands import migrate_databases
|
||||
from .core import init_core_routers
|
||||
from .core.db import core_app_extra
|
||||
from .core.services import check_admin_settings, check_webpush_settings
|
||||
from .core.views.api import add_installed_extension
|
||||
from .core.views.generic import update_installed_extension_state
|
||||
from .extension_manager import (
|
||||
Extension,
|
||||
InstallableExtension,
|
||||
get_valid_extensions,
|
||||
version_parse,
|
||||
)
|
||||
from .core.services import check_admin_settings
|
||||
from .core.views.generic import core_html_routes
|
||||
from .extension_manager import Extension, InstallableExtension, get_valid_extensions
|
||||
from .helpers import template_renderer
|
||||
from .middleware import ExtensionsRedirectMiddleware, InstalledExtensionMiddleware
|
||||
from .middleware import (
|
||||
CustomGZipMiddleware,
|
||||
ExtensionsRedirectMiddleware,
|
||||
InstalledExtensionMiddleware,
|
||||
add_first_install_middleware,
|
||||
add_ip_block_middleware,
|
||||
add_ratelimit_middleware,
|
||||
)
|
||||
from .requestvars import g
|
||||
from .tasks import (
|
||||
catch_everything_and_restart,
|
||||
check_pending_payments,
|
||||
internal_invoice_listener,
|
||||
invoice_listener,
|
||||
webhook_handler,
|
||||
)
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
|
||||
configure_logger()
|
||||
|
||||
app = FastAPI(
|
||||
title="LNbits API",
|
||||
description="API for LNbits, the free and open source bitcoin wallet and accounts system with plugins.",
|
||||
title=settings.lnbits_title,
|
||||
description=(
|
||||
"API for LNbits, the free and open source bitcoin wallet and "
|
||||
"accounts system with plugins."
|
||||
),
|
||||
version=settings.version,
|
||||
license_info={
|
||||
"name": "MIT License",
|
||||
@@ -59,12 +83,14 @@ def create_app() -> FastAPI:
|
||||
},
|
||||
)
|
||||
|
||||
app.mount("/static", StaticFiles(packages=[("lnbits", "static")]), name="static")
|
||||
app.mount(
|
||||
"/core/static",
|
||||
StaticFiles(packages=[("lnbits.core", "static")]),
|
||||
name="core_static",
|
||||
)
|
||||
# Allow registering new extensions routes without direct access to the `app` object
|
||||
setattr(core_app_extra, "register_new_ext_routes", register_new_ext_routes(app))
|
||||
setattr(core_app_extra, "register_new_ratelimiter", register_new_ratelimiter(app))
|
||||
|
||||
# register static files
|
||||
static_path = Path("lnbits", "static")
|
||||
static = StaticFiles(directory=static_path)
|
||||
app.mount("/static", static, name="static")
|
||||
|
||||
g().base_url = f"http://{settings.host}:{settings.port}"
|
||||
|
||||
@@ -72,29 +98,40 @@ def create_app() -> FastAPI:
|
||||
CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]
|
||||
)
|
||||
|
||||
app.add_middleware(GZipMiddleware, minimum_size=1000)
|
||||
app.add_middleware(
|
||||
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
|
||||
app.add_middleware(InstalledExtensionMiddleware)
|
||||
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_routes(app)
|
||||
register_async_tasks(app)
|
||||
register_exception_handlers(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))
|
||||
register_shutdown(app)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
async def check_funding_source() -> None:
|
||||
|
||||
original_sigint_handler = signal.getsignal(signal.SIGINT)
|
||||
|
||||
def signal_handler(signal, frame):
|
||||
logger.debug("SIGINT received, terminating LNbits.")
|
||||
logger.debug(
|
||||
f"SIGINT received, terminating LNbits. signal: {signal}, frame: {frame}"
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
signal.signal(signal.SIGINT, signal_handler)
|
||||
@@ -116,17 +153,16 @@ async def check_funding_source() -> None:
|
||||
break
|
||||
|
||||
logger.error(
|
||||
f"The backend for {WALLET.__class__.__name__} isn't working properly: '{error_message}'",
|
||||
f"The backend for {WALLET.__class__.__name__} isn't "
|
||||
f"working properly: '{error_message}'",
|
||||
RuntimeWarning,
|
||||
)
|
||||
except:
|
||||
except Exception as e:
|
||||
logger.error(f"Error connecting to {WALLET.__class__.__name__}: {e}")
|
||||
pass
|
||||
|
||||
if settings.lnbits_admin_ui and retry_counter == timeout:
|
||||
logger.warning(
|
||||
f"Fallback to VoidWallet, because the backend for {WALLET.__class__.__name__} isn't working properly"
|
||||
)
|
||||
set_wallet_class("VoidWallet")
|
||||
set_void_wallet_class()
|
||||
WALLET = get_wallet_class()
|
||||
break
|
||||
else:
|
||||
@@ -136,34 +172,53 @@ async def check_funding_source() -> None:
|
||||
|
||||
signal.signal(signal.SIGINT, original_sigint_handler)
|
||||
|
||||
logger.info(
|
||||
f"✔️ Backend {WALLET.__class__.__name__} connected and with a balance of {balance} msat."
|
||||
logger.success(
|
||||
f"✔️ Backend {WALLET.__class__.__name__} connected "
|
||||
f"and with a balance of {balance} msat."
|
||||
)
|
||||
|
||||
|
||||
def set_void_wallet_class():
|
||||
logger.warning(
|
||||
"Fallback to VoidWallet, because the backend for "
|
||||
f"{settings.lnbits_backend_wallet_class} isn't working properly"
|
||||
)
|
||||
set_wallet_class("VoidWallet")
|
||||
|
||||
|
||||
async def check_installed_extensions(app: FastAPI):
|
||||
"""
|
||||
Check extensions that have been installed, but for some reason no longer present in the 'lnbits/extensions' directory.
|
||||
One reason might be a docker-container that was re-created.
|
||||
The 'data' directory (where the '.zip' files live) is expected to persist state.
|
||||
Zips that are missing will be re-downloaded.
|
||||
Check extensions that have been installed, but for some reason no longer present in
|
||||
the 'lnbits/extensions' directory. One reason might be a docker-container that was
|
||||
re-created. The 'data' directory (where the '.zip' files live) is expected to
|
||||
persist state. Zips that are missing will be re-downloaded.
|
||||
"""
|
||||
shutil.rmtree(os.path.join("lnbits", "upgrades"), True)
|
||||
await load_disabled_extension_list()
|
||||
installed_extensions = await build_all_installed_extensions_list()
|
||||
installed_extensions = await build_all_installed_extensions_list(False)
|
||||
|
||||
for ext in installed_extensions:
|
||||
try:
|
||||
installed = check_installed_extension(ext)
|
||||
installed = await check_installed_extension_files(ext)
|
||||
if not installed:
|
||||
await restore_installed_extension(app, ext)
|
||||
logger.info(f"✔️ Successfully re-installed extension: {ext.id}")
|
||||
logger.info(
|
||||
"✔️ Successfully re-installed extension: "
|
||||
f"{ext.id} ({ext.installed_version})"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(e)
|
||||
logger.warning(f"Failed to re-install extension: {ext.id}")
|
||||
logger.warning(
|
||||
f"Failed to re-install extension: {ext.id} ({ext.installed_version})"
|
||||
)
|
||||
|
||||
logger.info(f"Installed Extensions ({len(installed_extensions)}):")
|
||||
for ext in installed_extensions:
|
||||
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
|
||||
MUST be installed by default (see LNBITS_EXTENSIONS_DEFAULT_INSTALL).
|
||||
@@ -176,39 +231,52 @@ async def build_all_installed_extensions_list() -> List[InstallableExtension]:
|
||||
continue
|
||||
|
||||
ext_releases = await InstallableExtension.get_extension_releases(ext_id)
|
||||
release = ext_releases[0] if len(ext_releases) else None
|
||||
ext_releases = sorted(
|
||||
ext_releases, key=lambda r: version_parse(r.version), reverse=True
|
||||
)
|
||||
|
||||
release = next((e for e in ext_releases if e.is_version_compatible), None)
|
||||
|
||||
if release:
|
||||
ext_info = InstallableExtension(
|
||||
id=ext_id, name=ext_id, installed_release=release, icon=release.icon
|
||||
)
|
||||
installed_extensions.append(ext_info)
|
||||
await add_installed_extension(ext_info)
|
||||
await update_installed_extension_state(ext_id=ext_id, active=True)
|
||||
|
||||
return installed_extensions
|
||||
if include_deactivated:
|
||||
return installed_extensions
|
||||
|
||||
if settings.lnbits_extensions_deactivate_all:
|
||||
return []
|
||||
|
||||
return [
|
||||
e
|
||||
for e in installed_extensions
|
||||
if e.id not in settings.lnbits_deactivated_extensions
|
||||
]
|
||||
|
||||
|
||||
def check_installed_extension(ext: InstallableExtension) -> bool:
|
||||
async def check_installed_extension_files(ext: InstallableExtension) -> bool:
|
||||
if ext.has_installed_version:
|
||||
return True
|
||||
|
||||
zip_files = glob.glob(
|
||||
os.path.join(settings.lnbits_data_folder, "extensions", "*.zip")
|
||||
)
|
||||
zip_files = glob.glob(os.path.join(settings.lnbits_data_folder, "zips", "*.zip"))
|
||||
|
||||
if f"./{str(ext.zip_path)}" not in zip_files:
|
||||
ext.download_archive()
|
||||
await ext.download_archive()
|
||||
ext.extract_archive()
|
||||
|
||||
return False
|
||||
|
||||
|
||||
async def restore_installed_extension(app: FastAPI, ext: InstallableExtension):
|
||||
await add_installed_extension(ext)
|
||||
await update_installed_extension_state(ext_id=ext.id, active=True)
|
||||
|
||||
extension = Extension.from_installable_ext(ext)
|
||||
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)
|
||||
|
||||
# mount routes for the new version
|
||||
@@ -219,28 +287,57 @@ async def restore_installed_extension(app: FastAPI, ext: InstallableExtension):
|
||||
|
||||
def register_routes(app: FastAPI) -> None:
|
||||
"""Register FastAPI routes / LNbits extensions."""
|
||||
app.include_router(core_app)
|
||||
app.include_router(core_html_routes)
|
||||
init_core_routers(app)
|
||||
|
||||
for ext in get_valid_extensions():
|
||||
for ext in get_valid_extensions(False):
|
||||
try:
|
||||
register_ext_routes(app, ext)
|
||||
except Exception as e:
|
||||
logger.error(str(e))
|
||||
raise ImportError(
|
||||
f"Please make sure that the extension `{ext.code}` follows conventions."
|
||||
)
|
||||
logger.error(f"Could not load extension `{ext.code}`: {str(e)}")
|
||||
|
||||
|
||||
def register_custom_extensions_path():
|
||||
if settings.has_default_extension_path:
|
||||
return
|
||||
default_ext_path = os.path.join("lnbits", "extensions")
|
||||
if os.path.isdir(default_ext_path) and len(os.listdir(default_ext_path)) != 0:
|
||||
logger.warning(
|
||||
"You are using a custom extensions path, "
|
||||
+ "but the default extensions directory is not empty. "
|
||||
+ f"Please clean-up the '{default_ext_path}' directory."
|
||||
)
|
||||
logger.warning(
|
||||
f"You can move the existing '{default_ext_path}' directory to: "
|
||||
+ f" '{settings.lnbits_extensions_path}/extensions'"
|
||||
)
|
||||
|
||||
sys.path.append(str(Path(settings.lnbits_extensions_path, "extensions")))
|
||||
sys.path.append(str(Path(settings.lnbits_extensions_path, "upgrades")))
|
||||
|
||||
|
||||
def register_new_ext_routes(app: FastAPI) -> Callable:
|
||||
# Returns a function that registers new routes for an extension.
|
||||
# The returned function encapsulates (creates a closure around) the `app` object but does expose it.
|
||||
# The returned function encapsulates (creates a closure around)
|
||||
# the `app` object but does expose it.
|
||||
def register_new_ext_routes_fn(ext: Extension):
|
||||
register_ext_routes(app, ext)
|
||||
|
||||
return register_new_ext_routes_fn
|
||||
|
||||
|
||||
def register_new_ratelimiter(app: FastAPI) -> Callable:
|
||||
def register_new_ratelimiter_fn():
|
||||
limiter = Limiter(
|
||||
key_func=get_remote_address,
|
||||
default_limits=[
|
||||
f"{settings.lnbits_rate_limit_no}/{settings.lnbits_rate_limit_unit}"
|
||||
],
|
||||
)
|
||||
app.state.limiter = limiter
|
||||
|
||||
return register_new_ratelimiter_fn
|
||||
|
||||
|
||||
def register_ext_routes(app: FastAPI, ext: Extension) -> None:
|
||||
"""Register FastAPI routes for extension."""
|
||||
ext_module = importlib.import_module(ext.module_name)
|
||||
@@ -254,7 +351,10 @@ def register_ext_routes(app: FastAPI, ext: Extension) -> None:
|
||||
if hasattr(ext_module, f"{ext.code}_static_files"):
|
||||
ext_statics = getattr(ext_module, f"{ext.code}_static_files")
|
||||
for s in ext_statics:
|
||||
app.mount(s["path"], s["app"], s["name"])
|
||||
static_dir = Path(
|
||||
settings.lnbits_extensions_path, "extensions", *s["path"].split("/")
|
||||
)
|
||||
app.mount(s["path"], StaticFiles(directory=static_dir), s["name"])
|
||||
|
||||
if hasattr(ext_module, f"{ext.code}_redirect_paths"):
|
||||
ext_redirects = getattr(ext_module, f"{ext.code}_redirect_paths")
|
||||
@@ -274,18 +374,24 @@ def register_ext_routes(app: FastAPI, ext: Extension) -> None:
|
||||
def register_startup(app: FastAPI):
|
||||
@app.on_event("startup")
|
||||
async def lnbits_startup():
|
||||
|
||||
try:
|
||||
# wait till migration is done
|
||||
await migrate_databases()
|
||||
|
||||
# setup admin settings
|
||||
await check_admin_settings()
|
||||
await check_webpush_settings()
|
||||
|
||||
log_server_info()
|
||||
|
||||
# initialize WALLET
|
||||
set_wallet_class()
|
||||
try:
|
||||
set_wallet_class()
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error initializing {settings.lnbits_backend_wallet_class}: {e}"
|
||||
)
|
||||
set_void_wallet_class()
|
||||
|
||||
# initialize funding source
|
||||
await check_funding_source()
|
||||
@@ -293,11 +399,45 @@ def register_startup(app: FastAPI):
|
||||
# check extensions after restart
|
||||
await check_installed_extensions(app)
|
||||
|
||||
# register core and extension routes
|
||||
register_routes(app)
|
||||
|
||||
if settings.lnbits_admin_ui:
|
||||
initialize_server_logger()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(str(e))
|
||||
raise ImportError("Failed to run 'startup' event.")
|
||||
|
||||
|
||||
def register_shutdown(app: FastAPI):
|
||||
@app.on_event("shutdown")
|
||||
async def on_shutdown():
|
||||
cancel_all_tasks()
|
||||
# wait a bit to allow them to finish, so that cleanup can run without problems
|
||||
await asyncio.sleep(0.1)
|
||||
WALLET = get_wallet_class()
|
||||
await WALLET.cleanup()
|
||||
|
||||
|
||||
def initialize_server_logger():
|
||||
super_user_hash = sha256(settings.super_user.encode("utf-8")).hexdigest()
|
||||
|
||||
serverlog_queue = asyncio.Queue()
|
||||
|
||||
async def update_websocket_serverlog():
|
||||
while True:
|
||||
msg = await serverlog_queue.get()
|
||||
await websocketUpdater(super_user_hash, msg)
|
||||
|
||||
create_permanent_task(update_websocket_serverlog)
|
||||
|
||||
logger.add(
|
||||
lambda msg: serverlog_queue.put_nowait(msg),
|
||||
format=Formatter().format,
|
||||
)
|
||||
|
||||
|
||||
def log_server_info():
|
||||
logger.info("Starting LNbits")
|
||||
logger.info(f"Version: {settings.version}")
|
||||
@@ -308,9 +448,10 @@ def log_server_info():
|
||||
logger.info(f"Site title: {settings.lnbits_site_title}")
|
||||
logger.info(f"Funding source: {settings.lnbits_backend_wallet_class}")
|
||||
logger.info(f"Data folder: {settings.lnbits_data_folder}")
|
||||
logger.info(f"Git version: {settings.lnbits_commit}")
|
||||
logger.info(f"Database: {get_db_vendor_name()}")
|
||||
logger.info(f"Service fee: {settings.lnbits_service_fee}")
|
||||
logger.info(f"Service fee max: {settings.lnbits_service_fee_max}")
|
||||
logger.info(f"Service fee wallet: {settings.lnbits_service_fee_wallet}")
|
||||
|
||||
|
||||
def get_db_vendor_name():
|
||||
@@ -318,29 +459,30 @@ def get_db_vendor_name():
|
||||
return (
|
||||
"PostgreSQL"
|
||||
if db_url and db_url.startswith("postgres://")
|
||||
else "CockroachDB"
|
||||
if db_url and db_url.startswith("cockroachdb://")
|
||||
else "SQLite"
|
||||
else (
|
||||
"CockroachDB"
|
||||
if db_url and db_url.startswith("cockroachdb://")
|
||||
else "SQLite"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def register_async_tasks(app):
|
||||
@app.route("/wallet/webhook")
|
||||
async def webhook_listener():
|
||||
return await webhook_handler()
|
||||
|
||||
@app.on_event("startup")
|
||||
async def listeners():
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.create_task(catch_everything_and_restart(check_pending_payments))
|
||||
loop.create_task(catch_everything_and_restart(invoice_listener))
|
||||
loop.create_task(catch_everything_and_restart(internal_invoice_listener))
|
||||
await register_task_listeners()
|
||||
# await run_deferred_async() # calle: doesn't do anyting?
|
||||
create_permanent_task(check_pending_payments)
|
||||
create_permanent_task(invoice_listener)
|
||||
create_permanent_task(internal_invoice_listener)
|
||||
create_permanent_task(cache.invalidate_forever)
|
||||
|
||||
@app.on_event("shutdown")
|
||||
async def stop_listeners():
|
||||
pass
|
||||
# core invoice listener
|
||||
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):
|
||||
@@ -399,6 +541,13 @@ def register_exception_handlers(app: FastAPI):
|
||||
and "accept" in request.headers
|
||||
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(
|
||||
"error.html",
|
||||
{
|
||||
@@ -417,26 +566,55 @@ def configure_logger() -> None:
|
||||
logger.remove()
|
||||
log_level: str = "DEBUG" if settings.debug else "INFO"
|
||||
formatter = Formatter()
|
||||
logger.add(sys.stderr, 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.access").handlers = [InterceptHandler()]
|
||||
logging.getLogger("uvicorn.error").handlers = [InterceptHandler()]
|
||||
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:
|
||||
def __init__(self):
|
||||
self.padding = 0
|
||||
self.minimal_fmt: str = "<green>{time:YYYY-MM-DD HH:mm:ss.SS}</green> | <level>{level}</level> | <level>{message}</level>\n"
|
||||
self.minimal_fmt = (
|
||||
"<green>{time:YYYY-MM-DD HH:mm:ss.SS}</green> | <level>{level}</level> | "
|
||||
"<level>{message}</level>\n"
|
||||
)
|
||||
if settings.debug:
|
||||
self.fmt: str = (
|
||||
"<green>{time:YYYY-MM-DD HH:mm:ss.SS}</green> | <level>{level: <4}</level> | "
|
||||
"<cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> | <level>{message}</level>\n"
|
||||
self.fmt = (
|
||||
"<green>{time:YYYY-MM-DD HH:mm:ss.SS}</green> | "
|
||||
"<level>{level: <4}</level> | "
|
||||
"<cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> | "
|
||||
"<level>{message}</level>\n"
|
||||
)
|
||||
else:
|
||||
self.fmt: str = self.minimal_fmt
|
||||
self.fmt = self.minimal_fmt
|
||||
|
||||
def format(self, record):
|
||||
function = "{function}".format(**record) # pylint: disable=C0209
|
||||
function = "{function}".format(**record)
|
||||
if function == "emit": # uvicorn logs
|
||||
return self.minimal_fmt
|
||||
return self.fmt
|
||||
|
||||
@@ -1,366 +1,7 @@
|
||||
import hashlib
|
||||
import re
|
||||
import time
|
||||
from decimal import Decimal
|
||||
from typing import List, NamedTuple, Optional
|
||||
|
||||
import bitstring
|
||||
import secp256k1
|
||||
from bech32 import CHARSET, bech32_decode, bech32_encode
|
||||
from ecdsa import SECP256k1, VerifyingKey
|
||||
from ecdsa.util import sigdecode_string
|
||||
|
||||
|
||||
class Route(NamedTuple):
|
||||
pubkey: str
|
||||
short_channel_id: str
|
||||
base_fee_msat: int
|
||||
ppm_fee: int
|
||||
cltv: int
|
||||
|
||||
|
||||
class Invoice:
|
||||
payment_hash: str
|
||||
amount_msat: int = 0
|
||||
description: Optional[str] = None
|
||||
description_hash: Optional[str] = None
|
||||
payee: Optional[str] = None
|
||||
date: int
|
||||
expiry: int = 3600
|
||||
secret: Optional[str] = None
|
||||
route_hints: List[Route] = []
|
||||
min_final_cltv_expiry: int = 18
|
||||
|
||||
|
||||
def decode(pr: str) -> Invoice:
|
||||
"""bolt11 decoder,
|
||||
based on https://github.com/rustyrussell/lightning-payencode/blob/master/lnaddr.py
|
||||
"""
|
||||
|
||||
hrp, decoded_data = bech32_decode(pr)
|
||||
if hrp is None or decoded_data is None:
|
||||
raise ValueError("Bad bech32 checksum")
|
||||
if not hrp.startswith("ln"):
|
||||
raise ValueError("Does not start with ln")
|
||||
|
||||
bitarray = _u5_to_bitarray(decoded_data)
|
||||
|
||||
# final signature 65 bytes, split it off.
|
||||
if len(bitarray) < 65 * 8:
|
||||
raise ValueError("Too short to contain signature")
|
||||
|
||||
# extract the signature
|
||||
signature = bitarray[-65 * 8 :].tobytes()
|
||||
|
||||
# the tagged fields as a bitstream
|
||||
data = bitstring.ConstBitStream(bitarray[: -65 * 8])
|
||||
|
||||
# build the invoice object
|
||||
invoice = Invoice()
|
||||
|
||||
# decode the amount from the hrp
|
||||
m = re.search(r"[^\d]+", hrp[2:])
|
||||
if m:
|
||||
amountstr = hrp[2 + m.end() :]
|
||||
if amountstr != "":
|
||||
invoice.amount_msat = _unshorten_amount(amountstr)
|
||||
|
||||
# pull out date
|
||||
date_bin = data.read(35)
|
||||
invoice.date = date_bin.uint # type: ignore
|
||||
|
||||
while data.pos != data.len:
|
||||
tag, tagdata, data = _pull_tagged(data)
|
||||
data_length = len(tagdata or []) / 5
|
||||
|
||||
if tag == "d":
|
||||
invoice.description = _trim_to_bytes(tagdata).decode()
|
||||
elif tag == "h" and data_length == 52:
|
||||
invoice.description_hash = _trim_to_bytes(tagdata).hex()
|
||||
elif tag == "p" and data_length == 52:
|
||||
invoice.payment_hash = _trim_to_bytes(tagdata).hex()
|
||||
elif tag == "x":
|
||||
invoice.expiry = tagdata.uint # type: ignore
|
||||
elif tag == "n":
|
||||
invoice.payee = _trim_to_bytes(tagdata).hex()
|
||||
# this won't work in most cases, we must extract the payee
|
||||
# from the signature
|
||||
elif tag == "s":
|
||||
invoice.secret = _trim_to_bytes(tagdata).hex()
|
||||
elif tag == "r":
|
||||
s = bitstring.ConstBitStream(tagdata)
|
||||
while s.pos + 264 + 64 + 32 + 32 + 16 < s.len:
|
||||
route = Route(
|
||||
pubkey=s.read(264).tobytes().hex(), # type: ignore
|
||||
short_channel_id=_readable_scid(s.read(64).intbe), # type: ignore
|
||||
base_fee_msat=s.read(32).intbe, # type: ignore
|
||||
ppm_fee=s.read(32).intbe, # type: ignore
|
||||
cltv=s.read(16).intbe, # type: ignore
|
||||
)
|
||||
invoice.route_hints.append(route)
|
||||
|
||||
# BOLT #11:
|
||||
# A reader MUST check that the `signature` is valid (see the `n` tagged
|
||||
# field specified below).
|
||||
# A reader MUST use the `n` field to validate the signature instead of
|
||||
# performing signature recovery if a valid `n` field is provided.
|
||||
message = bytearray([ord(c) for c in hrp]) + data.tobytes()
|
||||
sig = signature[0:64]
|
||||
if invoice.payee:
|
||||
key = VerifyingKey.from_string(bytes.fromhex(invoice.payee), curve=SECP256k1)
|
||||
key.verify(sig, message, hashlib.sha256, sigdecode=sigdecode_string)
|
||||
else:
|
||||
keys = VerifyingKey.from_public_key_recovery(
|
||||
sig, message, SECP256k1, hashlib.sha256
|
||||
)
|
||||
signaling_byte = signature[64]
|
||||
key = keys[int(signaling_byte)]
|
||||
invoice.payee = key.to_string("compressed").hex()
|
||||
|
||||
return invoice
|
||||
|
||||
|
||||
def encode(options):
|
||||
"""Convert options into LnAddr and pass it to the encoder"""
|
||||
addr = LnAddr()
|
||||
addr.currency = options["currency"]
|
||||
addr.fallback = options["fallback"] if options["fallback"] else None
|
||||
if options["amount"]:
|
||||
addr.amount = options["amount"]
|
||||
if options["timestamp"]:
|
||||
addr.date = int(options["timestamp"])
|
||||
|
||||
addr.paymenthash = bytes.fromhex(options["paymenthash"])
|
||||
|
||||
if options["description"]:
|
||||
addr.tags.append(("d", options["description"]))
|
||||
if options["description_hash"]:
|
||||
addr.tags.append(("h", options["description_hash"]))
|
||||
if options["expires"]:
|
||||
addr.tags.append(("x", options["expires"]))
|
||||
|
||||
if options["fallback"]:
|
||||
addr.tags.append(("f", options["fallback"]))
|
||||
if options["route"]:
|
||||
for r in options["route"]:
|
||||
splits = r.split("/")
|
||||
route = []
|
||||
while len(splits) >= 5:
|
||||
route.append(
|
||||
(
|
||||
bytes.fromhex(splits[0]),
|
||||
bytes.fromhex(splits[1]),
|
||||
int(splits[2]),
|
||||
int(splits[3]),
|
||||
int(splits[4]),
|
||||
)
|
||||
)
|
||||
splits = splits[5:]
|
||||
assert len(splits) == 0
|
||||
addr.tags.append(("r", route))
|
||||
return lnencode(addr, options["privkey"])
|
||||
|
||||
|
||||
def lnencode(addr, privkey):
|
||||
if addr.amount:
|
||||
amount = Decimal(str(addr.amount))
|
||||
# We can only send down to millisatoshi.
|
||||
if amount * 10**12 % 10:
|
||||
raise ValueError(f"Cannot encode {addr.amount}: too many decimal places")
|
||||
|
||||
amount = addr.currency + shorten_amount(amount)
|
||||
else:
|
||||
amount = addr.currency if addr.currency else ""
|
||||
|
||||
hrp = f"ln{amount}0n"
|
||||
|
||||
# Start with the timestamp
|
||||
data = bitstring.pack("uint:35", addr.date)
|
||||
|
||||
# Payment hash
|
||||
data += tagged_bytes("p", addr.paymenthash)
|
||||
tags_set = set()
|
||||
|
||||
for k, v in addr.tags:
|
||||
|
||||
# BOLT #11:
|
||||
#
|
||||
# A writer MUST NOT include more than one `d`, `h`, `n` or `x` fields,
|
||||
if k in ("d", "h", "n", "x"):
|
||||
if k in tags_set:
|
||||
raise ValueError(f"Duplicate '{k}' tag")
|
||||
|
||||
if k == "r":
|
||||
route = bitstring.BitArray()
|
||||
for step in v:
|
||||
pubkey, channel, feebase, feerate, cltv = step
|
||||
route.append(
|
||||
bitstring.BitArray(pubkey)
|
||||
+ bitstring.BitArray(channel)
|
||||
+ bitstring.pack("intbe:32", feebase)
|
||||
+ bitstring.pack("intbe:32", feerate)
|
||||
+ bitstring.pack("intbe:16", cltv)
|
||||
)
|
||||
data += tagged("r", route)
|
||||
elif k == "f":
|
||||
# NOTE: there was an error fallback here that's now removed
|
||||
continue
|
||||
elif k == "d":
|
||||
data += tagged_bytes("d", v.encode())
|
||||
elif k == "x":
|
||||
# Get minimal length by trimming leading 5 bits at a time.
|
||||
expirybits = bitstring.pack("intbe:64", v)[4:64]
|
||||
while expirybits.startswith("0b00000"):
|
||||
expirybits = expirybits[5:]
|
||||
data += tagged("x", expirybits)
|
||||
elif k == "h":
|
||||
data += tagged_bytes("h", v)
|
||||
elif k == "n":
|
||||
data += tagged_bytes("n", v)
|
||||
else:
|
||||
# FIXME: Support unknown tags?
|
||||
raise ValueError(f"Unknown tag {k}")
|
||||
|
||||
tags_set.add(k)
|
||||
|
||||
# BOLT #11:
|
||||
#
|
||||
# A writer MUST include either a `d` or `h` field, and MUST NOT include
|
||||
# both.
|
||||
if "d" in tags_set and "h" in tags_set:
|
||||
raise ValueError("Cannot include both 'd' and 'h'")
|
||||
if "d" not in tags_set and "h" not in tags_set:
|
||||
raise ValueError("Must include either 'd' or 'h'")
|
||||
|
||||
# We actually sign the hrp, then data (padded to 8 bits with zeroes).
|
||||
privkey = secp256k1.PrivateKey(bytes.fromhex(privkey))
|
||||
sig = privkey.ecdsa_sign_recoverable(
|
||||
bytearray([ord(c) for c in hrp]) + data.tobytes()
|
||||
)
|
||||
# This doesn't actually serialize, but returns a pair of values :(
|
||||
sig, recid = privkey.ecdsa_recoverable_serialize(sig)
|
||||
data += bytes(sig) + bytes([recid])
|
||||
|
||||
return bech32_encode(hrp, bitarray_to_u5(data))
|
||||
|
||||
|
||||
class LnAddr:
|
||||
def __init__(
|
||||
self,
|
||||
paymenthash=None,
|
||||
amount=None,
|
||||
currency="bc",
|
||||
tags=None,
|
||||
date=None,
|
||||
fallback=None,
|
||||
):
|
||||
self.date = int(time.time()) if not date else int(date)
|
||||
self.tags = [] if not tags else tags
|
||||
self.unknown_tags = []
|
||||
self.paymenthash = paymenthash
|
||||
self.signature = None
|
||||
self.pubkey = None
|
||||
self.fallback = fallback
|
||||
self.currency = currency
|
||||
self.amount = amount
|
||||
|
||||
def __str__(self):
|
||||
assert self.pubkey, "LnAddr, pubkey must be set"
|
||||
pubkey = bytes.hex(self.pubkey.serialize())
|
||||
tags = ", ".join([f"{k}={v}" for k, v in self.tags])
|
||||
return f"LnAddr[{pubkey}, amount={self.amount}{self.currency} tags=[{tags}]]"
|
||||
|
||||
|
||||
def shorten_amount(amount):
|
||||
"""Given an amount in bitcoin, shorten it"""
|
||||
# Convert to pico initially
|
||||
amount = int(amount * 10**12)
|
||||
units = ["p", "n", "u", "m", ""]
|
||||
unit = ""
|
||||
for unit in units:
|
||||
if amount % 1000 == 0:
|
||||
amount //= 1000
|
||||
else:
|
||||
break
|
||||
return str(amount) + unit
|
||||
|
||||
|
||||
def _unshorten_amount(amount: str) -> int:
|
||||
"""Given a shortened amount, return millisatoshis"""
|
||||
# BOLT #11:
|
||||
# The following `multiplier` letters are defined:
|
||||
#
|
||||
# * `m` (milli): multiply by 0.001
|
||||
# * `u` (micro): multiply by 0.000001
|
||||
# * `n` (nano): multiply by 0.000000001
|
||||
# * `p` (pico): multiply by 0.000000000001
|
||||
units = {"p": 10**12, "n": 10**9, "u": 10**6, "m": 10**3}
|
||||
unit = str(amount)[-1]
|
||||
|
||||
# BOLT #11:
|
||||
# A reader SHOULD fail if `amount` contains a non-digit, or is followed by
|
||||
# anything except a `multiplier` in the table above.
|
||||
if not re.fullmatch(r"\d+[pnum]?", str(amount)):
|
||||
raise ValueError(f"Invalid amount '{amount}'")
|
||||
|
||||
if unit in units:
|
||||
return int(int(amount[:-1]) * 100_000_000_000 / units[unit])
|
||||
else:
|
||||
return int(amount) * 100_000_000_000
|
||||
|
||||
|
||||
def _pull_tagged(stream):
|
||||
tag = stream.read(5).uint
|
||||
length = stream.read(5).uint * 32 + stream.read(5).uint
|
||||
return (CHARSET[tag], stream.read(length * 5), stream)
|
||||
|
||||
|
||||
# Tagged field containing BitArray
|
||||
def tagged(char, bits):
|
||||
# Tagged fields need to be zero-padded to 5 bits.
|
||||
while bits.len % 5 != 0:
|
||||
bits.append("0b0")
|
||||
return (
|
||||
bitstring.pack(
|
||||
"uint:5, uint:5, uint:5",
|
||||
CHARSET.find(char),
|
||||
(bits.len / 5) / 32,
|
||||
(bits.len / 5) % 32,
|
||||
)
|
||||
+ bits
|
||||
)
|
||||
|
||||
|
||||
def tagged_bytes(char, bits):
|
||||
return tagged(char, bitstring.BitArray(bits))
|
||||
|
||||
|
||||
def _trim_to_bytes(barr):
|
||||
# Adds a byte if necessary.
|
||||
b = barr.tobytes()
|
||||
if barr.len % 8 != 0:
|
||||
return b[:-1]
|
||||
return b
|
||||
|
||||
|
||||
def _readable_scid(short_channel_id: int) -> str:
|
||||
blockheight = (short_channel_id >> 40) & 0xFFFFFF
|
||||
transactionindex = (short_channel_id >> 16) & 0xFFFFFF
|
||||
outputindex = short_channel_id & 0xFFFF
|
||||
return f"{blockheight}x{transactionindex}x{outputindex}"
|
||||
|
||||
|
||||
def _u5_to_bitarray(arr: List[int]) -> bitstring.BitArray:
|
||||
ret = bitstring.BitArray()
|
||||
for a in arr:
|
||||
ret += bitstring.pack("uint:5", a)
|
||||
return ret
|
||||
|
||||
|
||||
def bitarray_to_u5(barr):
|
||||
assert barr.len % 5 == 0
|
||||
ret = []
|
||||
s = bitstring.ConstBitStream(barr)
|
||||
while s.pos != s.len:
|
||||
ret.append(s.read(5).uint) # type: ignore
|
||||
return ret
|
||||
from bolt11 import (
|
||||
Bolt11 as Invoice, # noqa: F401
|
||||
)
|
||||
from bolt11 import (
|
||||
decode, # noqa: F401
|
||||
encode, # noqa: F401
|
||||
)
|
||||
|
||||
@@ -1,20 +1,119 @@
|
||||
import asyncio
|
||||
from functools import wraps
|
||||
from pathlib import Path
|
||||
from typing import Optional, Tuple
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import click
|
||||
import httpx
|
||||
from fastapi.exceptions import HTTPException
|
||||
from loguru import logger
|
||||
from packaging import version
|
||||
|
||||
from lnbits.core.models import User
|
||||
from lnbits.core.services import check_admin_settings
|
||||
from lnbits.core.views.api import api_install_extension, api_uninstall_extension
|
||||
from lnbits.settings import settings
|
||||
|
||||
from .core import db as core_db
|
||||
from .core import migrations as core_migrations
|
||||
from .core.crud import get_dbversions, get_inactive_extensions
|
||||
from .core.crud import (
|
||||
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 .db import COCKROACH, POSTGRES, SQLITE
|
||||
from .extension_manager import get_valid_extensions
|
||||
from .extension_manager import (
|
||||
CreateExtension,
|
||||
ExtensionRelease,
|
||||
InstallableExtension,
|
||||
get_valid_extensions,
|
||||
)
|
||||
|
||||
|
||||
@click.command("migrate")
|
||||
def db_migrate():
|
||||
def coro(f):
|
||||
@wraps(f)
|
||||
def wrapper(*args, **kwargs):
|
||||
return asyncio.run(f(*args, **kwargs))
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
@click.group()
|
||||
def lnbits_cli():
|
||||
"""
|
||||
Python CLI for LNbits
|
||||
"""
|
||||
|
||||
|
||||
@lnbits_cli.group()
|
||||
def db():
|
||||
"""
|
||||
Database related commands
|
||||
"""
|
||||
|
||||
|
||||
@lnbits_cli.group()
|
||||
def extensions():
|
||||
"""
|
||||
Extensions related commands
|
||||
"""
|
||||
|
||||
|
||||
def get_super_user() -> Optional[str]:
|
||||
"""Get the superuser"""
|
||||
superuser_file = Path(settings.lnbits_data_folder, ".super_user")
|
||||
if not superuser_file.exists() or not superuser_file.is_file():
|
||||
raise ValueError(
|
||||
"Superuser id not found. Please check that the file "
|
||||
+ f"'{superuser_file.absolute()}' exists and has read permissions."
|
||||
)
|
||||
with open(superuser_file, "r") as file:
|
||||
return file.readline()
|
||||
|
||||
|
||||
@lnbits_cli.command("superuser")
|
||||
def superuser():
|
||||
"""Prints the superuser"""
|
||||
try:
|
||||
click.echo(get_super_user())
|
||||
except ValueError as e:
|
||||
click.echo(str(e))
|
||||
|
||||
|
||||
@lnbits_cli.command("superuser-url")
|
||||
def superuser_url():
|
||||
"""Prints the superuser"""
|
||||
try:
|
||||
click.echo(
|
||||
f"http://{settings.host}:{settings.port}/wallet?usr={get_super_user()}"
|
||||
)
|
||||
except ValueError as e:
|
||||
click.echo(str(e))
|
||||
|
||||
|
||||
@lnbits_cli.command("delete-settings")
|
||||
@coro
|
||||
async def delete_settings():
|
||||
"""Deletes the settings"""
|
||||
|
||||
async with core_db.connect() as conn:
|
||||
await conn.execute("DELETE from settings")
|
||||
|
||||
|
||||
@db.command("migrate")
|
||||
def database_migrate():
|
||||
"""Migrate databases"""
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.run_until_complete(migrate_databases())
|
||||
|
||||
|
||||
async def db_migrate():
|
||||
asyncio.create_task(migrate_databases())
|
||||
|
||||
|
||||
@@ -29,7 +128,8 @@ async def migrate_databases():
|
||||
)
|
||||
elif conn.type in {POSTGRES, COCKROACH}:
|
||||
exists = await conn.fetchone(
|
||||
"SELECT * FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'dbversions'"
|
||||
"SELECT * FROM information_schema.tables WHERE table_schema = 'public'"
|
||||
" AND table_name = 'dbversions'"
|
||||
)
|
||||
|
||||
if not exists:
|
||||
@@ -37,22 +137,461 @@ async def migrate_databases():
|
||||
|
||||
current_versions = await get_dbversions(conn)
|
||||
core_version = current_versions.get("core", 0)
|
||||
await run_migration(conn, core_migrations, core_version)
|
||||
await run_migration(conn, core_migrations, "core", core_version)
|
||||
|
||||
for ext in get_valid_extensions():
|
||||
# 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)
|
||||
await migrate_extension_database(ext, current_version)
|
||||
try:
|
||||
await migrate_extension_database(ext, current_version)
|
||||
except Exception as e:
|
||||
logger.exception(f"Error migrating extension {ext.code}: {e}")
|
||||
|
||||
logger.info("✔️ All migrations done.")
|
||||
|
||||
|
||||
@db.command("versions")
|
||||
@coro
|
||||
async def db_versions():
|
||||
"""Show current database versions"""
|
||||
async with core_db.connect() as conn:
|
||||
current_versions = await get_dbversions(conn)
|
||||
return current_versions
|
||||
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:
|
||||
"""Update list of extensions that have been explicitly disabled"""
|
||||
inactive_extensions = await get_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():
|
||||
"""main function"""
|
||||
lnbits_cli()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
|
||||
async def install_extension(
|
||||
extension: str,
|
||||
repo_index: Optional[str] = None,
|
||||
source_repo: Optional[str] = None,
|
||||
url: Optional[str] = None,
|
||||
admin_user: Optional[str] = None,
|
||||
) -> Tuple[bool, str]:
|
||||
try:
|
||||
release = await _select_release(extension, repo_index, source_repo)
|
||||
if not release:
|
||||
return False, "No release selected"
|
||||
|
||||
data = CreateExtension(
|
||||
ext_id=extension,
|
||||
archive=release.archive,
|
||||
source_repo=release.source_repo,
|
||||
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 lnbits.db import Database
|
||||
from .db import core_app_extra, db
|
||||
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()
|
||||
# backwards compatibility for extensions
|
||||
core_app = APIRouter(tags=["Core"])
|
||||
|
||||
core_app_extra: CoreAppExtra = CoreAppExtra()
|
||||
|
||||
from .views.admin_api import * # noqa: F401,F403
|
||||
from .views.api import * # noqa: F401,F403
|
||||
from .views.generic import * # noqa: F401,F403
|
||||
from .views.public_api import * # noqa: F401,F403
|
||||
def init_core_routers(app):
|
||||
app.include_router(core_app)
|
||||
app.include_router(generic_router)
|
||||
app.include_router(public_router)
|
||||
app.include_router(api_router)
|
||||
app.include_router(node_router)
|
||||
app.include_router(super_node_router)
|
||||
app.include_router(public_node_router)
|
||||
app.include_router(admin_router)
|
||||
app.include_router(tinyurl_router)
|
||||
app.include_router(webpush_router)
|
||||
app.include_router(auth_router)
|
||||
|
||||
@@ -1,26 +1,101 @@
|
||||
import datetime
|
||||
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 uuid import uuid4
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import shortuuid
|
||||
from passlib.context import CryptContext
|
||||
|
||||
from lnbits import bolt11
|
||||
from lnbits.db import COCKROACH, POSTGRES, Connection, Filters
|
||||
from lnbits.core.db import db
|
||||
from lnbits.core.models import WalletType
|
||||
from lnbits.db import DB_TYPE, SQLITE, Connection, Database, Filters, Page
|
||||
from lnbits.extension_manager import InstallableExtension
|
||||
from lnbits.settings import AdminSettings, EditableSettings, SuperSettings, settings
|
||||
from lnbits.settings import (
|
||||
AdminSettings,
|
||||
EditableSettings,
|
||||
SuperSettings,
|
||||
WebPushSettings,
|
||||
settings,
|
||||
)
|
||||
|
||||
from . import db
|
||||
from .models import BalanceCheck, Payment, TinyURL, User, Wallet
|
||||
from .models import (
|
||||
BalanceCheck,
|
||||
CreateUser,
|
||||
Payment,
|
||||
PaymentFilters,
|
||||
PaymentHistoryPoint,
|
||||
TinyURL,
|
||||
UpdateUserPassword,
|
||||
User,
|
||||
UserConfig,
|
||||
Wallet,
|
||||
WebPushSubscription,
|
||||
)
|
||||
|
||||
# accounts
|
||||
# --------
|
||||
|
||||
|
||||
async def create_account(conn: Optional[Connection] = None) -> User:
|
||||
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
|
||||
await (conn or db).execute("INSERT INTO accounts (id) VALUES (?)", (user_id,))
|
||||
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(
|
||||
conn: Optional[Connection] = None,
|
||||
user_id: Optional[str] = None,
|
||||
email: Optional[str] = None,
|
||||
user_config: Optional[UserConfig] = None,
|
||||
) -> User:
|
||||
if user_id:
|
||||
user_uuid4 = UUID(hex=user_id, version=4)
|
||||
assert user_uuid4.hex == user_id, "User ID is not valid UUID4 hex string"
|
||||
else:
|
||||
user_id = uuid4().hex
|
||||
|
||||
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)
|
||||
assert new_account, "Newly created account couldn't be retrieved"
|
||||
@@ -28,19 +103,176 @@ async def create_account(conn: Optional[Connection] = None) -> User:
|
||||
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(
|
||||
user_id: str, conn: Optional[Connection] = None
|
||||
) -> Optional[User]:
|
||||
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
|
||||
|
||||
|
||||
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]:
|
||||
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:
|
||||
@@ -50,9 +282,11 @@ async def get_user(user_id: str, conn: Optional[Connection] = None) -> Optional[
|
||||
)
|
||||
wallets = await (conn or db).fetchall(
|
||||
"""
|
||||
SELECT *, COALESCE((SELECT balance FROM balances WHERE wallet = wallets.id), 0) AS balance_msat
|
||||
SELECT *, COALESCE((
|
||||
SELECT balance FROM balances WHERE wallet = wallets.id
|
||||
), 0) AS balance_msat
|
||||
FROM wallets
|
||||
WHERE "user" = ?
|
||||
WHERE "user" = ? and wallets.deleted = false
|
||||
""",
|
||||
(user_id,),
|
||||
)
|
||||
@@ -62,12 +296,16 @@ async def get_user(user_id: str, conn: Optional[Connection] = None) -> Optional[
|
||||
return User(
|
||||
id=user["id"],
|
||||
email=user["email"],
|
||||
username=user["username"],
|
||||
extensions=[
|
||||
e[0] for e in extensions if User.is_extension_for_user(e[0], user["id"])
|
||||
],
|
||||
wallets=[Wallet(**w) for w in wallets],
|
||||
admin=user["id"] == settings.super_user
|
||||
or user["id"] in settings.lnbits_admin_users,
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
@@ -80,19 +318,22 @@ async def add_installed_extension(
|
||||
conn: Optional[Connection] = None,
|
||||
) -> None:
|
||||
meta = {
|
||||
"installed_release": dict(ext.installed_release)
|
||||
if ext.installed_release
|
||||
else None,
|
||||
"installed_release": (
|
||||
dict(ext.installed_release) if ext.installed_release else None
|
||||
),
|
||||
"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 ""
|
||||
|
||||
await (conn or db).execute(
|
||||
"""
|
||||
INSERT INTO installed_extensions (id, version, name, short_description, icon, stars, meta) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (id) DO
|
||||
UPDATE SET (version, name, active, short_description, icon, stars, meta) = (?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO installed_extensions
|
||||
(id, version, name, short_description, icon, stars, meta)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT (id) DO UPDATE SET
|
||||
(version, name, active, short_description, icon, stars, meta) =
|
||||
(?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
ext.id,
|
||||
@@ -135,13 +376,34 @@ async def delete_installed_extension(
|
||||
)
|
||||
|
||||
|
||||
async def get_installed_extension(ext_id: str, conn: Optional[Connection] = None):
|
||||
async def drop_extension_db(*, ext_id: str, conn: Optional[Connection] = None) -> None:
|
||||
db_version = await (conn or db).fetchone(
|
||||
"SELECT * FROM dbversions WHERE db = ?", (ext_id,)
|
||||
)
|
||||
# Check that 'ext_id' is a valid extension id and not a malicious string
|
||||
assert db_version, f"Extension '{ext_id}' db version cannot be found"
|
||||
|
||||
is_file_based_db = await Database.clean_ext_db_files(ext_id)
|
||||
if is_file_based_db:
|
||||
return
|
||||
|
||||
# String formatting is required, params are not accepted for 'DROP SCHEMA'.
|
||||
# The `ext_id` value is verified above.
|
||||
await (conn or db).execute(
|
||||
f"DROP SCHEMA IF EXISTS {ext_id} CASCADE",
|
||||
(),
|
||||
)
|
||||
|
||||
|
||||
async def get_installed_extension(
|
||||
ext_id: str, conn: Optional[Connection] = None
|
||||
) -> Optional[InstallableExtension]:
|
||||
row = await (conn or db).fetchone(
|
||||
"SELECT * FROM installed_extensions WHERE id = ?",
|
||||
(ext_id,),
|
||||
)
|
||||
|
||||
return dict(row) if row else None
|
||||
return InstallableExtension.from_row(row) if row else None
|
||||
|
||||
|
||||
async def get_installed_extensions(
|
||||
@@ -185,10 +447,11 @@ async def create_wallet(
|
||||
conn: Optional[Connection] = None,
|
||||
) -> Wallet:
|
||||
wallet_id = uuid4().hex
|
||||
now = int(time())
|
||||
await (conn or db).execute(
|
||||
"""
|
||||
INSERT INTO wallets (id, name, "user", adminkey, inkey)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
f"""
|
||||
INSERT INTO wallets (id, name, "user", adminkey, inkey, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, {db.timestamp_placeholder}, {db.timestamp_placeholder})
|
||||
""",
|
||||
(
|
||||
wallet_id,
|
||||
@@ -196,6 +459,8 @@ async def create_wallet(
|
||||
user_id,
|
||||
uuid4().hex,
|
||||
uuid4().hex,
|
||||
now,
|
||||
now,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -206,15 +471,28 @@ async def create_wallet(
|
||||
|
||||
|
||||
async def update_wallet(
|
||||
wallet_id: str, new_name: str, conn: Optional[Connection] = None
|
||||
wallet_id: str,
|
||||
name: Optional[str] = None,
|
||||
currency: Optional[str] = None,
|
||||
conn: Optional[Connection] = None,
|
||||
) -> Optional[Wallet]:
|
||||
set_clause = []
|
||||
values: list = []
|
||||
set_clause.append(f"updated_at = {db.timestamp_placeholder}")
|
||||
now = int(time())
|
||||
values.append(now)
|
||||
if name:
|
||||
set_clause.append("name = ?")
|
||||
values.append(name)
|
||||
if currency is not None:
|
||||
set_clause.append("currency = ?")
|
||||
values.append(currency)
|
||||
values.append(wallet_id)
|
||||
await (conn or db).execute(
|
||||
"""
|
||||
UPDATE wallets SET
|
||||
name = ?
|
||||
WHERE id = ?
|
||||
f"""
|
||||
UPDATE wallets SET {', '.join(set_clause)} WHERE id = ?
|
||||
""",
|
||||
(new_name, wallet_id),
|
||||
tuple(values),
|
||||
)
|
||||
wallet = await get_wallet(wallet_id=wallet_id, conn=conn)
|
||||
assert wallet, "updated created wallet couldn't be retrieved"
|
||||
@@ -224,16 +502,33 @@ async def update_wallet(
|
||||
async def delete_wallet(
|
||||
*, user_id: str, wallet_id: str, conn: Optional[Connection] = None
|
||||
) -> None:
|
||||
now = int(time())
|
||||
await (conn or db).execute(
|
||||
"""
|
||||
UPDATE wallets AS w
|
||||
SET
|
||||
"user" = 'del:' || w."user",
|
||||
adminkey = 'del:' || w.adminkey,
|
||||
inkey = 'del:' || w.inkey
|
||||
f"""
|
||||
UPDATE wallets
|
||||
SET deleted = true, updated_at = {db.timestamp_placeholder}
|
||||
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,),
|
||||
)
|
||||
|
||||
|
||||
@@ -242,9 +537,8 @@ async def get_wallet(
|
||||
) -> Optional[Wallet]:
|
||||
row = await (conn or db).fetchone(
|
||||
"""
|
||||
SELECT *, COALESCE((SELECT balance FROM balances WHERE wallet = wallets.id), 0) AS balance_msat
|
||||
FROM wallets
|
||||
WHERE id = ?
|
||||
SELECT *, COALESCE((SELECT balance FROM balances WHERE wallet = wallets.id), 0)
|
||||
AS balance_msat FROM wallets WHERE id = ?
|
||||
""",
|
||||
(wallet_id,),
|
||||
)
|
||||
@@ -253,13 +547,15 @@ async def get_wallet(
|
||||
|
||||
|
||||
async def get_wallet_for_key(
|
||||
key: str, key_type: str = "invoice", conn: Optional[Connection] = None
|
||||
key: str,
|
||||
key_type: WalletType = WalletType.invoice,
|
||||
conn: Optional[Connection] = None,
|
||||
) -> Optional[Wallet]:
|
||||
row = await (conn or db).fetchone(
|
||||
"""
|
||||
SELECT *, COALESCE((SELECT balance FROM balances WHERE wallet = wallets.id), 0) AS balance_msat
|
||||
FROM wallets
|
||||
WHERE adminkey = ? OR inkey = ?
|
||||
SELECT *, COALESCE((SELECT balance FROM balances WHERE wallet = wallets.id), 0)
|
||||
AS balance_msat FROM wallets
|
||||
WHERE (adminkey = ? OR inkey = ?) AND deleted = false
|
||||
""",
|
||||
(key, key),
|
||||
)
|
||||
@@ -267,7 +563,7 @@ async def get_wallet_for_key(
|
||||
if not row:
|
||||
return None
|
||||
|
||||
if key_type == "admin" and row["adminkey"] != key:
|
||||
if key_type == WalletType.admin and row["adminkey"] != key:
|
||||
return None
|
||||
|
||||
return Wallet(**row)
|
||||
@@ -278,6 +574,11 @@ async def get_total_balance(conn: Optional[Connection] = None):
|
||||
return 0 if row[0] is None else row[0]
|
||||
|
||||
|
||||
async def get_active_wallet_total_balance(conn: Optional[Connection] = None):
|
||||
row = await (conn or db).fetchone("SELECT SUM(balance) FROM balances")
|
||||
return 0 if row[0] is None else row[0]
|
||||
|
||||
|
||||
# wallet payments
|
||||
# ---------------
|
||||
|
||||
@@ -302,6 +603,7 @@ async def get_standalone_payment(
|
||||
SELECT *
|
||||
FROM apipayments
|
||||
WHERE {clause}
|
||||
ORDER BY amount
|
||||
LIMIT 1
|
||||
""",
|
||||
tuple(values),
|
||||
@@ -329,7 +631,7 @@ async def get_latest_payments_by_extension(ext_name: str, ext_id: str, limit: in
|
||||
rows = await db.fetchall(
|
||||
f"""
|
||||
SELECT * FROM apipayments
|
||||
WHERE pending = 'false'
|
||||
WHERE pending = false
|
||||
AND extra LIKE ?
|
||||
AND extra LIKE ?
|
||||
ORDER BY time DESC LIMIT {limit}
|
||||
@@ -343,7 +645,7 @@ async def get_latest_payments_by_extension(ext_name: str, ext_id: str, limit: in
|
||||
return rows
|
||||
|
||||
|
||||
async def get_payments(
|
||||
async def get_payments_paginated(
|
||||
*,
|
||||
wallet_id: Optional[str] = None,
|
||||
complete: bool = False,
|
||||
@@ -352,28 +654,23 @@ async def get_payments(
|
||||
incoming: bool = False,
|
||||
since: Optional[int] = None,
|
||||
exclude_uncheckable: bool = False,
|
||||
filters: Optional[Filters[Payment]] = None,
|
||||
filters: Optional[Filters[PaymentFilters]] = None,
|
||||
conn: Optional[Connection] = None,
|
||||
) -> List[Payment]:
|
||||
) -> Page[Payment]:
|
||||
"""
|
||||
Filters payments to be returned by complete | pending | outgoing | incoming.
|
||||
"""
|
||||
|
||||
args: List[Any] = []
|
||||
values: List[Any] = []
|
||||
clause: List[str] = []
|
||||
|
||||
if since is not None:
|
||||
if db.type == POSTGRES:
|
||||
clause.append("time > to_timestamp(?)")
|
||||
elif db.type == COCKROACH:
|
||||
clause.append("time > cast(? AS timestamp)")
|
||||
else:
|
||||
clause.append("time > ?")
|
||||
args.append(since)
|
||||
clause.append(f"time > {db.timestamp_placeholder}")
|
||||
values.append(since)
|
||||
|
||||
if wallet_id:
|
||||
clause.append("wallet = ?")
|
||||
args.append(wallet_id)
|
||||
values.append(wallet_id)
|
||||
|
||||
if complete and pending:
|
||||
pass
|
||||
@@ -397,21 +694,53 @@ async def get_payments(
|
||||
clause.append("checking_id NOT LIKE 'temp_%'")
|
||||
clause.append("checking_id NOT LIKE 'internal_%'")
|
||||
|
||||
if not filters:
|
||||
filters = Filters(limit=None, offset=None)
|
||||
|
||||
rows = await (conn or db).fetchall(
|
||||
f"""
|
||||
SELECT *
|
||||
FROM apipayments
|
||||
{filters.where(clause)}
|
||||
ORDER BY time DESC
|
||||
{filters.pagination()}
|
||||
""",
|
||||
filters.values(args),
|
||||
return await (conn or db).fetch_page(
|
||||
"SELECT * FROM apipayments",
|
||||
clause,
|
||||
values,
|
||||
filters=filters,
|
||||
model=Payment,
|
||||
)
|
||||
|
||||
return [Payment.from_row(row) for row in rows]
|
||||
|
||||
async def get_payments(
|
||||
*,
|
||||
wallet_id: Optional[str] = None,
|
||||
complete: bool = False,
|
||||
pending: bool = False,
|
||||
outgoing: bool = False,
|
||||
incoming: bool = False,
|
||||
since: Optional[int] = None,
|
||||
exclude_uncheckable: bool = False,
|
||||
filters: Optional[Filters[PaymentFilters]] = None,
|
||||
conn: Optional[Connection] = None,
|
||||
limit: Optional[int] = None,
|
||||
offset: Optional[int] = None,
|
||||
) -> list[Payment]:
|
||||
"""
|
||||
Filters payments to be returned by complete | pending | outgoing | incoming.
|
||||
"""
|
||||
|
||||
filters = filters or Filters()
|
||||
|
||||
filters.sortby = filters.sortby or "time"
|
||||
filters.direction = filters.direction or "desc"
|
||||
filters.limit = limit or filters.limit
|
||||
filters.offset = offset or filters.offset
|
||||
|
||||
page = await get_payments_paginated(
|
||||
wallet_id=wallet_id,
|
||||
complete=complete,
|
||||
pending=pending,
|
||||
outgoing=outgoing,
|
||||
incoming=incoming,
|
||||
since=since,
|
||||
exclude_uncheckable=exclude_uncheckable,
|
||||
filters=filters,
|
||||
conn=conn,
|
||||
)
|
||||
|
||||
return page.data
|
||||
|
||||
|
||||
async def delete_expired_invoices(
|
||||
@@ -449,22 +778,16 @@ async def create_payment(
|
||||
memo: str,
|
||||
fee: int = 0,
|
||||
preimage: Optional[str] = None,
|
||||
expiry: Optional[datetime.datetime] = None,
|
||||
pending: bool = True,
|
||||
extra: Optional[Dict] = None,
|
||||
webhook: Optional[str] = None,
|
||||
conn: Optional[Connection] = None,
|
||||
) -> Payment:
|
||||
|
||||
# todo: add this when tests are fixed
|
||||
# previous_payment = await get_wallet_payment(wallet_id, payment_hash, conn=conn)
|
||||
# assert previous_payment is None, "Payment already exists"
|
||||
|
||||
try:
|
||||
invoice = bolt11.decode(payment_request)
|
||||
expiration_date = datetime.datetime.fromtimestamp(invoice.date + invoice.expiry)
|
||||
except:
|
||||
# assume maximum bolt11 expiry of 31 days to be on the safe side
|
||||
expiration_date = datetime.datetime.now() + datetime.timedelta(days=31)
|
||||
# we don't allow the creation of the same invoice twice
|
||||
# note: this can be removed if the db uniquess constarints are set appropriately
|
||||
previous_payment = await get_standalone_payment(checking_id, conn=conn)
|
||||
assert previous_payment is None, "Payment already exists"
|
||||
|
||||
await (conn or db).execute(
|
||||
"""
|
||||
@@ -483,11 +806,13 @@ async def create_payment(
|
||||
pending,
|
||||
memo,
|
||||
fee,
|
||||
json.dumps(extra)
|
||||
if extra and extra != {} and type(extra) is dict
|
||||
else None,
|
||||
(
|
||||
json.dumps(extra)
|
||||
if extra and extra != {} and isinstance(extra, dict)
|
||||
else None
|
||||
),
|
||||
webhook,
|
||||
db.datetime_to_timestamp(expiration_date),
|
||||
db.datetime_to_timestamp(expiry) if expiry else None,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -514,7 +839,6 @@ async def update_payment_details(
|
||||
new_checking_id: Optional[str] = None,
|
||||
conn: Optional[Connection] = None,
|
||||
) -> None:
|
||||
|
||||
set_clause: List[str] = []
|
||||
set_variables: List[Any] = []
|
||||
|
||||
@@ -548,7 +872,8 @@ async def update_payment_extra(
|
||||
) -> None:
|
||||
"""
|
||||
Only update the `extra` field for the payment.
|
||||
Old values in the `extra` JSON object will be kept unless the new `extra` overwrites them.
|
||||
Old values in the `extra` JSON object will be kept
|
||||
unless the new `extra` overwrites them.
|
||||
"""
|
||||
|
||||
amount_clause = "AND amount < 0" if outgoing else "AND amount > 0"
|
||||
@@ -568,10 +893,77 @@ async def update_payment_extra(
|
||||
)
|
||||
|
||||
|
||||
async def delete_payment(checking_id: str, conn: Optional[Connection] = None) -> None:
|
||||
await (conn or db).execute(
|
||||
"DELETE FROM apipayments WHERE checking_id = ?", (checking_id,)
|
||||
async def update_pending_payments(wallet_id: str):
|
||||
pending_payments = await get_payments(
|
||||
wallet_id=wallet_id,
|
||||
pending=True,
|
||||
exclude_uncheckable=True,
|
||||
)
|
||||
for payment in pending_payments:
|
||||
await payment.check_status()
|
||||
|
||||
|
||||
DateTrunc = Literal["hour", "day", "month"]
|
||||
sqlite_formats = {
|
||||
"hour": "%Y-%m-%d %H:00:00",
|
||||
"day": "%Y-%m-%d 00:00:00",
|
||||
"month": "%Y-%m-01 00:00:00",
|
||||
}
|
||||
|
||||
|
||||
async def get_payments_history(
|
||||
wallet_id: Optional[str] = None,
|
||||
group: DateTrunc = "day",
|
||||
filters: Optional[Filters] = None,
|
||||
) -> List[PaymentHistoryPoint]:
|
||||
if not filters:
|
||||
filters = Filters()
|
||||
where = ["(pending = False OR amount < 0)"]
|
||||
values = []
|
||||
if wallet_id:
|
||||
where.append("wallet = ?")
|
||||
values.append(wallet_id)
|
||||
|
||||
if DB_TYPE == SQLITE and group in sqlite_formats:
|
||||
date_trunc = f"strftime('{sqlite_formats[group]}', time, 'unixepoch')"
|
||||
elif group in ("day", "hour", "month"):
|
||||
date_trunc = f"date_trunc('{group}', time)"
|
||||
else:
|
||||
raise ValueError(f"Invalid group value: {group}")
|
||||
|
||||
transactions = await db.fetchall(
|
||||
f"""
|
||||
SELECT {date_trunc} date,
|
||||
SUM(CASE WHEN amount > 0 THEN amount ELSE 0 END) income,
|
||||
SUM(CASE WHEN amount < 0 THEN abs(amount) + abs(fee) ELSE 0 END) spending
|
||||
FROM apipayments
|
||||
{filters.where(where)}
|
||||
GROUP BY date
|
||||
ORDER BY date DESC
|
||||
""",
|
||||
filters.values(values),
|
||||
)
|
||||
if wallet_id:
|
||||
wallet = await get_wallet(wallet_id)
|
||||
if wallet:
|
||||
balance = wallet.balance_msat
|
||||
else:
|
||||
raise ValueError("Unknown wallet")
|
||||
else:
|
||||
balance = await get_total_balance()
|
||||
|
||||
# since we dont know the balance at the starting point,
|
||||
# we take the current balance and walk backwards
|
||||
results: list[PaymentHistoryPoint] = []
|
||||
for row in transactions:
|
||||
results.insert(
|
||||
0,
|
||||
PaymentHistoryPoint(
|
||||
balance=balance, date=row[0], income=row[1], spending=row[2]
|
||||
),
|
||||
)
|
||||
balance -= row.income - row.spending
|
||||
return results
|
||||
|
||||
|
||||
async def delete_wallet_payment(
|
||||
@@ -586,6 +978,10 @@ async def delete_wallet_payment(
|
||||
async def check_internal(
|
||||
payment_hash: str, conn: Optional[Connection] = None
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Returns the checking_id of the internal payment if it exists,
|
||||
otherwise None
|
||||
"""
|
||||
row = await (conn or db).fetchone(
|
||||
"""
|
||||
SELECT checking_id FROM apipayments
|
||||
@@ -602,7 +998,10 @@ async def check_internal(
|
||||
async def check_internal_pending(
|
||||
payment_hash: str, conn: Optional[Connection] = None
|
||||
) -> bool:
|
||||
"""Returns False if the internal payment is not pending anymore (and thus paid), otherwise True"""
|
||||
"""
|
||||
Returns False if the internal payment is not pending anymore
|
||||
(and thus paid), otherwise True
|
||||
"""
|
||||
row = await (conn or db).fetchone(
|
||||
"""
|
||||
SELECT pending FROM apipayments
|
||||
@@ -616,6 +1015,16 @@ async def check_internal_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
|
||||
# -------------
|
||||
|
||||
@@ -701,20 +1110,27 @@ async def get_admin_settings(is_super_user: bool = False) -> Optional[AdminSetti
|
||||
return None
|
||||
row_dict = dict(sets)
|
||||
row_dict.pop("super_user")
|
||||
row_dict.pop("auth_all_methods")
|
||||
|
||||
admin_settings = AdminSettings(
|
||||
super_user=is_super_user,
|
||||
is_super_user=is_super_user,
|
||||
lnbits_allowed_funding_sources=settings.lnbits_allowed_funding_sources,
|
||||
**row_dict,
|
||||
)
|
||||
return admin_settings
|
||||
|
||||
|
||||
async def delete_admin_settings():
|
||||
async def delete_admin_settings() -> None:
|
||||
await db.execute("DELETE FROM settings")
|
||||
|
||||
|
||||
async def update_admin_settings(data: EditableSettings):
|
||||
await db.execute("UPDATE settings SET editable_settings = ?", (json.dumps(data),))
|
||||
async def update_admin_settings(data: EditableSettings) -> None:
|
||||
row = await db.fetchone("SELECT editable_settings FROM settings")
|
||||
editable_settings = json.loads(row["editable_settings"]) if row else {}
|
||||
editable_settings.update(data.dict(exclude_unset=True))
|
||||
await db.execute(
|
||||
"UPDATE settings SET editable_settings = ?", (json.dumps(editable_settings),)
|
||||
)
|
||||
|
||||
|
||||
async def update_super_user(super_user: str) -> SuperSettings:
|
||||
@@ -749,6 +1165,15 @@ async def update_migration_version(conn, db_name, version):
|
||||
)
|
||||
|
||||
|
||||
async def delete_dbversion(*, ext_id: str, conn: Optional[Connection] = None) -> None:
|
||||
await (conn or db).execute(
|
||||
"""
|
||||
DELETE FROM dbversions WHERE db = ?
|
||||
""",
|
||||
(ext_id,),
|
||||
)
|
||||
|
||||
|
||||
# tinyurl
|
||||
# -------
|
||||
|
||||
@@ -788,3 +1213,82 @@ async def delete_tinyurl(tinyurl_id: str):
|
||||
"DELETE FROM tiny_url WHERE id = ?",
|
||||
(tinyurl_id,),
|
||||
)
|
||||
|
||||
|
||||
# push_notification
|
||||
# -----------------
|
||||
|
||||
|
||||
async def get_webpush_settings() -> Optional[WebPushSettings]:
|
||||
row = await db.fetchone("SELECT * FROM webpush_settings")
|
||||
if not row:
|
||||
return None
|
||||
vapid_keypair = json.loads(row["vapid_keypair"])
|
||||
return WebPushSettings(**vapid_keypair)
|
||||
|
||||
|
||||
async def create_webpush_settings(webpush_settings: dict):
|
||||
await db.execute(
|
||||
"INSERT INTO webpush_settings (vapid_keypair) VALUES (?)",
|
||||
(json.dumps(webpush_settings),),
|
||||
)
|
||||
return await get_webpush_settings()
|
||||
|
||||
|
||||
async def get_webpush_subscription(
|
||||
endpoint: str, user: str
|
||||
) -> Optional[WebPushSubscription]:
|
||||
row = await db.fetchone(
|
||||
"SELECT * FROM webpush_subscriptions WHERE endpoint = ? AND user = ?",
|
||||
(
|
||||
endpoint,
|
||||
user,
|
||||
),
|
||||
)
|
||||
return WebPushSubscription(**dict(row)) if row else None
|
||||
|
||||
|
||||
async def get_webpush_subscriptions_for_user(
|
||||
user: str,
|
||||
) -> List[WebPushSubscription]:
|
||||
rows = await db.fetchall(
|
||||
"SELECT * FROM webpush_subscriptions WHERE user = ?",
|
||||
(user,),
|
||||
)
|
||||
return [WebPushSubscription(**dict(row)) for row in rows]
|
||||
|
||||
|
||||
async def create_webpush_subscription(
|
||||
endpoint: str, user: str, data: str, host: str
|
||||
) -> WebPushSubscription:
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO webpush_subscriptions (endpoint, user, data, host)
|
||||
VALUES (?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
endpoint,
|
||||
user,
|
||||
data,
|
||||
host,
|
||||
),
|
||||
)
|
||||
subscription = await get_webpush_subscription(endpoint, user)
|
||||
assert subscription, "Newly created webpush subscription couldn't be retrieved"
|
||||
return subscription
|
||||
|
||||
|
||||
async def delete_webpush_subscription(endpoint: str, user: str) -> None:
|
||||
await db.execute(
|
||||
"DELETE FROM webpush_subscriptions WHERE endpoint = ? AND user = ?",
|
||||
(
|
||||
endpoint,
|
||||
user,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def delete_webpush_subscriptions(endpoint: str) -> None:
|
||||
await db.execute(
|
||||
"DELETE FROM webpush_subscriptions WHERE endpoint = ?", (endpoint,)
|
||||
)
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
from lnbits.core.models import CoreAppExtra
|
||||
from lnbits.db import Database
|
||||
|
||||
db = Database("database")
|
||||
core_app_extra: CoreAppExtra = CoreAppExtra()
|
||||
@@ -1,15 +1,16 @@
|
||||
import importlib
|
||||
import re
|
||||
from typing import Any
|
||||
from typing import Any, Optional
|
||||
from uuid import UUID
|
||||
|
||||
import httpx
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.core.db import db as core_db
|
||||
from lnbits.db import Connection
|
||||
from lnbits.extension_manager import Extension
|
||||
from lnbits.settings import settings
|
||||
|
||||
from . import db as core_db
|
||||
from .crud import update_migration_version
|
||||
|
||||
|
||||
@@ -24,12 +25,13 @@ async def migrate_extension_database(ext: Extension, current_version):
|
||||
)
|
||||
|
||||
async with ext_db.connect() as ext_conn:
|
||||
await run_migration(ext_conn, ext_migrations, current_version)
|
||||
await run_migration(ext_conn, ext_migrations, ext.code, current_version)
|
||||
|
||||
|
||||
async def run_migration(db: Connection, migrations_module: Any, current_version: int):
|
||||
async def run_migration(
|
||||
db: Connection, migrations_module: Any, db_name: str, current_version: int
|
||||
):
|
||||
matcher = re.compile(r"^m(\d\d\d)_")
|
||||
db_name = migrations_module.__name__.split(".")[-2]
|
||||
for key, migrate in migrations_module.__dict__.items():
|
||||
match = matcher.match(key)
|
||||
if match:
|
||||
@@ -46,20 +48,72 @@ async def run_migration(db: Connection, migrations_module: Any, current_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).
|
||||
Extensions SHOULD expose a DELETE enpoint at the root level of their API.
|
||||
This function tries first to call the endpoint using `http` and if if fails it tries using `https`.
|
||||
Extensions SHOULD expose a `api_stop()` function and/or a DELETE enpoint
|
||||
at the root level of their API.
|
||||
"""
|
||||
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:
|
||||
try:
|
||||
url = f"http://{settings.host}:{settings.port}/{ext_id}/api/v1?usr={user}"
|
||||
await client.delete(url)
|
||||
headers = (
|
||||
{"Authorization": "Bearer " + access_token} if access_token else None
|
||||
)
|
||||
resp = await client.delete(url=url, headers=headers)
|
||||
resp.raise_for_status()
|
||||
logger.info(f"Stopped background work for extension '{ext_id}'.")
|
||||
except Exception as ex:
|
||||
logger.warning(
|
||||
f"Failed to stop background work for '{ext_id}' using the REST API."
|
||||
)
|
||||
logger.warning(ex)
|
||||
try:
|
||||
# try https
|
||||
url = f"https://{settings.host}:{settings.port}/{ext_id}/api/v1?usr={user}"
|
||||
except Exception as ex:
|
||||
logger.warning(ex)
|
||||
|
||||
|
||||
def to_valid_user_id(user_id: str) -> UUID:
|
||||
if len(user_id) < 32:
|
||||
raise ValueError("User ID must have at least 128 bits")
|
||||
try:
|
||||
int(user_id, 16)
|
||||
except Exception:
|
||||
raise ValueError("Invalid hex string for User ID.")
|
||||
|
||||
return UUID(hex=user_id[:32], version=4)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import datetime
|
||||
from time import time
|
||||
|
||||
from loguru import logger
|
||||
from sqlalchemy.exc import OperationalError
|
||||
@@ -239,7 +240,8 @@ async def m007_set_invoice_expiries(db):
|
||||
invoice.date + invoice.expiry
|
||||
)
|
||||
logger.info(
|
||||
f"Migration: {i+1}/{len(rows)} setting expiry of invoice {invoice.payment_hash} to {expiration_date}"
|
||||
f"Migration: {i+1}/{len(rows)} setting expiry of invoice"
|
||||
f" {invoice.payment_hash} to {expiration_date}"
|
||||
)
|
||||
await db.execute(
|
||||
"""
|
||||
@@ -251,7 +253,7 @@ async def m007_set_invoice_expiries(db):
|
||||
checking_id,
|
||||
),
|
||||
)
|
||||
except:
|
||||
except Exception:
|
||||
continue
|
||||
except OperationalError:
|
||||
# this is necessary now because it may be the case that this migration will
|
||||
@@ -301,3 +303,171 @@ async def m010_create_installed_extensions_table(db):
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
async def m011_optimize_balances_view(db):
|
||||
"""
|
||||
Make the calculation of the balance a single aggregation
|
||||
over the payments table instead of 2.
|
||||
"""
|
||||
await db.execute("DROP VIEW balances")
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE VIEW balances AS
|
||||
SELECT wallet, SUM(amount - abs(fee)) AS balance
|
||||
FROM apipayments
|
||||
WHERE (pending = false AND amount > 0) OR amount < 0
|
||||
GROUP BY wallet
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
async def m012_add_currency_to_wallet(db):
|
||||
await db.execute(
|
||||
"""
|
||||
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
|
||||
|
||||
@@ -3,28 +3,39 @@ import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from sqlite3 import Row
|
||||
from typing import Callable, Dict, List, Optional
|
||||
|
||||
from ecdsa import SECP256k1, SigningKey
|
||||
from lnurl import encode as lnurl_encode
|
||||
from fastapi import Query
|
||||
from loguru import logger
|
||||
from pydantic import BaseModel
|
||||
|
||||
from lnbits.db import Connection
|
||||
from lnbits.db import Connection, FilterModel, FromRowModel
|
||||
from lnbits.helpers import url_for
|
||||
from lnbits.settings import get_wallet_class, settings
|
||||
from lnbits.wallets.base import PaymentStatus
|
||||
from lnbits.lnurl import encode as lnurl_encode
|
||||
from lnbits.settings import settings
|
||||
from lnbits.wallets import get_wallet_class
|
||||
from lnbits.wallets.base import PaymentPendingStatus, PaymentStatus
|
||||
|
||||
|
||||
class Wallet(BaseModel):
|
||||
class BaseWallet(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
user: str
|
||||
adminkey: str
|
||||
inkey: str
|
||||
balance_msat: int
|
||||
|
||||
|
||||
class Wallet(BaseWallet):
|
||||
user: str
|
||||
currency: Optional[str]
|
||||
deleted: bool
|
||||
created_at: Optional[int] = None
|
||||
updated_at: Optional[int] = None
|
||||
|
||||
@property
|
||||
def balance(self) -> int:
|
||||
return self.balance_msat // 1000
|
||||
@@ -37,11 +48,10 @@ class Wallet(BaseModel):
|
||||
|
||||
@property
|
||||
def lnurlwithdraw_full(self) -> str:
|
||||
|
||||
url = url_for("/withdraw", external=True, usr=self.user, wal=self.id)
|
||||
try:
|
||||
return lnurl_encode(url)
|
||||
except:
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
def lnurlauth_key(self, domain: str) -> SigningKey:
|
||||
@@ -58,14 +68,47 @@ class Wallet(BaseModel):
|
||||
return await get_standalone_payment(payment_hash)
|
||||
|
||||
|
||||
class WalletType(Enum):
|
||||
admin = 0
|
||||
invoice = 1
|
||||
invalid = 2
|
||||
|
||||
# backwards compatibility
|
||||
def __eq__(self, other):
|
||||
return self.value == other
|
||||
|
||||
|
||||
@dataclass
|
||||
class WalletTypeInfo:
|
||||
wallet_type: WalletType
|
||||
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):
|
||||
id: str
|
||||
email: Optional[str] = None
|
||||
username: Optional[str] = None
|
||||
extensions: List[str] = []
|
||||
wallets: List[Wallet] = []
|
||||
password: Optional[str] = None
|
||||
admin: 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
|
||||
def wallet_ids(self) -> List[str]:
|
||||
@@ -86,7 +129,44 @@ class User(BaseModel):
|
||||
return False
|
||||
|
||||
|
||||
class Payment(BaseModel):
|
||||
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):
|
||||
checking_id: str
|
||||
pending: bool
|
||||
amount: int
|
||||
@@ -169,6 +249,8 @@ class Payment(BaseModel):
|
||||
async def set_pending(self, pending: bool) -> None:
|
||||
from .crud import update_payment_status
|
||||
|
||||
self.pending = pending
|
||||
|
||||
await update_payment_status(self.checking_id, pending)
|
||||
|
||||
async def check_status(
|
||||
@@ -176,10 +258,11 @@ class Payment(BaseModel):
|
||||
conn: Optional[Connection] = None,
|
||||
) -> PaymentStatus:
|
||||
if self.is_uncheckable:
|
||||
return PaymentStatus(None)
|
||||
return PaymentPendingStatus()
|
||||
|
||||
logger.debug(
|
||||
f"Checking {'outgoing' if self.is_out else 'incoming'} pending payment {self.checking_id}"
|
||||
f"Checking {'outgoing' if self.is_out else 'incoming'} "
|
||||
f"pending payment {self.checking_id}"
|
||||
)
|
||||
|
||||
WALLET = get_wallet_class()
|
||||
@@ -193,25 +276,59 @@ class Payment(BaseModel):
|
||||
if self.is_in and status.pending and self.is_expired and self.expiry:
|
||||
expiration_date = datetime.datetime.fromtimestamp(self.expiry)
|
||||
logger.debug(
|
||||
f"Deleting expired incoming pending payment {self.checking_id}: expired {expiration_date}"
|
||||
f"Deleting expired incoming pending payment {self.checking_id}: "
|
||||
f"expired {expiration_date}"
|
||||
)
|
||||
await self.delete(conn)
|
||||
# wait at least 15 minutes before deleting failed outgoing payments
|
||||
elif self.is_out and status.failed:
|
||||
logger.warning(
|
||||
f"Deleting outgoing failed payment {self.checking_id}: {status}"
|
||||
)
|
||||
await self.delete(conn)
|
||||
if self.time + 900 < int(time.time()):
|
||||
logger.warning(
|
||||
f"Deleting outgoing failed payment {self.checking_id}: {status}"
|
||||
)
|
||||
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:
|
||||
logger.info(
|
||||
f"Marking '{'in' if self.is_in else 'out'}' {self.checking_id} as not pending anymore: {status}"
|
||||
f"Marking '{'in' if self.is_in else 'out'}' "
|
||||
f"{self.checking_id} as not pending anymore: {status}"
|
||||
)
|
||||
await self.update_status(status, conn=conn)
|
||||
return status
|
||||
|
||||
async def delete(self, conn: Optional[Connection] = None) -> None:
|
||||
from .crud import delete_payment
|
||||
from .crud import delete_wallet_payment
|
||||
|
||||
await delete_payment(self.checking_id, conn=conn)
|
||||
await delete_wallet_payment(self.checking_id, self.wallet_id, conn=conn)
|
||||
|
||||
|
||||
class PaymentFilters(FilterModel):
|
||||
__search_fields__ = ["memo", "amount"]
|
||||
|
||||
checking_id: str
|
||||
amount: int
|
||||
fee: int
|
||||
memo: Optional[str]
|
||||
time: datetime.datetime
|
||||
bolt11: str
|
||||
preimage: str
|
||||
payment_hash: str
|
||||
expiry: Optional[datetime.datetime]
|
||||
extra: Dict = {}
|
||||
wallet_id: str
|
||||
webhook: Optional[str]
|
||||
webhook_status: Optional[int]
|
||||
|
||||
|
||||
class PaymentHistoryPoint(BaseModel):
|
||||
date: datetime.datetime
|
||||
income: int
|
||||
spending: int
|
||||
balance: int
|
||||
|
||||
|
||||
class BalanceCheck(BaseModel):
|
||||
@@ -224,8 +341,13 @@ class BalanceCheck(BaseModel):
|
||||
return cls(wallet=row["wallet"], service=row["service"], url=row["url"])
|
||||
|
||||
|
||||
def _do_nothing(*_):
|
||||
pass
|
||||
|
||||
|
||||
class CoreAppExtra:
|
||||
register_new_ext_routes: Callable
|
||||
register_new_ext_routes: Callable = _do_nothing
|
||||
register_new_ratelimiter: Callable
|
||||
|
||||
|
||||
class TinyURL(BaseModel):
|
||||
@@ -238,3 +360,67 @@ class TinyURL(BaseModel):
|
||||
@classmethod
|
||||
def from_row(cls, row: Row):
|
||||
return cls(**dict(row))
|
||||
|
||||
|
||||
class ConversionData(BaseModel):
|
||||
from_: str = "sat"
|
||||
amount: float
|
||||
to: str = "usd"
|
||||
|
||||
|
||||
class Callback(BaseModel):
|
||||
callback: str
|
||||
|
||||
|
||||
class DecodePayment(BaseModel):
|
||||
data: str
|
||||
|
||||
|
||||
class CreateLnurl(BaseModel):
|
||||
description_hash: str
|
||||
callback: str
|
||||
amount: int
|
||||
comment: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
unit: Optional[str] = None
|
||||
|
||||
|
||||
class CreateInvoice(BaseModel):
|
||||
unit: str = "sat"
|
||||
internal: bool = False
|
||||
out: bool = True
|
||||
amount: float = Query(None, ge=0)
|
||||
memo: Optional[str] = None
|
||||
description_hash: Optional[str] = None
|
||||
unhashed_description: Optional[str] = None
|
||||
expiry: Optional[int] = None
|
||||
lnurl_callback: Optional[str] = None
|
||||
lnurl_balance_check: Optional[str] = None
|
||||
extra: Optional[dict] = None
|
||||
webhook: Optional[str] = None
|
||||
bolt11: Optional[str] = None
|
||||
|
||||
|
||||
class CreateTopup(BaseModel):
|
||||
id: str
|
||||
amount: int
|
||||
|
||||
|
||||
class CreateLnurlAuth(BaseModel):
|
||||
callback: str
|
||||
|
||||
|
||||
class CreateWallet(BaseModel):
|
||||
name: Optional[str] = None
|
||||
|
||||
|
||||
class CreateWebPushSubscription(BaseModel):
|
||||
subscription: str
|
||||
|
||||
|
||||
class WebPushSubscription(BaseModel):
|
||||
endpoint: str
|
||||
user: str
|
||||
data: str
|
||||
host: str
|
||||
timestamp: str
|
||||
|
||||
@@ -1,31 +1,43 @@
|
||||
import asyncio
|
||||
import datetime
|
||||
import json
|
||||
import time
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Tuple, TypedDict
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import httpx
|
||||
from bolt11 import Bolt11
|
||||
from bolt11 import decode as bolt11_decode
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from fastapi import Depends, WebSocket
|
||||
from lnurl import LnurlErrorResponse
|
||||
from lnurl import decode as decode_lnurl
|
||||
from loguru import logger
|
||||
from py_vapid import Vapid
|
||||
from py_vapid.utils import b64urlencode
|
||||
|
||||
from lnbits import bolt11
|
||||
from lnbits.core.db import db
|
||||
from lnbits.db import Connection
|
||||
from lnbits.decorators import WalletTypeInfo, require_admin_key
|
||||
from lnbits.helpers import url_for
|
||||
from lnbits.lnurl import LnurlErrorResponse
|
||||
from lnbits.lnurl import decode as decode_lnurl
|
||||
from lnbits.settings import (
|
||||
FAKE_WALLET,
|
||||
EditableSettings,
|
||||
SuperSettings,
|
||||
get_wallet_class,
|
||||
readonly_variables,
|
||||
send_admin_user_to_saas,
|
||||
settings,
|
||||
)
|
||||
from lnbits.wallets.base import PaymentResponse, PaymentStatus
|
||||
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.base import (
|
||||
PaymentPendingStatus,
|
||||
PaymentResponse,
|
||||
PaymentStatus,
|
||||
PaymentSuccessStatus,
|
||||
)
|
||||
|
||||
from . import db
|
||||
from .crud import (
|
||||
check_internal,
|
||||
check_internal_pending,
|
||||
@@ -35,15 +47,19 @@ from .crud import (
|
||||
create_wallet,
|
||||
delete_wallet_payment,
|
||||
get_account,
|
||||
get_payments,
|
||||
get_standalone_payment,
|
||||
get_super_settings,
|
||||
get_total_balance,
|
||||
get_wallet,
|
||||
get_wallet_payment,
|
||||
update_admin_settings,
|
||||
update_payment_details,
|
||||
update_payment_status,
|
||||
update_super_user,
|
||||
)
|
||||
from .models import Payment
|
||||
from .helpers import to_valid_user_id
|
||||
from .models import Payment, UserConfig, Wallet
|
||||
|
||||
|
||||
class PaymentFailure(Exception):
|
||||
@@ -54,10 +70,49 @@ class InvoiceFailure(Exception):
|
||||
pass
|
||||
|
||||
|
||||
async def calculate_fiat_amounts(
|
||||
amount: float,
|
||||
wallet_id: str,
|
||||
currency: Optional[str] = None,
|
||||
extra: Optional[Dict] = None,
|
||||
conn: Optional[Connection] = None,
|
||||
) -> Tuple[int, Optional[Dict]]:
|
||||
wallet = await get_wallet(wallet_id, conn=conn)
|
||||
assert wallet, "invalid wallet_id"
|
||||
wallet_currency = wallet.currency or settings.lnbits_default_accounting_currency
|
||||
|
||||
if currency and currency != "sat":
|
||||
amount_sat = await fiat_amount_as_satoshis(amount, currency)
|
||||
extra = extra or {}
|
||||
if currency != wallet_currency:
|
||||
extra["fiat_currency"] = currency
|
||||
extra["fiat_amount"] = round(amount, ndigits=3)
|
||||
extra["fiat_rate"] = amount_sat / amount
|
||||
else:
|
||||
amount_sat = int(amount)
|
||||
|
||||
if wallet_currency:
|
||||
if wallet_currency == currency:
|
||||
fiat_amount = amount
|
||||
else:
|
||||
fiat_amount = await satoshis_amount_as_fiat(amount_sat, wallet_currency)
|
||||
extra = extra or {}
|
||||
extra["wallet_fiat_currency"] = wallet_currency
|
||||
extra["wallet_fiat_amount"] = round(fiat_amount, ndigits=3)
|
||||
extra["wallet_fiat_rate"] = amount_sat / fiat_amount
|
||||
|
||||
logger.debug(
|
||||
f"Calculated fiat amounts {wallet.id=} {amount=} {currency=}: {extra=}"
|
||||
)
|
||||
|
||||
return amount_sat, extra
|
||||
|
||||
|
||||
async def create_invoice(
|
||||
*,
|
||||
wallet_id: str,
|
||||
amount: int, # in satoshis
|
||||
amount: float,
|
||||
currency: Optional[str] = "sat",
|
||||
memo: str,
|
||||
description_hash: Optional[bytes] = None,
|
||||
unhashed_description: Optional[bytes] = None,
|
||||
@@ -70,30 +125,47 @@ async def create_invoice(
|
||||
if not amount > 0:
|
||||
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
|
||||
|
||||
# use the fake wallet if the invoice is for internal use only
|
||||
wallet = FAKE_WALLET if internal else get_wallet_class()
|
||||
|
||||
amount_sat, extra = await calculate_fiat_amounts(
|
||||
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(
|
||||
amount=amount,
|
||||
amount=amount_sat,
|
||||
memo=invoice_memo,
|
||||
description_hash=description_hash,
|
||||
unhashed_description=unhashed_description,
|
||||
expiry=expiry or settings.lightning_invoice_expiry,
|
||||
)
|
||||
if not ok:
|
||||
if not ok or not payment_request or not checking_id:
|
||||
raise InvoiceFailure(error_message or "unexpected backend error.")
|
||||
|
||||
invoice = bolt11.decode(payment_request)
|
||||
invoice = bolt11_decode(payment_request)
|
||||
|
||||
amount_msat = amount * 1000
|
||||
amount_msat = 1000 * amount_sat
|
||||
await create_payment(
|
||||
wallet_id=wallet_id,
|
||||
checking_id=checking_id,
|
||||
payment_request=payment_request,
|
||||
payment_hash=invoice.payment_hash,
|
||||
amount=amount_msat,
|
||||
expiry=get_bolt11_expiry(invoice),
|
||||
memo=memo,
|
||||
extra=extra,
|
||||
webhook=webhook,
|
||||
@@ -114,23 +186,33 @@ async def pay_invoice(
|
||||
) -> str:
|
||||
"""
|
||||
Pay a Lightning invoice.
|
||||
First, we create a temporary payment in the database with fees set to the reserve fee.
|
||||
We then check whether the balance of the payer would go negative.
|
||||
We then attempt to pay the invoice through the backend.
|
||||
If the payment is successful, we update the payment in the database with the payment details.
|
||||
First, we create a temporary payment in the database with fees set to the reserve
|
||||
fee. We then check whether the balance of the payer would go negative.
|
||||
We then attempt to pay the invoice through the backend. If the payment is
|
||||
successful, we update the payment in the database with the payment details.
|
||||
If the payment is unsuccessful, we delete the temporary payment.
|
||||
If the payment is still in flight, we hope that some other process will regularly check for the payment.
|
||||
If the payment is still in flight, we hope that some other process
|
||||
will regularly check for the payment.
|
||||
"""
|
||||
invoice = bolt11.decode(payment_request)
|
||||
fee_reserve_msat = fee_reserve(invoice.amount_msat)
|
||||
async with (db.reuse_conn(conn) if conn else db.connect()) as conn:
|
||||
try:
|
||||
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:
|
||||
temp_id = 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(
|
||||
invoice.amount_msat / 1000, wallet_id, extra=extra, conn=conn
|
||||
)
|
||||
|
||||
# put all parameters that don't change here
|
||||
class PaymentKwargs(TypedDict):
|
||||
@@ -139,6 +221,7 @@ async def pay_invoice(
|
||||
payment_hash: str
|
||||
amount: int
|
||||
memo: str
|
||||
expiry: Optional[datetime.datetime]
|
||||
extra: Optional[Dict]
|
||||
|
||||
payment_kwargs: PaymentKwargs = PaymentKwargs(
|
||||
@@ -146,17 +229,23 @@ async def pay_invoice(
|
||||
payment_request=payment_request,
|
||||
payment_hash=invoice.payment_hash,
|
||||
amount=-invoice.amount_msat,
|
||||
expiry=get_bolt11_expiry(invoice),
|
||||
memo=description or invoice.description or "",
|
||||
extra=extra,
|
||||
)
|
||||
|
||||
# we check if an internal invoice exists that has already been paid (not pending anymore)
|
||||
# we check if an internal invoice exists that has already been paid
|
||||
# (not pending anymore)
|
||||
if not await check_internal_pending(invoice.payment_hash, conn=conn):
|
||||
raise PaymentFailure("Internal invoice already paid.")
|
||||
|
||||
# check_internal() returns the checking_id of the invoice we're waiting for (pending only)
|
||||
# check_internal() returns the checking_id of the invoice we're waiting for
|
||||
# (pending only)
|
||||
internal_checking_id = await check_internal(invoice.payment_hash, conn=conn)
|
||||
if internal_checking_id:
|
||||
fee_reserve_total_msat = fee_reserve_total(
|
||||
invoice.amount_msat, internal=True
|
||||
)
|
||||
# perform additional checks on the internal payment
|
||||
# the payment hash is not enough to make sure that this is the same invoice
|
||||
internal_invoice = await get_standalone_payment(
|
||||
@@ -165,27 +254,30 @@ async def pay_invoice(
|
||||
assert internal_invoice is not None
|
||||
if (
|
||||
internal_invoice.amount != invoice.amount_msat
|
||||
or internal_invoice.bolt11 != payment_request
|
||||
or internal_invoice.bolt11 != payment_request.lower()
|
||||
):
|
||||
raise PaymentFailure("Invalid invoice.")
|
||||
|
||||
logger.debug(f"creating temporary internal payment with id {internal_id}")
|
||||
# create a new payment from this wallet
|
||||
await create_payment(
|
||||
new_payment = await create_payment(
|
||||
checking_id=internal_id,
|
||||
fee=0,
|
||||
fee=0 + abs(fee_reserve_total_msat),
|
||||
pending=False,
|
||||
conn=conn,
|
||||
**payment_kwargs,
|
||||
)
|
||||
else:
|
||||
fee_reserve_total_msat = fee_reserve_total(
|
||||
invoice.amount_msat, internal=False
|
||||
)
|
||||
logger.debug(f"creating temporary payment with id {temp_id}")
|
||||
# create a temporary payment here so we can check if
|
||||
# the balance is enough in the next step
|
||||
try:
|
||||
await create_payment(
|
||||
new_payment = await create_payment(
|
||||
checking_id=temp_id,
|
||||
fee=-fee_reserve_msat,
|
||||
fee=-abs(fee_reserve_total_msat),
|
||||
conn=conn,
|
||||
**payment_kwargs,
|
||||
)
|
||||
@@ -199,13 +291,18 @@ async def pay_invoice(
|
||||
assert wallet, "Wallet for balancecheck could not be fetched"
|
||||
if wallet.balance_msat < 0:
|
||||
logger.debug("balance is too low, deleting temporary payment")
|
||||
if not internal_checking_id and wallet.balance_msat > -fee_reserve_msat:
|
||||
if (
|
||||
not internal_checking_id
|
||||
and wallet.balance_msat > -fee_reserve_total_msat
|
||||
):
|
||||
raise PaymentFailure(
|
||||
f"You must reserve at least ({round(fee_reserve_msat/1000)} sat) to cover potential routing fees."
|
||||
f"You must reserve at least ({round(fee_reserve_total_msat/1000)}"
|
||||
" sat) to cover potential routing fees."
|
||||
)
|
||||
raise PermissionError("Insufficient balance.")
|
||||
|
||||
if internal_checking_id:
|
||||
service_fee_msat = service_fee(invoice.amount_msat, internal=True)
|
||||
logger.debug(f"marking temporary payment as not pending {internal_checking_id}")
|
||||
# mark the invoice from the other side as not pending anymore
|
||||
# so the other side only has access to his new money when we are sure
|
||||
@@ -214,6 +311,7 @@ async def pay_invoice(
|
||||
await update_payment_status(
|
||||
checking_id=internal_checking_id, pending=False, conn=conn
|
||||
)
|
||||
await send_payment_notification(wallet, new_payment)
|
||||
|
||||
# notify receiver asynchronously
|
||||
from lnbits.tasks import internal_invoice_queue
|
||||
@@ -221,6 +319,8 @@ async def pay_invoice(
|
||||
logger.debug(f"enqueuing internal invoice {internal_checking_id}")
|
||||
await internal_invoice_queue.put(internal_checking_id)
|
||||
else:
|
||||
fee_reserve_msat = fee_reserve(invoice.amount_msat, internal=False)
|
||||
service_fee_msat = service_fee(invoice.amount_msat, internal=False)
|
||||
logger.debug(f"backend: sending payment {temp_id}")
|
||||
# actually pay the external invoice
|
||||
WALLET = get_wallet_class()
|
||||
@@ -230,7 +330,8 @@ async def pay_invoice(
|
||||
|
||||
if payment.checking_id and payment.checking_id != temp_id:
|
||||
logger.warning(
|
||||
f"backend sent unexpected checking_id (expected: {temp_id} got: {payment.checking_id})"
|
||||
f"backend sent unexpected checking_id (expected: {temp_id} got:"
|
||||
f" {payment.checking_id})"
|
||||
)
|
||||
|
||||
logger.debug(f"backend: pay_invoice finished {temp_id}")
|
||||
@@ -241,11 +342,20 @@ async def pay_invoice(
|
||||
await update_payment_details(
|
||||
checking_id=temp_id,
|
||||
pending=payment.ok is not True,
|
||||
fee=payment.fee_msat,
|
||||
fee=-(
|
||||
abs(payment.fee_msat if payment.fee_msat else 0)
|
||||
+ abs(service_fee_msat)
|
||||
),
|
||||
preimage=payment.preimage,
|
||||
new_checking_id=payment.checking_id,
|
||||
conn=conn,
|
||||
)
|
||||
wallet = await get_wallet(wallet_id, conn=conn)
|
||||
updated = await get_wallet_payment(
|
||||
wallet_id, payment.checking_id, conn=conn
|
||||
)
|
||||
if wallet and updated:
|
||||
await send_payment_notification(wallet, updated)
|
||||
logger.debug(f"payment successful {payment.checking_id}")
|
||||
elif payment.checking_id is None and payment.ok is False:
|
||||
# payment failed
|
||||
@@ -259,12 +369,77 @@ async def pay_invoice(
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f"didn't receive checking_id from backend, payment may be stuck in database: {temp_id}"
|
||||
"didn't receive checking_id from backend, payment may be stuck in"
|
||||
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
|
||||
|
||||
|
||||
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(
|
||||
wallet_id: str,
|
||||
lnurl_request: str,
|
||||
@@ -278,7 +453,8 @@ async def redeem_lnurl_withdraw(
|
||||
|
||||
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)
|
||||
r = await client.get(str(lnurl))
|
||||
res = r.json()
|
||||
@@ -291,9 +467,10 @@ async def redeem_lnurl_withdraw(
|
||||
extra=extra,
|
||||
conn=conn,
|
||||
)
|
||||
except:
|
||||
except Exception:
|
||||
logger.warning(
|
||||
f"failed to create invoice on redeem_lnurl_withdraw from {lnurl}. params: {res}"
|
||||
f"failed to create invoice on redeem_lnurl_withdraw "
|
||||
f"from {lnurl}. params: {res}"
|
||||
)
|
||||
return None
|
||||
|
||||
@@ -311,7 +488,8 @@ async def redeem_lnurl_withdraw(
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
headers = {"User-Agent": settings.user_agent}
|
||||
async with httpx.AsyncClient(headers=headers) as client:
|
||||
try:
|
||||
await client.get(res["callback"], params=params)
|
||||
except Exception:
|
||||
@@ -374,7 +552,8 @@ async def perform_lnurlauth(
|
||||
|
||||
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"
|
||||
r = await client.get(
|
||||
callback,
|
||||
@@ -403,22 +582,56 @@ async def check_transaction_status(
|
||||
wallet_id, payment_hash, conn=conn
|
||||
)
|
||||
if not payment:
|
||||
return PaymentStatus(None)
|
||||
return PaymentPendingStatus()
|
||||
if not payment.pending:
|
||||
# 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()
|
||||
return status
|
||||
|
||||
|
||||
# WARN: this same value must be used for balance check and passed to WALLET.pay_invoice(), it may cause a vulnerability if the values differ
|
||||
def fee_reserve(amount_msat: int) -> int:
|
||||
# WARN: this same value must be used for balance check and passed to
|
||||
# WALLET.pay_invoice(), it may cause a vulnerability if the values differ
|
||||
def fee_reserve(amount_msat: int, internal: bool = False) -> int:
|
||||
if internal:
|
||||
return 0
|
||||
reserve_min = settings.lnbits_reserve_fee_min
|
||||
reserve_percent = settings.lnbits_reserve_fee_percent
|
||||
return max(int(reserve_min), int(amount_msat * reserve_percent / 100.0))
|
||||
|
||||
|
||||
def service_fee(amount_msat: int, internal: bool = False) -> int:
|
||||
service_fee_percent = settings.lnbits_service_fee
|
||||
fee_max = settings.lnbits_service_fee_max * 1000
|
||||
if settings.lnbits_service_fee_wallet:
|
||||
if internal and settings.lnbits_service_fee_ignore_internal:
|
||||
return 0
|
||||
fee_percentage = int(amount_msat / 100 * service_fee_percent)
|
||||
if fee_max > 0 and fee_percentage > fee_max:
|
||||
return fee_max
|
||||
else:
|
||||
return fee_percentage
|
||||
else:
|
||||
return 0
|
||||
|
||||
|
||||
def fee_reserve_total(amount_msat: int, internal: bool = False) -> int:
|
||||
return fee_reserve(amount_msat, internal) + service_fee(amount_msat, internal)
|
||||
|
||||
|
||||
async def send_payment_notification(wallet: Wallet, payment: Payment):
|
||||
await websocketUpdater(
|
||||
wallet.id,
|
||||
json.dumps(
|
||||
{
|
||||
"wallet_balance": wallet.balance,
|
||||
"payment": payment.dict(),
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def update_wallet_balance(wallet_id: str, amount: int):
|
||||
payment_hash, _ = await create_invoice(
|
||||
wallet_id=wallet_id,
|
||||
@@ -437,13 +650,16 @@ async def update_wallet_balance(wallet_id: str, amount: int):
|
||||
|
||||
|
||||
async def check_admin_settings():
|
||||
if settings.super_user:
|
||||
settings.super_user = to_valid_user_id(settings.super_user).hex
|
||||
|
||||
if settings.lnbits_admin_ui:
|
||||
settings_db = await get_super_settings()
|
||||
if not settings_db:
|
||||
# create new settings if table is empty
|
||||
logger.warning("Settings DB empty. Inserting default settings.")
|
||||
settings_db = await init_admin_settings(settings.super_user)
|
||||
logger.warning("Initialized settings from enviroment variables.")
|
||||
logger.warning("Initialized settings from environment variables.")
|
||||
|
||||
if settings.super_user and settings.super_user != settings_db.super_user:
|
||||
# .env super_user overwrites DB super_user
|
||||
@@ -451,16 +667,8 @@ async def check_admin_settings():
|
||||
|
||||
update_cached_settings(settings_db.dict())
|
||||
|
||||
# printing settings for debugging
|
||||
logger.debug("Admin settings:")
|
||||
for key, value in settings.dict(exclude_none=True).items():
|
||||
logger.debug(f"{key}: {value}")
|
||||
|
||||
admin_url = f"{settings.lnbits_baseurl}wallet?usr={settings.super_user}"
|
||||
logger.success(f"✔️ Access super user account at: {admin_url}")
|
||||
|
||||
# saving it to .super_user file
|
||||
with open(".super_user", "w") as file:
|
||||
# saving superuser to {data_dir}/.super_user file
|
||||
with open(Path(settings.lnbits_data_folder) / ".super_user", "w") as file:
|
||||
file.write(settings.super_user)
|
||||
|
||||
# callback for saas
|
||||
@@ -471,14 +679,49 @@ async def check_admin_settings():
|
||||
):
|
||||
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):
|
||||
for key, value in sets_dict.items():
|
||||
if key not in readonly_variables:
|
||||
try:
|
||||
setattr(settings, key, value)
|
||||
except:
|
||||
logger.error(f"error overriding setting: {key}, value: {value}")
|
||||
if key in readonly_variables:
|
||||
continue
|
||||
if key not in settings.dict().keys():
|
||||
continue
|
||||
try:
|
||||
setattr(settings, key, value)
|
||||
except Exception:
|
||||
logger.warning(f"Failed overriding setting: {key}, value: {value}")
|
||||
if "super_user" in sets_dict:
|
||||
setattr(settings, "super_user", sets_dict["super_user"])
|
||||
|
||||
@@ -488,8 +731,9 @@ async def init_admin_settings(super_user: Optional[str] = None) -> SuperSettings
|
||||
if super_user:
|
||||
account = await get_account(super_user)
|
||||
if not account:
|
||||
account = await create_account()
|
||||
super_user = account.id
|
||||
account = await create_account(
|
||||
user_id=super_user, user_config=UserConfig(provider="env")
|
||||
)
|
||||
if not account.wallets or len(account.wallets) == 0:
|
||||
await create_wallet(user_id=account.id)
|
||||
|
||||
@@ -499,12 +743,12 @@ async def init_admin_settings(super_user: Optional[str] = None) -> SuperSettings
|
||||
|
||||
|
||||
class WebsocketConnectionManager:
|
||||
def __init__(self):
|
||||
def __init__(self) -> None:
|
||||
self.active_connections: List[WebSocket] = []
|
||||
|
||||
async def connect(self, websocket: WebSocket):
|
||||
async def connect(self, websocket: WebSocket, item_id: str):
|
||||
logger.debug(f"Websocket connected to {item_id}")
|
||||
await websocket.accept()
|
||||
logger.debug(websocket)
|
||||
self.active_connections.append(websocket)
|
||||
|
||||
def disconnect(self, websocket: WebSocket):
|
||||
@@ -521,3 +765,28 @@ websocketManager = WebsocketConnectionManager()
|
||||
|
||||
async def websocketUpdater(item_id, data):
|
||||
return await websocketManager.send_data(f"{data}", item_id)
|
||||
|
||||
|
||||
async def switch_to_voidwallet() -> None:
|
||||
WALLET = get_wallet_class()
|
||||
if WALLET.__class__.__name__ == "VoidWallet":
|
||||
return
|
||||
set_wallet_class("VoidWallet")
|
||||
settings.lnbits_backend_wallet_class = "VoidWallet"
|
||||
|
||||
|
||||
async def get_balance_delta() -> Tuple[int, int, int]:
|
||||
WALLET = get_wallet_class()
|
||||
total_balance = await get_total_balance()
|
||||
error_message, node_balance = await WALLET.status()
|
||||
if error_message:
|
||||
raise Exception(error_message)
|
||||
return node_balance - total_balance, node_balance, total_balance
|
||||
|
||||
|
||||
def get_bolt11_expiry(invoice: Bolt11) -> datetime.datetime:
|
||||
if invoice.expiry:
|
||||
return datetime.datetime.fromtimestamp(invoice.date + invoice.expiry)
|
||||
else:
|
||||
# assume maximum bolt11 expiry of 31 days to be on the safe side
|
||||
return datetime.datetime.now() + datetime.timedelta(days=31)
|
||||
|
||||
@@ -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 |
|
Before Width: | Height: | Size: 18 KiB |
@@ -1,43 +0,0 @@
|
||||
new Vue({
|
||||
el: '#vue',
|
||||
data: function () {
|
||||
return {
|
||||
searchTerm: '',
|
||||
filteredExtensions: [],
|
||||
maxStars: 5,
|
||||
user: null
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.filteredExtensions = this.g.extensions
|
||||
},
|
||||
watch: {
|
||||
searchTerm(term) {
|
||||
// Reset the filter
|
||||
this.filteredExtensions = this.g.extensions
|
||||
if (term !== '') {
|
||||
// Filter the extensions list
|
||||
function extensionNameContains(searchTerm) {
|
||||
return function (extension) {
|
||||
return (
|
||||
extension.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
extension.shortDescription
|
||||
.toLowerCase()
|
||||
.includes(searchTerm.toLowerCase())
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
this.filteredExtensions = this.filteredExtensions.filter(
|
||||
extensionNameContains(term)
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
created() {
|
||||
if (window.user) {
|
||||
this.user = LNbits.map.user(window.user)
|
||||
}
|
||||
},
|
||||
mixins: [windowMixin]
|
||||
})
|
||||
@@ -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,55 +4,111 @@ from typing import Dict
|
||||
import httpx
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.tasks import SseListenersDict, register_invoice_listener
|
||||
|
||||
from . import db
|
||||
from .crud import get_balance_notify
|
||||
from .models import Payment
|
||||
from .services import websocketUpdater
|
||||
|
||||
api_invoice_listeners: Dict[str, asyncio.Queue] = SseListenersDict(
|
||||
"api_invoice_listeners"
|
||||
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.tasks import send_push_notification
|
||||
|
||||
api_invoice_listeners: Dict[str, asyncio.Queue] = {}
|
||||
|
||||
|
||||
async def register_task_listeners():
|
||||
async def killswitch_task():
|
||||
"""
|
||||
Registers an invoice listener queue for the core tasks.
|
||||
Incoming payaments in this queue will eventually trigger the signals sent to all other extensions
|
||||
and fulfill other core tasks such as dispatching webhooks.
|
||||
killswitch will check lnbits-status repository for a signal from
|
||||
LNbits and will switch to VoidWallet if the killswitch is triggered.
|
||||
"""
|
||||
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
|
||||
asyncio.create_task(wait_for_paid_invoices(invoice_paid_queue))
|
||||
while True:
|
||||
WALLET = get_wallet_class()
|
||||
if settings.lnbits_killswitch and WALLET.__class__.__name__ != "VoidWallet":
|
||||
with httpx.Client() as client:
|
||||
try:
|
||||
r = client.get(settings.lnbits_status_manifest, timeout=4)
|
||||
r.raise_for_status()
|
||||
if r.status_code == 200:
|
||||
ks = r.json().get("killswitch")
|
||||
if ks and ks == 1:
|
||||
logger.error(
|
||||
"Switching to VoidWallet. Killswitch triggered."
|
||||
)
|
||||
await switch_to_voidwallet()
|
||||
except (httpx.RequestError, httpx.HTTPStatusError):
|
||||
logger.error(
|
||||
"Cannot fetch lnbits status manifest."
|
||||
f" {settings.lnbits_status_manifest}"
|
||||
)
|
||||
await asyncio.sleep(settings.lnbits_killswitch_interval * 60)
|
||||
|
||||
|
||||
async def watchdog_task():
|
||||
"""
|
||||
Registers a watchdog which will check lnbits balance and nodebalance
|
||||
and will switch to VoidWallet if the watchdog delta is reached.
|
||||
"""
|
||||
while True:
|
||||
WALLET = get_wallet_class()
|
||||
if settings.lnbits_watchdog and WALLET.__class__.__name__ != "VoidWallet":
|
||||
try:
|
||||
delta, *_ = await get_balance_delta()
|
||||
logger.debug(f"Running watchdog task. current delta: {delta}")
|
||||
if delta + settings.lnbits_watchdog_delta <= 0:
|
||||
logger.error(f"Switching to VoidWallet. current delta: {delta}")
|
||||
await switch_to_voidwallet()
|
||||
except Exception as e:
|
||||
logger.error("Error in watchdog task", e)
|
||||
await asyncio.sleep(settings.lnbits_watchdog_interval * 60)
|
||||
|
||||
|
||||
async def wait_for_paid_invoices(invoice_paid_queue: asyncio.Queue):
|
||||
"""
|
||||
This worker dispatches events to all extensions, dispatches webhooks and balance notifys.
|
||||
This task dispatches events to all api_invoice_listeners,
|
||||
webhooks, push notifications and balance notifications.
|
||||
"""
|
||||
while True:
|
||||
payment = await invoice_paid_queue.get()
|
||||
logger.trace("received invoice paid event")
|
||||
# send information to sse channel
|
||||
# dispatch api_invoice_listeners
|
||||
await dispatch_api_invoice_listeners(payment)
|
||||
await websocketUpdater(payment.wallet_id, payment.dict())
|
||||
|
||||
# payment notification
|
||||
wallet = await get_wallet(payment.wallet_id)
|
||||
if wallet:
|
||||
await send_payment_notification(wallet, payment)
|
||||
# dispatch webhook
|
||||
if payment.webhook and not payment.webhook_status:
|
||||
await dispatch_webhook(payment)
|
||||
|
||||
# dispatch balance_notify
|
||||
url = await get_balance_notify(payment.wallet_id)
|
||||
if url:
|
||||
async with httpx.AsyncClient() as client:
|
||||
headers = {"User-Agent": settings.user_agent}
|
||||
async with httpx.AsyncClient(headers=headers) as client:
|
||||
try:
|
||||
r = await client.post(url, timeout=4)
|
||||
await mark_webhook_sent(payment, r.status_code)
|
||||
except (httpx.ConnectError, httpx.RequestError):
|
||||
pass
|
||||
r.raise_for_status()
|
||||
await mark_webhook_sent(payment.payment_hash, r.status_code)
|
||||
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):
|
||||
@@ -61,10 +117,12 @@ async def dispatch_api_invoice_listeners(payment: Payment):
|
||||
"""
|
||||
for chan_name, send_channel in api_invoice_listeners.items():
|
||||
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)
|
||||
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)
|
||||
|
||||
|
||||
@@ -72,21 +130,47 @@ async def dispatch_webhook(payment: Payment):
|
||||
"""
|
||||
Dispatches the webhook to the webhook url.
|
||||
"""
|
||||
async with httpx.AsyncClient() as client:
|
||||
logger.debug("sending webhook", payment.webhook)
|
||||
|
||||
if not payment.webhook:
|
||||
return await mark_webhook_sent(payment.payment_hash, -1)
|
||||
|
||||
headers = {"User-Agent": settings.user_agent}
|
||||
async with httpx.AsyncClient(headers=headers) as client:
|
||||
data = payment.dict()
|
||||
try:
|
||||
logger.debug("sending webhook", payment.webhook)
|
||||
r = await client.post(payment.webhook, json=data, timeout=40) # type: ignore
|
||||
await mark_webhook_sent(payment, r.status_code)
|
||||
except (httpx.ConnectError, httpx.RequestError):
|
||||
await mark_webhook_sent(payment, -1)
|
||||
r = await client.post(payment.webhook, json=data, timeout=40)
|
||||
r.raise_for_status()
|
||||
await mark_webhook_sent(payment.payment_hash, r.status_code)
|
||||
except httpx.HTTPStatusError as exc:
|
||||
await mark_webhook_sent(payment.payment_hash, exc.response.status_code)
|
||||
logger.warning(
|
||||
f"webhook returned a bad status_code: {exc.response.status_code} "
|
||||
f"while requesting {exc.request.url!r}."
|
||||
)
|
||||
except httpx.RequestError:
|
||||
await mark_webhook_sent(payment.payment_hash, -1)
|
||||
logger.warning(f"Could not send webhook to {payment.webhook}")
|
||||
|
||||
|
||||
async def mark_webhook_sent(payment: Payment, status: int) -> None:
|
||||
await db.execute(
|
||||
"""
|
||||
UPDATE apipayments SET webhook_status = ?
|
||||
WHERE hash = ?
|
||||
""",
|
||||
(status, payment.payment_hash),
|
||||
)
|
||||
async def send_payment_push_notification(payment: Payment):
|
||||
wallet = await get_wallet(payment.wallet_id)
|
||||
|
||||
if wallet:
|
||||
subscriptions = await get_webpush_subscriptions_for_user(wallet.user)
|
||||
|
||||
amount = int(payment.amount / 1000)
|
||||
|
||||
title = f"LNbits: {wallet.name}"
|
||||
body = f"You just received {amount} sat{'s'[:amount^1]}!"
|
||||
|
||||
if payment.memo:
|
||||
body += f"\r\n{payment.memo}"
|
||||
|
||||
for subscription in subscriptions:
|
||||
# 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,100 +7,89 @@
|
||||
<div class="col">
|
||||
<p>Funding Source Info</p>
|
||||
<ul>
|
||||
{%raw%}
|
||||
<li>Funding Source: {{settings.lnbits_backend_wallet_class}}</li>
|
||||
<li>Balance: {{balance / 1000}} sats</li>
|
||||
{%endraw%}
|
||||
<li
|
||||
v-text="'Funding Source: '+ settings.lnbits_backend_wallet_class"
|
||||
></li>
|
||||
<li
|
||||
v-text="'Node Balance: ' + (auditData.node_balance_msats /
|
||||
1000).toLocaleString() + ' sats'"
|
||||
></li>
|
||||
<li
|
||||
v-text="'LNbits Balance: ' + (auditData.lnbits_balance_msats /
|
||||
1000).toLocaleString() + ' sats'"
|
||||
></li>
|
||||
<li
|
||||
v-text="'Reserve Percent: ' + (auditData.node_balance_msats /
|
||||
auditData.lnbits_balance_msats * 100).toFixed(2) + ' %'"
|
||||
></li>
|
||||
</ul>
|
||||
<br />
|
||||
</div>
|
||||
<div class="col">
|
||||
{% if LNBITS_NODE_UI_AVAILABLE %}
|
||||
<p>Node Management</p>
|
||||
<q-toggle
|
||||
label="Node UI"
|
||||
v-model="formData.lnbits_node_ui"
|
||||
></q-toggle>
|
||||
<q-toggle
|
||||
v-if="formData.lnbits_node_ui"
|
||||
label="Public node UI"
|
||||
v-model="formData.lnbits_public_node_ui"
|
||||
></q-toggle>
|
||||
<br />
|
||||
<q-toggle
|
||||
v-if="formData.lnbits_node_ui"
|
||||
label="Transactions Tab (Disable on large CLN nodes)"
|
||||
v-model="formData.lnbits_node_ui_transactions"
|
||||
></q-toggle>
|
||||
{% else %}
|
||||
<p>Node Management not supported by active funding source</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 col-md-4">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<p>Active Funding<small> (Requires server restart)</small></p>
|
||||
<q-select
|
||||
:disable="!isSuperUser"
|
||||
filled
|
||||
v-model="formData.lnbits_backend_wallet_class"
|
||||
hint="Select the active funding wallet"
|
||||
:options="settings.lnbits_allowed_funding_sources"
|
||||
></q-select>
|
||||
</div>
|
||||
</div>
|
||||
<p>Invoice Expiry</p>
|
||||
<q-input
|
||||
filled
|
||||
v-model.number="formData.lightning_invoice_expiry"
|
||||
type="number"
|
||||
label="Invoice expiry (seconds)"
|
||||
mask="#######"
|
||||
>
|
||||
</q-input>
|
||||
</div>
|
||||
<div class="col-12 col-md-8">
|
||||
<p>Fee reserve</p>
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 col-md-4">
|
||||
<p>Invoice Expiry</p>
|
||||
<div class="col-6">
|
||||
<q-input
|
||||
type="number"
|
||||
filled
|
||||
v-model="formData.lightning_invoice_expiry"
|
||||
label="Invoice expiry (seconds)"
|
||||
mask="#######"
|
||||
v-model="formData.lnbits_reserve_fee_min"
|
||||
label="Reserve fee in msats"
|
||||
>
|
||||
</q-input>
|
||||
</div>
|
||||
<div class="col-12 col-md-8">
|
||||
<p>Fee reserve</p>
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-6">
|
||||
<q-input
|
||||
type="number"
|
||||
filled
|
||||
v-model="formData.lnbits_reserve_fee_min"
|
||||
label="Reserve fee in msats"
|
||||
>
|
||||
</q-input>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<q-input
|
||||
type="number"
|
||||
filled
|
||||
name="lnbits_reserve_fee_percent"
|
||||
v-model="formData.lnbits_reserve_fee_percent"
|
||||
label="Reserve fee in percent"
|
||||
step="0.1"
|
||||
></q-input>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<q-input
|
||||
type="number"
|
||||
filled
|
||||
name="lnbits_reserve_fee_percent"
|
||||
v-model="formData.lnbits_reserve_fee_percent"
|
||||
label="Reserve fee in percent"
|
||||
step="0.1"
|
||||
></q-input>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isSuperUser">
|
||||
<p class="q-my-md">
|
||||
Funding Sources<small> (Requires server restart)</small>
|
||||
</p>
|
||||
<q-list
|
||||
v-for="(fund, idx) in settings.lnbits_allowed_funding_sources"
|
||||
:key="idx"
|
||||
>
|
||||
<q-expansion-item
|
||||
expand-separator
|
||||
icon="payments"
|
||||
:label="fund"
|
||||
v-if="funding_sources.get(fund)"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section
|
||||
v-for="([key, prop], i) in Object.entries(funding_sources.get(fund))"
|
||||
:key="i"
|
||||
>
|
||||
<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>
|
||||
<lnbits-funding-sources
|
||||
:form-data="formData"
|
||||
:allowed-funding-sources="settings.lnbits_allowed_funding_sources"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
|
||||
@@ -0,0 +1,404 @@
|
||||
<q-tab-panel name="security">
|
||||
<q-card-section class="q-pa-none">
|
||||
<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 />
|
||||
<h6 class="q-my-none" v-text="$t('security_tools')"></h6>
|
||||
<div>
|
||||
<div class="row">
|
||||
<div v-if="serverlogEnabled" class="column" style="width: 100%">
|
||||
<div
|
||||
class="col bg-primary"
|
||||
style="padding-left: 5px; max-height: 20px; color: #fafafa"
|
||||
v-text="$t('server_logs')"
|
||||
></div>
|
||||
<div class="col" style="background-color: #292929">
|
||||
<q-scroll-area
|
||||
ref="logScroll"
|
||||
style="padding: 10px; color: #fafafa; height: 320px"
|
||||
>
|
||||
<small v-for="log in logs"
|
||||
><span v-text="log"></span><br
|
||||
/></small>
|
||||
</q-scroll-area>
|
||||
</div>
|
||||
</div>
|
||||
<q-btn
|
||||
@click="toggleServerLog"
|
||||
dense
|
||||
flat
|
||||
color="primary"
|
||||
:label="(serverlogEnabled) ? $t('disable_server_log') : $t('enable_server_log')"
|
||||
></q-btn>
|
||||
</div>
|
||||
<br />
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 col-md-12">
|
||||
<p v-text="$t('ip_blocker')"></p>
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-6">
|
||||
<q-input
|
||||
filled
|
||||
v-model="formBlockedIPs"
|
||||
@keydown.enter="addBlockedIPs"
|
||||
type="text"
|
||||
:label="$t('enter_ip')"
|
||||
:hint="$t('block_access_hint')"
|
||||
>
|
||||
<q-btn
|
||||
@click="addExtensionsManifest"
|
||||
dense
|
||||
flat
|
||||
icon="add"
|
||||
></q-btn>
|
||||
</q-input>
|
||||
<div>
|
||||
<q-chip
|
||||
v-for="blocked_ip in formData.lnbits_blocked_ips"
|
||||
:key="blocked_ip"
|
||||
removable
|
||||
@remove="removeBlockedIPs(blocked_ip)"
|
||||
color="primary"
|
||||
text-color="white"
|
||||
v-text="blocked_ip"
|
||||
></q-chip>
|
||||
</div>
|
||||
<br />
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<q-input
|
||||
filled
|
||||
v-model="formAllowedIPs"
|
||||
@keydown.enter="addAllowedIPs"
|
||||
type="text"
|
||||
:label="$t('enter_ip')"
|
||||
:hint="$t('allow_access_hint')"
|
||||
>
|
||||
<q-btn
|
||||
@click="addExtensionsManifest"
|
||||
dense
|
||||
flat
|
||||
icon="add"
|
||||
></q-btn>
|
||||
</q-input>
|
||||
<div>
|
||||
<q-chip
|
||||
v-for="allowed_ip in formData.lnbits_allowed_ips"
|
||||
:key="allowed_ip"
|
||||
removable
|
||||
@remove="removeAllowedIPs(allowed_ip)"
|
||||
color="primary"
|
||||
text-color="white"
|
||||
v-text="allowed_ip"
|
||||
></q-chip>
|
||||
</div>
|
||||
<br />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-12">
|
||||
<p v-text="$t('rate_limiter')"></p>
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-6">
|
||||
<q-input
|
||||
filled
|
||||
type="number"
|
||||
v-model.number="formData.lnbits_rate_limit_no"
|
||||
:label="$t('number_of_requests')"
|
||||
></q-input>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<q-select
|
||||
filled
|
||||
:options="[$t('second'),$t('minute'),$t('hour')]"
|
||||
v-model="formData.lnbits_rate_limit_unit"
|
||||
:label="$t('time_unit')"
|
||||
></q-select>
|
||||
</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>
|
||||
<br />
|
||||
<div>
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 col-md-6">
|
||||
<q-item tag="label" v-ripple>
|
||||
<q-item-section>
|
||||
<q-item-label v-text="$t('enable_notifications')"></q-item-label>
|
||||
<q-item-label
|
||||
caption
|
||||
v-text="$t('enable_notifications_desc')"
|
||||
></q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section avatar>
|
||||
<q-toggle
|
||||
size="md"
|
||||
v-model="formData.lnbits_notifications"
|
||||
checked-icon="check"
|
||||
color="green"
|
||||
unchecked-icon="clear"
|
||||
/>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<br />
|
||||
<p
|
||||
v-if="!formData.lnbits_notifications"
|
||||
v-text="$t('notifications_disabled')"
|
||||
></p>
|
||||
<div v-if="formData.lnbits_notifications">
|
||||
{% include "admin/_tab_security_notifications.html" %}
|
||||
</div>
|
||||
<br />
|
||||
<div>
|
||||
<p v-text="$t('notification_source')"></p>
|
||||
<q-input
|
||||
filled
|
||||
v-model="formData.lnbits_status_manifest"
|
||||
type="text"
|
||||
:label="$t('notification_source_label')"
|
||||
/>
|
||||
</div>
|
||||
<br />
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<p v-text="$t('killswitch')"></p>
|
||||
<q-item tag="label" v-ripple>
|
||||
<q-item-section>
|
||||
<q-item-label v-text="$t('enable_killswitch')"></q-item-label>
|
||||
<q-item-label
|
||||
caption
|
||||
v-text="$t('enable_killswitch_desc')"
|
||||
></q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section avatar>
|
||||
<q-toggle
|
||||
size="md"
|
||||
v-model="formData.lnbits_killswitch"
|
||||
checked-icon="check"
|
||||
color="green"
|
||||
unchecked-icon="clear"
|
||||
/>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item tag="label" v-ripple>
|
||||
<q-item-section>
|
||||
<q-item-label v-text="$t('killswitch_interval')"></q-item-label>
|
||||
<q-item-label
|
||||
caption
|
||||
v-text="$t('killswitch_interval_desc')"
|
||||
></q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-input
|
||||
filled
|
||||
v-model="formData.lnbits_killswitch_interval"
|
||||
type="number"
|
||||
/>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<br />
|
||||
<p v-text="$t('watchdog')"></p>
|
||||
<q-item disabled tag="label" v-ripple>
|
||||
<q-tooltip><span v-text="$t('coming_soon')"></span></q-tooltip>
|
||||
<q-item-section>
|
||||
<q-item-label v-text="$t('enable_watchdog')"></q-item-label>
|
||||
<q-item-label
|
||||
caption
|
||||
v-text="$t('enable_watchdog_desc')"
|
||||
></q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section avatar>
|
||||
<q-toggle
|
||||
size="md"
|
||||
v-model="formData.lnbits_watchdog"
|
||||
checked-icon="check"
|
||||
color="green"
|
||||
unchecked-icon="clear"
|
||||
/>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item disabled tag="label" v-ripple>
|
||||
<q-tooltip><span v-text="$t('coming_soon')"></span></q-tooltip>
|
||||
<q-item-section>
|
||||
<q-item-label v-text="$t('watchdog_interval')"></q-item-label>
|
||||
<q-item-label
|
||||
caption
|
||||
v-text="$t('watchdog_interval_desc')"
|
||||
></q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-input
|
||||
filled
|
||||
v-model="formData.lnbits_watchdog_interval"
|
||||
type="number"
|
||||
/>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item disabled tag="label" v-ripple>
|
||||
<q-tooltip><span v-text="$t('coming_soon')"></span></q-tooltip>
|
||||
<q-item-section>
|
||||
<q-item-label v-text="$t('watchdog_delta')"></q-item-label>
|
||||
<q-item-label
|
||||
caption
|
||||
v-text="$t('watchdog_delta_desc')"
|
||||
></q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-input
|
||||
filled
|
||||
v-model="formData.lnbits_watchdog_delta"
|
||||
type="number"
|
||||
/>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<br />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-tab-panel>
|
||||
@@ -0,0 +1,77 @@
|
||||
<q-banner v-if="updateAvailable" class="bg-primary text-white">
|
||||
<q-icon size="28px" name="update"></q-icon>
|
||||
|
||||
<span v-text="$t('update_available', {version: statusData.version})"></span>
|
||||
<template v-slot:action>
|
||||
<a
|
||||
class="q-btn"
|
||||
color="white"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href="https://github.com/lnbits/lnbits/releases"
|
||||
v-text="$t('releases')"
|
||||
></a>
|
||||
</template>
|
||||
</q-banner>
|
||||
<q-banner v-if="!updateAvailable" class="bg-green text-white">
|
||||
<q-icon size="28px" name="checkmark"></q-icon>
|
||||
<span v-text="$t('latest_update', {version: statusData.version})"></span>
|
||||
</q-banner>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<q-table
|
||||
dense
|
||||
flat
|
||||
:data="statusData.notifications"
|
||||
:columns="statusDataTable.columns"
|
||||
:no-data-label="$t('no_notifications')"
|
||||
>
|
||||
<template v-slot:header="props">
|
||||
<q-tr :props="props">
|
||||
<q-th auto-width> </q-th>
|
||||
<q-th
|
||||
v-for="col in props.cols"
|
||||
:key="col.name"
|
||||
:props="props"
|
||||
v-text="col.label"
|
||||
></q-th>
|
||||
</q-tr>
|
||||
</template>
|
||||
<template v-slot:body="props">
|
||||
<q-tr :props="props">
|
||||
<q-td auto-width class="text-center">
|
||||
<q-icon
|
||||
v-if="props.row.type === 'update'"
|
||||
size="18px"
|
||||
name="update"
|
||||
color="green"
|
||||
></q-icon>
|
||||
<q-icon
|
||||
v-if="props.row.type === 'warning'"
|
||||
size="18px"
|
||||
name="warning"
|
||||
color="red"
|
||||
></q-icon>
|
||||
</q-td>
|
||||
<q-td
|
||||
auto-width
|
||||
key="date"
|
||||
:props="props"
|
||||
v-text="formatDate(props.row.date)"
|
||||
>
|
||||
</q-td>
|
||||
<q-td key="message" :props="props"
|
||||
><span v-text="props.row.message"></span
|
||||
><a
|
||||
v-if="props.row.link"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
:href="props.row.link"
|
||||
v-text="$t('more')"
|
||||
></a
|
||||
></q-td>
|
||||
</q-tr>
|
||||
</template>
|
||||
</q-table>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
@@ -7,29 +7,55 @@
|
||||
<div class="col">
|
||||
<p>Server Info</p>
|
||||
<ul>
|
||||
{%raw%}
|
||||
<li v-if="settings.lnbits_data_folder">
|
||||
SQlite: {{settings.lnbits_data_folder}}
|
||||
</li>
|
||||
<li v-if="settings.lnbits_database_url">
|
||||
Postgres: {{settings.lnbits_database_url}}
|
||||
</li>
|
||||
{%endraw%}
|
||||
<li
|
||||
v-if="settings.lnbits_data_folder"
|
||||
v-text="'SQlite: ' + settings.lnbits_data_folder"
|
||||
></li>
|
||||
<li
|
||||
v-if="settings.lnbits_database_url"
|
||||
v-text="'Postgres: ' + settings.lnbits_database_url"
|
||||
></li>
|
||||
</ul>
|
||||
<br />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 col-md-6">
|
||||
<p>Service Fee</p>
|
||||
<q-input
|
||||
<p>Allowed currencies</p>
|
||||
<q-select
|
||||
filled
|
||||
type="number"
|
||||
v-model.number="formData.lnbits_service_fee"
|
||||
label="Service fee (%)"
|
||||
step="0.1"
|
||||
hint="Fee charged per tx (%)"
|
||||
></q-input>
|
||||
v-model="formData.lnbits_allowed_currencies"
|
||||
multiple
|
||||
hint="Limit the number of available fiat currencies"
|
||||
label="Allowed currencies"
|
||||
:options="{{ currencies | safe }}"
|
||||
></q-select>
|
||||
<br />
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<p>Default Accounting Currency</p>
|
||||
<q-select
|
||||
filled
|
||||
v-model="formData.lnbits_default_accounting_currency"
|
||||
clearable
|
||||
hint="Default currency for accounting"
|
||||
label="Currency"
|
||||
:options="formData.lnbits_allowed_currencies?.length ? formData.lnbits_allowed_currencies : {{ currencies }}"
|
||||
></q-select>
|
||||
<br />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 col-md-6">
|
||||
<p>Admin Extensions</p>
|
||||
<q-select
|
||||
filled
|
||||
v-model="formData.lnbits_admin_extensions"
|
||||
multiple
|
||||
hint="Extensions only user with admin privileges can use"
|
||||
label="Admin extensions"
|
||||
:options="g.extensions.map(e => e.code)"
|
||||
></q-select>
|
||||
<br />
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
@@ -54,21 +80,67 @@
|
||||
<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>Admin Extensions</p>
|
||||
<q-select
|
||||
<p>Service Fee</p>
|
||||
<q-input
|
||||
filled
|
||||
v-model="formData.lnbits_admin_extensions"
|
||||
multiple
|
||||
hint="Extensions only user with admin privileges can use"
|
||||
label="Admin extensions"
|
||||
:options="g.extensions.map(e => e.code)"
|
||||
></q-select>
|
||||
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>
|
||||
|
||||
<h6 class="q-my-none">Extensions</h6>
|
||||
<div>
|
||||
<p>Extension Sources</p>
|
||||
<q-input
|
||||
@@ -82,7 +154,6 @@
|
||||
<q-btn @click="addExtensionsManifest" dense flat icon="add"></q-btn>
|
||||
</q-input>
|
||||
<div>
|
||||
{%raw%}
|
||||
<q-chip
|
||||
v-for="manifestUrl in formData.lnbits_extensions_manifests"
|
||||
:key="manifestUrl"
|
||||
@@ -90,10 +161,8 @@
|
||||
@remove="removeExtensionsManifest(manifestUrl)"
|
||||
color="primary"
|
||||
text-color="white"
|
||||
>
|
||||
{{ manifestUrl }}
|
||||
</q-chip>
|
||||
{%endraw%}
|
||||
v-text="manifestUrl"
|
||||
></q-chip>
|
||||
</div>
|
||||
<br />
|
||||
</div>
|
||||
|
||||
@@ -31,12 +31,12 @@
|
||||
v-model="formData.lnbits_site_description"
|
||||
filled
|
||||
type="textarea"
|
||||
hint="Use plain text or raw HTML"
|
||||
hint="Use plain text, Markdown, or raw HTML"
|
||||
/>
|
||||
</div>
|
||||
<br />
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 col-md-6">
|
||||
<div class="col-12 col-md-4">
|
||||
<p>Default Wallet Name</p>
|
||||
<q-input
|
||||
filled
|
||||
@@ -46,7 +46,7 @@
|
||||
></q-input>
|
||||
<br />
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<div class="col-12 col-md-4">
|
||||
<p>Denomination</p>
|
||||
<q-input
|
||||
filled
|
||||
@@ -57,6 +57,17 @@
|
||||
></q-input>
|
||||
<br />
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<p>QR code logo</p>
|
||||
<q-input
|
||||
filled
|
||||
type="text"
|
||||
v-model="formData.lnbits_qr_logo"
|
||||
label="https://example.com/image.svg"
|
||||
hint="URL to logo image in QR code"
|
||||
></q-input>
|
||||
<br />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 col-md-6">
|
||||
|
||||
@@ -1,62 +1,78 @@
|
||||
<q-tab-panel name="users">
|
||||
<q-card-section class="q-pa-none">
|
||||
<h6 class="q-my-none">User Management</h6>
|
||||
<br />
|
||||
<div>
|
||||
<p>Admin Users</p>
|
||||
<q-input
|
||||
filled
|
||||
v-model="formAddAdmin"
|
||||
@keydown.enter="addAdminUser"
|
||||
type="text"
|
||||
label="User ID"
|
||||
hint="Users with admin privileges"
|
||||
>
|
||||
<q-btn @click="addAdminUser" dense flat icon="add"></q-btn>
|
||||
</q-input>
|
||||
<div>
|
||||
{%raw%}
|
||||
<q-chip
|
||||
v-for="user in formData.lnbits_admin_users"
|
||||
:key="user"
|
||||
removable
|
||||
@remove="removeAdminUser(user)"
|
||||
color="primary"
|
||||
text-color="white"
|
||||
<h6 class="q-my-none q-mb-sm">User Management</h6>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 col-sm-12 q-pr-sm">
|
||||
<p>Admin Users</p>
|
||||
<q-input
|
||||
filled
|
||||
v-model="formAddAdmin"
|
||||
@keydown.enter="addAdminUser"
|
||||
type="text"
|
||||
label="User ID"
|
||||
hint="Users with admin privileges"
|
||||
>
|
||||
{{ user }}
|
||||
</q-chip>
|
||||
{%endraw%}
|
||||
<q-btn @click="addAdminUser" dense flat icon="add"></q-btn>
|
||||
</q-input>
|
||||
<div>
|
||||
<q-chip
|
||||
v-for="user in formData.lnbits_admin_users"
|
||||
:key="user"
|
||||
removable
|
||||
@remove="removeAdminUser(user)"
|
||||
color="primary"
|
||||
text-color="white"
|
||||
>
|
||||
<span v-text="user" />
|
||||
</q-chip>
|
||||
</div>
|
||||
<br />
|
||||
</div>
|
||||
<br />
|
||||
</div>
|
||||
<div>
|
||||
<p>Allowed Users</p>
|
||||
<q-input
|
||||
filled
|
||||
v-model="formAddUser"
|
||||
@keydown.enter="addAllowedUser"
|
||||
type="text"
|
||||
label="User ID"
|
||||
hint="Only these users can use LNbits"
|
||||
>
|
||||
<q-btn @click="addAllowedUser" dense flat icon="add"></q-btn>
|
||||
</q-input>
|
||||
<div>
|
||||
{% raw %}
|
||||
<q-chip
|
||||
v-for="user in formData.lnbits_allowed_users"
|
||||
:key="user"
|
||||
removable
|
||||
@remove="removeAllowedUser(user)"
|
||||
color="primary"
|
||||
text-color="white"
|
||||
<div class="col-md-6 col-sm-12">
|
||||
<p>Allowed Users</p>
|
||||
<q-input
|
||||
filled
|
||||
v-model="formAddUser"
|
||||
@keydown.enter="addAllowedUser"
|
||||
type="text"
|
||||
label="User ID"
|
||||
hint="Only these users can use LNbits"
|
||||
>
|
||||
{{ user }}
|
||||
</q-chip>
|
||||
{% endraw %}
|
||||
<q-btn @click="addAllowedUser" dense flat icon="add"></q-btn>
|
||||
</q-input>
|
||||
<div>
|
||||
<q-chip
|
||||
v-for="user in formData.lnbits_allowed_users"
|
||||
:key="user"
|
||||
removable
|
||||
@remove="removeAllowedUser(user)"
|
||||
color="primary"
|
||||
text-color="white"
|
||||
>
|
||||
<span v-text="user" />
|
||||
</q-chip>
|
||||
</div>
|
||||
<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>
|
||||
<br />
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-tab-panel>
|
||||
|
||||
@@ -8,9 +8,9 @@
|
||||
@click="updateSettings"
|
||||
:disabled="!checkChanges"
|
||||
>
|
||||
<q-tooltip v-if="checkChanges"
|
||||
>{%raw%}{{ $t('save_tooltip') }}{%endraw%}</q-tooltip
|
||||
>
|
||||
<q-tooltip v-if="checkChanges">
|
||||
<span v-text="$t('save_tooltip')"></span>
|
||||
</q-tooltip>
|
||||
|
||||
<q-badge
|
||||
v-if="checkChanges"
|
||||
@@ -27,9 +27,9 @@
|
||||
color="primary"
|
||||
@click="restartServer"
|
||||
>
|
||||
<q-tooltip v-if="needsRestart"
|
||||
>{%raw%}{{ $t('restart_tooltip') }}{%endraw%}</q-tooltip
|
||||
>
|
||||
<q-tooltip v-if="needsRestart">
|
||||
<span v-text="$t('restart_tooltip')"></span>
|
||||
</q-tooltip>
|
||||
|
||||
<q-badge
|
||||
v-if="needsRestart"
|
||||
@@ -46,7 +46,9 @@
|
||||
color="primary"
|
||||
@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 :label="$t('download_backup')" flat @click="downloadBackup"></q-btn>
|
||||
@@ -59,7 +61,9 @@
|
||||
@click="deleteSettings"
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -88,6 +92,12 @@
|
||||
@update="val => tab = val.name"
|
||||
></q-tab>
|
||||
|
||||
<q-tab
|
||||
name="security"
|
||||
:label="$t('security')"
|
||||
@update="val => tab = val.name"
|
||||
></q-tab>
|
||||
|
||||
<q-tab
|
||||
name="theme"
|
||||
:label="$t('theme')"
|
||||
@@ -101,7 +111,8 @@
|
||||
<q-tab-panels v-model="tab" animated>
|
||||
{% include "admin/_tab_funding.html" %} {% include
|
||||
"admin/_tab_users.html" %} {% include "admin/_tab_server.html" %} {%
|
||||
include "admin/_tab_theme.html" %}
|
||||
include "admin/_tab_security.html" %} {% include
|
||||
"admin/_tab_theme.html" %}
|
||||
</q-tab-panels>
|
||||
</q-form>
|
||||
</q-card>
|
||||
@@ -157,379 +168,5 @@
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
{% endblock %} {% block scripts %} {{ window_vars(user) }}
|
||||
<script>
|
||||
new Vue({
|
||||
el: '#vue',
|
||||
mixins: [windowMixin],
|
||||
data: function () {
|
||||
return {
|
||||
settings: {},
|
||||
lnbits_theme_options: [
|
||||
'classic',
|
||||
'bitcoin',
|
||||
'flamingo',
|
||||
'cyber',
|
||||
'freedom',
|
||||
'mint',
|
||||
'autumn',
|
||||
'monochrome',
|
||||
'salvador'
|
||||
],
|
||||
formData: {},
|
||||
formAddAdmin: '',
|
||||
formAddUser: '',
|
||||
formAddExtensionsManifest: '',
|
||||
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'
|
||||
}
|
||||
}
|
||||
],
|
||||
[
|
||||
'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: function () {
|
||||
this.getSettings()
|
||||
this.balance = +'{{ balance|safe }}'
|
||||
},
|
||||
computed: {
|
||||
checkChanges() {
|
||||
return !_.isEqual(this.settings, this.formData)
|
||||
}
|
||||
},
|
||||
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 = ''
|
||||
//console.log(this.checkChanges)
|
||||
}
|
||||
},
|
||||
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
|
||||
)
|
||||
},
|
||||
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]
|
||||
})
|
||||
})
|
||||
},
|
||||
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.super_user || false
|
||||
this.settings = response.data
|
||||
this.formData = _.clone(this.settings)
|
||||
this.updateFundingData()
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
updateSettings() {
|
||||
let data = _.omit(this.formData, [
|
||||
'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 = this.formData
|
||||
this.formData = _.clone(this.settings)
|
||||
this.updateFundingData()
|
||||
this.$q.notify({
|
||||
type: 'positive',
|
||||
message: 'Success! Settings changed!',
|
||||
icon: null
|
||||
})
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
deleteSettings() {
|
||||
LNbits.utils
|
||||
.confirmDialog(
|
||||
'Are you sure you want to restore settings to default?'
|
||||
)
|
||||
.onOk(() => {
|
||||
LNbits.api
|
||||
.request(
|
||||
'DELETE',
|
||||
'/admin/api/v1/settings/?usr=' + this.g.user.id
|
||||
)
|
||||
.then(response => {
|
||||
this.$q.notify({
|
||||
type: 'positive',
|
||||
message:
|
||||
'Success! Restored settings to defaults, restart required!',
|
||||
icon: null
|
||||
})
|
||||
this.needsRestart = true
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
})
|
||||
},
|
||||
downloadBackup() {
|
||||
window.open('/admin/api/v1/backup/?usr=' + this.g.user.id, '_blank')
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
<script src="{{ static_url_for('static', 'js/admin.js') }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
<q-expansion-item
|
||||
group="extras"
|
||||
icon="swap_vertical_circle"
|
||||
:label="$t('api_docs')"
|
||||
icon="vpn_key"
|
||||
:label="$t('api_keys_api_docs')"
|
||||
:content-inset-level="0.5"
|
||||
>
|
||||
<q-card-section>
|
||||
<strong>Node URL: </strong><em v-text="origin"></em><br />
|
||||
<strong>Wallet ID: </strong><em>{{ wallet.id }}</em><br />
|
||||
<strong>Admin key: </strong><em>{{ wallet.adminkey }}</em><br />
|
||||
<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 %}
|
||||
@@ -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,74 +1,343 @@
|
||||
{% extends "public.html" %} {% block scripts %}
|
||||
<script src="/core/static/js/index.js"></script>
|
||||
<script src="{{ static_url_for('static', 'js/index.js') }}"></script>
|
||||
{% endblock %} {% block page %}
|
||||
<div class="row q-col-gutter-md justify-center">
|
||||
<div class="col-12 col-md-7 col-lg-6 q-gutter-y-md">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
{% if lnurl %}
|
||||
<q-btn
|
||||
unelevated
|
||||
color="primary"
|
||||
@click="processing"
|
||||
type="a"
|
||||
href="{{ url_for('core.lnurlwallet') }}?lightning={{ lnurl }}"
|
||||
v-text="$t('press_to_claim')"
|
||||
></q-btn>
|
||||
{% else %}
|
||||
<q-form @submit="createWallet" class="q-gutter-md">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model="walletName"
|
||||
:label='$t("name_your_wallet", { name: "{{ SITE_TITLE }} *" })'
|
||||
></q-input>
|
||||
<q-btn
|
||||
unelevated
|
||||
color="primary"
|
||||
:disable="walletName == ''"
|
||||
type="submit"
|
||||
:label="$t('add_wallet')"
|
||||
></q-btn>
|
||||
</q-form>
|
||||
{% endif %}
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
<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-section>
|
||||
<q-btn
|
||||
unelevated
|
||||
color="primary"
|
||||
@click="processing"
|
||||
type="a"
|
||||
href="{{ url_for('core.lnurlwallet') }}?lightning={{ lnurl }}"
|
||||
v-text="$t('press_to_claim')"
|
||||
class="full-width"
|
||||
></q-btn>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
{%else%} {% endif %}
|
||||
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<h3 class="q-my-none">{{SITE_TITLE}}</h3>
|
||||
<h5 class="q-my-md">{{SITE_TAGLINE}}</h5>
|
||||
<div v-if="'{{SITE_TITLE}}' == 'LNbits'">
|
||||
<p v-text="$t('lnbits_description')"></p>
|
||||
<div class="row q-mt-md q-gutter-sm">
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
type="a"
|
||||
href="https://github.com/lnbits/lnbits"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
:label="$t('view_github')"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
type="a"
|
||||
href="https://legend.lnbits.com/paywall/GAqKguK5S8f6w5VNjS9DfK"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
:label="$t('donate')"
|
||||
></q-btn>
|
||||
<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>
|
||||
</div>
|
||||
<p v-else>{{SITE_DESCRIPTION | safe}}</p>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</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-input
|
||||
filled
|
||||
dense
|
||||
v-model="walletName"
|
||||
:label='$t("name_your_wallet", { name: "{{ SITE_TITLE }} *" })'
|
||||
></q-input>
|
||||
<div>
|
||||
<q-btn
|
||||
color="primary"
|
||||
:disable="walletName == ''"
|
||||
type="submit"
|
||||
:label="$t('add_wallet')"
|
||||
class="full-width"
|
||||
></q-btn>
|
||||
</div>
|
||||
</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%}
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ads -->
|
||||
<div class="col-12 col-md-3 col-lg-3" v-if="'{{SITE_TITLE}}' == 'LNbits'">
|
||||
<div
|
||||
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>
|
||||
<h5 class="q-my-md">{{SITE_TAGLINE}}</h5>
|
||||
<div v-html="formatDescription"></div>
|
||||
</div>
|
||||
|
||||
<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
|
||||
outline
|
||||
color="grey"
|
||||
type="a"
|
||||
href="https://github.com/lnbits/lnbits"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
:label="$t('view_github')"
|
||||
class="full-width"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
type="a"
|
||||
href="https://legend.lnbits.com/paywall/GAqKguK5S8f6w5VNjS9DfK"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
:label="$t('donate')"
|
||||
class="full-width q-mb-lg"
|
||||
></q-btn>
|
||||
</div>
|
||||
<div class="col-6 col-sm-4 col-md-8 q-gutter-y-sm">
|
||||
<q-btn
|
||||
flat
|
||||
@@ -78,18 +347,26 @@
|
||||
></q-btn>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<a href="https://github.com/ElementsProject/lightning">
|
||||
<a
|
||||
href="https://github.com/ElementsProject/lightning"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '/static/images/cln.png' : '/static/images/clnl.png'"
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/cln.png') }}' : '{{ static_url_for('static', 'images/clnl.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<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
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '/static/images/lnd.png' : '/static/images/lnd.png'"
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/lnd.png') }}' : '{{ static_url_for('static', 'images/lnd.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
@@ -97,18 +374,26 @@
|
||||
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<a href="https://opennode.com">
|
||||
<a
|
||||
href="https://opennode.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '/static/images/opennode.png' : '/static/images/opennodel.png'"
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/opennode.png') }}' : '{{ static_url_for('static', 'images/opennodel.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col q-pl-md">
|
||||
<a href="https://lnpay.co/">
|
||||
<a
|
||||
href="https://lnpay.co/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '/static/images/lnpay.png' : '/static/images/lnpayl.png'"
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/lnpay.png') }}' : '{{ static_url_for('static', 'images/lnpayl.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
@@ -116,59 +401,125 @@
|
||||
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<a href="https://github.com/rootzoll/raspiblitz">
|
||||
<a
|
||||
href="https://github.com/rootzoll/raspiblitz"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '/static/images/blitz.png' : '/static/images/blitzl.png'"
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/blitz.png') }}' : '{{ static_url_for('static', 'images/blitzl.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col q-pl-md">
|
||||
<a href="https://getumbrel.com/">
|
||||
<a
|
||||
href="https://start9.com/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '/static/images/umbrel.png' : '/static/images/umbrell.png'"
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/start9.png') }}' : '{{ static_url_for('static', 'images/start9l.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<a href="https://mynodebtc.com">
|
||||
<a
|
||||
href="https://getumbrel.com/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '/static/images/mynode.png' : '/static/images/mynodel.png'"
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/umbrel.png') }}' : '{{ static_url_for('static', 'images/umbrell.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col q-pl-md">
|
||||
<a href="https://github.com/shesek/spark-wallet">
|
||||
<a
|
||||
href="https://mynodebtc.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '/static/images/spark.png' : '/static/images/sparkl.png'"
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/mynode.png') }}' : '{{ static_url_for('static', 'images/mynodel.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<a
|
||||
href="https://github.com/shesek/spark-wallet"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/spark.png') }}' : '{{ static_url_for('static', 'images/sparkl.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col q-pl-md">
|
||||
<a
|
||||
href="https://voltage.cloud"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/voltage.png') }}' : '{{ static_url_for('static', 'images/voltagel.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% 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">
|
||||
<q-btn flat color="secondary" class="full-width q-mb-md"
|
||||
>{{ AD_TITLE }}</q-btn
|
||||
>
|
||||
|
||||
<a href="{{ AD[0] }}" class="q-ma-md">
|
||||
<img
|
||||
v-if="($q.dark.isActive)"
|
||||
src="{{ AD[1] }}"
|
||||
style="max-width: 90%"
|
||||
/>
|
||||
<img v-else src="{{ AD[2] }}" style="max-width: 90%" />
|
||||
</a>
|
||||
</div>
|
||||
{% endfor %} {% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% 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">
|
||||
<q-btn flat color="secondary" class="full-width q-mb-md"
|
||||
>{{ AD_SPACE_TITLE }}</q-btn
|
||||
>
|
||||
|
||||
<a href="{{ AD[0] }}" class="q-ma-md">
|
||||
<img v-if="($q.dark.isActive)" src="{{ AD[1] }}" style="max-width: 90%" />
|
||||
<img v-else src="{{ AD[2] }}" style="max-width: 90%" />
|
||||
</a>
|
||||
</div>
|
||||
{% endfor %} {% endif %}
|
||||
</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 class="col-1"></div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,505 +0,0 @@
|
||||
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
|
||||
%} {{ window_vars(user, extensions) }}{% block page %}
|
||||
<div class="row q-col-gutter-md q-mb-md">
|
||||
<div class="col-sm-9 col-xs-12">
|
||||
<p class="text-h4 gt-sm">Manage Extensions</p>
|
||||
</div>
|
||||
|
||||
<div class="col-sm-3 col-xs-12 q-ml-auto">
|
||||
<q-input v-model="searchTerm" label="Search extensions">
|
||||
<q-icon
|
||||
v-if="searchTerm !== ''"
|
||||
name="close"
|
||||
@click="searchTerm = ''"
|
||||
class="cursor-pointer"
|
||||
/>
|
||||
</q-input>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row q-col-gutter-md q-mb-md">
|
||||
<div class="col-12">
|
||||
<q-card>
|
||||
<div class="q-pa-xs">
|
||||
<div class="q-gutter-y-md">
|
||||
<q-tabs
|
||||
v-model="tab"
|
||||
@input="handleTabChanged"
|
||||
active-color="primary"
|
||||
align="left"
|
||||
>
|
||||
<q-tab
|
||||
name="installed"
|
||||
label="Installed"
|
||||
@update="val => tab = val.name"
|
||||
></q-tab>
|
||||
<q-tab
|
||||
name="all"
|
||||
label="All"
|
||||
@update="val => tab = val.name"
|
||||
></q-tab>
|
||||
<q-tab
|
||||
name="featured"
|
||||
label="Featured"
|
||||
@update="val => tab = val.name"
|
||||
></q-tab>
|
||||
</q-tabs>
|
||||
</div>
|
||||
</div>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-col-gutter-md">
|
||||
<div
|
||||
class="col-6 col-md-4 col-lg-3"
|
||||
v-for="extension in filteredExtensions"
|
||||
:key="extension.id + extension.hash"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section style="min-height: 140px">
|
||||
<div class="row">
|
||||
<div class="col-3">
|
||||
<q-img
|
||||
v-if="extension.icon"
|
||||
:src="extension.icon"
|
||||
spinner-color="white"
|
||||
style="max-width: 100%"
|
||||
></q-img>
|
||||
<div v-else>
|
||||
<q-icon
|
||||
class="gt-sm"
|
||||
name="extension"
|
||||
color="primary"
|
||||
size="70px"
|
||||
></q-icon>
|
||||
<q-icon
|
||||
class="lt-md"
|
||||
name="extension"
|
||||
color="primary"
|
||||
size="35px"
|
||||
></q-icon>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-9 q-pl-sm">
|
||||
<q-badge
|
||||
v-if="hasNewVersion(extension)"
|
||||
color="green"
|
||||
class="float-right"
|
||||
>
|
||||
<small>New Version</small>
|
||||
</q-badge>
|
||||
{% raw %}
|
||||
<div class="text-h5 gt-sm q-mt-sm q-mb-xs gt-sm">
|
||||
{{ extension.name }}
|
||||
</div>
|
||||
<div
|
||||
class="text-h5 gt-sm q-mt-sm q-mb-xs lt-md"
|
||||
style="min-height: 60px"
|
||||
>
|
||||
{{ extension.name }}
|
||||
</div>
|
||||
<div
|
||||
class="text-subtitle2 gt-sm"
|
||||
style="font-size: 11px; height: 34px"
|
||||
>
|
||||
{{ extension.shortDescription }}
|
||||
</div>
|
||||
<div class="text-subtitle1 lt-md q-mt-sm q-mb-xs">
|
||||
{{ extension.name }}
|
||||
</div>
|
||||
<div
|
||||
class="text-subtitle2 lt-md"
|
||||
style="font-size: 9px; height: 34px"
|
||||
>
|
||||
{{ extension.shortDescription }}
|
||||
</div>
|
||||
{% endraw %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-pt-sm">
|
||||
<div class="col">
|
||||
<small v-if="extension.dependencies?.length">Depends on:</small>
|
||||
<small v-else> </small>
|
||||
<q-badge
|
||||
v-for="dep in extension.dependencies"
|
||||
:key="dep"
|
||||
color="orange"
|
||||
>
|
||||
<small v-text="dep"></small>
|
||||
</q-badge>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
<q-card-section>
|
||||
<div>
|
||||
<q-rating
|
||||
class="gt-sm"
|
||||
v-model="maxStars"
|
||||
disable
|
||||
size="1.5em"
|
||||
:max="5"
|
||||
color="primary"
|
||||
></q-rating>
|
||||
<q-rating
|
||||
v-model="maxStars"
|
||||
class="lt-md"
|
||||
size="1.5em"
|
||||
:max="5"
|
||||
color="primary"
|
||||
></q-rating
|
||||
><q-tooltip>Ratings coming soon</q-tooltip>
|
||||
</div>
|
||||
</q-card-section>
|
||||
<q-separator v-if="g.user.admin"></q-separator>
|
||||
<q-card-actions>
|
||||
<div class="col-10">
|
||||
<div v-if="g.user.admin">
|
||||
<div v-if="!extension.inProgress">
|
||||
<q-btn @click="showUpgrade(extension)" flat color="primary">
|
||||
Manage</q-btn
|
||||
>
|
||||
<q-toggle
|
||||
v-if="extension.isAvailable && extension.isInstalled"
|
||||
:label="extension.isActive ? 'Activated': 'Deactivated' "
|
||||
color="secodary"
|
||||
v-model="extension.isActive"
|
||||
@input="toggleExtension(extension)"
|
||||
></q-toggle>
|
||||
</div>
|
||||
<div v-else>
|
||||
<q-spinner color="primary" size="2.55em"></q-spinner>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-2">
|
||||
<div class="float-right"></div>
|
||||
</div>
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</div>
|
||||
<q-dialog v-model="showUninstallDialog">
|
||||
<q-card class="q-pa-lg">
|
||||
<h6 class="q-my-md text-primary">Warning</h6>
|
||||
<p>
|
||||
You are about to remove the extension for all users. <br />
|
||||
Are you sure you want to continue?
|
||||
</p>
|
||||
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn outline color="grey" @click="uninstallExtension()"
|
||||
>Yes, Uninstall</q-btn
|
||||
>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
|
||||
</div>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<q-dialog v-model="showUpgradeDialog">
|
||||
<q-card class="q-pa-lg lnbits__dialog-card">
|
||||
<q-card-section>
|
||||
<div class="text-h6" v-text="selectedExtension?.name"></div>
|
||||
</q-card-section>
|
||||
<div class="col-12 col-md-5 q-gutter-y-md" v-if="selectedExtensionRepos">
|
||||
<q-card
|
||||
flat
|
||||
bordered
|
||||
class="my-card"
|
||||
v-for="repoName of Object.keys(selectedExtensionRepos)"
|
||||
:key="repoName"
|
||||
>
|
||||
<q-expansion-item
|
||||
:key="repoName"
|
||||
group="repos"
|
||||
:caption="repoName"
|
||||
:content-inset-level="0.5"
|
||||
:default-opened="selectedExtensionRepos[repoName].isInstalled"
|
||||
>
|
||||
<template v-slot:header>
|
||||
<q-item-section avatar>
|
||||
<q-avatar
|
||||
:icon="selectedExtensionRepos[repoName].isInstalled ? 'download_done': 'download'"
|
||||
:text-color="selectedExtensionRepos[repoName].isInstalled ? 'green' : ''"
|
||||
/>
|
||||
</q-item-section>
|
||||
|
||||
<q-item-section>
|
||||
<div class="row">
|
||||
<div class="col-10">
|
||||
Repository
|
||||
<br />
|
||||
<small v-text="repoName"></small>
|
||||
</div>
|
||||
<div class="col-2">
|
||||
<!-- <div v-if="selectedExtension.stars" class="float-right">
|
||||
<small v-text="selectedExtension.stars"> </small>
|
||||
<q-rating
|
||||
max="1"
|
||||
v-model="maxStars"
|
||||
size="1.5em"
|
||||
color="yellow"
|
||||
icon="star"
|
||||
icon-selected="star"
|
||||
readonly
|
||||
no-dimming
|
||||
>
|
||||
</q-rating>
|
||||
</div> -->
|
||||
</div>
|
||||
</div>
|
||||
</q-item-section>
|
||||
</template>
|
||||
|
||||
<q-card-section class="q-pa-none">
|
||||
<q-separator></q-separator>
|
||||
|
||||
<q-list>
|
||||
<q-expansion-item
|
||||
v-for="release of selectedExtensionRepos[repoName].releases"
|
||||
:key="release.version"
|
||||
group="releases"
|
||||
:icon="release.isInstalled ? 'download_done' : 'download'"
|
||||
:label="release.description"
|
||||
:caption="release.version"
|
||||
:content-inset-level="0.5"
|
||||
:header-class="release.isInstalled ? 'text-green' : ''"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<q-btn
|
||||
v-if="!release.isInstalled"
|
||||
@click="installExtension(release)"
|
||||
color="primary unelevated mt-lg pt-lg"
|
||||
>Install</q-btn
|
||||
>
|
||||
<q-btn v-else @click="showUninstall()" flat color="red">
|
||||
Uninstall</q-btn
|
||||
>
|
||||
<a
|
||||
v-if="release.html_url"
|
||||
class="text-secondary float-right"
|
||||
:href="release.html_url"
|
||||
target="_blank"
|
||||
style="color: inherit"
|
||||
>Release Notes</a
|
||||
>
|
||||
</q-card-section>
|
||||
|
||||
<div
|
||||
v-if="release.details_html"
|
||||
v-html="release.details_html"
|
||||
></div>
|
||||
<q-separator></q-separator> </q-card
|
||||
></q-expansion-item>
|
||||
</q-list>
|
||||
</q-card-section>
|
||||
</q-expansion-item>
|
||||
</q-card>
|
||||
</div>
|
||||
<q-spinner v-else color="primary" size="2.55em"></q-spinner>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
v-if="selectedExtension?.isInstalled"
|
||||
@click="showUninstall()"
|
||||
flat
|
||||
color="red"
|
||||
>
|
||||
Uninstall</q-btn
|
||||
>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
|
||||
</div>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</div>
|
||||
{% endblock %} {% block scripts %} {{ window_vars(user) }}
|
||||
<script>
|
||||
new Vue({
|
||||
el: '#vue',
|
||||
data: function () {
|
||||
return {
|
||||
searchTerm: '',
|
||||
tab: 'all',
|
||||
filteredExtensions: null,
|
||||
showUninstallDialog: false,
|
||||
showUpgradeDialog: false,
|
||||
selectedExtension: null,
|
||||
selectedExtensionRepos: null,
|
||||
maxStars: 5
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
searchTerm(term) {
|
||||
this.filterExtensions(term, this.tab)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
handleTabChanged: function (tab) {
|
||||
this.filterExtensions(this.searchTerm, tab)
|
||||
},
|
||||
filterExtensions: function (term, tab) {
|
||||
// Filter the extensions list
|
||||
function extensionNameContains(searchTerm) {
|
||||
return function (extension) {
|
||||
return (
|
||||
extension.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
extension.shortDescription
|
||||
?.toLowerCase()
|
||||
.includes(searchTerm.toLowerCase())
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
this.filteredExtensions = this.extensions
|
||||
.filter(e => (tab === 'installed' ? e.isInstalled : true))
|
||||
.filter(e => (tab === 'featured' ? e.isFeatured : true))
|
||||
.filter(extensionNameContains(term))
|
||||
},
|
||||
installExtension: async function (release) {
|
||||
const extension = this.selectedExtension
|
||||
extension.inProgress = true
|
||||
this.showUpgradeDialog = false
|
||||
LNbits.api
|
||||
.request(
|
||||
'POST',
|
||||
`/api/v1/extension?usr=${this.g.user.id}`,
|
||||
this.g.user.wallets[0].adminkey,
|
||||
{
|
||||
ext_id: extension.id,
|
||||
archive: release.archive,
|
||||
source_repo: release.source_repo
|
||||
}
|
||||
)
|
||||
.then(response => {
|
||||
extension.isAvailable = true
|
||||
extension.isInstalled = true
|
||||
this.toggleExtension(extension)
|
||||
extension.inProgress = false
|
||||
this.filteredExtensions = this.extensions.concat([])
|
||||
this.handleTabChanged('installed')
|
||||
this.tab = 'installed'
|
||||
})
|
||||
.catch(err => {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
extension.inProgress = false
|
||||
})
|
||||
},
|
||||
uninstallExtension: async function () {
|
||||
const extension = this.selectedExtension
|
||||
this.showUpgradeDialog = false
|
||||
this.showUninstallDialog = false
|
||||
extension.inProgress = true
|
||||
LNbits.api
|
||||
.request(
|
||||
'DELETE',
|
||||
`/api/v1/extension/${extension.id}?usr=${this.g.user.id}`,
|
||||
this.g.user.wallets[0].adminkey
|
||||
)
|
||||
.then(response => {
|
||||
extension.isAvailable = false
|
||||
extension.isInstalled = false
|
||||
extension.inProgress = false
|
||||
this.filteredExtensions = this.extensions.concat([])
|
||||
this.handleTabChanged('installed')
|
||||
this.tab = 'installed'
|
||||
})
|
||||
.catch(err => {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
extension.inProgress = false
|
||||
})
|
||||
},
|
||||
toggleExtension: function (extension) {
|
||||
const action = extension.isActive ? 'activate' : 'deactivate'
|
||||
LNbits.api
|
||||
.request(
|
||||
'GET',
|
||||
"{{ url_for('install.extensions') }}?usr=" +
|
||||
this.g.user.id +
|
||||
'&' +
|
||||
action +
|
||||
'=' +
|
||||
extension.id
|
||||
)
|
||||
.then(response => {})
|
||||
.catch(err => {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
extension.inProgress = false
|
||||
})
|
||||
},
|
||||
|
||||
showUninstall: function () {
|
||||
this.showUpgradeDialog = false
|
||||
this.showUninstallDialog = true
|
||||
},
|
||||
|
||||
showUpgrade: async function (extension) {
|
||||
this.selectedExtension = extension
|
||||
this.showUpgradeDialog = true
|
||||
this.selectedExtensionRepos = null
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
'GET',
|
||||
`/api/v1/extension/${extension.id}/releases?usr=${this.g.user.id}`,
|
||||
this.g.user.wallets[0].adminkey
|
||||
)
|
||||
|
||||
this.selectedExtensionRepos = data.reduce((repos, release) => {
|
||||
repos[release.source_repo] = repos[release.source_repo] || {
|
||||
releases: [],
|
||||
isInstalled: false
|
||||
}
|
||||
release.isInstalled = this.isInstalledVersion(
|
||||
this.selectedExtension,
|
||||
release
|
||||
)
|
||||
if (release.isInstalled) {
|
||||
repos[release.source_repo].isInstalled = true
|
||||
}
|
||||
repos[release.source_repo].releases.push(release)
|
||||
return repos
|
||||
}, {})
|
||||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
extension.inProgress = false
|
||||
}
|
||||
},
|
||||
hasNewVersion: function (extension) {
|
||||
if (extension.installedRelease && extension.latestRelease) {
|
||||
return (
|
||||
extension.installedRelease.version !==
|
||||
extension.latestRelease.version
|
||||
)
|
||||
}
|
||||
},
|
||||
isInstalledVersion: function (extension, release) {
|
||||
if (extension.installedRelease) {
|
||||
return (
|
||||
extension.installedRelease.source_repo === release.source_repo &&
|
||||
extension.installedRelease.version === release.version
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
created: function () {
|
||||
if (!this.g.user.admin) {
|
||||
this.$q.notify({
|
||||
timeout: 3000,
|
||||
message: 'Only admin accounts can install extensions',
|
||||
icon: null
|
||||
})
|
||||
}
|
||||
this.extensions = JSON.parse('{{extensions | tojson | safe}}').map(e => ({
|
||||
...e,
|
||||
inProgress: false
|
||||
}))
|
||||
this.filteredExtensions = this.extensions.concat([])
|
||||
for (let i = 0; i < this.filteredExtensions.length; i++) {
|
||||
if (this.filteredExtensions[i].isInstalled != false) {
|
||||
this.handleTabChanged('installed')
|
||||
this.tab = 'installed'
|
||||
}
|
||||
}
|
||||
},
|
||||
mixins: [windowMixin]
|
||||
})
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -3,7 +3,7 @@
|
||||
{% from "macros.jinja" import window_vars with context %}
|
||||
<!---->
|
||||
{% block scripts %} {{ window_vars(user, wallet) }}
|
||||
<script src="/core/static/js/wallet.js"></script>
|
||||
<script src="{{ static_url_for('static', 'js/wallet.js') }}"></script>
|
||||
{% endblock %}
|
||||
<!---->
|
||||
{% block title %} {{ wallet.name }} - {{ SITE_TITLE }} {% endblock %}
|
||||
@@ -15,46 +15,58 @@
|
||||
{% elif HIDE_API %}
|
||||
<div class="col-12 q-gutter-y-md">
|
||||
{% 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 %}
|
||||
<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>
|
||||
<h3 class="q-my-none">
|
||||
<strong>{% raw %}{{ formattedBalance }} {% endraw %}</strong>
|
||||
{{LNBITS_DENOMINATION}}
|
||||
<q-btn
|
||||
<h3 class="q-my-none text-no-wrap">
|
||||
<strong v-text="formattedBalance"></strong>
|
||||
<small>{{LNBITS_DENOMINATION}}</small>
|
||||
<lnbits-update-balance
|
||||
v-if="'{{user.super_user}}' == 'True'"
|
||||
:wallet_id="this.g.wallet.id"
|
||||
flat
|
||||
:callback="updateBalanceCallback"
|
||||
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>
|
||||
<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>
|
||||
<div class="row q-pb-md q-px-md q-col-gutter-md gt-sm">
|
||||
<div class="col">
|
||||
@@ -84,14 +96,20 @@
|
||||
:label="$t('scan')"
|
||||
>
|
||||
<q-tooltip
|
||||
>{% raw %}{{$t('camera_tooltip')}}{% endraw %}</q-tooltip
|
||||
>
|
||||
><span v-text="$t('camera_tooltip')"></span
|
||||
></q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
<div class="row items-center no-wrap q-mb-sm">
|
||||
<div class="col">
|
||||
@@ -100,16 +118,13 @@
|
||||
:v-text="$t('transactions')"
|
||||
></h5>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<div class="gt-sm col-auto">
|
||||
<q-btn
|
||||
flat
|
||||
color="grey"
|
||||
@click="exportCSV"
|
||||
:label="$t('export_csv')"
|
||||
></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
|
||||
dense
|
||||
flat
|
||||
@@ -118,18 +133,20 @@
|
||||
color="grey"
|
||||
@click="showChart"
|
||||
>
|
||||
<q-tooltip
|
||||
>{% raw %}{{$t('chart_tooltip')}}{% endraw %}</q-tooltip
|
||||
>
|
||||
<q-tooltip>
|
||||
<span v-text="$t('chart_tooltip')"></span
|
||||
></q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
<q-input
|
||||
v-if="payments.length > 10"
|
||||
:style="$q.screen.lt.md ? {
|
||||
display: mobileSimple ? 'none !important': ''
|
||||
} : ''"
|
||||
filled
|
||||
dense
|
||||
clearable
|
||||
v-model="paymentsTable.filter"
|
||||
v-model="paymentsTable.search"
|
||||
debounce="300"
|
||||
:placeholder="$t('search_by_tag_memo_amount')"
|
||||
class="q-mb-md"
|
||||
@@ -138,20 +155,26 @@
|
||||
<q-table
|
||||
dense
|
||||
flat
|
||||
:data="filteredPayments"
|
||||
:data="paymentsOmitter"
|
||||
:row-key="paymentTableRowKey"
|
||||
:columns="paymentsTable.columns"
|
||||
:pagination.sync="paymentsTable.pagination"
|
||||
:no-data-label="$t('no_transactions')"
|
||||
:filter="paymentsTable.filter"
|
||||
:filter="paymentsTable.search"
|
||||
:loading="paymentsTable.loading"
|
||||
:hide-header="mobileSimple"
|
||||
:hide-bottom="mobileSimple"
|
||||
@request="fetchPayments"
|
||||
>
|
||||
{% raw %}
|
||||
<template v-slot:header="props">
|
||||
<q-tr :props="props">
|
||||
<q-th auto-width></q-th>
|
||||
<q-th v-for="col in props.cols" :key="col.name" :props="props"
|
||||
>{{ col.label }}</q-th
|
||||
>
|
||||
<q-th
|
||||
v-for="col in props.cols"
|
||||
:key="col.name"
|
||||
:props="props"
|
||||
v-text="col.label"
|
||||
></q-th>
|
||||
</q-tr>
|
||||
</template>
|
||||
<template v-slot:body="props">
|
||||
@@ -170,11 +193,13 @@
|
||||
color="grey"
|
||||
@click="props.expand = !props.expand"
|
||||
>
|
||||
<q-tooltip>{{$t('pending')}}</q-tooltip>
|
||||
<q-tooltip
|
||||
><span v-text="$t('pending')"></span
|
||||
></q-tooltip>
|
||||
</q-icon>
|
||||
</q-td>
|
||||
<q-td
|
||||
key="memo"
|
||||
key="time"
|
||||
:props="props"
|
||||
style="white-space: normal; word-break: break-all"
|
||||
>
|
||||
@@ -184,38 +209,47 @@
|
||||
text-color="black"
|
||||
>
|
||||
<a
|
||||
v-text="'#'+props.row.tag"
|
||||
class="inherit"
|
||||
:href="['/', props.row.tag, '/?usr=', user.id].join('')"
|
||||
>
|
||||
#{{ props.row.tag }}
|
||||
</a>
|
||||
:href="['/', props.row.tag].join('')"
|
||||
></a>
|
||||
</q-badge>
|
||||
{{ props.row.memo }}
|
||||
<span v-text="props.row.memo"></span>
|
||||
<br />
|
||||
|
||||
<i>
|
||||
<span v-text="props.row.dateFrom"></span>
|
||||
<q-tooltip
|
||||
><span v-text="props.row.date"></span
|
||||
></q-tooltip>
|
||||
</i>
|
||||
</q-td>
|
||||
<q-td auto-width key="date" :props="props">
|
||||
<q-tooltip>{{ props.row.date }}</q-tooltip>
|
||||
{{ props.row.dateFrom }}
|
||||
</q-td>
|
||||
{% endraw %}
|
||||
<q-td
|
||||
auto-width
|
||||
key="sat"
|
||||
key="amount"
|
||||
v-if="'{{LNBITS_DENOMINATION}}' != 'sats'"
|
||||
:props="props"
|
||||
>{% raw %} {{
|
||||
parseFloat(String(props.row.fsat).replaceAll(",", "")) / 100
|
||||
}}
|
||||
v-text="parseFloat(String(props.row.fsat).replaceAll(',', '')) / 100"
|
||||
>
|
||||
</q-td>
|
||||
|
||||
<q-td auto-width key="sat" v-else :props="props">
|
||||
{{ props.row.fsat }}
|
||||
</q-td>
|
||||
<q-td auto-width key="fee" :props="props">
|
||||
{{ props.row.fee }}
|
||||
<q-td auto-width key="amount" v-else :props="props">
|
||||
<span v-text="props.row.fsat"></span>
|
||||
<br />
|
||||
<i v-if="props.row.extra.wallet_fiat_currency">
|
||||
<span
|
||||
v-text="formatFiat(props.row.extra.wallet_fiat_currency, props.row.extra.wallet_fiat_amount)"
|
||||
></span>
|
||||
<br />
|
||||
</i>
|
||||
<i v-if="props.row.extra.fiat_currency">
|
||||
<span
|
||||
v-text="formatFiat(props.row.extra.fiat_currency, props.row.extra.fiat_amount)"
|
||||
></span>
|
||||
</i>
|
||||
</q-td>
|
||||
</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">
|
||||
<div class="text-center q-mb-lg">
|
||||
<div v-if="props.row.isIn && props.row.pending">
|
||||
@@ -230,11 +264,9 @@
|
||||
>
|
||||
<a :href="'lightning:' + props.row.bolt11">
|
||||
<q-responsive :ratio="1" class="q-mx-xl">
|
||||
<qrcode
|
||||
<lnbits-qrcode
|
||||
:value="'lightning:' + props.row.bolt11.toUpperCase()"
|
||||
:options="{width: 340}"
|
||||
class="rounded-borders"
|
||||
></qrcode>
|
||||
></lnbits-qrcode>
|
||||
</q-responsive>
|
||||
</a>
|
||||
</div>
|
||||
@@ -287,30 +319,28 @@
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</template>
|
||||
{% endraw %}
|
||||
</q-table>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
|
||||
{% if HIDE_API %}
|
||||
<div class="col-12 col-md-4 q-gutter-y-md">
|
||||
{% 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-section>
|
||||
<h6 class="text-subtitle1 q-mt-none q-mb-sm">
|
||||
{{ SITE_TITLE }} Wallet:
|
||||
<strong><em>{{ wallet.name }}</em></strong>
|
||||
<strong><em>{{wallet.name}}</em></strong>
|
||||
</h6>
|
||||
</q-card-section>
|
||||
<q-card-section class="q-pa-none">
|
||||
<q-separator></q-separator>
|
||||
|
||||
<q-list>
|
||||
{% include "core/_api_docs.html" %}
|
||||
<q-separator></q-separator>
|
||||
|
||||
{% if wallet.lnurlwithdraw_full %}
|
||||
<q-expansion-item
|
||||
group="extras"
|
||||
@@ -320,12 +350,11 @@
|
||||
<q-card>
|
||||
<q-card-section class="text-center">
|
||||
<a href="lightning:{{wallet.lnurlwithdraw_full}}">
|
||||
<qrcode
|
||||
value="lightning:{{wallet.lnurlwithdraw_full}}"
|
||||
:options="{width:240}"
|
||||
></qrcode>
|
||||
<lnbits-qrcode
|
||||
:value="lightning:{{wallet.lnurlwithdraw_full}}"
|
||||
></lnbits-qrcode>
|
||||
</a>
|
||||
<p v-text="$('drain_funds_desc')"></p>
|
||||
<p v-text="$t('drain_funds_desc')"></p>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
@@ -341,7 +370,7 @@
|
||||
<q-card-section class="text-center">
|
||||
<p v-text="$t('export_to_phone_desc')"></p>
|
||||
<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}"
|
||||
></qrcode>
|
||||
</q-card-section>
|
||||
@@ -358,23 +387,54 @@
|
||||
<div class="" style="max-width: 320px">
|
||||
<q-input
|
||||
filled
|
||||
v-model.trim="newName"
|
||||
label="Label"
|
||||
dense="dense"
|
||||
v-model.trim="update.name"
|
||||
label="Name"
|
||||
dense
|
||||
@update:model-value="(e) => console.log(e)"
|
||||
/>
|
||||
</div>
|
||||
<q-btn
|
||||
:disable="!newName.length"
|
||||
:disable="!update.name.length"
|
||||
unelevated
|
||||
class="q-mt-sm"
|
||||
color="primary"
|
||||
:label="$t('update_name')"
|
||||
@click="updateWalletName()"
|
||||
@click="updateWallet({ name: update.name })"
|
||||
></q-btn>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-separator></q-separator>
|
||||
<q-expansion-item
|
||||
group="extras"
|
||||
icon="attach_money"
|
||||
:label="$t('fiat_tracking')"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<div style="max-width: 360px">
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
clearable
|
||||
v-model="update.currency"
|
||||
type="text"
|
||||
:label="$t('currency')"
|
||||
:options="receive.units.filter((u) => u !== 'sat')"
|
||||
></q-select>
|
||||
</div>
|
||||
<q-btn
|
||||
:disable="!update.name.length"
|
||||
unelevated
|
||||
class="q-mt-sm"
|
||||
color="primary"
|
||||
:label="$t('update_currency')"
|
||||
@click="updateWallet({ currency: update.currency || '' })"
|
||||
></q-btn>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
|
||||
<q-separator></q-separator>
|
||||
<q-expansion-item
|
||||
group="extras"
|
||||
@@ -387,12 +447,15 @@
|
||||
<q-btn
|
||||
unelevated
|
||||
color="red-10"
|
||||
@click="deleteWallet('{{ wallet.id }}', '{{ user.id }}')"
|
||||
@click="deleteWallet()"
|
||||
:label="$t('delete_wallet')"
|
||||
></q-btn>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
|
||||
<q-separator></q-separator>
|
||||
{% include "core/_api_docs.html" %}
|
||||
</q-list>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
@@ -425,22 +488,21 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<q-dialog v-model="receive.show" @hide="closeReceiveDialog">
|
||||
{% raw %}
|
||||
<q-dialog v-model="receive.show" position="top">
|
||||
<q-card
|
||||
v-if="!receive.paymentReq"
|
||||
class="q-pa-lg q-pt-xl lnbits__dialog-card"
|
||||
>
|
||||
<q-form @submit="createInvoice" class="q-gutter-md">
|
||||
<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>
|
||||
{% endraw %} {% if LNBITS_DENOMINATION != 'sats' %}
|
||||
{% if LNBITS_DENOMINATION != 'sats' %}
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.number="receive.data.amount"
|
||||
:label="$('amount')+' ({{LNBITS_DENOMINATION}}) *'"
|
||||
:label="$t('amount')+' ({{LNBITS_DENOMINATION}}) *'"
|
||||
mask="#.##"
|
||||
fill-mask="0"
|
||||
reverse-fill-mask
|
||||
@@ -479,7 +541,6 @@
|
||||
v-model.trim="receive.data.memo"
|
||||
:label="$t('memo')"
|
||||
></q-input>
|
||||
{% raw %}
|
||||
<div v-if="receive.status == 'pending'" class="row q-mt-lg">
|
||||
<q-btn
|
||||
unelevated
|
||||
@@ -487,9 +548,10 @@
|
||||
:disable="receive.data.amount == null || receive.data.amount <= 0"
|
||||
type="submit"
|
||||
>
|
||||
<span v-if="receive.lnurl">
|
||||
{{$t('withdraw_from')}} {{receive.lnurl.domain}}
|
||||
</span>
|
||||
<span
|
||||
v-if="receive.lnurl"
|
||||
v-text="$t('withdraw_from') + receive.lnurl.domain"
|
||||
></span>
|
||||
<span v-else v-text="$t('create_invoice')"></span>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
@@ -507,15 +569,16 @@
|
||||
></q-spinner>
|
||||
</q-form>
|
||||
</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">
|
||||
<a :href="'lightning:' + receive.paymentReq">
|
||||
<q-responsive :ratio="1" class="q-mx-xl">
|
||||
<qrcode
|
||||
<lnbits-qrcode
|
||||
:value="'lightning:' + receive.paymentReq.toUpperCase()"
|
||||
:options="{width: 340}"
|
||||
class="rounded-borders"
|
||||
></qrcode>
|
||||
></lnbits-qrcode>
|
||||
</q-responsive>
|
||||
</a>
|
||||
</div>
|
||||
@@ -535,28 +598,32 @@
|
||||
></q-btn>
|
||||
</div>
|
||||
</q-card>
|
||||
{% endraw %}
|
||||
</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">
|
||||
<div v-if="parse.invoice">
|
||||
<h6 v-if="'{{LNBITS_DENOMINATION}}' != 'sats'" class="q-my-none">
|
||||
{% raw %} {{ parseFloat(String(parse.invoice.fsat).replaceAll(",",
|
||||
"")) / 100 }} {% endraw %} {{LNBITS_DENOMINATION}} {% raw %}
|
||||
</h6>
|
||||
<h6 v-else class="q-my-none">
|
||||
{{ parse.invoice.fsat }}{% endraw %} {{LNBITS_DENOMINATION}} {%
|
||||
raw %}
|
||||
</h6>
|
||||
<h6
|
||||
v-if="'{{LNBITS_DENOMINATION}}' != 'sats'"
|
||||
class="q-my-none"
|
||||
v-text="parseFloat(String(parse.invoice.fsat).replaceAll(',', '')) / 100 + '{{LNBITS_DENOMINATION}}'"
|
||||
></h6>
|
||||
<h6
|
||||
v-else
|
||||
class="q-my-none"
|
||||
v-text="parse.invoice.fsat + '{{LNBITS_DENOMINATION}}'"
|
||||
></h6>
|
||||
<q-separator class="q-my-sm"></q-separator>
|
||||
<p class="text-wrap">
|
||||
<strong v-text="$t('description')">:</strong> {{
|
||||
parse.invoice.description }}<br />
|
||||
<strong>Expire date:</strong> {{ parse.invoice.expireDate }}<br />
|
||||
<strong>Hash:</strong> {{ parse.invoice.hash }}
|
||||
<strong v-text="$t('memo') + ': '"></strong>
|
||||
<span v-text="parse.invoice.description"></span>
|
||||
<br />
|
||||
<strong>Expire date: </strong>
|
||||
<span v-text="parse.invoice.expireDate"></span>
|
||||
<br />
|
||||
<strong>Hash: </strong>
|
||||
<span v-text="parse.invoice.hash"></span>
|
||||
</p>
|
||||
{% endraw %}
|
||||
<div v-if="canPay" class="row q-mt-lg">
|
||||
<q-btn
|
||||
unelevated
|
||||
@@ -590,24 +657,31 @@
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="parse.lnurlauth">
|
||||
{% raw %}
|
||||
<q-form @submit="authLnurl" class="q-gutter-md">
|
||||
<p class="q-my-none text-h6">
|
||||
Authenticate with <b>{{ parse.lnurlauth.domain }}</b>?
|
||||
Authenticate with <b v-text="parse.lnurlauth.domain"></b>?
|
||||
</p>
|
||||
<q-separator class="q-my-sm"></q-separator>
|
||||
<p>
|
||||
For every website and for every LNbits wallet, a new keypair
|
||||
will be deterministically generated so your identity can't be
|
||||
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>Your public key for <b>{{ parse.lnurlauth.domain }}</b> is:</p>
|
||||
<p class="q-mx-xl">
|
||||
<code class="text-wrap"> {{ parse.lnurlauth.pubkey }} </code>
|
||||
<code class="text-wrap" v-text="parse.lnurlauth.pubkey"></code>
|
||||
</p>
|
||||
<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
|
||||
:label="$t('cancel')"
|
||||
v-close-popup
|
||||
@@ -617,55 +691,74 @@
|
||||
></q-btn>
|
||||
</div>
|
||||
</q-form>
|
||||
{% endraw %}
|
||||
</div>
|
||||
<div v-else-if="parse.lnurlpay">
|
||||
{% raw %}
|
||||
<q-form @submit="payLnurl" class="q-gutter-md">
|
||||
<p v-if="parse.lnurlpay.fixed" class="q-my-none text-h6">
|
||||
<b>{{ parse.lnurlpay.domain }}</b> is requesting {{
|
||||
parse.lnurlpay.maxSendable | msatoshiFormat }}
|
||||
{{LNBITS_DENOMINATION}}
|
||||
<b v-text="parse.lnurlpay.domain"></b> is requesting
|
||||
<span
|
||||
v-text="msatoshiFormat(parse.lnurlpay.maxSendable)"
|
||||
></span>
|
||||
<span v-text="'{{LNBITS_DENOMINATION}}'"></span>
|
||||
<span v-if="parse.lnurlpay.commentAllowed > 0">
|
||||
<br />
|
||||
and a {{parse.lnurlpay.commentAllowed}}-char comment
|
||||
and a
|
||||
<span v-text="parse.lnurlpay.commentAllowed"></span>-char
|
||||
comment
|
||||
</span>
|
||||
</p>
|
||||
<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 />
|
||||
between
|
||||
<b>{{ parse.lnurlpay.minSendable | msatoshiFormat }}</b> and
|
||||
<b>{{ parse.lnurlpay.maxSendable | msatoshiFormat }}</b>
|
||||
{% endraw %} {{LNBITS_DENOMINATION}} {% raw %}
|
||||
<b v-text="msatoshiFormat(parse.lnurlpay.minSendable)"></b> and
|
||||
<b v-text="msatoshiFormat(parse.lnurlpay.maxSendable)"></b>
|
||||
<span v-text="'{{LNBITS_DENOMINATION}}'"></span>
|
||||
<span v-if="parse.lnurlpay.commentAllowed > 0">
|
||||
<br />
|
||||
and a {{parse.lnurlpay.commentAllowed}}-char comment
|
||||
and a
|
||||
<span v-text="parse.lnurlpay.commentAllowed"></span>-char
|
||||
comment
|
||||
</span>
|
||||
</p>
|
||||
<q-separator class="q-my-sm"></q-separator>
|
||||
<div class="row">
|
||||
<p class="col text-justify text-italic">
|
||||
{{ parse.lnurlpay.description }}
|
||||
</p>
|
||||
<p
|
||||
class="col text-justify text-italic"
|
||||
v-text="parse.lnurlpay.description"
|
||||
></p>
|
||||
<p class="col-4 q-pl-md" v-if="parse.lnurlpay.image">
|
||||
<q-img :src="parse.lnurlpay.image" />
|
||||
</p>
|
||||
</div>
|
||||
<div class="row">
|
||||
<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
|
||||
ref="setAmount"
|
||||
filled
|
||||
dense
|
||||
v-model.number="parse.data.amount"
|
||||
type="number"
|
||||
label="Amount ({{LNBITS_DENOMINATION}}) *"
|
||||
:label="$t('amount') + ' (' + parse.data.unit + ') *'"
|
||||
:mask="parse.data.unit != 'sat' ? '#.##' : '#'"
|
||||
:step="parse.data.unit != 'sat' ? '0.01' : '1'"
|
||||
fill-mask="0"
|
||||
reverse-fill-mask
|
||||
:min="parse.lnurlpay.minSendable / 1000"
|
||||
:max="parse.lnurlpay.maxSendable / 1000"
|
||||
:readonly="parse.lnurlpay.fixed"
|
||||
:readonly="parse.lnurlpay && parse.lnurlpay.fixed"
|
||||
></q-input>
|
||||
{% raw %}
|
||||
</div>
|
||||
<div
|
||||
class="col-8 q-pl-md"
|
||||
@@ -682,9 +775,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn unelevated color="primary" type="submit"
|
||||
>Send {{LNBITS_DENOMINATION}}</q-btn
|
||||
>
|
||||
<q-btn unelevated color="primary" type="submit">Send</q-btn>
|
||||
<q-btn
|
||||
:label="$t('cancel')"
|
||||
v-close-popup
|
||||
@@ -694,7 +785,6 @@
|
||||
></q-btn>
|
||||
</div>
|
||||
</q-form>
|
||||
{% endraw %}
|
||||
</div>
|
||||
<div v-else>
|
||||
<q-form
|
||||
@@ -708,6 +798,7 @@
|
||||
v-model.trim="parse.data.request"
|
||||
type="textarea"
|
||||
:label="$t('paste_invoice_label')"
|
||||
ref="textArea"
|
||||
>
|
||||
</q-input>
|
||||
<div class="row q-mt-lg">
|
||||
@@ -718,6 +809,17 @@
|
||||
type="submit"
|
||||
:label="$t('read')"
|
||||
></q-btn>
|
||||
<q-icon
|
||||
name="content_paste"
|
||||
color="grey"
|
||||
class="q-mt-xs q-ml-sm q-mr-auto"
|
||||
v-if="parse.copy.show"
|
||||
@click="pasteToTextArea"
|
||||
>
|
||||
<q-tooltip>
|
||||
<span v-text="$t('paste_from_clipboard')"></span>
|
||||
</q-tooltip>
|
||||
</q-icon>
|
||||
<q-btn
|
||||
v-close-popup
|
||||
flat
|
||||
@@ -750,7 +852,7 @@
|
||||
</q-card>
|
||||
</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">
|
||||
<div class="text-center q-mb-lg">
|
||||
<qrcode-stream
|
||||
@@ -771,42 +873,67 @@
|
||||
</q-card>
|
||||
</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-section>
|
||||
<div class="row q-gutter-sm justify-between">
|
||||
<div class="text-h6">Payments Chart</div>
|
||||
<q-select
|
||||
label="Group"
|
||||
filled
|
||||
dense
|
||||
v-model="paymentsChart.group"
|
||||
style="min-width: 120px"
|
||||
:options="paymentsChart.groupOptions"
|
||||
>
|
||||
</q-select>
|
||||
</div>
|
||||
|
||||
<canvas ref="canvas" width="600" height="400"></canvas>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<q-tabs
|
||||
class="lt-md fixed-bottom left-0 right-0 bg-primary text-white shadow-2 z-top"
|
||||
active-class="px-0"
|
||||
indicator-color="transparent"
|
||||
align="justify"
|
||||
>
|
||||
<q-tab
|
||||
icon="account_balance_wallet"
|
||||
label="Wallets"
|
||||
@click="g.visibleDrawer = !g.visibleDrawer"
|
||||
icon="file_download"
|
||||
@click="showReceiveDialog"
|
||||
:label="$t('receive')"
|
||||
>
|
||||
</q-tab>
|
||||
<q-tab icon="content_paste" label="Paste" @click="showParseDialog">
|
||||
</q-tab>
|
||||
<q-tab icon="file_download" label="Receive" @click="showReceiveDialog">
|
||||
</q-tab>
|
||||
|
||||
<q-tab icon="photo_camera" label="Scan" @click="showCamera"> </q-tab>
|
||||
<q-tab @click="showParseDialog" icon="file_upload" :label="$t('send')">
|
||||
</q-tab>
|
||||
<q-btn
|
||||
round
|
||||
size="35px"
|
||||
unelevated
|
||||
icon="qr_code_scanner"
|
||||
@click="showCamera"
|
||||
class="text-white bg-primary z-top vertical-bottom absolute-center absolute"
|
||||
>
|
||||
</q-btn>
|
||||
</q-tabs>
|
||||
|
||||
<q-dialog v-model="disclaimerDialog.show">
|
||||
<q-dialog v-model="disclaimerDialog.show" position="top">
|
||||
<q-card class="q-pa-lg">
|
||||
<h6 class="q-my-md text-primary">Warning</h6>
|
||||
<p v-text="$t('disclaimer_dialog')"></p>
|
||||
<h6
|
||||
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">
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
@click="copyText(disclaimerDialog.location.href)"
|
||||
:label="$t('copy_wallet_url')"
|
||||
type="a"
|
||||
href="/account"
|
||||
:label="$t('my_account')"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
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
|
||||
// so the service worker reinitializes the cache
|
||||
const CACHE_VERSION = 5
|
||||
const CURRENT_CACHE = `lnbits-${CACHE_VERSION}-`
|
||||
const CURRENT_CACHE = 'lnbits-{{ cache_version }}-'
|
||||
|
||||
const getApiKey = request => {
|
||||
let api_key = request.headers.get('X-Api-Key')
|
||||
@@ -17,8 +16,7 @@ self.addEventListener('activate', evt =>
|
||||
caches.keys().then(cacheNames => {
|
||||
return Promise.all(
|
||||
cacheNames.map(cacheName => {
|
||||
const currentCacheVersion = cacheName.split('-').slice(-2, 2)
|
||||
if (currentCacheVersion !== CACHE_VERSION) {
|
||||
if (!cacheName.startsWith(CURRENT_CACHE)) {
|
||||
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,22 +6,64 @@ from subprocess import Popen
|
||||
from typing import Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from fastapi import Body, Depends
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi.responses import FileResponse
|
||||
from starlette.exceptions import HTTPException
|
||||
|
||||
from lnbits.core.crud import get_wallet
|
||||
from lnbits.core.models import User
|
||||
from lnbits.core.services import update_cached_settings, update_wallet_balance
|
||||
from lnbits.core.models import CreateTopup, User
|
||||
from lnbits.core.services import (
|
||||
get_balance_delta,
|
||||
update_cached_settings,
|
||||
update_wallet_balance,
|
||||
)
|
||||
from lnbits.core.tasks import api_invoice_listeners
|
||||
from lnbits.decorators import check_admin, check_super_user
|
||||
from lnbits.server import server_restart
|
||||
from lnbits.settings import AdminSettings, EditableSettings, settings
|
||||
from lnbits.settings import AdminSettings, UpdateSettings, settings
|
||||
from lnbits.tasks import invoice_listeners
|
||||
|
||||
from .. import core_app
|
||||
from .. import core_app_extra
|
||||
from ..crud import delete_admin_settings, get_admin_settings, update_admin_settings
|
||||
|
||||
admin_router = APIRouter()
|
||||
|
||||
@core_app.get("/admin/api/v1/settings/")
|
||||
|
||||
@admin_router.get(
|
||||
"/admin/api/v1/audit",
|
||||
name="Audit",
|
||||
description="show the current balance of the node and the LNbits database",
|
||||
dependencies=[Depends(check_admin)],
|
||||
)
|
||||
async def api_auditor():
|
||||
try:
|
||||
delta, node_balance, total_balance = await get_balance_delta()
|
||||
return {
|
||||
"delta_msats": int(delta),
|
||||
"node_balance_msats": int(node_balance),
|
||||
"lnbits_balance_msats": int(total_balance),
|
||||
}
|
||||
except Exception:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
detail="Could not audit balance.",
|
||||
)
|
||||
|
||||
|
||||
@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(
|
||||
user: User = Depends(check_admin),
|
||||
) -> Optional[AdminSettings]:
|
||||
@@ -29,21 +71,20 @@ async def api_get_settings(
|
||||
return admin_settings
|
||||
|
||||
|
||||
@core_app.put(
|
||||
@admin_router.put(
|
||||
"/admin/api/v1/settings/",
|
||||
status_code=HTTPStatus.OK,
|
||||
)
|
||||
async def api_update_settings(
|
||||
data: EditableSettings, user: User = Depends(check_admin)
|
||||
):
|
||||
async def api_update_settings(data: UpdateSettings, user: User = Depends(check_admin)):
|
||||
await update_admin_settings(data)
|
||||
admin_settings = await get_admin_settings(user.super_user)
|
||||
assert admin_settings, "Updated admin settings not found."
|
||||
update_cached_settings(admin_settings.dict())
|
||||
core_app_extra.register_new_ratelimiter()
|
||||
return {"status": "Success"}
|
||||
|
||||
|
||||
@core_app.delete(
|
||||
@admin_router.delete(
|
||||
"/admin/api/v1/settings/",
|
||||
status_code=HTTPStatus.OK,
|
||||
dependencies=[Depends(check_super_user)],
|
||||
@@ -53,7 +94,7 @@ async def api_delete_settings() -> None:
|
||||
server_restart.set()
|
||||
|
||||
|
||||
@core_app.get(
|
||||
@admin_router.get(
|
||||
"/admin/api/v1/restart/",
|
||||
status_code=HTTPStatus.OK,
|
||||
dependencies=[Depends(check_super_user)],
|
||||
@@ -63,27 +104,31 @@ async def api_restart_server() -> dict[str, str]:
|
||||
return {"status": "Success"}
|
||||
|
||||
|
||||
@core_app.put(
|
||||
@admin_router.put(
|
||||
"/admin/api/v1/topup/",
|
||||
name="Topup",
|
||||
status_code=HTTPStatus.OK,
|
||||
dependencies=[Depends(check_super_user)],
|
||||
)
|
||||
async def api_topup_balance(
|
||||
id: str = Body(...), amount: int = Body(...)
|
||||
) -> dict[str, str]:
|
||||
async def api_topup_balance(data: CreateTopup) -> dict[str, str]:
|
||||
try:
|
||||
await get_wallet(id)
|
||||
except:
|
||||
await get_wallet(data.id)
|
||||
except Exception:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.FORBIDDEN, detail="wallet does not exist."
|
||||
)
|
||||
|
||||
await update_wallet_balance(wallet_id=id, amount=int(amount))
|
||||
if settings.lnbits_backend_wallet_class == "VoidWallet":
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.FORBIDDEN, detail="VoidWallet active"
|
||||
)
|
||||
|
||||
await update_wallet_balance(wallet_id=data.id, amount=int(data.amount))
|
||||
|
||||
return {"status": "Success"}
|
||||
|
||||
|
||||
@core_app.get(
|
||||
@admin_router.get(
|
||||
"/admin/api/v1/backup/",
|
||||
status_code=HTTPStatus.OK,
|
||||
dependencies=[Depends(check_super_user)],
|
||||
@@ -100,10 +145,10 @@ async def api_download_backup() -> FileResponse:
|
||||
p = urlparse(db_url)
|
||||
command = (
|
||||
f"pg_dump --host={p.hostname} "
|
||||
f'--dbname={p.path.replace("/", "")} '
|
||||
f"--dbname={p.path.replace('/', '')} "
|
||||
f"--username={p.username} "
|
||||
f"--no-password "
|
||||
f"--format=c "
|
||||
"--no-password "
|
||||
"--format=c "
|
||||
f"--file={pg_backup_filename}"
|
||||
)
|
||||
proc = Popen(
|
||||
|
||||
@@ -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,27 +1,32 @@
|
||||
import asyncio
|
||||
import sys
|
||||
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.responses import FileResponse, RedirectResponse
|
||||
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse
|
||||
from fastapi.routing import APIRouter
|
||||
from loguru import logger
|
||||
from pydantic.types import UUID4
|
||||
from starlette.responses import HTMLResponse, JSONResponse
|
||||
|
||||
from lnbits.core import db
|
||||
from lnbits.core.db import core_app_extra, db
|
||||
from lnbits.core.helpers import to_valid_user_id
|
||||
from lnbits.core.models import User
|
||||
from lnbits.decorators import check_admin, check_user_exists
|
||||
from lnbits.helpers import template_renderer, url_for
|
||||
from lnbits.settings import get_wallet_class, settings
|
||||
from lnbits.settings import settings
|
||||
from lnbits.wallets import get_wallet_class
|
||||
|
||||
from ...extension_manager import InstallableExtension, get_valid_extensions
|
||||
from ...utils.exchange_rates import allowed_currencies, currencies
|
||||
from ..crud import (
|
||||
create_account,
|
||||
create_wallet,
|
||||
delete_wallet,
|
||||
get_balance_check,
|
||||
get_dbversions,
|
||||
get_inactive_extensions,
|
||||
get_installed_extensions,
|
||||
get_user,
|
||||
@@ -31,22 +36,40 @@ from ..crud import (
|
||||
)
|
||||
from ..services import pay_invoice, redeem_lnurl_withdraw
|
||||
|
||||
core_html_routes: APIRouter = APIRouter(tags=["Core NON-API Website Routes"])
|
||||
generic_router = APIRouter(
|
||||
tags=["Core NON-API Website Routes"], include_in_schema=False
|
||||
)
|
||||
|
||||
|
||||
@core_html_routes.get("/favicon.ico", response_class=FileResponse)
|
||||
@generic_router.get("/favicon.ico", response_class=FileResponse)
|
||||
async def favicon():
|
||||
return FileResponse("lnbits/core/static/favicon.ico")
|
||||
return FileResponse(Path("lnbits", "static", "favicon.ico"))
|
||||
|
||||
|
||||
@core_html_routes.get("/", response_class=HTMLResponse)
|
||||
@generic_router.get("/", response_class=HTMLResponse)
|
||||
async def home(request: Request, lightning: str = ""):
|
||||
return template_renderer().TemplateResponse(
|
||||
"core/index.html", {"request": request, "lnurl": lightning}
|
||||
)
|
||||
|
||||
|
||||
@core_html_routes.get("/robots.txt", response_class=HTMLResponse)
|
||||
@generic_router.get("/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():
|
||||
data = """
|
||||
User-agent: *
|
||||
@@ -55,46 +78,27 @@ async def robots():
|
||||
return HTMLResponse(content=data, media_type="text/plain")
|
||||
|
||||
|
||||
@core_html_routes.get(
|
||||
"/extensions", name="core.extensions", response_class=HTMLResponse
|
||||
)
|
||||
async def extensions(
|
||||
request: Request,
|
||||
user: User = Depends(check_user_exists),
|
||||
enable: str = Query(None),
|
||||
disable: str = Query(None),
|
||||
):
|
||||
await toggle_extension(enable, disable, user.id)
|
||||
|
||||
# Update user as his extensions have been updated
|
||||
if enable or disable:
|
||||
updated_user = await get_user(user.id)
|
||||
assert updated_user, "User does not exist."
|
||||
user = updated_user
|
||||
|
||||
return template_renderer().TemplateResponse(
|
||||
"core/extensions.html", {"request": request, "user": user.dict()}
|
||||
)
|
||||
|
||||
|
||||
@core_html_routes.get(
|
||||
"/install", name="install.extensions", response_class=HTMLResponse
|
||||
@generic_router.get(
|
||||
"/extensions", name="install.extensions", response_class=HTMLResponse
|
||||
)
|
||||
async def extensions_install(
|
||||
request: Request,
|
||||
user: User = Depends(check_user_exists),
|
||||
activate: str = Query(None),
|
||||
deactivate: str = Query(None),
|
||||
enable: str = Query(None),
|
||||
disable: str = Query(None),
|
||||
):
|
||||
await toggle_extension(enable, disable, user.id)
|
||||
|
||||
try:
|
||||
installed_exts: List["InstallableExtension"] = await get_installed_extensions()
|
||||
installed_exts_ids = [e.id for e in installed_exts]
|
||||
|
||||
installable_exts: List[
|
||||
InstallableExtension
|
||||
] = await InstallableExtension.get_installable_extensions()
|
||||
installable_exts = await InstallableExtension.get_installable_extensions()
|
||||
installable_exts_ids = [e.id for e in 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:
|
||||
@@ -112,21 +116,30 @@ async def extensions_install(
|
||||
|
||||
try:
|
||||
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 deactivate and deactivate not in settings.lnbits_deactivated_extensions:
|
||||
settings.lnbits_deactivated_extensions += [deactivate]
|
||||
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(
|
||||
filter(
|
||||
lambda e: e != activate, settings.lnbits_deactivated_extensions
|
||||
)
|
||||
)
|
||||
|
||||
await update_installed_extension_state(
|
||||
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()
|
||||
db_version = await get_dbversions()
|
||||
extensions = list(
|
||||
map(
|
||||
lambda ext: {
|
||||
@@ -138,21 +151,26 @@ async def extensions_install(
|
||||
"isFeatured": ext.featured,
|
||||
"dependencies": ext.dependencies,
|
||||
"isInstalled": ext.id in installed_exts_ids,
|
||||
"isAvailable": ext.id in all_extensions,
|
||||
"hasDatabaseTables": ext.id in db_version,
|
||||
"isAvailable": ext.id in all_ext_ids,
|
||||
"isAdminOnly": ext.id in settings.lnbits_admin_extensions,
|
||||
"isActive": ext.id not in inactive_extensions,
|
||||
"latestRelease": dict(ext.latest_release)
|
||||
if ext.latest_release
|
||||
else None,
|
||||
"installedRelease": dict(ext.installed_release)
|
||||
if ext.installed_release
|
||||
else None,
|
||||
"latestRelease": (
|
||||
dict(ext.latest_release) if ext.latest_release else None
|
||||
),
|
||||
"installedRelease": (
|
||||
dict(ext.installed_release) if ext.installed_release else None
|
||||
),
|
||||
},
|
||||
installable_exts,
|
||||
)
|
||||
)
|
||||
|
||||
# refresh user state. Eg: enabled extensions.
|
||||
user = await get_user(user.id) or user
|
||||
|
||||
return template_renderer().TemplateResponse(
|
||||
"core/install.html",
|
||||
"core/extensions.html",
|
||||
{
|
||||
"request": request,
|
||||
"user": user.dict(),
|
||||
@@ -164,96 +182,83 @@ async def extensions_install(
|
||||
raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e))
|
||||
|
||||
|
||||
@core_html_routes.get(
|
||||
@generic_router.get(
|
||||
"/wallet",
|
||||
response_class=HTMLResponse,
|
||||
description="""
|
||||
Args:
|
||||
|
||||
just **wallet_name**: create a new user, then create a new wallet for user with wallet_name<br>
|
||||
just **user_id**: return the first user wallet or create one if none found (with default wallet_name)<br>
|
||||
**user_id** and **wallet_name**: create a new wallet for user with wallet_name<br>
|
||||
**user_id** and **wallet_id**: return that wallet if user is the owner<br>
|
||||
nothing: create everything<br>
|
||||
""",
|
||||
description="show wallet page",
|
||||
)
|
||||
async def wallet(
|
||||
request: Request = Query(None),
|
||||
nme: Optional[str] = Query(None),
|
||||
usr: Optional[UUID4] = Query(None),
|
||||
request: Request,
|
||||
lnbits_last_active_wallet: Annotated[Union[str, None], Cookie()] = None,
|
||||
user: User = Depends(check_user_exists),
|
||||
wal: Optional[UUID4] = Query(None),
|
||||
):
|
||||
user_id = usr.hex if usr else None
|
||||
wallet_id = wal.hex if wal else None
|
||||
wallet_name = nme
|
||||
|
||||
if not user_id:
|
||||
new_user = await create_account()
|
||||
user = await get_user(new_user.id)
|
||||
assert user, "Newly created user has to exist."
|
||||
logger.info(f"Create user {user.id}")
|
||||
if wal:
|
||||
wallet_id = wal.hex
|
||||
elif len(user.wallets) == 0:
|
||||
wallet = await create_wallet(user_id=user.id)
|
||||
user = await get_user(user_id=user.id) or user
|
||||
wallet_id = wallet.id
|
||||
elif lnbits_last_active_wallet and user.get_wallet(lnbits_last_active_wallet):
|
||||
wallet_id = lnbits_last_active_wallet
|
||||
else:
|
||||
user = await get_user(user_id)
|
||||
if not user:
|
||||
return template_renderer().TemplateResponse(
|
||||
"error.html", {"request": request, "err": "User does not exist."}
|
||||
)
|
||||
if (
|
||||
len(settings.lnbits_allowed_users) > 0
|
||||
and user_id not in settings.lnbits_allowed_users
|
||||
and user_id not in settings.lnbits_admin_users
|
||||
and user_id != settings.super_user
|
||||
):
|
||||
return template_renderer().TemplateResponse(
|
||||
"error.html", {"request": request, "err": "User not authorized."}
|
||||
)
|
||||
if user_id == settings.super_user or user_id in settings.lnbits_admin_users:
|
||||
user.admin = True
|
||||
if user_id == settings.super_user:
|
||||
user.super_user = True
|
||||
wallet_id = user.wallets[0].id
|
||||
|
||||
if not wallet_id:
|
||||
if user.wallets and not wallet_name:
|
||||
wallet = user.wallets[0]
|
||||
else:
|
||||
wallet = await create_wallet(user_id=user.id, wallet_name=wallet_name)
|
||||
logger.info(
|
||||
f"Created new wallet {wallet_name if wallet_name else '(no name)'} for user {user.id}"
|
||||
)
|
||||
|
||||
return RedirectResponse(
|
||||
f"/wallet?usr={user.id}&wal={wallet.id}",
|
||||
status_code=status.HTTP_307_TEMPORARY_REDIRECT,
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f"Access {'user '+ user.id + ' ' if user else ''} {'wallet ' + wallet_name if wallet_name else ''}"
|
||||
)
|
||||
userwallet = user.get_wallet(wallet_id)
|
||||
if not userwallet:
|
||||
user_wallet = user.get_wallet(wallet_id)
|
||||
if not user_wallet or user_wallet.deleted:
|
||||
return template_renderer().TemplateResponse(
|
||||
"error.html", {"request": request, "err": "Wallet not found"}
|
||||
)
|
||||
|
||||
return template_renderer().TemplateResponse(
|
||||
resp = template_renderer().TemplateResponse(
|
||||
"core/wallet.html",
|
||||
{
|
||||
"request": request,
|
||||
"user": user.dict(),
|
||||
"wallet": userwallet.dict(),
|
||||
"wallet": user_wallet.dict(),
|
||||
"currencies": allowed_currencies(),
|
||||
"service_fee": settings.lnbits_service_fee,
|
||||
"service_fee_max": settings.lnbits_service_fee_max,
|
||||
"web_manifest": f"/manifest/{user.id}.webmanifest",
|
||||
},
|
||||
)
|
||||
resp.set_cookie("lnbits_last_active_wallet", wallet_id)
|
||||
return resp
|
||||
|
||||
|
||||
@generic_router.get(
|
||||
"/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):
|
||||
user = await get_user(request.query_params.get("usr"))
|
||||
usr_param = request.query_params.get("usr")
|
||||
if not usr_param:
|
||||
return {"status": "ERROR", "reason": "usr parameter not provided."}
|
||||
|
||||
user = await get_user(usr_param)
|
||||
if not user:
|
||||
return {"status": "ERROR", "reason": "User does not exist."}
|
||||
|
||||
wallet = user.get_wallet(request.query_params.get("wal"))
|
||||
wal_param = request.query_params.get("wal")
|
||||
if not wal_param:
|
||||
return {"status": "ERROR", "reason": "wal parameter not provided."}
|
||||
|
||||
wallet = user.get_wallet(wal_param)
|
||||
if not wallet:
|
||||
return {"status": "ERROR", "reason": "Wallet does not exist."}
|
||||
|
||||
@@ -263,27 +268,39 @@ async def lnurl_full_withdraw(request: Request):
|
||||
"k1": "0",
|
||||
"minWithdrawable": 1000 if wallet.withdrawable_balance else 0,
|
||||
"maxWithdrawable": wallet.withdrawable_balance,
|
||||
"defaultDescription": f"{settings.lnbits_site_title} balance withdraw from {wallet.id[0:5]}",
|
||||
"defaultDescription": (
|
||||
f"{settings.lnbits_site_title} balance withdraw from {wallet.id[0:5]}"
|
||||
),
|
||||
"balanceCheck": url_for("/withdraw", external=True, usr=user.id, wal=wallet.id),
|
||||
}
|
||||
|
||||
|
||||
@core_html_routes.get("/withdraw/cb", response_class=JSONResponse)
|
||||
@generic_router.get("/withdraw/cb", response_class=JSONResponse)
|
||||
async def lnurl_full_withdraw_callback(request: Request):
|
||||
user = await get_user(request.query_params.get("usr"))
|
||||
usr_param = request.query_params.get("usr")
|
||||
if not usr_param:
|
||||
return {"status": "ERROR", "reason": "usr parameter not provided."}
|
||||
|
||||
user = await get_user(usr_param)
|
||||
if not user:
|
||||
return {"status": "ERROR", "reason": "User does not exist."}
|
||||
|
||||
wallet = user.get_wallet(request.query_params.get("wal"))
|
||||
wal_param = request.query_params.get("wal")
|
||||
if not wal_param:
|
||||
return {"status": "ERROR", "reason": "wal parameter not provided."}
|
||||
|
||||
wallet = user.get_wallet(wal_param)
|
||||
if not wallet:
|
||||
return {"status": "ERROR", "reason": "Wallet does not exist."}
|
||||
|
||||
pr = request.query_params.get("pr")
|
||||
if not pr:
|
||||
return {"status": "ERROR", "reason": "payment_request not provided."}
|
||||
|
||||
async def pay():
|
||||
try:
|
||||
await pay_invoice(wallet_id=wallet.id, payment_request=pr)
|
||||
except:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
asyncio.create_task(pay())
|
||||
@@ -295,40 +312,18 @@ async def lnurl_full_withdraw_callback(request: Request):
|
||||
return {"status": "OK"}
|
||||
|
||||
|
||||
@core_html_routes.get("/deletewallet", response_class=RedirectResponse)
|
||||
async def deletewallet(wal: str = Query(...), usr: str = Query(...)):
|
||||
user = await get_user(usr)
|
||||
if not user:
|
||||
raise HTTPException(HTTPStatus.FORBIDDEN, "User not found.")
|
||||
|
||||
user_wallet_ids = [u.id for u in user.wallets]
|
||||
|
||||
if wal not in user_wallet_ids:
|
||||
raise HTTPException(HTTPStatus.FORBIDDEN, "Not your wallet.")
|
||||
else:
|
||||
await delete_wallet(user_id=user.id, wallet_id=wal)
|
||||
user_wallet_ids.remove(wal)
|
||||
logger.debug("Deleted wallet {wal} of user {user.id}")
|
||||
|
||||
if user_wallet_ids:
|
||||
return RedirectResponse(
|
||||
url_for("/wallet", usr=user.id, wal=user_wallet_ids[0]),
|
||||
status_code=status.HTTP_307_TEMPORARY_REDIRECT,
|
||||
)
|
||||
|
||||
return RedirectResponse(
|
||||
url_for("/"), status_code=status.HTTP_307_TEMPORARY_REDIRECT
|
||||
)
|
||||
|
||||
|
||||
@core_html_routes.get("/withdraw/notify/{service}")
|
||||
@generic_router.get("/withdraw/notify/{service}")
|
||||
async def lnurl_balance_notify(request: Request, service: str):
|
||||
bc = await get_balance_check(request.query_params.get("wal"), service)
|
||||
wal_param = request.query_params.get("wal")
|
||||
if not wal_param:
|
||||
return {"status": "ERROR", "reason": "wal parameter not provided."}
|
||||
|
||||
bc = await get_balance_check(wal_param, service)
|
||||
if bc:
|
||||
await redeem_lnurl_withdraw(bc.wallet, bc.url)
|
||||
|
||||
|
||||
@core_html_routes.get(
|
||||
@generic_router.get(
|
||||
"/lnurlwallet", response_class=RedirectResponse, name="core.lnurlwallet"
|
||||
)
|
||||
async def lnurlwallet(request: Request):
|
||||
@@ -338,10 +333,14 @@ async def lnurlwallet(request: Request):
|
||||
assert user, "Newly created user not found."
|
||||
wallet = await create_wallet(user_id=user.id, conn=conn)
|
||||
|
||||
lightning_param = request.query_params.get("lightning")
|
||||
if not lightning_param:
|
||||
return {"status": "ERROR", "reason": "lightning parameter not provided."}
|
||||
|
||||
asyncio.create_task(
|
||||
redeem_lnurl_withdraw(
|
||||
wallet.id,
|
||||
request.query_params.get("lightning"),
|
||||
lightning_param,
|
||||
"LNbits initial funding: voucher redeem.",
|
||||
{"tag": "lnurlwallet"},
|
||||
5, # wait 5 seconds before sending the invoice to the service
|
||||
@@ -354,13 +353,22 @@ async def lnurlwallet(request: Request):
|
||||
)
|
||||
|
||||
|
||||
@core_html_routes.get("/service-worker.js", response_class=FileResponse)
|
||||
async def service_worker():
|
||||
return FileResponse("lnbits/core/static/js/service-worker.js")
|
||||
@generic_router.get("/service-worker.js")
|
||||
async def service_worker(request: Request):
|
||||
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")
|
||||
async def manifest(usr: str):
|
||||
@generic_router.get("/manifest/{usr}.webmanifest")
|
||||
async def manifest(request: Request, usr: str):
|
||||
host = urlparse(str(request.url)).netloc
|
||||
|
||||
user = await get_user(usr)
|
||||
if not user:
|
||||
raise HTTPException(status_code=HTTPStatus.NOT_FOUND)
|
||||
@@ -370,12 +378,49 @@ async def manifest(usr: str):
|
||||
"name": settings.lnbits_site_title + " Wallet",
|
||||
"icons": [
|
||||
{
|
||||
"src": settings.lnbits_custom_logo
|
||||
if settings.lnbits_custom_logo
|
||||
else "https://cdn.jsdelivr.net/gh/lnbits/lnbits@0.3.0/docs/logos/lnbits.png",
|
||||
"src": (
|
||||
settings.lnbits_custom_logo
|
||||
if settings.lnbits_custom_logo
|
||||
else "https://cdn.jsdelivr.net/gh/lnbits/lnbits@main/docs/logos/lnbits.png"
|
||||
),
|
||||
"sizes": "512x512",
|
||||
"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}",
|
||||
"background_color": "#1F2234",
|
||||
@@ -389,13 +434,59 @@ async def manifest(usr: str):
|
||||
"short_name": wallet.name,
|
||||
"description": wallet.name,
|
||||
"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
|
||||
],
|
||||
"url_handlers": [{"origin": f"https://{host}"}],
|
||||
}
|
||||
|
||||
|
||||
@core_html_routes.get("/admin", response_class=HTMLResponse)
|
||||
@generic_router.get("/node", response_class=HTMLResponse)
|
||||
async def node(request: Request, user: User = Depends(check_admin)):
|
||||
if not settings.lnbits_node_ui:
|
||||
raise HTTPException(status_code=HTTPStatus.SERVICE_UNAVAILABLE)
|
||||
|
||||
WALLET = get_wallet_class()
|
||||
_, balance = await WALLET.status()
|
||||
|
||||
return template_renderer().TemplateResponse(
|
||||
"node/index.html",
|
||||
{
|
||||
"request": request,
|
||||
"user": user.dict(),
|
||||
"settings": settings.dict(),
|
||||
"balance": balance,
|
||||
"wallets": user.wallets[0].dict(),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@generic_router.get("/node/public", response_class=HTMLResponse)
|
||||
async def node_public(request: Request):
|
||||
if not settings.lnbits_public_node_ui:
|
||||
raise HTTPException(status_code=HTTPStatus.SERVICE_UNAVAILABLE)
|
||||
|
||||
WALLET = get_wallet_class()
|
||||
_, balance = await WALLET.status()
|
||||
|
||||
return template_renderer().TemplateResponse(
|
||||
"node/public.html",
|
||||
{
|
||||
"request": request,
|
||||
"settings": settings.dict(),
|
||||
"balance": balance,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@generic_router.get("/admin", response_class=HTMLResponse)
|
||||
async def index(request: Request, user: User = Depends(check_admin)):
|
||||
if not settings.lnbits_admin_ui:
|
||||
raise HTTPException(status_code=HTTPStatus.NOT_FOUND)
|
||||
@@ -410,10 +501,20 @@ async def index(request: Request, user: User = Depends(check_admin)):
|
||||
"user": user.dict(),
|
||||
"settings": settings.dict(),
|
||||
"balance": balance,
|
||||
"currencies": list(currencies.keys()),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@generic_router.get("/uuidv4/{hex_value}")
|
||||
async def hex_to_uuid4(hex_value: str):
|
||||
try:
|
||||
user_id = to_valid_user_id(hex_value).hex
|
||||
return RedirectResponse(url=f"/wallet?usr={user_id}")
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e))
|
||||
|
||||
|
||||
async def toggle_extension(extension_to_enable, extension_to_disable, user_id):
|
||||
if extension_to_enable and extension_to_disable:
|
||||
raise HTTPException(
|
||||
|
||||
@@ -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 datetime
|
||||
from http import HTTPStatus
|
||||
|
||||
from fastapi import HTTPException
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from loguru import logger
|
||||
|
||||
from lnbits import bolt11
|
||||
|
||||
from .. import core_app
|
||||
from ..crud import get_standalone_payment
|
||||
from ..tasks import api_invoice_listeners
|
||||
|
||||
public_router = APIRouter()
|
||||
|
||||
@core_app.get("/public/v1/payment/{payment_hash}")
|
||||
|
||||
@public_router.get("/public/v1/payment/{payment_hash}")
|
||||
async def api_public_payment_longpolling(payment_hash):
|
||||
payment = await get_standalone_payment(payment_hash)
|
||||
|
||||
@@ -25,10 +25,9 @@ async def api_public_payment_longpolling(payment_hash):
|
||||
|
||||
try:
|
||||
invoice = bolt11.decode(payment.bolt11)
|
||||
expiration = datetime.datetime.fromtimestamp(invoice.date + invoice.expiry)
|
||||
if expiration < datetime.datetime.now():
|
||||
if invoice.has_expired():
|
||||
return {"status": "expired"}
|
||||
except:
|
||||
except Exception:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST, detail="Invalid bolt11 invoice."
|
||||
)
|
||||
|
||||