Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f53fee6896 | ||
|
|
f14dfd2522 | ||
|
|
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 | ||
|
|
3c92ef2900 | ||
|
|
7feac3bb5e | ||
|
|
590b52cb31 | ||
|
|
b4fce8b00b | ||
|
|
bd47527d34 | ||
|
|
171aa08e0c | ||
|
|
bc2c5fd1a9 | ||
|
|
67c377affc | ||
|
|
09d67249cc | ||
|
|
068965d81b | ||
|
|
aec15ae820 | ||
|
|
c714a2641d | ||
|
|
b9d3fc5744 | ||
|
|
21157fffe2 | ||
|
|
f8058825d8 | ||
|
|
bfb27020ee | ||
|
|
667de4d1f0 | ||
|
|
5c119e903d | ||
|
|
3f3af53240 | ||
|
|
1b84ebf13d | ||
|
|
bef2d4a72e | ||
|
|
04471b8b5b | ||
|
|
be1d515307 | ||
|
|
87d1de2a31 | ||
|
|
0546fb0a77 | ||
|
|
efafd00b43 | ||
|
|
07946d873f | ||
|
|
91b28ebf83 | ||
|
|
dfb6378976 | ||
|
|
64fde0093c | ||
|
|
c8f9473e01 | ||
|
|
fea6183aef | ||
|
|
adae3b4302 | ||
|
|
ce82439fd8 | ||
|
|
0a210f766d | ||
|
|
fe315d0a9b | ||
|
|
156a19f05c | ||
|
|
06fa6c10c1 | ||
|
|
0b596c00ca | ||
|
|
0af22a6256 | ||
|
|
1ac9d2572d | ||
|
|
4a72a5b6b3 | ||
|
|
b465ecf22a | ||
|
|
42b7cd3046 | ||
|
|
45b36b896b | ||
|
|
99964ee6ab | ||
|
|
d82118db20 | ||
|
|
ffb0f4ccb0 | ||
|
|
de38f38723 | ||
|
|
8c3b4a87b1 | ||
|
|
8ee6be30eb | ||
|
|
cf3b169480 | ||
|
|
95a718c6c0 | ||
|
|
2473e0e962 | ||
|
|
90d1e1ebb6 | ||
|
|
554ad88cbd | ||
|
|
0d14d2b56e | ||
|
|
d7e7d89e9a | ||
|
|
1d0fcaa579 | ||
|
|
af212c820c | ||
|
|
0a74ca3972 | ||
|
|
47df94178e | ||
|
|
255638a9ce | ||
|
|
50149c360c | ||
|
|
6fae6dab92 | ||
|
|
be5f909d1c | ||
|
|
2a1a0f3a2f | ||
|
|
5dbd9a9192 | ||
|
|
1f6a961840 | ||
|
|
9b7bda4e4e | ||
|
|
a8103ba091 | ||
|
|
234b508368 | ||
|
|
f766f9b2c3 | ||
|
|
370ec7e366 | ||
|
|
0822058661 | ||
|
|
7be46587db | ||
|
|
fa58a5fa4c | ||
|
|
486631b455 | ||
|
|
435743a265 | ||
|
|
72386033a9 | ||
|
|
69c0cd0276 | ||
|
|
abfee29676 | ||
|
|
9c8a79eb9b | ||
|
|
0607cb1d6e | ||
|
|
02306148df | ||
|
|
3855cf47f3 | ||
|
|
b2bc378a6f | ||
|
|
e59a218912 | ||
|
|
689c443d72 | ||
|
|
58c3b05e29 | ||
|
|
8ce84ce592 | ||
|
|
fe9e821af5 | ||
|
|
a9bdf24425 | ||
|
|
43c9c9754b | ||
|
|
9127ebc621 | ||
|
|
407a61cd29 | ||
|
|
994b203a8e | ||
|
|
239f166a14 | ||
|
|
e1e34def8c | ||
|
|
a805d2a0e8 | ||
|
|
281d37f79c | ||
|
|
0039c60a27 | ||
|
|
3317bc1bc5 | ||
|
|
9cf43dcea1 | ||
|
|
b3910da6d7 | ||
|
|
ec1caa6015 | ||
|
|
0d8d580fc9 | ||
|
|
4dd206da2a | ||
|
|
fbe982af36 | ||
|
|
a360a47be9 | ||
|
|
552f253b5d | ||
|
|
5021d86488 | ||
|
|
c257e42222 | ||
|
|
17cdf57b8f | ||
|
|
375b575eed | ||
|
|
f860c170fc | ||
|
|
095206e2ed | ||
|
|
4f3159339a | ||
|
|
9ba2983572 | ||
|
|
acaf4849d2 | ||
|
|
3fa4135a12 | ||
|
|
a8f534cd2e | ||
|
|
c58fac39cd | ||
|
|
161cb9cfbc | ||
|
|
892d8ffa62 | ||
|
|
3d55d98d33 | ||
|
|
1e39e7dd63 |
@@ -3,7 +3,6 @@ data
|
||||
docker
|
||||
docs
|
||||
tests
|
||||
venv
|
||||
|
||||
lnbits/static/css/*
|
||||
lnbits/static/bundle.js
|
||||
|
||||
@@ -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, 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,10 @@ LNPAY_API_KEY=LNPAY_API_KEY
|
||||
# Wallet Admin in Wallet Access Keys
|
||||
LNPAY_WALLET_KEY=LNPAY_ADMIN_KEY
|
||||
|
||||
# AlbyWallet
|
||||
ALBY_API_ENDPOINT=https://api.getalby.com/
|
||||
ALBY_ACCESS_TOKEN=ALBY_ACCESS_TOKEN
|
||||
|
||||
# OpenNodeWallet
|
||||
OPENNODE_API_ENDPOINT=https://api.opennode.com/
|
||||
OPENNODE_KEY=OPENNODE_ADMIN_KEY
|
||||
@@ -125,7 +101,135 @@ 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
|
||||
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,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,50 @@
|
||||
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 }}
|
||||
@@ -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,37 +0,0 @@
|
||||
name: formatting
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
|
||||
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,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,93 +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:
|
||||
venv-sqlite:
|
||||
runs-on: ubuntu-latest
|
||||
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: Install dependencies
|
||||
env:
|
||||
VIRTUAL_ENV: ./venv
|
||||
PATH: ${{ env.VIRTUAL_ENV }}/bin:${{ env.PATH }}
|
||||
run: |
|
||||
python -m venv ${{ env.VIRTUAL_ENV }}
|
||||
./venv/bin/python -m pip install --upgrade pip
|
||||
./venv/bin/pip install -r requirements.txt
|
||||
./venv/bin/pip install pytest pytest-asyncio pytest-cov requests mock
|
||||
- name: Run tests
|
||||
run: make test-venv
|
||||
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,8 +23,7 @@ tests/data/*.sqlite3
|
||||
*.pyc
|
||||
*.env
|
||||
.env
|
||||
.venv
|
||||
venv
|
||||
.pre-commit-config.yaml
|
||||
|
||||
data
|
||||
*.sqlite3
|
||||
@@ -34,7 +33,10 @@ __bundle__
|
||||
|
||||
coverage.xml
|
||||
node_modules
|
||||
lnbits/static/bundle.*
|
||||
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: 23.7.0
|
||||
hooks:
|
||||
- id: black
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.0.283
|
||||
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']
|
||||
@@ -5,6 +5,9 @@
|
||||
|
||||
*.yml
|
||||
|
||||
**/lnbits/extensions/*
|
||||
**/lnbits/upgrades/*
|
||||
|
||||
**/lnbits/static/vendor
|
||||
**/lnbits/static/bundle.*
|
||||
**/lnbits/static/css/*
|
||||
|
||||
@@ -1,20 +1,26 @@
|
||||
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 curl -sSL https://install.python-poetry.org | python3 -
|
||||
|
||||
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 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
|
||||
RUN apt-get install -y postgresql-client-14
|
||||
|
||||
WORKDIR /app
|
||||
RUN mkdir -p lnbits/data
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN poetry config virtualenvs.create false
|
||||
RUN mkdir data
|
||||
|
||||
RUN poetry install --only main
|
||||
RUN poetry run python tools/build.py
|
||||
|
||||
ENV LNBITS_PORT="5000"
|
||||
ENV LNBITS_HOST="0.0.0.0"
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
.PHONY: test
|
||||
|
||||
all: format check requirements.txt
|
||||
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" \
|
||||
@@ -50,32 +50,73 @@ test-real-wallet:
|
||||
DEBUG=true \
|
||||
poetry run pytest
|
||||
|
||||
test-venv:
|
||||
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
|
||||
FAKE_WALLET_SECRET="ToTheMoon1" \
|
||||
LNBITS_DATA_FOLDER="./tests/data" \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
DEBUG=true \
|
||||
./venv/bin/pytest --durations=1 -s --cov=lnbits --cov-report=xml tests
|
||||
|
||||
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
|
||||
#
|
||||
|
||||
sass:
|
||||
npm run sass
|
||||
|
||||
bundle_no_bump:
|
||||
npm install
|
||||
npm run sass
|
||||
npm run vendor_copy
|
||||
npm run vendor_json
|
||||
poetry run ./node_modules/.bin/prettier -w ./lnbits/static/vendor.json
|
||||
npm run vendor_bundle_css
|
||||
npm run vendor_minify_css
|
||||
npm run vendor_bundle_js
|
||||
npm run vendor_minify_js
|
||||
|
||||
bundle:
|
||||
make bundle_no_bump
|
||||
# increment serviceworker version
|
||||
awk '/CACHE_VERSION =/ {sub(/[0-9]+$$/, $$NF+1)} 1' lnbits/static/js/service-worker.js > lnbits/static/js/service-worker.js.new
|
||||
mv lnbits/static/js/service-worker.js.new lnbits/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_no_bump
|
||||
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,23 @@ 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
|
||||
- LightningTipBot
|
||||
|
||||
See [LNbits Wiki](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 +39,7 @@ LNbits is inspired by all the great work of [opennode.com](https://www.opennode.
|
||||
|
||||
## Running LNbits
|
||||
|
||||
See the [install guide](github.com/lnbits/lnbits/wiki/) 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 +60,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 +74,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,18 +6,32 @@ nav_order: 2
|
||||
---
|
||||
|
||||
|
||||
Making extensions
|
||||
Extension set up
|
||||
=================
|
||||
|
||||
Start off by copying the example extension in `lnbits/extensions/example` into your own:
|
||||
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
|
||||
cp lnbits/extensions/example lnbits/extensions/mysuperplugin -r # Let's not use dashes or anything; it doesn't like those.
|
||||
cd lnbits/extensions/mysuperplugin
|
||||
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'.
|
||||
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.
|
||||
@@ -28,18 +42,16 @@ Going over the example extension's structure:
|
||||
Adding new dependencies
|
||||
-----------------------
|
||||
|
||||
DO NOT ADD NEW DEPENDENCIES. Try to use the dependencies that are availabe in `pyproject.toml`. Getting the LNbits project to accept a new dependency is time consuming and uncertain, and may result in your extension NOT being made available to others.
|
||||
DO NOT ADD NEW DEPENDENCIES. Try to use the dependencies that are availabe in `pyproject.toml`. Getting the LNbits project to accept a new dependency is time consuming and uncertain, and may result in your extension NOT being made available to others.
|
||||
|
||||
If for some reason your extensions must have a new python package to work, and its nees are not met in `pyproject.toml`, you can add a new package using `venv`, or `poerty`:
|
||||
If for some reason your extensions must have a new python package to work, and its nees are not met in `pyproject.toml`, you can add a new package using `poerty`:
|
||||
|
||||
```sh
|
||||
$ poetry add <package>
|
||||
# or
|
||||
$ ./venv/bin/pip install <package>
|
||||
```
|
||||
|
||||
**But we need an extra step to make sure LNbits doesn't break in production.**
|
||||
Dependencies need to be added to `pyproject.toml` and `requirements.txt`, then tested by running on `venv` and `poetry` compatability can be tested with `nix build .#checks.x86_64-linux.vmTest`.
|
||||
Dependencies need to be added to `pyproject.toml`, then tested by running on `poetry` compatability can be tested with `nix build .#checks.x86_64-linux.vmTest`.
|
||||
|
||||
|
||||
SQLite to PostgreSQL migration
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Extension Install
|
||||
|
||||
Anyone can create an extension by following the [example extension](https://github.com/lnbits/lnbits/tree/extension_install_02/lnbits/extensions/example).
|
||||
Anyone can create an extension by following the [example extension](https://github.com/lnbits/example) and [making extensions](https://github.com/lnbits/lnbits/blob/main/docs/devs/extensions.md) dev guide.
|
||||
|
||||
Extensions can be installed by an admin user after the **LNbits** instance has been started.
|
||||
|
||||
@@ -12,10 +12,10 @@ Go to `Manage Server` > `Server` > `Extensions Manifests`
|
||||
|
||||
|
||||
An `Extension Manifest` is a link to a `JSON` file which contains information about various extensions that can be installed (repository of extensions).
|
||||
Multiple repositories can be configured. For more information check the [Manifest File](https://github.com/lnbits/lnbits/blob/extension_install_02/docs/guide/extension-install.md#manifest-file) section.
|
||||
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
|
||||
|
||||
@@ -6,17 +6,18 @@ nav_order: 2
|
||||
|
||||
# Basic installation
|
||||
|
||||
You can choose between four package managers, `poetry`, `nix` and `venv`.
|
||||
You can choose between four package managers, `poetry` and `nix`
|
||||
|
||||
By default, LNbits will use SQLite as its database. You can also use PostgreSQL which is recommended for applications with a high load (see guide below).
|
||||
|
||||
## 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,69 +61,62 @@ 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
|
||||
```
|
||||
|
||||
## Option 3: venv
|
||||
Ideally you would set the environment via the `.env` file,
|
||||
but you can also set the env variables or pass command line arguments:
|
||||
|
||||
```sh
|
||||
git clone https://github.com/lnbits/lnbits.git
|
||||
cd lnbits
|
||||
# ensure you have virtualenv installed, on debian/ubuntu 'apt install python3.9-venv'
|
||||
python3.9 -m venv venv
|
||||
# If you have problems here, try `sudo apt install -y pkg-config libpq-dev`
|
||||
./venv/bin/pip install -r requirements.txt
|
||||
# create the data folder and the .env file
|
||||
mkdir data && cp .env.example .env
|
||||
# build the static files
|
||||
./venv/bin/python tools/build.py
|
||||
``` 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
|
||||
```
|
||||
|
||||
#### Running the server
|
||||
|
||||
```sh
|
||||
./venv/bin/uvicorn lnbits.__main__:app --port 5000
|
||||
```
|
||||
|
||||
If you want to host LNbits on the internet, run with the option `--host 0.0.0.0`.
|
||||
|
||||
## Option 4: Docker
|
||||
## 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 5: Fly.io
|
||||
## Option 4: Fly.io
|
||||
|
||||
Fly.io is a docker container hosting platform that has a generous free tier. You can host LNbits for free on Fly.io for personal use.
|
||||
|
||||
@@ -210,15 +204,8 @@ sudo apt install python3.9-dev gcc build-essential
|
||||
# if the secp256k1 build fails:
|
||||
# if you used poetry
|
||||
poetry add setuptools wheel
|
||||
|
||||
# if you used venv
|
||||
./venv/bin/pip install 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:
|
||||
@@ -303,7 +290,7 @@ Description=LNbits
|
||||
|
||||
[Service]
|
||||
# replace with the absolute path of your lnbits installation
|
||||
WorkingDirectory=/home/lnbits/lnbits-legend
|
||||
WorkingDirectory=/home/lnbits/lnbits
|
||||
# same here. run `which poetry` if you can't find the poetry binary
|
||||
ExecStart=/home/lnbits/.local/bin/poetry run lnbits
|
||||
# replace with the user that you're running lnbits on
|
||||
@@ -323,7 +310,8 @@ Save the file and run the following commands:
|
||||
sudo systemctl enable lnbits.service
|
||||
sudo systemctl start lnbits.service
|
||||
```
|
||||
## Reverse proxy with automatic https using Caddy
|
||||
|
||||
## Reverse proxy with automatic HTTPS using Caddy
|
||||
|
||||
Use Caddy to make your LNbits install accessible over clearnet with a domain and https cert.
|
||||
|
||||
@@ -335,11 +323,15 @@ https://caddyserver.com/docs/install#debian-ubuntu-raspbian
|
||||
```
|
||||
sudo caddy stop
|
||||
```
|
||||
|
||||
Create a Caddyfile
|
||||
|
||||
```
|
||||
sudo nano Caddyfile
|
||||
```
|
||||
|
||||
Assuming your LNbits is running on port `5000` add:
|
||||
|
||||
```
|
||||
yourdomain.com {
|
||||
handle /api/v1/payments/sse* {
|
||||
@@ -356,22 +348,30 @@ yourdomain.com {
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Save and exit `CTRL + x`
|
||||
|
||||
```
|
||||
sudo caddy start
|
||||
```
|
||||
|
||||
## Running behind an apache2 reverse proxy over https
|
||||
Install apache2 and enable apache2 mods
|
||||
## Running behind an Apache2 reverse proxy over HTTPS
|
||||
|
||||
Install Apache2 and enable Apache2 mods:
|
||||
|
||||
```sh
|
||||
apt-get install apache2 certbot
|
||||
a2enmod headers ssl proxy proxy-http
|
||||
```
|
||||
create a ssl certificate with letsencrypt
|
||||
|
||||
Create a SSL certificate with LetsEncrypt:
|
||||
|
||||
```sh
|
||||
certbot certonly --webroot --agree-tos --text --non-interactive --webroot-path /var/www/html -d lnbits.org
|
||||
certbot certonly --webroot --agree-tos --non-interactive --webroot-path /var/www/html -d lnbits.org
|
||||
```
|
||||
create a apache2 vhost at: /etc/apache2/sites-enabled/lnbits.conf
|
||||
|
||||
Create an Apache2 vhost at: `/etc/apache2/sites-enabled/lnbits.conf`:
|
||||
|
||||
```sh
|
||||
cat <<EOF > /etc/apache2/sites-enabled/lnbits.conf
|
||||
<VirtualHost *:443>
|
||||
@@ -396,11 +396,57 @@ cat <<EOF > /etc/apache2/sites-enabled/lnbits.conf
|
||||
</VirtualHost>
|
||||
EOF
|
||||
```
|
||||
restart apache2
|
||||
|
||||
Restart Apache2:
|
||||
|
||||
```sh
|
||||
service restart apache2
|
||||
```
|
||||
|
||||
## Running behind an Nginx reverse proxy over HTTPS
|
||||
|
||||
Install nginx:
|
||||
|
||||
```sh
|
||||
apt-get install nginx certbot
|
||||
```
|
||||
|
||||
Create a SSL certificate with LetsEncrypt:
|
||||
|
||||
```sh
|
||||
certbot certonly --nginx --agree-tos -d lnbits.org
|
||||
```
|
||||
|
||||
Create an nginx vhost at `/etc/nginx/sites-enabled/lnbits.org`:
|
||||
|
||||
```sh
|
||||
cat <<EOF > /etc/nginx/sites-enabled/lnbits.org
|
||||
server {
|
||||
server_name lnbits.org;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:5000;
|
||||
}
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
listen [::]:443 ssl;
|
||||
listen 443 ssl;
|
||||
ssl_certificate /etc/letsencrypt/live/lnbits.org/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/lnbits.org/privkey.pem;
|
||||
include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||
}
|
||||
EOF
|
||||
```
|
||||
|
||||
Restart nginx:
|
||||
|
||||
```sh
|
||||
service restart nginx
|
||||
```
|
||||
|
||||
## Using https without reverse proxy
|
||||
The most common way of using LNbits via https is to use a reverse proxy such as Caddy, nginx, or ngriok. However, you can also run LNbits via https without additional software. This is useful for development purposes or if you want to use LNbits in your local network.
|
||||
@@ -433,7 +479,7 @@ mkcert localhost 127.0.0.1 ::1
|
||||
You can then pass the certificate files to uvicorn when you start LNbits:
|
||||
|
||||
```sh
|
||||
./venv/bin/uvicorn lnbits.__main__:app --host 0.0.0.0 --port 5000 --ssl-keyfile ./key.pem --ssl-certfile ./cert.pem
|
||||
poetry run uvicorn lnbits.__main__:app --host 0.0.0.0 --port 5000 --ssl-keyfile ./key.pem --ssl-certfile ./cert.pem
|
||||
```
|
||||
|
||||
|
||||
|
||||
@@ -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,13 +48,13 @@ 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
|
||||
|
||||
- `LND_GRPC_MACAROON_ENCRYPTED`: eNcRyPtEdMaCaRoOn
|
||||
|
||||
To encrypt your macaroon, run `./venv/bin/python lnbits/wallets/macaroon/macaroon.py`.
|
||||
To encrypt your macaroon, run `poetry run python lnbits/wallets/macaroon/macaroon.py`.
|
||||
|
||||
### LNbits
|
||||
|
||||
@@ -57,14 +64,13 @@ To encrypt your macaroon, run `./venv/bin/python lnbits/wallets/macaroon/macaroo
|
||||
|
||||
### LNPay
|
||||
|
||||
For the invoice listener to work you have a publicly accessible URL in your LNbits and must set up [LNPay webhooks](https://dashboard.lnpay.co/webhook/) pointing to `<your LNbits host>/wallet/webhook` with the "Wallet Receive" event and no secret. For example, `https://mylnbits/wallet/webhook` will be the Endpoint Url that gets notified about the payment.
|
||||
For the invoice listener to work you have a publicly accessible URL in your LNbits and must set up [LNPay webhooks](https://dashboard.lnpay.co/webhook/) pointing to `<your LNbits host>/wallet/webhook` with the "Wallet Receive" event and no secret. For example, `https://mylnbits/wallet/webhook` will be the Endpoint Url that gets notified about the payment.
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: **LNPayWallet**
|
||||
- `LNPAY_API_ENDPOINT`: https://api.lnpay.co/v1/
|
||||
- `LNPAY_API_KEY`: sak_apiKey
|
||||
- `LNPAY_WALLET_KEY`: waka_apiKey
|
||||
|
||||
|
||||
### OpenNode
|
||||
|
||||
For the invoice to work you must have a publicly accessible URL in your LNbits. No manual webhook setting is necessary.
|
||||
@@ -73,6 +79,13 @@ For the invoice to work you must have a publicly accessible URL in your LNbits.
|
||||
- `OPENNODE_API_ENDPOINT`: https://api.opennode.com/
|
||||
- `OPENNODE_KEY`: opennodeAdminApiKey
|
||||
|
||||
### Alby
|
||||
|
||||
For the invoice to work you must have a publicly accessible URL in your LNbits. No manual webhook setting is necessary. You can generate an alby access token here: https://getalby.com/developer/access_tokens/new
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: **AlbyWallet**
|
||||
- `ALBY_API_ENDPOINT`: https://api.getalby.com/
|
||||
- `ALBY_ACCESS_TOKEN`: AlbyAccessToken
|
||||
|
||||
### Cliche Wallet
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ LNbits, free and open-source lightning-network wallet/accounts system
|
||||
LNbits is a very simple Python application that sits on top of any funding source, and can be used as:
|
||||
|
||||
* Accounts system to mitigate the risk of exposing applications to your full balance, via unique API keys for each wallet
|
||||
* Extendable platform for exploring lightning-network functionality via LNbits extension framework
|
||||
* Extendable platform for exploring lightning-network functionality via LNbits extension framework
|
||||
* Part of a development stack via LNbits API
|
||||
* Fallback wallet for the LNURL scheme
|
||||
* Instant wallet for LN demonstrations
|
||||
|
||||
|
Before Width: | Height: | Size: 69 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 101.041 30.0001"><defs><path id="a" d="M33.2619 148.1667h154.2143v68.7917H33.2619z"/></defs><g fill="#1f2234" aria-label="LNbits" font-family="sans-serif" font-size=".3095" font-weight="400" letter-spacing=".0031" style="line-height:1.25;white-space:pre;shape-inside:url(#a)" transform="matrix(72.4607 0 0 72.4607 -2399.2814 -10741.3589)"><g transform="matrix(.00244 0 0 .00244 33.0708 148.1594)"><circle cx="101.2976" cy="116.4167" r="84.6667" fill="#673ab7" fill-rule="evenodd"/><path fill="#eee" d="M79.1105 71.9667v49.0613h13.3803v40.141l31.2208-53.5213h-17.8404l17.8404-35.681z"/></g><g fill="#eee" font-family="roboto"><path d="M33.6755 148.518h.0962v.0364h-.1416v-.22h.0454zM33.9796 148.5544h-.0453l-.0883-.1448v.1448h-.0453v-.22h.0453l.0884.145v-.145h.0452z" font-weight="700" style="-inkscape-font-specification:'roboto Bold'"/><path d="M34.1625 148.4744q0 .0375-.0172.0603-.0172.0227-.0462.0227-.031 0-.048-.0219l-.0014.019h-.0256v-.2322h.028v.0866q.0168-.021.0466-.021.0298 0 .0467.0225.017.0225.017.0617zm-.028-.003q0-.0286-.011-.044-.011-.0157-.0317-.0157-.0277 0-.0398.0257v.0707q.0129.0257.04.0257.0202 0 .0314-.0156.0112-.0156.0112-.0469zM34.231 148.5544h-.028v-.1635h.028zm-.0302-.2069q0-.007.0041-.0115.0042-.005.0124-.005.0082 0 .0124.005.0042.005.0042.0115 0 .007-.0042.0113-.0042.005-.0124.005-.0082 0-.0124-.005-.004-.005-.004-.0113zM34.3167 148.3513v.0396h.0305v.0216h-.0305v.1014q0 .01.004.0148.0042.005.014.005.0048 0 .0133-.002v.0227q-.011.003-.0215.003-.0187 0-.0283-.0113-.0095-.0113-.0095-.0322v-.1014h-.0297v-.0216h.0297v-.0396zM34.478 148.511q0-.0113-.0086-.0175-.0085-.006-.0298-.0109-.0211-.005-.0337-.0109-.0124-.006-.0184-.015t-.006-.0209q0-.02.017-.034.017-.0139.0435-.0139.0278 0 .045.0144.0174.0144.0174.0367h-.028q0-.0115-.0099-.0198-.0097-.008-.0245-.008-.0152 0-.0238.007-.0086.007-.0086.0174 0 .0101.008.0153.008.005.0288.01.021.005.034.0112.013.007.0192.0157.0063.009.0063.0222 0 .022-.0175.0352-.0175.0131-.0455.0131-.0197 0-.0348-.007-.015-.007-.0237-.0193-.0085-.0125-.0085-.027h.028q.0007.014.0111.0223.0106.008.0279.008.0158 0 .0253-.006.0097-.006.0097-.0172z" style="-inkscape-font-specification:roboto"/></g></g></svg>
|
||||
|
Before Width: | Height: | Size: 2.2 KiB |
|
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": 1656928814,
|
||||
"narHash": "sha256-RIFfgBuKz6Hp89yRr7+NR5tzIAbn52h8vT6vXkYjZoM=",
|
||||
"lastModified": 1694529238,
|
||||
"narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "7e2a3b3dfd9af950a856d66b0a7d01e3c18aa249",
|
||||
"rev": "ff7b65b44d01cf9ba6a71320833626af21126384",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -15,33 +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": 1657114324,
|
||||
"narHash": "sha256-fWuaUNXrHcz/ciHRHlcSO92dvV3EVS0GJQUSBO5JIB4=",
|
||||
"lastModified": 1702233072,
|
||||
"narHash": "sha256-H5G2wgbim2Ku6G6w+NSaQaauv6B6DlPhY9fMvArKqRo=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "a5c867d9fe9e4380452628e8f171c26b69fa9d3d",
|
||||
"rev": "781e2a9797ecf0f146e81425c822dca69fe4a348",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nixos",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs_2": {
|
||||
"locked": {
|
||||
"lastModified": 1657261001,
|
||||
"narHash": "sha256-sUZeuRYfhG59uD6xafM07bc7bAIkpcGq84Vj4B+cyms=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "0be91cefefde5701f8fa957904618a13e3bb51d8",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-23.11",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
@@ -49,14 +58,19 @@
|
||||
"poetry2nix": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": "nixpkgs_2"
|
||||
"nix-github-actions": "nix-github-actions",
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
],
|
||||
"systems": "systems_2",
|
||||
"treefmt-nix": "treefmt-nix"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1657149754,
|
||||
"narHash": "sha256-iSnZoqwNDDVoO175whSuvl4sS9lAb/2zZ3Sa4ywo970=",
|
||||
"lastModified": 1702334837,
|
||||
"narHash": "sha256-QZG6+zFshyY+L8m2tlOTm75U5m9y7z01g0josVK+8Os=",
|
||||
"owner": "nix-community",
|
||||
"repo": "poetry2nix",
|
||||
"rev": "fc1930e011dea149db81863aac22fe701f36f1b5",
|
||||
"rev": "1f4bcbf1be73abc232a972a77102a3e820485a99",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -70,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",
|
||||
|
||||
@@ -1,35 +1,39 @@
|
||||
{
|
||||
description = "LNbits, free and open-source Lightning wallet and accounts system";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
|
||||
poetry2nix.url = "github:nix-community/poetry2nix";
|
||||
nixpkgs.url = "github:nixos/nixpkgs/nixos-23.11";
|
||||
poetry2nix = {
|
||||
url = "github:nix-community/poetry2nix";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
};
|
||||
outputs = { self, nixpkgs, poetry2nix }@inputs:
|
||||
let
|
||||
supportedSystems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ];
|
||||
forSystems = systems: f:
|
||||
nixpkgs.lib.genAttrs systems
|
||||
(system: f system (import nixpkgs { inherit system; overlays = [ poetry2nix.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
|
||||
];
|
||||
};
|
||||
});
|
||||
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 = ./.;
|
||||
python = pkgs.python39;
|
||||
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 = {
|
||||
|
||||
@@ -7,42 +7,57 @@ 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 ( # register_watchdog,; unregister_watchdog,
|
||||
register_killswitch,
|
||||
register_task_listeners,
|
||||
)
|
||||
from lnbits.settings import settings
|
||||
from lnbits.tasks import cancel_all_tasks, create_permanent_task
|
||||
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 (
|
||||
get_css_vendored,
|
||||
get_js_vendored,
|
||||
template_renderer,
|
||||
url_for_vendored,
|
||||
from .helpers import template_renderer
|
||||
from .middleware import (
|
||||
CustomGZipMiddleware,
|
||||
ExtensionsRedirectMiddleware,
|
||||
InstalledExtensionMiddleware,
|
||||
add_first_install_middleware,
|
||||
add_ip_block_middleware,
|
||||
add_ratelimit_middleware,
|
||||
)
|
||||
from .middleware import ExtensionsRedirectMiddleware, InstalledExtensionMiddleware
|
||||
from .requestvars import g
|
||||
from .tasks import (
|
||||
catch_everything_and_restart,
|
||||
check_pending_payments,
|
||||
internal_invoice_listener,
|
||||
invoice_listener,
|
||||
@@ -51,24 +66,28 @@ from .tasks import (
|
||||
|
||||
|
||||
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",
|
||||
"url": "https://raw.githubusercontent.com/lnbits/lnbits/main/LICENSE",
|
||||
},
|
||||
)
|
||||
|
||||
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}"
|
||||
|
||||
@@ -76,30 +95,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_assets(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)
|
||||
@@ -121,17 +150,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:
|
||||
@@ -141,34 +169,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 = 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).
|
||||
@@ -181,28 +228,38 @@ 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:
|
||||
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 ext.zip_path not in zip_files:
|
||||
if f"./{str(ext.zip_path)}" not in zip_files:
|
||||
ext.download_archive()
|
||||
ext.extract_archive()
|
||||
|
||||
@@ -210,10 +267,13 @@ def check_installed_extension(ext: InstallableExtension) -> bool:
|
||||
|
||||
|
||||
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
|
||||
@@ -224,28 +284,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)
|
||||
@@ -259,7 +348,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")
|
||||
@@ -279,18 +371,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()
|
||||
@@ -298,13 +396,48 @@ 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}")
|
||||
logger.info(f"Baseurl: {settings.lnbits_baseurl}")
|
||||
logger.info(f"Host: {settings.host}")
|
||||
logger.info(f"Port: {settings.port}")
|
||||
@@ -312,9 +445,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():
|
||||
@@ -322,25 +456,14 @@ 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_assets(app: FastAPI):
|
||||
"""Serve each vendored asset separately or a bundle."""
|
||||
|
||||
@app.on_event("startup")
|
||||
async def vendored_assets_variable():
|
||||
if settings.debug:
|
||||
g().VENDORED_JS = map(url_for_vendored, get_js_vendored())
|
||||
g().VENDORED_CSS = map(url_for_vendored, get_css_vendored())
|
||||
else:
|
||||
g().VENDORED_JS = ["/static/bundle.js"]
|
||||
g().VENDORED_CSS = ["/static/bundle.css"]
|
||||
|
||||
|
||||
def register_async_tasks(app):
|
||||
@app.route("/wallet/webhook")
|
||||
async def webhook_listener():
|
||||
@@ -348,17 +471,14 @@ def register_async_tasks(app):
|
||||
|
||||
@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()
|
||||
create_permanent_task(check_pending_payments)
|
||||
create_permanent_task(invoice_listener)
|
||||
create_permanent_task(internal_invoice_listener)
|
||||
create_permanent_task(cache.invalidate_forever)
|
||||
register_task_listeners()
|
||||
register_killswitch()
|
||||
# await run_deferred_async() # calle: doesn't do anyting?
|
||||
|
||||
@app.on_event("shutdown")
|
||||
async def stop_listeners():
|
||||
pass
|
||||
|
||||
|
||||
def register_exception_handlers(app: FastAPI):
|
||||
@app.exception_handler(Exception)
|
||||
@@ -416,6 +536,15 @@ 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", samesite="none", secure=True
|
||||
)
|
||||
return response
|
||||
|
||||
return template_renderer().TemplateResponse(
|
||||
"error.html",
|
||||
{
|
||||
@@ -434,23 +563,49 @@ 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
|
||||
|
||||
|
||||
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,363 +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
|
||||
invoice.date = data.read(35).uint
|
||||
|
||||
while data.pos != data.len:
|
||||
tag, tagdata, data = _pull_tagged(data)
|
||||
data_length = len(tagdata) / 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
|
||||
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(),
|
||||
short_channel_id=_readable_scid(s.read(64).intbe),
|
||||
base_fee_msat=s.read(32).intbe,
|
||||
ppm_fee=s.read(32).intbe,
|
||||
cltv=s.read(16).intbe,
|
||||
)
|
||||
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 = "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":
|
||||
data += encode_fallback(v, addr.currency)
|
||||
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
|
||||
):
|
||||
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.currency = currency
|
||||
self.amount = amount
|
||||
|
||||
def __str__(self):
|
||||
pubkey = bytes.hex(self.pubkey.serialize()).decode()
|
||||
tags = ", ".join([k + "=" + str(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", ""]
|
||||
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)
|
||||
|
||||
|
||||
def is_p2pkh(currency, prefix):
|
||||
return prefix == base58_prefix_map[currency][0]
|
||||
|
||||
|
||||
def is_p2sh(currency, prefix):
|
||||
return prefix == base58_prefix_map[currency][1]
|
||||
|
||||
|
||||
# Tagged field containing BitArray
|
||||
def tagged(char, l):
|
||||
# Tagged fields need to be zero-padded to 5 bits.
|
||||
while l.len % 5 != 0:
|
||||
l.append("0b0")
|
||||
return (
|
||||
bitstring.pack(
|
||||
"uint:5, uint:5, uint:5",
|
||||
CHARSET.find(char),
|
||||
(l.len / 5) / 32,
|
||||
(l.len / 5) % 32,
|
||||
)
|
||||
+ l
|
||||
)
|
||||
|
||||
|
||||
def tagged_bytes(char, l):
|
||||
return tagged(char, bitstring.BitArray(l))
|
||||
|
||||
|
||||
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)
|
||||
return ret
|
||||
from bolt11 import (
|
||||
Bolt11 as Invoice, # noqa: F401
|
||||
)
|
||||
from bolt11 import (
|
||||
decode, # noqa: F401
|
||||
encode, # noqa: F401
|
||||
)
|
||||
|
||||
@@ -1,68 +1,135 @@
|
||||
import asyncio
|
||||
import os
|
||||
import warnings
|
||||
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 .helpers import get_css_vendored, get_js_vendored, url_for_vendored
|
||||
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())
|
||||
|
||||
|
||||
@click.command("assets")
|
||||
def handle_assets():
|
||||
transpile_scss()
|
||||
bundle_vendored()
|
||||
|
||||
|
||||
def transpile_scss():
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore")
|
||||
from scss.compiler import compile_string # type: ignore
|
||||
|
||||
with open(os.path.join(settings.lnbits_path, "static/scss/base.scss")) as scss:
|
||||
with open(
|
||||
os.path.join(settings.lnbits_path, "static/css/base.css"), "w"
|
||||
) as css:
|
||||
css.write(compile_string(scss.read()))
|
||||
|
||||
|
||||
def bundle_vendored():
|
||||
for getfiles, outputpath in [
|
||||
(get_js_vendored, os.path.join(settings.lnbits_path, "static/bundle.js")),
|
||||
(get_css_vendored, os.path.join(settings.lnbits_path, "static/bundle.css")),
|
||||
]:
|
||||
output = ""
|
||||
for path in getfiles():
|
||||
with open(path) as f:
|
||||
output += "/* " + url_for_vendored(path) + " */\n" + f.read() + ";\n"
|
||||
with open(outputpath, "w") as f:
|
||||
f.write(output)
|
||||
|
||||
|
||||
async def migrate_databases():
|
||||
"""Creates the necessary databases if they don't exist already; or migrates them."""
|
||||
|
||||
async with core_db.connect() as conn:
|
||||
exists = False
|
||||
if conn.type == SQLITE:
|
||||
exists = await conn.fetchone(
|
||||
"SELECT * FROM sqlite_master WHERE type='table' AND name='dbversions'"
|
||||
)
|
||||
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:
|
||||
@@ -70,22 +137,455 @@ 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
|
||||
)
|
||||
await _call_install_extension(data, url, admin_user)
|
||||
click.echo(f"Extension '{extension}' ({release.version}) installed.")
|
||||
return True, release.version
|
||||
except HTTPException as ex:
|
||||
click.echo(f"Failed to install '{extension}' Error: '{ex.detail}'.")
|
||||
return False, ex.detail
|
||||
except Exception as ex:
|
||||
click.echo(f"Failed to install '{extension}': {str(ex)}.")
|
||||
return False, str(ex)
|
||||
|
||||
|
||||
async def update_extension(
|
||||
extension: str,
|
||||
repo_index: Optional[str] = None,
|
||||
source_repo: Optional[str] = None,
|
||||
url: Optional[str] = None,
|
||||
admin_user: Optional[str] = None,
|
||||
) -> Tuple[bool, str]:
|
||||
try:
|
||||
click.echo(f"Updating '{extension}' extension.")
|
||||
installed_ext = await get_installed_extension(extension)
|
||||
if not installed_ext:
|
||||
click.echo(
|
||||
f"Extension '{extension}' is not installed. Preparing to install..."
|
||||
)
|
||||
return await install_extension(extension, repo_index, source_repo, url)
|
||||
|
||||
click.echo(f"Current '{extension}' version: {installed_ext.installed_version}.")
|
||||
|
||||
assert (
|
||||
installed_ext.installed_release
|
||||
), "Cannot find previously installed release. Please uninstall first."
|
||||
|
||||
release = await _select_release(extension, repo_index, source_repo)
|
||||
if not release:
|
||||
return False, "No release selected."
|
||||
if (
|
||||
release.version == installed_ext.installed_version
|
||||
and release.source_repo == installed_ext.installed_release.source_repo
|
||||
):
|
||||
click.echo(f"Extension '{extension}' already up to date.")
|
||||
return False, "Already up to date"
|
||||
|
||||
click.echo(f"Updating '{extension}' extension to version: {release.version }")
|
||||
|
||||
data = CreateExtension(
|
||||
ext_id=extension, archive=release.archive, source_repo=release.source_repo
|
||||
)
|
||||
|
||||
await _call_install_extension(data, url, admin_user)
|
||||
click.echo(f"Extension '{extension}' updated.")
|
||||
return True, release.version
|
||||
except HTTPException as ex:
|
||||
click.echo(f"Failed to update '{extension}' Error: '{ex.detail}.")
|
||||
return False, ex.detail
|
||||
except Exception as ex:
|
||||
click.echo(f"Failed to update '{extension}': {str(ex)}.")
|
||||
return False, str(ex)
|
||||
|
||||
|
||||
async def _select_release(
|
||||
extension: str,
|
||||
repo_index: Optional[str] = None,
|
||||
source_repo: Optional[str] = None,
|
||||
) -> Optional[ExtensionRelease]:
|
||||
all_releases = await InstallableExtension.get_extension_releases(extension)
|
||||
if len(all_releases) == 0:
|
||||
click.echo(f"No repository found for extension '{extension}'.")
|
||||
return None
|
||||
|
||||
latest_repo_releases = _get_latest_release_per_repo(all_releases)
|
||||
|
||||
if source_repo:
|
||||
if source_repo not in latest_repo_releases:
|
||||
click.echo(f"Repository not found: '{source_repo}'")
|
||||
return None
|
||||
return latest_repo_releases[source_repo]
|
||||
|
||||
if len(latest_repo_releases) == 1:
|
||||
return latest_repo_releases[list(latest_repo_releases.keys())[0]]
|
||||
|
||||
repos = list(latest_repo_releases.keys())
|
||||
repos.sort()
|
||||
if not repo_index:
|
||||
click.echo("Multiple repos found. Please select one using:")
|
||||
click.echo(" --repo-index option for index of the repo, or")
|
||||
click.echo(" --source-repo option for the manifest URL")
|
||||
click.echo("")
|
||||
click.echo("Repositories: ")
|
||||
|
||||
for index, repo in enumerate(repos):
|
||||
release = latest_repo_releases[repo]
|
||||
click.echo(f" [{index}] {repo} --> {release.version}")
|
||||
click.echo("")
|
||||
return None
|
||||
|
||||
if not repo_index.isnumeric() or not 0 <= int(repo_index) < len(repos):
|
||||
click.echo(f"--repo-index must be between '0' and '{len(repos) - 1}'")
|
||||
return None
|
||||
|
||||
return latest_repo_releases[repos[int(repo_index)]]
|
||||
|
||||
|
||||
def _get_latest_release_per_repo(all_releases):
|
||||
latest_repo_releases = {}
|
||||
for release in all_releases:
|
||||
try:
|
||||
if not release.is_version_compatible:
|
||||
continue
|
||||
# do not remove, parsing also validates
|
||||
release_version = version.parse(release.version)
|
||||
if release.source_repo not in latest_repo_releases:
|
||||
latest_repo_releases[release.source_repo] = release
|
||||
continue
|
||||
if release_version > version.parse(
|
||||
latest_repo_releases[release.source_repo].version
|
||||
):
|
||||
latest_repo_releases[release.source_repo] = release
|
||||
except version.InvalidVersion as ex:
|
||||
logger.warning(f"Invalid version {release.name}: {ex}")
|
||||
return latest_repo_releases
|
||||
|
||||
|
||||
async def _call_install_extension(
|
||||
data: CreateExtension, url: Optional[str], user_id: Optional[str] = None
|
||||
):
|
||||
if url:
|
||||
user_id = user_id or get_super_user()
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.post(
|
||||
f"{url}/api/v1/extension?usr={user_id}", json=data.dict(), timeout=40
|
||||
)
|
||||
resp.raise_for_status()
|
||||
else:
|
||||
await api_install_extension(data, User(id="mock_id"))
|
||||
|
||||
|
||||
async def _call_uninstall_extension(
|
||||
extension: str, url: Optional[str], user_id: Optional[str] = None
|
||||
):
|
||||
if url:
|
||||
user_id = user_id or get_super_user()
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.delete(
|
||||
f"{url}/api/v1/extension/{extension}?usr={user_id}", timeout=40
|
||||
)
|
||||
resp.raise_for_status()
|
||||
else:
|
||||
await api_uninstall_extension(extension, User(id="mock_id"))
|
||||
|
||||
|
||||
async def _can_run_operation(url) -> bool:
|
||||
await check_admin_settings()
|
||||
if await _is_lnbits_started(url):
|
||||
if not url:
|
||||
click.echo("LNbits server is started. Please either:")
|
||||
click.echo(
|
||||
f" - use the '--url' option. Eg: --url=http://{settings.host}:{settings.port}"
|
||||
)
|
||||
click.echo(
|
||||
f" - stop the server running at 'http://{settings.host}:{settings.port}'"
|
||||
)
|
||||
|
||||
return False
|
||||
elif url:
|
||||
click.echo(
|
||||
"The option '--url' has been provided,"
|
||||
+ f" but no server found runnint at '{url}'"
|
||||
)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def _is_lnbits_started(url: Optional[str]):
|
||||
try:
|
||||
url = url or f"http://{settings.host}:{settings.port}/api/v1/health"
|
||||
async with httpx.AsyncClient() as client:
|
||||
await client.get(url)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _is_url(url):
|
||||
try:
|
||||
result = urlparse(url)
|
||||
return all([result.scheme, result.netloc])
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
@@ -1,15 +1,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
|
||||
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,10 +296,16 @@ async def get_user(user_id: str, conn: Optional[Connection] = None) -> Optional[
|
||||
return User(
|
||||
id=user["id"],
|
||||
email=user["email"],
|
||||
extensions=[e[0] for e in extensions],
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
@@ -78,9 +318,9 @@ 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,
|
||||
}
|
||||
|
||||
@@ -88,9 +328,11 @@ async def add_installed_extension(
|
||||
|
||||
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,
|
||||
@@ -133,13 +375,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(
|
||||
@@ -183,10 +446,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,
|
||||
@@ -194,6 +458,8 @@ async def create_wallet(
|
||||
user_id,
|
||||
uuid4().hex,
|
||||
uuid4().hex,
|
||||
now,
|
||||
now,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -204,31 +470,64 @@ 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]:
|
||||
return await (conn or db).execute(
|
||||
"""
|
||||
UPDATE wallets SET
|
||||
name = ?
|
||||
WHERE id = ?
|
||||
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(
|
||||
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"
|
||||
return 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,),
|
||||
)
|
||||
|
||||
|
||||
@@ -237,9 +536,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,),
|
||||
)
|
||||
@@ -248,13 +546,14 @@ 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 = ?
|
||||
""",
|
||||
(key, key),
|
||||
)
|
||||
@@ -262,7 +561,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)
|
||||
@@ -273,6 +572,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
|
||||
# ---------------
|
||||
|
||||
@@ -324,7 +628,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}
|
||||
@@ -338,7 +642,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,
|
||||
@@ -347,29 +651,23 @@ async def get_payments(
|
||||
incoming: bool = False,
|
||||
since: Optional[int] = None,
|
||||
exclude_uncheckable: bool = False,
|
||||
limit: Optional[int] = None,
|
||||
offset: Optional[int] = 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
|
||||
@@ -393,30 +691,53 @@ async def get_payments(
|
||||
clause.append("checking_id NOT LIKE 'temp_%'")
|
||||
clause.append("checking_id NOT LIKE 'internal_%'")
|
||||
|
||||
limit_clause = f"LIMIT {limit}" if type(limit) == int and limit > 0 else ""
|
||||
offset_clause = f"OFFSET {offset}" if type(offset) == int and offset > 0 else ""
|
||||
# combine limit and offset clauses
|
||||
limit_offset_clause = (
|
||||
f"{limit_clause} {offset_clause}"
|
||||
if limit_clause and offset_clause
|
||||
else limit_clause or offset_clause
|
||||
return await (conn or db).fetch_page(
|
||||
"SELECT * FROM apipayments",
|
||||
clause,
|
||||
values,
|
||||
filters=filters,
|
||||
model=Payment,
|
||||
)
|
||||
|
||||
where = ""
|
||||
if clause:
|
||||
where = f"WHERE {' AND '.join(clause)}"
|
||||
|
||||
rows = await (conn or db).fetchall(
|
||||
f"""
|
||||
SELECT *
|
||||
FROM apipayments
|
||||
{where}
|
||||
ORDER BY time DESC
|
||||
{limit_offset_clause}
|
||||
""",
|
||||
tuple(args),
|
||||
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 [Payment.from_row(row) for row in rows]
|
||||
|
||||
return page.data
|
||||
|
||||
|
||||
async def delete_expired_invoices(
|
||||
@@ -454,22 +775,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(
|
||||
"""
|
||||
@@ -488,11 +803,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,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -519,7 +836,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] = []
|
||||
|
||||
@@ -553,7 +869,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"
|
||||
@@ -573,10 +890,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(
|
||||
@@ -591,6 +975,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
|
||||
@@ -604,6 +992,26 @@ async def check_internal(
|
||||
return row["checking_id"]
|
||||
|
||||
|
||||
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
|
||||
"""
|
||||
row = await (conn or db).fetchone(
|
||||
"""
|
||||
SELECT pending FROM apipayments
|
||||
WHERE hash = ? AND amount > 0
|
||||
""",
|
||||
(payment_hash,),
|
||||
)
|
||||
if not row:
|
||||
return True
|
||||
else:
|
||||
return row["pending"]
|
||||
|
||||
|
||||
# balance_check
|
||||
# -------------
|
||||
|
||||
@@ -689,31 +1097,42 @@ 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):
|
||||
async def update_super_user(super_user: str) -> SuperSettings:
|
||||
await db.execute("UPDATE settings SET super_user = ?", (super_user,))
|
||||
return await get_super_settings()
|
||||
settings = await get_super_settings()
|
||||
assert settings, "updated super_user settings could not be retrieved"
|
||||
return settings
|
||||
|
||||
|
||||
async def create_admin_settings(super_user: str, new_settings: dict):
|
||||
sql = "INSERT INTO settings (super_user, editable_settings) VALUES (?, ?)"
|
||||
await db.execute(sql, (super_user, json.dumps(new_settings)))
|
||||
return await get_super_settings()
|
||||
settings = await get_super_settings()
|
||||
assert settings, "created admin settings could not be retrieved"
|
||||
return settings
|
||||
|
||||
|
||||
# db versions
|
||||
@@ -733,6 +1152,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
|
||||
# -------
|
||||
|
||||
@@ -772,3 +1200,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,31 @@ 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`.
|
||||
"""
|
||||
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()
|
||||
except Exception as ex:
|
||||
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
|
||||
@@ -107,7 +108,7 @@ async def m002_add_fields_to_apipayments(db):
|
||||
continue
|
||||
|
||||
for ext in ["withdraw", "events", "lnticket", "paywall", "tpos"]:
|
||||
prefix = "#" + ext + " "
|
||||
prefix = f"#{ext} "
|
||||
if row["memo"].startswith(prefix):
|
||||
new = row["memo"][len(prefix) :]
|
||||
await db.execute(
|
||||
@@ -168,7 +169,8 @@ async def m004_ensure_fees_are_always_negative(db):
|
||||
|
||||
async def m005_balance_check_balance_notify(db):
|
||||
"""
|
||||
Keep track of balanceCheck-enabled lnurl-withdrawals to be consumed by an LNbits wallet and of balanceNotify URLs supplied by users to empty their wallets.
|
||||
Keep track of balanceCheck-enabled lnurl-withdrawals to be consumed by an
|
||||
LNbits wallet and of balanceNotify URLs supplied by users to empty their wallets.
|
||||
"""
|
||||
|
||||
await db.execute(
|
||||
@@ -238,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(
|
||||
"""
|
||||
@@ -250,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
|
||||
@@ -300,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,17 +3,21 @@ 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
|
||||
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 PaymentStatus
|
||||
|
||||
|
||||
@@ -23,7 +27,11 @@ class Wallet(BaseModel):
|
||||
user: str
|
||||
adminkey: str
|
||||
inkey: str
|
||||
currency: Optional[str]
|
||||
balance_msat: int
|
||||
deleted: bool
|
||||
created_at: Optional[int] = None
|
||||
updated_at: Optional[int] = None
|
||||
|
||||
@property
|
||||
def balance(self) -> int:
|
||||
@@ -37,11 +45,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 +65,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]:
|
||||
@@ -75,8 +115,55 @@ class User(BaseModel):
|
||||
w = [wallet for wallet in self.wallets if wallet.id == wallet_id]
|
||||
return w[0] if w else None
|
||||
|
||||
@classmethod
|
||||
def is_extension_for_user(cls, ext: str, user: str) -> bool:
|
||||
if ext not in settings.lnbits_admin_extensions:
|
||||
return True
|
||||
if user == settings.super_user:
|
||||
return True
|
||||
if user in settings.lnbits_admin_users:
|
||||
return True
|
||||
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
|
||||
@@ -159,6 +246,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(
|
||||
@@ -169,7 +258,8 @@ class Payment(BaseModel):
|
||||
return PaymentStatus(None)
|
||||
|
||||
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()
|
||||
@@ -183,25 +273,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):
|
||||
@@ -214,8 +338,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):
|
||||
@@ -228,3 +357,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,51 +1,60 @@
|
||||
import asyncio
|
||||
import datetime
|
||||
import json
|
||||
import time
|
||||
from io import BytesIO
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
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, urlsafe_short_hash
|
||||
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,
|
||||
get_wallet_class,
|
||||
SuperSettings,
|
||||
readonly_variables,
|
||||
send_admin_user_to_saas,
|
||||
settings,
|
||||
)
|
||||
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 PaymentResponse, PaymentStatus
|
||||
|
||||
from . import db
|
||||
from .crud import (
|
||||
check_internal,
|
||||
check_internal_pending,
|
||||
create_account,
|
||||
create_admin_settings,
|
||||
create_payment,
|
||||
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
|
||||
|
||||
try:
|
||||
from typing import TypedDict
|
||||
except ImportError: # pragma: nocover
|
||||
from typing_extensions import TypedDict
|
||||
from .helpers import to_valid_user_id
|
||||
from .models import Payment, UserConfig, Wallet
|
||||
|
||||
|
||||
class PaymentFailure(Exception):
|
||||
@@ -56,10 +65,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,
|
||||
@@ -69,34 +117,50 @@ async def create_invoice(
|
||||
internal: Optional[bool] = False,
|
||||
conn: Optional[Connection] = None,
|
||||
) -> Tuple[str, str]:
|
||||
|
||||
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,
|
||||
@@ -117,16 +181,24 @@ 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:
|
||||
invoice = bolt11_decode(payment_request)
|
||||
|
||||
if not invoice.amount_msat or not invoice.amount_msat > 0:
|
||||
raise ValueError("Amountless invoices not supported.")
|
||||
if max_sat and invoice.amount_msat > max_sat * 1000:
|
||||
raise ValueError("Amount in invoice is too high.")
|
||||
|
||||
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}"
|
||||
|
||||
@@ -135,6 +207,10 @@ async def pay_invoice(
|
||||
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):
|
||||
wallet_id: str
|
||||
@@ -142,6 +218,7 @@ async def pay_invoice(
|
||||
payment_hash: str
|
||||
amount: int
|
||||
memo: str
|
||||
expiry: Optional[datetime.datetime]
|
||||
extra: Optional[Dict]
|
||||
|
||||
payment_kwargs: PaymentKwargs = PaymentKwargs(
|
||||
@@ -149,45 +226,80 @@ 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)
|
||||
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)
|
||||
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(
|
||||
internal_checking_id, incoming=True, conn=conn
|
||||
)
|
||||
assert internal_invoice is not None
|
||||
if (
|
||||
internal_invoice.amount != invoice.amount_msat
|
||||
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
|
||||
await create_payment(
|
||||
checking_id=temp_id,
|
||||
fee=-fee_reserve_msat,
|
||||
conn=conn,
|
||||
**payment_kwargs,
|
||||
)
|
||||
try:
|
||||
new_payment = await create_payment(
|
||||
checking_id=temp_id,
|
||||
fee=-abs(fee_reserve_total_msat),
|
||||
conn=conn,
|
||||
**payment_kwargs,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"could not create temporary payment: {e}")
|
||||
# happens if the same wallet tries to pay an invoice twice
|
||||
raise PaymentFailure("Could not make payment.")
|
||||
|
||||
# do the balance check
|
||||
wallet = await get_wallet(wallet_id, conn=conn)
|
||||
assert wallet
|
||||
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
|
||||
@@ -196,6 +308,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
|
||||
@@ -203,6 +316,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()
|
||||
@@ -212,7 +327,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}")
|
||||
@@ -223,11 +339,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
|
||||
@@ -241,12 +366,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,
|
||||
@@ -260,7 +450,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()
|
||||
@@ -273,9 +464,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
|
||||
|
||||
@@ -293,7 +485,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:
|
||||
@@ -326,19 +519,20 @@ async def perform_lnurlauth(
|
||||
|
||||
return b
|
||||
|
||||
def encode_strict_der(r_int, s_int, order):
|
||||
def encode_strict_der(r: int, s: int, order: int):
|
||||
# if s > order/2 verification will fail sometimes
|
||||
# so we must fix it here (see https://github.com/indutny/elliptic/blob/e71b2d9359c5fe9437fbf46f1f05096de447de57/lib/elliptic/ec/index.js#L146-L147)
|
||||
if s_int > order // 2:
|
||||
s_int = order - s_int
|
||||
# so we must fix it here see:
|
||||
# https://github.com/indutny/elliptic/blob/e71b2d9359c5fe9437fbf46f1f05096de447de57/lib/elliptic/ec/index.js#L146-L147
|
||||
if s > order // 2:
|
||||
s = order - s
|
||||
|
||||
# now we do the strict DER encoding copied from
|
||||
# https://github.com/KiriKiri/bip66 (without any checks)
|
||||
r = int_to_bytes_suitable_der(r_int)
|
||||
s = int_to_bytes_suitable_der(s_int)
|
||||
r_temp = int_to_bytes_suitable_der(r)
|
||||
s_temp = int_to_bytes_suitable_der(s)
|
||||
|
||||
r_len = len(r)
|
||||
s_len = len(s)
|
||||
r_len = len(r_temp)
|
||||
s_len = len(s_temp)
|
||||
sign_len = 6 + r_len + s_len
|
||||
|
||||
signature = BytesIO()
|
||||
@@ -346,16 +540,18 @@ async def perform_lnurlauth(
|
||||
signature.write((sign_len - 2).to_bytes(1, "big", signed=False))
|
||||
signature.write(0x02.to_bytes(1, "big", signed=False))
|
||||
signature.write(r_len.to_bytes(1, "big", signed=False))
|
||||
signature.write(r)
|
||||
signature.write(r_temp)
|
||||
signature.write(0x02.to_bytes(1, "big", signed=False))
|
||||
signature.write(s_len.to_bytes(1, "big", signed=False))
|
||||
signature.write(s)
|
||||
signature.write(s_temp)
|
||||
|
||||
return signature.getvalue()
|
||||
|
||||
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,
|
||||
params={
|
||||
@@ -386,45 +582,81 @@ async def check_transaction_status(
|
||||
return PaymentStatus(None)
|
||||
if not payment.pending:
|
||||
# note: before, we still checked the status of the payment again
|
||||
return PaymentStatus(True)
|
||||
return PaymentStatus(True, 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))
|
||||
|
||||
|
||||
async def update_wallet_balance(wallet_id: str, amount: int):
|
||||
internal_id = f"internal_{urlsafe_short_hash()}"
|
||||
payment = await create_payment(
|
||||
wallet_id=wallet_id,
|
||||
checking_id=internal_id,
|
||||
payment_request="admin_internal",
|
||||
payment_hash="admin_internal",
|
||||
amount=amount * 1000,
|
||||
memo="Admin top up",
|
||||
pending=False,
|
||||
)
|
||||
# manually send this for now
|
||||
from lnbits.tasks import internal_invoice_queue
|
||||
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
|
||||
|
||||
await internal_invoice_queue.put(internal_id)
|
||||
return payment
|
||||
|
||||
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,
|
||||
amount=amount,
|
||||
memo="Admin top up",
|
||||
internal=True,
|
||||
)
|
||||
async with db.connect() as conn:
|
||||
checking_id = await check_internal(payment_hash, conn=conn)
|
||||
assert checking_id, "newly created checking_id cannot be retrieved"
|
||||
await update_payment_status(checking_id=checking_id, pending=False, conn=conn)
|
||||
# notify receiver asynchronously
|
||||
from lnbits.tasks import internal_invoice_queue
|
||||
|
||||
await internal_invoice_queue.put(checking_id)
|
||||
|
||||
|
||||
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
|
||||
@@ -432,13 +664,9 @@ 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 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
|
||||
if (
|
||||
@@ -448,25 +676,58 @@ 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}")
|
||||
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"])
|
||||
|
||||
|
||||
async def init_admin_settings(super_user: str = None):
|
||||
async def init_admin_settings(super_user: Optional[str] = None) -> SuperSettings:
|
||||
account = None
|
||||
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)
|
||||
|
||||
@@ -476,12 +737,12 @@ async def init_admin_settings(super_user: str = None):
|
||||
|
||||
|
||||
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):
|
||||
@@ -498,3 +759,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,40 +4,115 @@ 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 lnbits.core.crud import (
|
||||
get_balance_notify,
|
||||
get_wallet,
|
||||
get_webpush_subscriptions_for_user,
|
||||
)
|
||||
from lnbits.core.db import db
|
||||
from lnbits.core.models import Payment
|
||||
from lnbits.core.services import (
|
||||
get_balance_delta,
|
||||
send_payment_notification,
|
||||
switch_to_voidwallet,
|
||||
)
|
||||
from lnbits.settings import get_wallet_class, settings
|
||||
from lnbits.tasks import (
|
||||
SseListenersDict,
|
||||
create_permanent_task,
|
||||
create_task,
|
||||
register_invoice_listener,
|
||||
send_push_notification,
|
||||
)
|
||||
|
||||
api_invoice_listeners: Dict[str, asyncio.Queue] = SseListenersDict(
|
||||
"api_invoice_listeners"
|
||||
)
|
||||
|
||||
|
||||
async def register_task_listeners():
|
||||
def register_killswitch():
|
||||
"""
|
||||
Registers an invoice listener queue for the core tasks.
|
||||
Incoming payaments in this queue will eventually trigger the signals sent to all other extensions
|
||||
Registers a killswitch which will check lnbits-status repository for a signal from
|
||||
LNbits and will switch to VoidWallet if the killswitch is triggered.
|
||||
"""
|
||||
logger.debug("Starting killswitch task")
|
||||
create_permanent_task(killswitch_task)
|
||||
|
||||
|
||||
async def killswitch_task():
|
||||
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.ConnectError, httpx.RequestError):
|
||||
logger.error(
|
||||
"Cannot fetch lnbits status manifest."
|
||||
f" {settings.lnbits_status_manifest}"
|
||||
)
|
||||
await asyncio.sleep(settings.lnbits_killswitch_interval * 60)
|
||||
|
||||
|
||||
async def register_watchdog():
|
||||
"""
|
||||
Registers a watchdog which will check lnbits balance and nodebalance
|
||||
and will switch to VoidWallet if the watchdog delta is reached.
|
||||
"""
|
||||
# TODO: implement watchdog properly
|
||||
# logger.debug("Starting watchdog task")
|
||||
# create_permanent_task(watchdog_task)
|
||||
|
||||
|
||||
async def watchdog_task():
|
||||
while True:
|
||||
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)
|
||||
|
||||
|
||||
def register_task_listeners():
|
||||
"""
|
||||
Registers an invoice listener queue for the core tasks. Incoming payments in this
|
||||
queue will eventually trigger the signals sent to all other extensions
|
||||
and fulfill other core tasks such as dispatching webhooks.
|
||||
"""
|
||||
invoice_paid_queue = asyncio.Queue(5)
|
||||
# we register invoice_paid_queue to receive all incoming invoices
|
||||
register_invoice_listener(invoice_paid_queue, "core/tasks.py")
|
||||
# register a worker that will react to invoices
|
||||
asyncio.create_task(wait_for_paid_invoices(invoice_paid_queue))
|
||||
create_task(wait_for_paid_invoices(invoice_paid_queue))
|
||||
|
||||
|
||||
async def wait_for_paid_invoices(invoice_paid_queue: asyncio.Queue):
|
||||
"""
|
||||
This worker dispatches events to all extensions, dispatches webhooks and balance notifys.
|
||||
This worker dispatches events to all extensions,
|
||||
dispatches webhooks and balance notifys.
|
||||
"""
|
||||
while True:
|
||||
payment = await invoice_paid_queue.get()
|
||||
logger.trace("received invoice paid event")
|
||||
# send information to sse channel
|
||||
await dispatch_api_invoice_listeners(payment)
|
||||
|
||||
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)
|
||||
@@ -45,13 +120,16 @@ async def wait_for_paid_invoices(invoice_paid_queue: asyncio.Queue):
|
||||
# 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
|
||||
|
||||
await send_payment_push_notification(payment)
|
||||
|
||||
|
||||
async def dispatch_api_invoice_listeners(payment: Payment):
|
||||
"""
|
||||
@@ -70,11 +148,16 @@ 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, -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
|
||||
r = await client.post(payment.webhook, json=data, timeout=40)
|
||||
await mark_webhook_sent(payment, r.status_code)
|
||||
except (httpx.ConnectError, httpx.RequestError):
|
||||
await mark_webhook_sent(payment, -1)
|
||||
@@ -88,3 +171,26 @@ async def mark_webhook_sent(payment: Payment, status: int) -> None:
|
||||
""",
|
||||
(status, payment.payment_hash),
|
||||
)
|
||||
|
||||
|
||||
async def send_payment_push_notification(payment: Payment):
|
||||
wallet = await get_wallet(payment.wallet_id)
|
||||
|
||||
if wallet:
|
||||
subscriptions = await get_webpush_subscriptions_for_user(wallet.user)
|
||||
|
||||
amount = int(payment.amount / 1000)
|
||||
|
||||
title = f"LNbits: {wallet.name}"
|
||||
body = f"You just received {amount} sat{'s'[:amount^1]}!"
|
||||
|
||||
if payment.memo:
|
||||
body += f"\r\n{payment.memo}"
|
||||
|
||||
for subscription in subscriptions:
|
||||
# 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)
|
||||
|
||||
@@ -9,98 +9,87 @@
|
||||
<ul>
|
||||
{%raw%}
|
||||
<li>Funding Source: {{settings.lnbits_backend_wallet_class}}</li>
|
||||
<li>Balance: {{balance / 1000}} sats</li>
|
||||
<li>
|
||||
Node Balance: {{(auditData.node_balance_msats /
|
||||
1000).toLocaleString()}} sats
|
||||
</li>
|
||||
<li>
|
||||
LNbits Balance: {{(auditData.lnbits_balance_msats /
|
||||
1000).toLocaleString()}} sats
|
||||
</li>
|
||||
<li>
|
||||
Reserve Percent: {{(auditData.node_balance_msats /
|
||||
auditData.lnbits_balance_msats * 100).toFixed(2)}} %
|
||||
</li>
|
||||
{%endraw%}
|
||||
</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>
|
||||
<div v-if="isSuperUser" id="funding-sources">
|
||||
<lnbits-funding-sources
|
||||
:form-data="formData"
|
||||
:allowed-funding-sources="settings.lnbits_allowed_funding_sources"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
|
||||
@@ -0,0 +1,410 @@
|
||||
<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"
|
||||
>{% raw %}{{ log }}{% endraw %}<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>
|
||||
{%raw%}
|
||||
<q-chip
|
||||
v-for="blocked_ip in formData.lnbits_blocked_ips"
|
||||
:key="blocked_ip"
|
||||
removable
|
||||
@remove="removeBlockedIPs(blocked_ip)"
|
||||
color="primary"
|
||||
text-color="white"
|
||||
>
|
||||
{{ blocked_ip }}
|
||||
</q-chip>
|
||||
{%endraw%}
|
||||
</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>
|
||||
{%raw%}
|
||||
<q-chip
|
||||
v-for="allowed_ip in formData.lnbits_allowed_ips"
|
||||
:key="allowed_ip"
|
||||
removable
|
||||
@remove="removeAllowedIPs(allowed_ip)"
|
||||
color="primary"
|
||||
text-color="white"
|
||||
>
|
||||
{{ allowed_ip }}
|
||||
</q-chip>
|
||||
{%endraw%}
|
||||
</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,72 @@
|
||||
{% raw %}
|
||||
<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"
|
||||
>{{ 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">
|
||||
{{ formatDate(props.row.date) }}
|
||||
</q-td>
|
||||
<q-td key="message" :props="props"
|
||||
>{{ props.row.message }}
|
||||
<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>
|
||||
{% endraw %}
|
||||
@@ -21,15 +21,41 @@
|
||||
</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.name)"
|
||||
></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
|
||||
|
||||
@@ -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"
|
||||
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"
|
||||
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>
|
||||
|
||||
@@ -3,12 +3,14 @@
|
||||
<div class="row q-col-gutter-md justify-center">
|
||||
<div class="col q-my-md">
|
||||
<q-btn
|
||||
label="Save"
|
||||
:label="$t('save')"
|
||||
color="primary"
|
||||
@click="updateSettings"
|
||||
:disabled="!checkChanges"
|
||||
>
|
||||
<q-tooltip v-if="checkChanges"> Save your changes </q-tooltip>
|
||||
<q-tooltip v-if="checkChanges"
|
||||
>{%raw%}{{ $t('save_tooltip') }}{%endraw%}</q-tooltip
|
||||
>
|
||||
|
||||
<q-badge
|
||||
v-if="checkChanges"
|
||||
@@ -21,13 +23,13 @@
|
||||
|
||||
<q-btn
|
||||
v-if="isSuperUser"
|
||||
label="Restart server"
|
||||
:label="$t('restart')"
|
||||
color="primary"
|
||||
@click="restartServer"
|
||||
>
|
||||
<q-tooltip v-if="needsRestart">
|
||||
Restart the server for changes to take effect
|
||||
</q-tooltip>
|
||||
<q-tooltip v-if="needsRestart"
|
||||
>{%raw%}{{ $t('restart_tooltip') }}{%endraw%}</q-tooltip
|
||||
>
|
||||
|
||||
<q-badge
|
||||
v-if="needsRestart"
|
||||
@@ -40,28 +42,24 @@
|
||||
|
||||
<q-btn
|
||||
v-if="isSuperUser"
|
||||
label="Topup"
|
||||
:label="$t('topup')"
|
||||
color="primary"
|
||||
@click="topUpDialog.show = true"
|
||||
>
|
||||
<q-tooltip> Add funds to a wallet. </q-tooltip>
|
||||
<q-tooltip>{%raw%}{{ $t('add_funds_tooltip') }}{%endraw%}</q-tooltip>
|
||||
</q-btn>
|
||||
|
||||
<q-btn
|
||||
label="Download Database Backup"
|
||||
flat
|
||||
@click="downloadBackup"
|
||||
></q-btn>
|
||||
<q-btn :label="$t('download_backup')" flat @click="downloadBackup"></q-btn>
|
||||
|
||||
<q-btn
|
||||
flat
|
||||
v-if="isSuperUser"
|
||||
label="Reset to defaults"
|
||||
:label="$t('reset_defaults')"
|
||||
color="primary"
|
||||
@click="deleteSettings"
|
||||
class="float-right"
|
||||
>
|
||||
<q-tooltip> Delete all settings and reset to defaults. </q-tooltip>
|
||||
<q-tooltip>{%raw%}{{ $t('reset_defaults_tooltip') }}{%endraw%}</q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
@@ -74,25 +72,31 @@
|
||||
<q-tabs v-model="tab" active-color="primary" align="justify">
|
||||
<q-tab
|
||||
name="funding"
|
||||
label="Funding"
|
||||
:label="$t('funding')"
|
||||
@update="val => tab = val.name"
|
||||
></q-tab>
|
||||
|
||||
<q-tab
|
||||
name="users"
|
||||
label="Users"
|
||||
:label="$t('users')"
|
||||
@update="val => tab = val.name"
|
||||
></q-tab>
|
||||
|
||||
<q-tab
|
||||
name="server"
|
||||
label="Server"
|
||||
:label="$t('server')"
|
||||
@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="Theme"
|
||||
:label="$t('theme')"
|
||||
@update="val => tab = val.name"
|
||||
></q-tab>
|
||||
</q-tabs>
|
||||
@@ -103,7 +107,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>
|
||||
@@ -113,7 +118,7 @@
|
||||
<q-dialog v-if="isSuperUser" v-model="topUpDialog.show" position="top">
|
||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<q-form class="q-gutter-md">
|
||||
<p>TopUp a wallet</p>
|
||||
<p v-text="$t('topup_wallet')"></p>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
@@ -123,7 +128,7 @@
|
||||
filled
|
||||
v-model="wallet.id"
|
||||
label="Wallet ID"
|
||||
hint="Use the wallet ID to topup any wallet"
|
||||
:hint="$t('topup_hint')"
|
||||
></q-input>
|
||||
|
||||
<br />
|
||||
@@ -135,409 +140,35 @@
|
||||
type="number"
|
||||
filled
|
||||
v-model="wallet.amount"
|
||||
label="Topup amount"
|
||||
:label="$t('amount')"
|
||||
></q-input>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn label="Topup" color="primary" @click="topupWallet"></q-btn>
|
||||
<q-btn
|
||||
:label="$t('topup')"
|
||||
color="primary"
|
||||
@click="topupWallet"
|
||||
></q-btn>
|
||||
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</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>
|
||||
{% 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'
|
||||
},
|
||||
lnd_cert: {
|
||||
value: null,
|
||||
label: 'Certificate'
|
||||
},
|
||||
lnd_rest_admin_macaroon: {
|
||||
value: null,
|
||||
label: 'Admin Macaroon'
|
||||
},
|
||||
lnd_rest_invoice_macaroon: {
|
||||
value: null,
|
||||
label: 'Invoice 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_invoice_macaroon: {
|
||||
value: null,
|
||||
label: 'Invoice 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>
|
||||
<style>
|
||||
.introjs-tooltip-custom .introjs-tooltiptext,
|
||||
.introjs-tooltip-header {
|
||||
color: #111;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<q-expansion-item
|
||||
group="extras"
|
||||
icon="swap_vertical_circle"
|
||||
label="API info"
|
||||
icon="vpn_key"
|
||||
:label="$t('api_keys_api_docs')"
|
||||
:content-inset-level="0.5"
|
||||
>
|
||||
<q-card-section>
|
||||
|
||||
@@ -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 %}
|
||||
@@ -1,48 +1,70 @@
|
||||
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
|
||||
%} {% block scripts %} {{ window_vars(user) }}
|
||||
<script src="/core/static/js/extensions.js"></script>
|
||||
{% endblock %} {% block page %}
|
||||
%} {{ window_vars(user, extensions) }}{% block page %}
|
||||
<div class="row q-col-gutter-md q-mb-md">
|
||||
<div class="col-sm-9 gt-sm col-xs-12 mt-lg">
|
||||
<p class="text-h4">
|
||||
Extensions
|
||||
<q-btn
|
||||
flat
|
||||
color="primary"
|
||||
type="a"
|
||||
:href="['/install?usr=', user.id].join('')"
|
||||
>Manage Extensions</q-btn
|
||||
>
|
||||
</p>
|
||||
<div class="col-sm-9 col-xs-12">
|
||||
<p class="text-h4 gt-sm">{%raw%}{{ $t('extensions') }}{%endraw%}</p>
|
||||
</div>
|
||||
|
||||
<div class="col-sm-3 col-xs-12 q-ml-auto">
|
||||
<q-input v-model="searchTerm" label="Search extensions">
|
||||
<q-input v-model="searchTerm" :label="$t('search_extensions')">
|
||||
<q-icon
|
||||
v-if="searchTerm !== ''"
|
||||
name="close"
|
||||
@click="searchTerm = ''"
|
||||
class="cursor-pointer q-mt-lg"
|
||||
class="cursor-pointer"
|
||||
/>
|
||||
</q-input>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="!filteredExtensions.length">
|
||||
You don't have any extensions installed :(
|
||||
</p>
|
||||
|
||||
<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="$t('installed')"
|
||||
@update="val => tab = val.name"
|
||||
></q-tab>
|
||||
<q-tab
|
||||
name="all"
|
||||
:label="$t('all')"
|
||||
@update="val => tab = val.name"
|
||||
></q-tab>
|
||||
<q-tab
|
||||
name="featured"
|
||||
:label="$t('featured')"
|
||||
@update="val => tab = val.name"
|
||||
></q-tab>
|
||||
<i v-if="!g.user.admin && tab != 'installed'"
|
||||
>{%raw%}{{ $t('only_admins_can_install') }}{%endraw%}</i
|
||||
>
|
||||
</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.code"
|
||||
:key="extension.id + extension.hash"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section style="min-height: 140px">
|
||||
<q-card-section style="min-height: 140px" class="q-pb-none">
|
||||
<div class="row">
|
||||
<div class="col-3">
|
||||
<q-img
|
||||
v-if="extension.tile"
|
||||
:src="extension.tile"
|
||||
v-if="extension.icon"
|
||||
:src="extension.icon"
|
||||
spinner-color="white"
|
||||
style="max-width: 100%"
|
||||
></q-img>
|
||||
@@ -62,6 +84,16 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-9 q-pl-sm">
|
||||
<q-badge
|
||||
v-if="hasNewVersion(extension)"
|
||||
color="green"
|
||||
class="float-right"
|
||||
>
|
||||
<small>{%raw%}{{ $t('new_version') }}{%endraw%}</small>
|
||||
<q-tooltip
|
||||
><span v-text="extension.latestRelease.version"></span
|
||||
></q-tooltip>
|
||||
</q-badge>
|
||||
{% raw %}
|
||||
<div class="text-h5 gt-sm q-mt-sm q-mb-xs gt-sm">
|
||||
{{ extension.name }}
|
||||
@@ -76,7 +108,8 @@
|
||||
class="text-subtitle2 gt-sm"
|
||||
style="font-size: 11px; height: 34px"
|
||||
>
|
||||
{{ extension.shortDescription }}
|
||||
{{ extension.shortDescription ||
|
||||
extension.installedRelease?.description }}
|
||||
</div>
|
||||
<div class="text-subtitle1 lt-md q-mt-sm q-mb-xs">
|
||||
{{ extension.name }}
|
||||
@@ -90,6 +123,21 @@
|
||||
{% endraw %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-pt-sm">
|
||||
<div class="col">
|
||||
<small v-if="extension.dependencies?.length"
|
||||
>{%raw%}{{ $t('extension_depends_on') }}{%endraw%}</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>
|
||||
@@ -100,47 +148,584 @@
|
||||
size="1.5em"
|
||||
:max="5"
|
||||
color="primary"
|
||||
></q-rating>
|
||||
><q-tooltip
|
||||
>{%raw%}{{ $t('extension_rating_soon') }}{%endraw%}</q-tooltip
|
||||
></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>
|
||||
><q-tooltip
|
||||
>{%raw%}{{ $t('extension_rating_soon') }}{%endraw%}</q-tooltip
|
||||
></q-rating
|
||||
>
|
||||
<q-toggle
|
||||
v-if="extension.isAvailable && extension.isInstalled && g.user.admin"
|
||||
:label="extension.isActive ? $t('activated'): $t('deactivated') "
|
||||
color="secondary"
|
||||
style="max-height: 21px"
|
||||
v-model="extension.isActive"
|
||||
@input="toggleExtension(extension)"
|
||||
><q-tooltip
|
||||
>{%raw%}{{ $t('activate_extension_details')
|
||||
}}{%endraw%}</q-tooltip
|
||||
></q-toggle
|
||||
>
|
||||
</div>
|
||||
</q-card-section>
|
||||
<q-separator></q-separator>
|
||||
<q-card-actions>
|
||||
<div v-if="extension.isEnabled">
|
||||
<q-btn
|
||||
flat
|
||||
color="primary"
|
||||
type="a"
|
||||
:href="[extension.url, '?usr=', g.user.id].join('')"
|
||||
>Open</q-btn
|
||||
<q-card-actions style="min-height: 52px">
|
||||
<div class="col-10">
|
||||
<div v-if="!extension.inProgress">
|
||||
<q-btn
|
||||
v-if="user.extensions.includes(extension.id) && extension.isActive && extension.isInstalled"
|
||||
flat
|
||||
color="primary"
|
||||
type="a"
|
||||
:href="extension.id + '/'"
|
||||
>{%raw%}{{ $t('open') }}{%endraw%}</q-btn
|
||||
>
|
||||
<q-btn
|
||||
v-if="user.extensions.includes(extension.id) && extension.isActive && extension.isInstalled"
|
||||
flat
|
||||
color="grey-5"
|
||||
type="a"
|
||||
:href="['{{
|
||||
url_for('install.extensions')
|
||||
}}', '?disable=', extension.id].join('')"
|
||||
>
|
||||
{%raw%}{{ $t('disable') }}{%endraw%}</q-btn
|
||||
>
|
||||
<q-badge v-if="extension.isAdminOnly && !user.admin">
|
||||
{%raw%}{{ $t('admin_only') }}{%endraw%}
|
||||
</q-badge>
|
||||
<q-btn
|
||||
v-else-if="extension.isInstalled && extension.isActive && !user.extensions.includes(extension.id)"
|
||||
flat
|
||||
color="primary"
|
||||
type="a"
|
||||
:href="['{{
|
||||
url_for('install.extensions')
|
||||
}}', '?enable=', extension.id].join('')"
|
||||
>
|
||||
{%raw%}{{ $t('enable') }}{%endraw%}
|
||||
<q-tooltip>
|
||||
<span v-text="$t('enable_extension_details')">
|
||||
</span> </q-tooltip
|
||||
></q-btn>
|
||||
|
||||
<q-btn
|
||||
@click="showUpgrade(extension)"
|
||||
flat
|
||||
color="primary"
|
||||
v-if="g.user.admin"
|
||||
>
|
||||
{%raw%}{{ $t('manage') }}{%endraw%}<q-tooltip
|
||||
>{%raw%}{{ $t('manage_extension_details')
|
||||
}}{%endraw%}</q-tooltip
|
||||
></q-btn
|
||||
>
|
||||
</div>
|
||||
<div v-else>
|
||||
<q-spinner color="primary" size="2.55em"></q-spinner>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-2">
|
||||
<div
|
||||
v-if="extension.isInstalled && extension.installedRelease"
|
||||
class="float-right"
|
||||
>
|
||||
<q-btn
|
||||
flat
|
||||
color="grey-5"
|
||||
type="a"
|
||||
:href="['{{ url_for('core.extensions') }}', '?usr=', g.user.id, '&disable=', extension.code].join('')"
|
||||
>
|
||||
Disable</q-btn
|
||||
>
|
||||
<q-badge>
|
||||
{% raw %}{{ extension.installedRelease.version }}{% endraw %}
|
||||
<q-tooltip>
|
||||
<span v-text="$t('extension_installed_version')"></span>
|
||||
</q-tooltip>
|
||||
</q-badge>
|
||||
</div>
|
||||
</div>
|
||||
<q-btn
|
||||
v-else
|
||||
flat
|
||||
color="primary"
|
||||
type="a"
|
||||
:href="['{{ url_for('core.extensions') }}', '?usr=', g.user.id, '&enable=', extension.code].join('')"
|
||||
>
|
||||
Enable</q-btn
|
||||
>
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</div>
|
||||
<q-dialog v-model="showUninstallDialog">
|
||||
<q-card class="q-pa-lg">
|
||||
<h6 class="q-my-md text-primary">{%raw%}{{ $t('warning') }}{%endraw%}</h6>
|
||||
<p>
|
||||
{%raw%}{{ $t('extension_uninstall_warning') }}{%endraw%} <br />
|
||||
{%raw%}{{ $t('confirm_continue') }}{%endraw%}
|
||||
</p>
|
||||
|
||||
<div class="row q-mt-lg">
|
||||
<q-checkbox
|
||||
v-model="uninstallAndDropDb"
|
||||
value="false"
|
||||
label="Cleanup database tables"
|
||||
>
|
||||
<q-tooltip class="bg-grey-8" anchor="bottom left" self="top left">
|
||||
{%raw%}{{ $t('extension_db_drop_info') }}{%endraw%}
|
||||
</q-tooltip>
|
||||
</q-checkbox>
|
||||
</div>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn outline color="grey" @click="uninstallExtension()"
|
||||
>{%raw%}{{ $t('uninstall_confirm') }}{%endraw%}</q-btn
|
||||
>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
|
||||
>{%raw%}{{ $t('cancel') }}{%endraw%}</q-btn
|
||||
>
|
||||
</div>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<q-dialog v-model="showDropDbDialog">
|
||||
<q-card v-if="selectedExtension" class="q-pa-lg">
|
||||
<h6 class="q-my-md text-primary">{%raw%}{{ $t('warning') }}{%endraw%}</h6>
|
||||
<p>{%raw%}{{ $t('extension_db_drop_warning') }}{%endraw%} <br /></p>
|
||||
<q-input
|
||||
v-model="dropDbExtensionId"
|
||||
:label="selectedExtension.id"
|
||||
></q-input>
|
||||
<br />
|
||||
<p>{%raw%}{{ $t('confirm_continue') }}{%endraw%}</p>
|
||||
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
:disable="dropDbExtensionId !== selectedExtension.id"
|
||||
outline
|
||||
color="red"
|
||||
@click="dropExtensionDb()"
|
||||
>{%raw%}{{ $t('confirm') }}{%endraw%}</q-btn
|
||||
>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
|
||||
>{%raw%}{{ $t('cancel') }}{%endraw%}</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">
|
||||
{%raw%}{{ $t('repository') }}{%endraw%}
|
||||
<br />
|
||||
<small v-text="repoName"></small>
|
||||
<q-tooltip
|
||||
><span
|
||||
v-text="selectedExtensionRepos[repoName].repo"
|
||||
></span
|
||||
></q-tooltip>
|
||||
</div>
|
||||
<div class="col-2"></div>
|
||||
</div>
|
||||
</q-item-section>
|
||||
</template>
|
||||
|
||||
<q-card-section class="q-pa-none">
|
||||
<q-separator></q-separator>
|
||||
|
||||
<q-list>
|
||||
<q-expansion-item
|
||||
v-for="release of selectedExtensionRepos[repoName].releases"
|
||||
:key="release.version"
|
||||
group="releases"
|
||||
@click="getGitHubReleaseDetails(release)"
|
||||
:icon="getReleaseIcon(release)"
|
||||
:label="release.description"
|
||||
:caption="release.version"
|
||||
:content-inset-level="0.5"
|
||||
:header-class="getReleaseIconColor(release)"
|
||||
>
|
||||
<div v-if="release.inProgress">
|
||||
<q-spinner color="primary" size="2.55em"></q-spinner>
|
||||
</div>
|
||||
<div v-else-if="release.error">
|
||||
<q-icon
|
||||
class="gt-sm"
|
||||
name="error"
|
||||
color="pink"
|
||||
size="70px"
|
||||
></q-icon>
|
||||
Cannot get the release details.
|
||||
</div>
|
||||
<q-card v-else>
|
||||
<q-card-section v-if="release.is_version_compatible">
|
||||
<q-btn
|
||||
v-if="!release.isInstalled"
|
||||
@click="installExtension(release)"
|
||||
color="primary unelevated mt-lg pt-lg"
|
||||
>{%raw%}{{ $t('install') }}{%endraw%}</q-btn
|
||||
>
|
||||
<q-btn v-else @click="showUninstall()" flat color="red">
|
||||
{%raw%}{{ $t('uninstall') }}{%endraw%}</q-btn
|
||||
>
|
||||
<a
|
||||
v-if="release.html_url"
|
||||
class="text-secondary float-right"
|
||||
:href="release.html_url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style="color: inherit"
|
||||
>{%raw%}{{ $t('release_notes') }}{%endraw%}</a
|
||||
>
|
||||
</q-card-section>
|
||||
<q-card-section v-else>
|
||||
{%raw%}{{ $t('extension_min_lnbits_version') }}{%endraw%}
|
||||
<strong>
|
||||
<span v-text="release.min_lnbits_version"></span>
|
||||
</strong>
|
||||
</q-card-section>
|
||||
<q-card v-if="release.warning">
|
||||
<q-card-section>
|
||||
<div class="text-h6">
|
||||
<q-badge color="yellow" text-color="black">
|
||||
{%raw%}{{ $t('warning') }}{%endraw%}
|
||||
</q-badge>
|
||||
</div>
|
||||
<div class="text-subtitle2">
|
||||
<span v-text="release.warning"></span>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
<q-separator></q-separator> </q-card
|
||||
></q-expansion-item>
|
||||
</q-list>
|
||||
</q-card-section>
|
||||
</q-expansion-item>
|
||||
</q-card>
|
||||
</div>
|
||||
<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"
|
||||
>
|
||||
{%raw%}{{ $t('uninstall') }}{%endraw%}</q-btn
|
||||
>
|
||||
<q-btn
|
||||
v-else-if="selectedExtension?.hasDatabaseTables"
|
||||
@click="showDropDb()"
|
||||
flat
|
||||
color="red"
|
||||
:label="$t('drop_db')"
|
||||
></q-btn>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto">
|
||||
{%raw%}{{ $t('close') }}{%endraw%}</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,
|
||||
showDropDbDialog: false,
|
||||
dropDbExtensionId: '',
|
||||
selectedExtension: null,
|
||||
selectedExtensionRepos: null,
|
||||
uninstallAndDropDb: false,
|
||||
maxStars: 5,
|
||||
user: null
|
||||
}
|
||||
},
|
||||
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 === 'installed'
|
||||
? e.isActive
|
||||
? true
|
||||
: !!this.g.user.admin
|
||||
: true
|
||||
)
|
||||
.filter(e => (tab === 'featured' ? e.isFeatured : true))
|
||||
.filter(extensionNameContains(term))
|
||||
this.tab = tab
|
||||
},
|
||||
installExtension: async function (release) {
|
||||
const extension = this.selectedExtension
|
||||
extension.inProgress = true
|
||||
this.showUpgradeDialog = false
|
||||
LNbits.api
|
||||
.request(
|
||||
'POST',
|
||||
`/api/v1/extension`,
|
||||
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
|
||||
extension.installedRelease = release
|
||||
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}`,
|
||||
this.g.user.wallets[0].adminkey
|
||||
)
|
||||
.then(response => {
|
||||
extension.isAvailable = false
|
||||
extension.isInstalled = false
|
||||
extension.inProgress = false
|
||||
extension.installedRelease = null
|
||||
this.filteredExtensions = this.extensions.concat([])
|
||||
this.handleTabChanged('installed')
|
||||
this.tab = 'installed'
|
||||
this.$q.notify({
|
||||
type: 'positive',
|
||||
message: 'Extension uninstalled!'
|
||||
})
|
||||
if (this.uninstallAndDropDb) {
|
||||
this.showDropDb()
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
extension.inProgress = false
|
||||
})
|
||||
},
|
||||
|
||||
dropExtensionDb: async function () {
|
||||
const extension = this.selectedExtension
|
||||
this.showUpgradeDialog = false
|
||||
this.showDropDbDialog = false
|
||||
this.dropDbExtensionId = ''
|
||||
extension.inProgress = true
|
||||
LNbits.api
|
||||
.request(
|
||||
'DELETE',
|
||||
`/api/v1/extension/${extension.id}/db`,
|
||||
this.g.user.wallets[0].adminkey
|
||||
)
|
||||
.then(response => {
|
||||
extension.installedRelease = null
|
||||
extension.inProgress = false
|
||||
extension.hasDatabaseTables = false
|
||||
this.$q.notify({
|
||||
type: 'positive',
|
||||
message: 'Extension DB deleted!'
|
||||
})
|
||||
})
|
||||
.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') }}" +
|
||||
'?' +
|
||||
action +
|
||||
'=' +
|
||||
extension.id
|
||||
)
|
||||
.then(response => {})
|
||||
.catch(err => {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
extension.inProgress = false
|
||||
})
|
||||
},
|
||||
|
||||
showUninstall: function () {
|
||||
this.showUpgradeDialog = false
|
||||
this.showUninstallDialog = true
|
||||
this.uninstallAndDropDb = false
|
||||
},
|
||||
|
||||
showDropDb: function () {
|
||||
this.showDropDbDialog = 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`,
|
||||
this.g.user.wallets[0].adminkey
|
||||
)
|
||||
|
||||
this.selectedExtensionRepos = data.reduce((repos, release) => {
|
||||
repos[release.source_repo] = repos[release.source_repo] || {
|
||||
releases: [],
|
||||
isInstalled: false,
|
||||
repo: release.repo
|
||||
}
|
||||
release.inProgress = false
|
||||
release.error = null
|
||||
release.loaded = 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
|
||||
)
|
||||
}
|
||||
},
|
||||
getReleaseIcon: function (release) {
|
||||
if (!release.is_version_compatible) return 'block'
|
||||
if (release.isInstalled) return 'download_done'
|
||||
|
||||
return 'download'
|
||||
},
|
||||
getReleaseIconColor: function (release) {
|
||||
if (!release.is_version_compatible) return 'text-red'
|
||||
if (release.isInstalled) return 'text-green'
|
||||
|
||||
return ''
|
||||
},
|
||||
getGitHubReleaseDetails: async function (release) {
|
||||
if (!release.is_github_release || release.loaded) {
|
||||
return
|
||||
}
|
||||
const [org, repo] = release.source_repo.split('/')
|
||||
release.inProgress = true
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
'GET',
|
||||
`/api/v1/extension/release/${org}/${repo}/${release.version}`,
|
||||
this.g.user.wallets[0].adminkey
|
||||
)
|
||||
release.loaded = true
|
||||
release.is_version_compatible = data.is_version_compatible
|
||||
release.min_lnbits_version = data.min_lnbits_version
|
||||
release.warning = data.warning
|
||||
} catch (error) {
|
||||
console.warn(error)
|
||||
release.error = error
|
||||
LNbits.utils.notifyApiError(error)
|
||||
} finally {
|
||||
release.inProgress = false
|
||||
}
|
||||
}
|
||||
},
|
||||
created: function () {
|
||||
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'
|
||||
}
|
||||
}
|
||||
if (window.user) {
|
||||
this.user = LNbits.map.user(window.user)
|
||||
}
|
||||
},
|
||||
mixins: [windowMixin]
|
||||
})
|
||||
</script>
|
||||
{% 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,116 +1,372 @@
|
||||
{% 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 }}"
|
||||
>
|
||||
Press to claim bitcoin
|
||||
</q-btn>
|
||||
{% else %}
|
||||
<q-form @submit="createWallet" class="q-gutter-md">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model="walletName"
|
||||
label="Name your {{SITE_TITLE}} wallet *"
|
||||
></q-input>
|
||||
<q-btn
|
||||
unelevated
|
||||
color="primary"
|
||||
:disable="walletName == ''"
|
||||
type="submit"
|
||||
>Add a new 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>
|
||||
Easy to set up and lightweight, LNbits can run on any
|
||||
lightning-network funding source, currently supporting LND,
|
||||
c-lightning, OpenNode, LNPay and even LNbits itself!
|
||||
</p>
|
||||
<p>
|
||||
You can run LNbits for yourself, or easily offer a custodian
|
||||
solution for others.
|
||||
</p>
|
||||
<p>
|
||||
Each wallet has its own API keys and there is no limit to the number
|
||||
of wallets you can make. Being able to partition funds makes LNbits
|
||||
a useful tool for money management and as a development tool.
|
||||
</p>
|
||||
<p>
|
||||
Extensions add extra functionality to LNbits so you can experiment
|
||||
with a range of cutting-edge technologies on the lightning network.
|
||||
We have made developing extensions as easy as possible, and as a
|
||||
free and open-source project, we encourage people to develop and
|
||||
submit their own.
|
||||
</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"
|
||||
>View project in GitHub</q-btn
|
||||
>
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
type="a"
|
||||
href="https://legend.lnbits.com/paywall/GAqKguK5S8f6w5VNjS9DfK"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>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
|
||||
color="secondary"
|
||||
label="Runs on"
|
||||
:label="$t('runs_on')"
|
||||
class="full-width"
|
||||
></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>
|
||||
@@ -118,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>
|
||||
@@ -137,60 +401,118 @@
|
||||
|
||||
<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://mynodebtc.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/mynode.png') }}' : '{{ static_url_for('static', 'images/mynodel.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col q-pl-md"> </div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<a href="https://github.com/shesek/spark-wallet">
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '/static/images/spark.png' : '/static/images/sparkl.png'"
|
||||
></q-img>
|
||||
</a>
|
||||
<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"></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 %}
|
||||
@@ -0,0 +1,312 @@
|
||||
<q-tab-panel name="channels">
|
||||
<q-dialog v-model="connectPeerDialog.show">
|
||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<q-form class="q-gutter-md">
|
||||
<q-input
|
||||
dense
|
||||
type="text"
|
||||
filled
|
||||
v-model="connectPeerDialog.data.uri"
|
||||
label="Node URI"
|
||||
hint="pubkey@host:port"
|
||||
></q-input>
|
||||
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
:label="$t('connect')"
|
||||
color="primary"
|
||||
@click="connectPeer"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
v-close-popup
|
||||
flat
|
||||
color="grey"
|
||||
class="q-ml-auto"
|
||||
:label="$t('cancel')"
|
||||
></q-btn>
|
||||
</div>
|
||||
</q-form>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<q-dialog v-model="openChannelDialog.show">
|
||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<q-form class="q-gutter-md">
|
||||
<q-input
|
||||
dense
|
||||
type="text"
|
||||
filled
|
||||
v-model="openChannelDialog.data.peer_id"
|
||||
label="Peer ID"
|
||||
></q-input>
|
||||
<q-input
|
||||
dense
|
||||
type="number"
|
||||
filled
|
||||
v-model.number="openChannelDialog.data.funding_amount"
|
||||
label="Funding Amount"
|
||||
></q-input>
|
||||
<q-expansion-item icon="warning" label="Advanced">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<div class="column q-gutter-md">
|
||||
<q-input
|
||||
dense
|
||||
type="number"
|
||||
filled
|
||||
v-model.number="openChannelDialog.data.push_amount"
|
||||
label="Push Amount"
|
||||
hint="This gifts sats to the other side!"
|
||||
></q-input>
|
||||
|
||||
<q-input
|
||||
dense
|
||||
type="number"
|
||||
filled
|
||||
v-model.number="openChannelDialog.data.fee_rate"
|
||||
label="Fee Rate"
|
||||
></q-input>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
:label="$t('open')"
|
||||
color="primary"
|
||||
@click="openChannel"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
v-close-popup
|
||||
flat
|
||||
color="grey"
|
||||
class="q-ml-auto"
|
||||
:label="$t('cancel')"
|
||||
></q-btn>
|
||||
</div>
|
||||
</q-form>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<q-dialog v-model="closeChannelDialog.show">
|
||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<q-form class="q-gutter-md">
|
||||
<div>
|
||||
<q-checkbox v-model="closeChannelDialog.data.force" label="Force" />
|
||||
</div>
|
||||
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
:label="$t('close')"
|
||||
color="primary"
|
||||
@click="closeChannel"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
v-close-popup
|
||||
flat
|
||||
color="grey"
|
||||
class="q-ml-auto"
|
||||
:label="$t('cancel')"
|
||||
></q-btn>
|
||||
</div>
|
||||
</q-form>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<q-card-section class="q-pa-none">
|
||||
<div class="row q-col-gutter-lg">
|
||||
<div class="col-12 col-xl-6">
|
||||
<q-card class="full-height">
|
||||
<q-card-section class="q-gutter-y-sm">
|
||||
<div class="row items-center q-mt-none q-gutter-x-sm no-wrap">
|
||||
<div class="col-grow text-h6 q-my-none col-grow">Channels</div>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
clearable
|
||||
v-model="channels.filter"
|
||||
placeholder="Search..."
|
||||
class="col-auto"
|
||||
></q-input>
|
||||
<q-select
|
||||
dense
|
||||
size="sm"
|
||||
style="min-width: 200px"
|
||||
filled
|
||||
multiple
|
||||
clearable
|
||||
v-model="stateFilters"
|
||||
:options="this.states"
|
||||
class="col-auto"
|
||||
></q-select>
|
||||
<q-btn
|
||||
unelevated
|
||||
color="primary"
|
||||
size="md"
|
||||
class="col-auto"
|
||||
@click="showOpenChannelDialog()"
|
||||
>
|
||||
Open channel
|
||||
</q-btn>
|
||||
</div>
|
||||
{% raw %}
|
||||
<div>
|
||||
<div class="text-subtitle1 col-grow">Total</div>
|
||||
<lnbits-channel-balance
|
||||
:balance="this.totalBalance"
|
||||
></lnbits-channel-balance>
|
||||
</div>
|
||||
<q-separator></q-separator>
|
||||
<q-table
|
||||
dense
|
||||
flat
|
||||
:data="this.filteredChannels"
|
||||
:filter="channels.filter"
|
||||
no-data-label="No channels opened"
|
||||
>
|
||||
<template v-slot:header="props">
|
||||
<q-tr :props="props" style="height: 0"> </q-tr>
|
||||
</template>
|
||||
<template v-slot:body="props">
|
||||
<q-tr :props="props">
|
||||
<div class="q-pb-sm">
|
||||
<div class="row items-center q-gutter-sm">
|
||||
<div class="text-subtitle1 col-grow">
|
||||
{{props.row.name}}
|
||||
</div>
|
||||
<div class="text-caption" v-if="props.row.short_id">
|
||||
{{ props.row.short_id }}
|
||||
<q-btn
|
||||
size="xs"
|
||||
flat
|
||||
dense
|
||||
icon="content_paste"
|
||||
@click="copyText(props.row.short_id)"
|
||||
></q-btn>
|
||||
</div>
|
||||
<q-badge
|
||||
rounded
|
||||
:color="states.find(s => s.value == props.row.state)?.color"
|
||||
>
|
||||
{{ states.find(s => s.value == props.row.state)?.label
|
||||
}}
|
||||
</q-badge>
|
||||
<q-btn
|
||||
:disable='props.row.state !== "active"'
|
||||
flat
|
||||
dense
|
||||
size="md"
|
||||
@click="showCloseChannelDialog(props.row)"
|
||||
icon="cancel"
|
||||
color="pink"
|
||||
></q-btn>
|
||||
</div>
|
||||
<lnbits-channel-balance
|
||||
:balance="props.row.balance"
|
||||
:color="props.row.color"
|
||||
></lnbits-channel-balance>
|
||||
</div>
|
||||
</q-tr>
|
||||
</template>
|
||||
</q-table>
|
||||
|
||||
{% endraw %}
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
<div class="col-12 col-xl-6">
|
||||
<q-card class="full-height">
|
||||
<q-card-section class="column q-gutter-y-sm">
|
||||
{% raw %}
|
||||
<div
|
||||
class="row items-center q-mt-none justify-between q-gutter-x-md no-wrap"
|
||||
>
|
||||
<div class="col-grow text-h6 q-my-none">Peers</div>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
clearable
|
||||
v-model="peers.filter"
|
||||
placeholder="Search..."
|
||||
class="col-auto"
|
||||
></q-input>
|
||||
<q-btn
|
||||
class="col-auto"
|
||||
color="primary"
|
||||
@click="connectPeerDialog.show = true"
|
||||
>
|
||||
Connect Peer
|
||||
</q-btn>
|
||||
</div>
|
||||
<q-separator></q-separator>
|
||||
<q-table
|
||||
dense
|
||||
flat
|
||||
:data="peers.data"
|
||||
:filter="peers.filter"
|
||||
no-data-label="No transactions made yet"
|
||||
>
|
||||
<template v-slot:header="props">
|
||||
<q-tr :props="props" style="height: 0"> </q-tr>
|
||||
</template>
|
||||
<template v-slot:body="props">
|
||||
<q-tr :props="props">
|
||||
<div class="row no-wrap items-center q-gutter-sm">
|
||||
<div class="q-my-sm col-grow">
|
||||
<div class="text-subtitle1 text-bold">
|
||||
{{ props.row.alias }}
|
||||
</div>
|
||||
<div class="row items-center q-gutter-sm">
|
||||
<q-badge
|
||||
:style="`background-color: #${props.row.color}`"
|
||||
class="text-bold"
|
||||
>
|
||||
#{{ props.row.color }}
|
||||
</q-badge>
|
||||
<div class="text-bold">
|
||||
{{ shortenNodeId(props.row.id) }}
|
||||
</div>
|
||||
<q-btn
|
||||
size="xs"
|
||||
flat
|
||||
dense
|
||||
icon="content_paste"
|
||||
@click="copyText(props.row.id)"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
size="xs"
|
||||
flat
|
||||
dense
|
||||
icon="qr_code"
|
||||
@click="showNodeInfoDialog(props.row)"
|
||||
></q-btn>
|
||||
</div>
|
||||
</div>
|
||||
<q-btn
|
||||
unelevated
|
||||
color="primary"
|
||||
@click="showOpenChannelDialog(props.row.id)"
|
||||
>
|
||||
Open channel
|
||||
</q-btn>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
size="md"
|
||||
@click="disconnectPeer(props.row.id)"
|
||||
icon="cancel"
|
||||
color="pink"
|
||||
></q-btn>
|
||||
</div>
|
||||
</q-tr>
|
||||
</template>
|
||||
</q-table>
|
||||
|
||||
{% endraw %}
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-tab-panel>
|
||||
@@ -0,0 +1,70 @@
|
||||
<q-tab-panel name="dashboard">
|
||||
<q-card-section class="q-pa-none">
|
||||
{% raw %}
|
||||
<lnbits-node-info :info="this.info"></lnbits-node-info>
|
||||
<div class="row q-col-gutter-lg q-mt-sm">
|
||||
<div class="col-12 col-md-8 q-gutter-y-md">
|
||||
<div class="row q-col-gutter-md q-pb-lg">
|
||||
<div class="col-12 col-md-6 col-xl-4 q-gutter-y-md">
|
||||
<lnbits-stat
|
||||
:title="$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>
|
||||
{% endraw %}
|
||||
</q-card-section>
|
||||
</q-tab-panel>
|
||||
@@ -0,0 +1,316 @@
|
||||
<q-tab-panel name="transactions">
|
||||
<q-card-section class="q-pa-none">
|
||||
<q-dialog v-model="transactionDetailsDialog.show">
|
||||
<q-card class="my-card">
|
||||
<q-card-section>
|
||||
{% raw %}
|
||||
<div class="text-center q-mb-lg">
|
||||
<div
|
||||
v-if="transactionDetailsDialog.data.isIn && transactionDetailsDialog.data.pending"
|
||||
>
|
||||
<q-icon
|
||||
size="18px"
|
||||
:name="'call_received'"
|
||||
:color="'green'"
|
||||
></q-icon>
|
||||
<span v-text="$t('payment_received')"></span>
|
||||
</div>
|
||||
<div class="row q-my-md">
|
||||
<div class="col-3"><b v-text="$t('payment_hash')"></b>:</div>
|
||||
<div class="col-9 text-wrap mono">
|
||||
{{ transactionDetailsDialog.data.payment_hash }}
|
||||
<q-icon
|
||||
name="content_copy"
|
||||
@click="copyText(transactionDetailsDialog.data.payment_hash)"
|
||||
size="1em"
|
||||
color="grey"
|
||||
class="q-mb-xs cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="row"
|
||||
v-if="transactionDetailsDialog.data.preimage && !transactionDetailsDialog.data.pending"
|
||||
>
|
||||
<div class="col-3"><b v-text="$t('payment_proof')"></b>:</div>
|
||||
<div class="col-9 text-wrap mono">
|
||||
{{ transactionDetailsDialog.data.preimage }}
|
||||
<q-icon
|
||||
name="content_copy"
|
||||
@click="copyText(transactionDetailsDialog.data.preimage)"
|
||||
size="1em"
|
||||
color="grey"
|
||||
class="q-mb-xs cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="transactionDetailsDialog.data.bolt11"
|
||||
class="text-center q-mb-lg"
|
||||
>
|
||||
<a :href="'lightning:' + transactionDetailsDialog.data.bolt11">
|
||||
<q-responsive :ratio="1" class="q-mx-xl">
|
||||
<qrcode
|
||||
:value="'lightning:' + transactionDetailsDialog.data.bolt11.toUpperCase()"
|
||||
:options="{width: 340}"
|
||||
class="rounded-borders"
|
||||
></qrcode>
|
||||
</q-responsive>
|
||||
</a>
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
@click="copyText(transactionDetailsDialog.data.bolt11)"
|
||||
:label="$t('copy_invoice')"
|
||||
class="q-mt-sm"
|
||||
></q-btn>
|
||||
</div>
|
||||
</div>
|
||||
{% endraw %}
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<div class="row q-col-gutter-md q-pb-lg"></div>
|
||||
|
||||
<div class="row q-col-gutter-lg">
|
||||
<div class="col-12 col-lg-6 q-gutter-y-md">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<div class="row items-center no-wrap q-mb-sm">
|
||||
<div class="col text-h6 q-my-none">Payments</div>
|
||||
<q-input
|
||||
v-if="payments.length > 10"
|
||||
filled
|
||||
dense
|
||||
clearable
|
||||
v-model="paymentsTable.filter"
|
||||
debounce="300"
|
||||
placeholder="Search by tag, memo, amount"
|
||||
class="q-mb-md"
|
||||
>
|
||||
</q-input>
|
||||
</div>
|
||||
<q-table
|
||||
dense
|
||||
flat
|
||||
:data="paymentsTable.data"
|
||||
:columns="paymentsTable.columns"
|
||||
:pagination.sync="paymentsTable.pagination"
|
||||
row-key="payment_hash"
|
||||
no-data-label="No transactions made yet"
|
||||
:filter="paymentsTable.filter"
|
||||
@request="getPayments"
|
||||
>
|
||||
{% raw %}
|
||||
<template v-slot:body-cell-pending="props">
|
||||
<q-td auto-width class="text-center">
|
||||
<q-icon
|
||||
v-if="!props.row.pending"
|
||||
size="xs"
|
||||
name="call_made"
|
||||
color="green"
|
||||
@click="showTransactionDetailsDialog(props.row)"
|
||||
></q-icon>
|
||||
<q-icon
|
||||
v-else
|
||||
size="xs"
|
||||
name="settings_ethernet"
|
||||
color="grey"
|
||||
@click="showTransactionDetailsDialog(props.row)"
|
||||
>
|
||||
<q-tooltip>Pending</q-tooltip>
|
||||
</q-icon>
|
||||
<q-dialog v-model="props.row.expand" :props="props">
|
||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<div class="text-center q-mb-lg">
|
||||
<div v-if="props.row.isIn && props.row.pending">
|
||||
<q-icon
|
||||
name="settings_ethernet"
|
||||
color="grey"
|
||||
></q-icon>
|
||||
<span v-text="$t('invoice_waiting')"></span>
|
||||
<lnbits-payment-details
|
||||
:payment="props.row"
|
||||
></lnbits-payment-details>
|
||||
<div
|
||||
v-if="props.row.bolt11"
|
||||
class="text-center q-mb-lg"
|
||||
>
|
||||
<a :href="'lightning:' + props.row.bolt11">
|
||||
<q-responsive :ratio="1" class="q-mx-xl">
|
||||
<qrcode
|
||||
:value="'lightning:' + props.row.bolt11.toUpperCase()"
|
||||
:options="{width: 340}"
|
||||
class="rounded-borders"
|
||||
></qrcode>
|
||||
</q-responsive>
|
||||
</a>
|
||||
</div>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
@click="copyText(props.row.bolt11)"
|
||||
:label="$t('copy_invoice')"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
v-close-popup
|
||||
flat
|
||||
color="grey"
|
||||
class="q-ml-auto"
|
||||
:label="$t('close')"
|
||||
></q-btn>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="props.row.isPaid && props.row.isIn">
|
||||
<q-icon
|
||||
size="18px"
|
||||
:name="'call_received'"
|
||||
:color="'green'"
|
||||
></q-icon>
|
||||
<span v-text="$t('payment_received')"></span>
|
||||
<lnbits-payment-details
|
||||
:payment="props.row"
|
||||
></lnbits-payment-details>
|
||||
</div>
|
||||
<div v-else-if="props.row.isPaid && props.row.isOut">
|
||||
<q-icon
|
||||
size="18px"
|
||||
:name="'call_made'"
|
||||
:color="'pink'"
|
||||
></q-icon>
|
||||
<span v-text="$t('payment_sent')"></span>
|
||||
<lnbits-payment-details
|
||||
:payment="props.row"
|
||||
></lnbits-payment-details>
|
||||
</div>
|
||||
<div v-else-if="props.row.isOut && props.row.pending">
|
||||
<q-icon
|
||||
name="settings_ethernet"
|
||||
color="grey"
|
||||
></q-icon>
|
||||
<span v-text="$t('outgoing_payment_pending')"></span>
|
||||
<lnbits-payment-details
|
||||
:payment="props.row"
|
||||
></lnbits-payment-details>
|
||||
</div>
|
||||
</div>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</q-td>
|
||||
</template>
|
||||
<template v-slot:body-cell-date="props">
|
||||
<q-td auto-width key="date" :props="props">
|
||||
<lnbits-date :ts="props.row.time"></lnbits-date>
|
||||
</q-td>
|
||||
</template>
|
||||
<template v-slot:body-cell-destination="props">
|
||||
<q-td auto-width key="destination">
|
||||
<div class="row items-center justify-between no-wrap">
|
||||
<q-badge
|
||||
:style="`background-color: #${props.row.destination?.color}`"
|
||||
class="text-bold"
|
||||
>
|
||||
{{ props.row.destination?.alias }}
|
||||
</q-badge>
|
||||
<div>
|
||||
<q-btn
|
||||
size="xs"
|
||||
flat
|
||||
dense
|
||||
icon="content_paste"
|
||||
@click="copyText(info.id)"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
size="xs"
|
||||
flat
|
||||
dense
|
||||
icon="qr_code"
|
||||
@click="showNodeInfoDialog(props.row.destination)"
|
||||
></q-btn>
|
||||
</div>
|
||||
</div>
|
||||
</q-td>
|
||||
</template>
|
||||
{% endraw %}
|
||||
</q-table>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
<div class="col-12 col-lg-6 q-gutter-y-md">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<div class="row items-center no-wrap q-mb-sm">
|
||||
<div class="col text-h6 q-my-none">Invoices</div>
|
||||
<q-input
|
||||
v-if="payments.length > 10"
|
||||
filled
|
||||
dense
|
||||
clearable
|
||||
v-model="paymentsTable.filter"
|
||||
debounce="300"
|
||||
placeholder="Search by tag, memo, amount"
|
||||
class="q-mb-md"
|
||||
>
|
||||
</q-input>
|
||||
</div>
|
||||
|
||||
<q-table
|
||||
dense
|
||||
flat
|
||||
:data="invoiceTable.data"
|
||||
:columns="invoiceTable.columns"
|
||||
:pagination.sync="invoiceTable.pagination"
|
||||
no-data-label="No transactions made yet"
|
||||
:filter="invoiceTable.filter"
|
||||
@request="getInvoices"
|
||||
>
|
||||
{% raw %}
|
||||
<template v-slot:body-cell-pending="props">
|
||||
<q-td auto-width class="text-center">
|
||||
<q-icon
|
||||
v-if="!props.row.pending"
|
||||
size="xs"
|
||||
name="call_received"
|
||||
color="green"
|
||||
@click="showTransactionDetailsDialog(props.row)"
|
||||
></q-icon>
|
||||
<q-icon
|
||||
v-else
|
||||
size="xs"
|
||||
name="settings_ethernet"
|
||||
color="grey"
|
||||
@click="showTransactionDetailsDialog(props.row)"
|
||||
>
|
||||
<q-tooltip>Pending</q-tooltip>
|
||||
</q-icon>
|
||||
</q-td>
|
||||
</template>
|
||||
|
||||
<template v-slot:body-cell-paid_at="props">
|
||||
<q-td auto-width :props="props">
|
||||
<lnbits-date
|
||||
v-if="props.row.paid_at"
|
||||
:ts="props.row.paid_at"
|
||||
></lnbits-date>
|
||||
</q-td>
|
||||
</template>
|
||||
|
||||
<template v-slot:body-cell-expiry="props">
|
||||
<q-td auto-width :props="props">
|
||||
<lnbits-date
|
||||
v-if="props.row.expiry"
|
||||
:ts="props.row.expiry"
|
||||
></lnbits-date>
|
||||
</q-td>
|
||||
</template>
|
||||
|
||||
{% endraw %}
|
||||
</q-table>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-tab-panel>
|
||||
@@ -0,0 +1,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 %}
|
||||
@@ -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,18 +71,20 @@ async def api_get_settings(
|
||||
return admin_settings
|
||||
|
||||
|
||||
@core_app.put(
|
||||
@admin_router.put(
|
||||
"/admin/api/v1/settings/",
|
||||
status_code=HTTPStatus.OK,
|
||||
dependencies=[Depends(check_admin)],
|
||||
)
|
||||
async def api_update_settings(data: EditableSettings):
|
||||
async def api_update_settings(data: UpdateSettings, user: User = Depends(check_admin)):
|
||||
await update_admin_settings(data)
|
||||
update_cached_settings(dict(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)],
|
||||
@@ -50,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)],
|
||||
@@ -60,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)],
|
||||
@@ -97,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(
|
||||
|
||||
@@ -1,43 +1,58 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
from http import HTTPStatus
|
||||
from io import BytesIO
|
||||
from typing import Dict, List, Optional, Tuple, Union
|
||||
from math import ceil
|
||||
from typing import Dict, List, Optional, Union
|
||||
from urllib.parse import ParseResult, parse_qs, urlencode, urlparse, urlunparse
|
||||
|
||||
import async_timeout
|
||||
import httpx
|
||||
import pyqrcode
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
Body,
|
||||
Depends,
|
||||
Header,
|
||||
Query,
|
||||
Request,
|
||||
Response,
|
||||
WebSocket,
|
||||
WebSocketDisconnect,
|
||||
)
|
||||
from fastapi.exceptions import HTTPException
|
||||
from fastapi.responses import JSONResponse
|
||||
from loguru import logger
|
||||
from pydantic import BaseModel
|
||||
from pydantic.fields import Field
|
||||
from sse_starlette.sse import EventSourceResponse
|
||||
from starlette.responses import RedirectResponse, StreamingResponse
|
||||
from starlette.responses import StreamingResponse
|
||||
|
||||
from lnbits import bolt11, lnurl
|
||||
from lnbits import bolt11
|
||||
from lnbits.core.db import core_app_extra, db
|
||||
from lnbits.core.helpers import (
|
||||
migrate_extension_database,
|
||||
stop_extension_background_work,
|
||||
)
|
||||
from lnbits.core.models import Payment, User, Wallet
|
||||
from lnbits.core.models import (
|
||||
ConversionData,
|
||||
CreateInvoice,
|
||||
CreateLnurl,
|
||||
CreateLnurlAuth,
|
||||
CreateWallet,
|
||||
DecodePayment,
|
||||
Payment,
|
||||
PaymentFilters,
|
||||
PaymentHistoryPoint,
|
||||
Query,
|
||||
User,
|
||||
Wallet,
|
||||
WalletType,
|
||||
)
|
||||
from lnbits.db import Filters, Page
|
||||
from lnbits.decorators import (
|
||||
WalletTypeInfo,
|
||||
check_access_token,
|
||||
check_admin,
|
||||
get_key_type,
|
||||
parse_filters,
|
||||
require_admin_key,
|
||||
require_invoice_key,
|
||||
)
|
||||
@@ -46,30 +61,35 @@ from lnbits.extension_manager import (
|
||||
Extension,
|
||||
ExtensionRelease,
|
||||
InstallableExtension,
|
||||
fetch_github_release_config,
|
||||
get_valid_extensions,
|
||||
)
|
||||
from lnbits.helpers import url_for
|
||||
from lnbits.settings import get_wallet_class, settings
|
||||
from lnbits.helpers import generate_filter_params_openapi, url_for
|
||||
from lnbits.lnurl import decode as lnurl_decode
|
||||
from lnbits.settings import settings
|
||||
from lnbits.utils.exchange_rates import (
|
||||
currencies,
|
||||
fiat_amount_as_satoshis,
|
||||
satoshis_amount_as_fiat,
|
||||
)
|
||||
|
||||
from .. import core_app, core_app_extra, db
|
||||
from ..crud import (
|
||||
DateTrunc,
|
||||
add_installed_extension,
|
||||
create_tinyurl,
|
||||
create_account,
|
||||
create_wallet,
|
||||
delete_dbversion,
|
||||
delete_installed_extension,
|
||||
delete_tinyurl,
|
||||
delete_wallet,
|
||||
drop_extension_db,
|
||||
get_dbversions,
|
||||
get_payments,
|
||||
get_payments_history,
|
||||
get_payments_paginated,
|
||||
get_standalone_payment,
|
||||
get_tinyurl,
|
||||
get_tinyurl_by_url,
|
||||
get_total_balance,
|
||||
get_wallet_for_key,
|
||||
save_balance_check,
|
||||
update_pending_payments,
|
||||
update_wallet,
|
||||
)
|
||||
from ..services import (
|
||||
@@ -77,6 +97,7 @@ from ..services import (
|
||||
PaymentFailure,
|
||||
check_transaction_status,
|
||||
create_invoice,
|
||||
fee_reserve_total,
|
||||
pay_invoice,
|
||||
perform_lnurlauth,
|
||||
websocketManager,
|
||||
@@ -84,15 +105,17 @@ from ..services import (
|
||||
)
|
||||
from ..tasks import api_invoice_listeners
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
@core_app.get("/api/v1/health", status_code=HTTPStatus.OK)
|
||||
|
||||
@api_router.get("/api/v1/health", status_code=HTTPStatus.OK)
|
||||
async def health():
|
||||
return
|
||||
|
||||
|
||||
@core_app.get("/api/v1/wallet")
|
||||
@api_router.get("/api/v1/wallet")
|
||||
async def api_wallet(wallet: WalletTypeInfo = Depends(get_key_type)):
|
||||
if wallet.wallet_type == 0:
|
||||
if wallet.wallet_type == WalletType.admin:
|
||||
return {
|
||||
"id": wallet.wallet.id,
|
||||
"name": wallet.wallet.name,
|
||||
@@ -102,8 +125,8 @@ async def api_wallet(wallet: WalletTypeInfo = Depends(get_key_type)):
|
||||
return {"name": wallet.wallet.name, "balance": wallet.wallet.balance_msat}
|
||||
|
||||
|
||||
@core_app.put("/api/v1/wallet/{new_name}")
|
||||
async def api_update_wallet(
|
||||
@api_router.put("/api/v1/wallet/{new_name}")
|
||||
async def api_update_wallet_name(
|
||||
new_name: str, wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||
):
|
||||
await update_wallet(wallet.wallet.id, new_name)
|
||||
@@ -114,83 +137,133 @@ async def api_update_wallet(
|
||||
}
|
||||
|
||||
|
||||
@core_app.get("/api/v1/payments")
|
||||
async def api_payments(
|
||||
limit: Optional[int] = None,
|
||||
offset: Optional[int] = None,
|
||||
wallet: WalletTypeInfo = Depends(get_key_type),
|
||||
@api_router.patch("/api/v1/wallet", response_model=Wallet)
|
||||
async def api_update_wallet(
|
||||
name: Optional[str] = Body(None),
|
||||
currency: Optional[str] = Body(None),
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||
):
|
||||
pendingPayments = await get_payments(
|
||||
return await update_wallet(wallet.wallet.id, name, currency)
|
||||
|
||||
|
||||
@api_router.delete("/api/v1/wallet")
|
||||
async def api_delete_wallet(
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||
) -> None:
|
||||
await delete_wallet(
|
||||
user_id=wallet.wallet.user,
|
||||
wallet_id=wallet.wallet.id,
|
||||
pending=True,
|
||||
exclude_uncheckable=True,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
for payment in pendingPayments:
|
||||
await check_transaction_status(
|
||||
wallet_id=payment.wallet_id, payment_hash=payment.payment_hash
|
||||
|
||||
|
||||
@api_router.post("/api/v1/wallet", response_model=Wallet)
|
||||
async def api_create_wallet(
|
||||
data: CreateWallet,
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||
) -> Wallet:
|
||||
return await create_wallet(user_id=wallet.wallet.user, wallet_name=data.name)
|
||||
|
||||
|
||||
@api_router.post("/api/v1/account", response_model=Wallet)
|
||||
async def api_create_account(data: CreateWallet) -> Wallet:
|
||||
if not settings.new_accounts_allowed:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail="Account creation is disabled.",
|
||||
)
|
||||
account = await create_account()
|
||||
return await create_wallet(user_id=account.id, wallet_name=data.name)
|
||||
|
||||
|
||||
@api_router.get(
|
||||
"/api/v1/payments",
|
||||
name="Payment List",
|
||||
summary="get list of payments",
|
||||
response_description="list of payments",
|
||||
response_model=List[Payment],
|
||||
openapi_extra=generate_filter_params_openapi(PaymentFilters),
|
||||
)
|
||||
async def api_payments(
|
||||
wallet: WalletTypeInfo = Depends(get_key_type),
|
||||
filters: Filters = Depends(parse_filters(PaymentFilters)),
|
||||
):
|
||||
await update_pending_payments(wallet.wallet.id)
|
||||
return await get_payments(
|
||||
wallet_id=wallet.wallet.id,
|
||||
pending=True,
|
||||
complete=True,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
filters=filters,
|
||||
)
|
||||
|
||||
|
||||
class CreateInvoiceData(BaseModel):
|
||||
out: Optional[bool] = True
|
||||
amount: float = Query(None, ge=0)
|
||||
memo: Optional[str] = None
|
||||
unit: Optional[str] = "sat"
|
||||
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
|
||||
internal: Optional[bool] = False
|
||||
bolt11: Optional[str] = None
|
||||
@api_router.get(
|
||||
"/api/v1/payments/history",
|
||||
name="Get payments history",
|
||||
response_model=List[PaymentHistoryPoint],
|
||||
openapi_extra=generate_filter_params_openapi(PaymentFilters),
|
||||
)
|
||||
async def api_payments_history(
|
||||
wallet: WalletTypeInfo = Depends(get_key_type),
|
||||
group: DateTrunc = Query("day"),
|
||||
filters: Filters[PaymentFilters] = Depends(parse_filters(PaymentFilters)),
|
||||
):
|
||||
await update_pending_payments(wallet.wallet.id)
|
||||
return await get_payments_history(wallet.wallet.id, group, filters)
|
||||
|
||||
|
||||
async def api_payments_create_invoice(data: CreateInvoiceData, wallet: Wallet):
|
||||
@api_router.get(
|
||||
"/api/v1/payments/paginated",
|
||||
name="Payment List",
|
||||
summary="get paginated list of payments",
|
||||
response_description="list of payments",
|
||||
response_model=Page[Payment],
|
||||
openapi_extra=generate_filter_params_openapi(PaymentFilters),
|
||||
)
|
||||
async def api_payments_paginated(
|
||||
wallet: WalletTypeInfo = Depends(get_key_type),
|
||||
filters: Filters = Depends(parse_filters(PaymentFilters)),
|
||||
):
|
||||
await update_pending_payments(wallet.wallet.id)
|
||||
page = await get_payments_paginated(
|
||||
wallet_id=wallet.wallet.id,
|
||||
pending=True,
|
||||
complete=True,
|
||||
filters=filters,
|
||||
)
|
||||
return page
|
||||
|
||||
|
||||
async def api_payments_create_invoice(data: CreateInvoice, wallet: Wallet):
|
||||
description_hash = b""
|
||||
unhashed_description = b""
|
||||
memo = data.memo or settings.lnbits_site_title
|
||||
if data.description_hash or data.unhashed_description:
|
||||
try:
|
||||
description_hash = (
|
||||
bytes.fromhex(data.description_hash) if data.description_hash else b""
|
||||
)
|
||||
unhashed_description = (
|
||||
bytes.fromhex(data.unhashed_description)
|
||||
if data.unhashed_description
|
||||
else b""
|
||||
)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail="'description_hash' and 'unhashed_description' must be a valid hex strings",
|
||||
)
|
||||
if data.description_hash:
|
||||
try:
|
||||
description_hash = bytes.fromhex(data.description_hash)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail="'description_hash' must be a valid hex string",
|
||||
)
|
||||
if data.unhashed_description:
|
||||
try:
|
||||
unhashed_description = bytes.fromhex(data.unhashed_description)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail="'unhashed_description' must be a valid hex string",
|
||||
)
|
||||
# do not save memo if description_hash or unhashed_description is set
|
||||
memo = ""
|
||||
else:
|
||||
description_hash = b""
|
||||
unhashed_description = b""
|
||||
memo = data.memo or settings.lnbits_site_title
|
||||
|
||||
if data.unit == "sat":
|
||||
amount = int(data.amount)
|
||||
else:
|
||||
assert data.unit is not None, "unit not set"
|
||||
price_in_sats = await fiat_amount_as_satoshis(data.amount, data.unit)
|
||||
amount = price_in_sats
|
||||
|
||||
async with db.connect() as conn:
|
||||
try:
|
||||
_, payment_request = await create_invoice(
|
||||
payment_hash, payment_request = await create_invoice(
|
||||
wallet_id=wallet.id,
|
||||
amount=amount,
|
||||
amount=data.amount,
|
||||
memo=memo,
|
||||
currency=data.unit,
|
||||
description_hash=description_hash,
|
||||
unhashed_description=unhashed_description,
|
||||
expiry=data.expiry,
|
||||
@@ -199,6 +272,12 @@ async def api_payments_create_invoice(data: CreateInvoiceData, wallet: Wallet):
|
||||
internal=data.internal,
|
||||
conn=conn,
|
||||
)
|
||||
# NOTE: we get the checking_id with a seperate query because create_invoice
|
||||
# does not return it and it would be a big hustle to change its return type
|
||||
# (used across extensions)
|
||||
payment_db = await get_standalone_payment(payment_hash, conn=conn)
|
||||
assert payment_db is not None, "payment not found"
|
||||
checking_id = payment_db.checking_id
|
||||
except InvoiceFailure as e:
|
||||
raise HTTPException(status_code=520, detail=str(e))
|
||||
except Exception as exc:
|
||||
@@ -211,7 +290,8 @@ async def api_payments_create_invoice(data: CreateInvoiceData, wallet: Wallet):
|
||||
if data.lnurl_balance_check is not None:
|
||||
await save_balance_check(wallet.id, data.lnurl_balance_check)
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
headers = {"User-Agent": settings.user_agent}
|
||||
async with httpx.AsyncClient(headers=headers) as client:
|
||||
try:
|
||||
r = await client.get(
|
||||
data.lnurl_callback,
|
||||
@@ -241,14 +321,18 @@ async def api_payments_create_invoice(data: CreateInvoiceData, wallet: Wallet):
|
||||
"payment_hash": invoice.payment_hash,
|
||||
"payment_request": payment_request,
|
||||
# maintain backwards compatibility with API clients:
|
||||
"checking_id": invoice.payment_hash,
|
||||
"checking_id": checking_id,
|
||||
"lnurl_response": lnurl_response,
|
||||
}
|
||||
|
||||
|
||||
async def api_payments_pay_invoice(bolt11: str, wallet: Wallet):
|
||||
async def api_payments_pay_invoice(
|
||||
bolt11: str, wallet: Wallet, extra: Optional[dict] = None
|
||||
):
|
||||
try:
|
||||
payment_hash = await pay_invoice(wallet_id=wallet.id, payment_request=bolt11)
|
||||
payment_hash = await pay_invoice(
|
||||
wallet_id=wallet.id, payment_request=bolt11, extra=extra
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e))
|
||||
except PermissionError as e:
|
||||
@@ -265,22 +349,31 @@ async def api_payments_pay_invoice(bolt11: str, wallet: Wallet):
|
||||
}
|
||||
|
||||
|
||||
@core_app.post(
|
||||
@api_router.post(
|
||||
"/api/v1/payments",
|
||||
summary="Create or pay an invoice",
|
||||
description="""
|
||||
This endpoint can be used both to generate and pay a BOLT11 invoice.
|
||||
To generate a new invoice for receiving funds into the authorized account,
|
||||
specify at least the first four fields in the POST body: `out: false`,
|
||||
`amount`, `unit`, and `memo`. To pay an arbitrary invoice from the funds
|
||||
already in the authorized account, specify `out: true` and use the `bolt11`
|
||||
field to supply the BOLT11 invoice to be paid.
|
||||
""",
|
||||
status_code=HTTPStatus.CREATED,
|
||||
)
|
||||
async def api_payments_create(
|
||||
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
||||
invoiceData: CreateInvoiceData = Body(...),
|
||||
invoiceData: CreateInvoice = Body(...),
|
||||
):
|
||||
if invoiceData.out is True and wallet.wallet_type == 0:
|
||||
if invoiceData.out is True and wallet.wallet_type == WalletType.admin:
|
||||
if not invoiceData.bolt11:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail="BOLT11 string is invalid or not given",
|
||||
)
|
||||
return await api_payments_pay_invoice(
|
||||
invoiceData.bolt11, wallet.wallet
|
||||
invoiceData.bolt11, wallet.wallet, invoiceData.extra
|
||||
) # admin key
|
||||
elif not invoiceData.out:
|
||||
# invoice key
|
||||
@@ -292,29 +385,44 @@ async def api_payments_create(
|
||||
)
|
||||
|
||||
|
||||
class CreateLNURLData(BaseModel):
|
||||
description_hash: str
|
||||
callback: str
|
||||
amount: int
|
||||
comment: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
@api_router.get("/api/v1/payments/fee-reserve")
|
||||
async def api_payments_fee_reserve(invoice: str = Query("invoice")) -> JSONResponse:
|
||||
invoice_obj = bolt11.decode(invoice)
|
||||
if invoice_obj.amount_msat:
|
||||
response = {
|
||||
"fee_reserve": fee_reserve_total(invoice_obj.amount_msat),
|
||||
}
|
||||
return JSONResponse(response)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail="Invoice has no amount.",
|
||||
)
|
||||
|
||||
|
||||
@core_app.post("/api/v1/payments/lnurl")
|
||||
@api_router.post("/api/v1/payments/lnurl")
|
||||
async def api_payments_pay_lnurl(
|
||||
data: CreateLNURLData, wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||
data: CreateLnurl, wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||
):
|
||||
domain = urlparse(data.callback).netloc
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
headers = {"User-Agent": settings.user_agent}
|
||||
async with httpx.AsyncClient(headers=headers, follow_redirects=True) as client:
|
||||
try:
|
||||
if data.unit and data.unit != "sat":
|
||||
amount_msat = await fiat_amount_as_satoshis(data.amount, data.unit)
|
||||
# no msat precision
|
||||
amount_msat = ceil(amount_msat // 1000) * 1000
|
||||
else:
|
||||
amount_msat = data.amount
|
||||
r = await client.get(
|
||||
data.callback,
|
||||
params={"amount": data.amount, "comment": data.comment},
|
||||
params={"amount": amount_msat, "comment": data.comment},
|
||||
timeout=40,
|
||||
)
|
||||
if r.is_error:
|
||||
raise httpx.ConnectError("LNURL callback connection error")
|
||||
r.raise_for_status()
|
||||
except (httpx.ConnectError, httpx.RequestError):
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
@@ -335,16 +443,15 @@ async def api_payments_pay_lnurl(
|
||||
)
|
||||
|
||||
invoice = bolt11.decode(params["pr"])
|
||||
if invoice.amount_msat != data.amount:
|
||||
if invoice.amount_msat != amount_msat:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail=f"{domain} returned an invalid invoice. Expected {data.amount} msat, got {invoice.amount_msat}.",
|
||||
)
|
||||
|
||||
if invoice.description_hash != data.description_hash:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail=f"{domain} returned an invalid invoice. Expected description_hash == {data.description_hash}, got {invoice.description_hash}.",
|
||||
detail=(
|
||||
(
|
||||
f"{domain} returned an invalid invoice. Expected"
|
||||
f" {amount_msat} msat, got {invoice.amount_msat}."
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
extra = {}
|
||||
@@ -353,6 +460,9 @@ async def api_payments_pay_lnurl(
|
||||
extra["success_action"] = params["successAction"]
|
||||
if data.comment:
|
||||
extra["comment"] = data.comment
|
||||
if data.unit and data.unit != "sat":
|
||||
extra["fiat_currency"] = data.unit
|
||||
extra["fiat_amount"] = data.amount / 1000
|
||||
assert data.description is not None, "description is required"
|
||||
payment_hash = await pay_invoice(
|
||||
wallet_id=wallet.wallet.id,
|
||||
@@ -382,39 +492,24 @@ async def subscribe_wallet_invoices(request: Request, wallet: Wallet):
|
||||
logger.debug(f"adding sse listener for wallet: {uid}")
|
||||
api_invoice_listeners[uid] = payment_queue
|
||||
|
||||
send_queue: asyncio.Queue[Tuple[str, Payment]] = asyncio.Queue(0)
|
||||
|
||||
async def payment_received() -> None:
|
||||
while True:
|
||||
try:
|
||||
async with async_timeout.timeout(1):
|
||||
payment: Payment = await payment_queue.get()
|
||||
if payment.wallet_id == this_wallet_id:
|
||||
logger.debug("sse listener: payment received", payment)
|
||||
await send_queue.put(("payment-received", payment))
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
|
||||
task = asyncio.create_task(payment_received())
|
||||
|
||||
try:
|
||||
while True:
|
||||
if await request.is_disconnected():
|
||||
await request.close()
|
||||
break
|
||||
typ, data = await send_queue.get()
|
||||
if data:
|
||||
jdata = json.dumps(dict(data.dict(), pending=False))
|
||||
|
||||
yield dict(data=jdata, event=typ)
|
||||
payment: Payment = await payment_queue.get()
|
||||
if payment.wallet_id == this_wallet_id:
|
||||
logger.debug("sse listener: payment received", payment)
|
||||
yield dict(data=payment.json(), event="payment-received")
|
||||
except asyncio.CancelledError:
|
||||
logger.debug(f"removing listener for wallet {uid}")
|
||||
except Exception as exc:
|
||||
logger.error(f"Error in sse: {exc}")
|
||||
finally:
|
||||
api_invoice_listeners.pop(uid)
|
||||
task.cancel()
|
||||
return
|
||||
|
||||
|
||||
@core_app.get("/api/v1/payments/sse")
|
||||
@api_router.get("/api/v1/payments/sse")
|
||||
async def api_payments_sse(
|
||||
request: Request, wallet: WalletTypeInfo = Depends(get_key_type)
|
||||
):
|
||||
@@ -425,14 +520,16 @@ async def api_payments_sse(
|
||||
)
|
||||
|
||||
|
||||
@core_app.get("/api/v1/payments/{payment_hash}")
|
||||
# TODO: refactor this route into a public and admin one
|
||||
@api_router.get("/api/v1/payments/{payment_hash}")
|
||||
async def api_payment(payment_hash, X_Api_Key: Optional[str] = Header(None)):
|
||||
# We use X_Api_Key here because we want this call to work with and without keys
|
||||
# If a valid key is given, we also return the field "details", otherwise not
|
||||
wallet = await get_wallet_for_key(X_Api_Key) if type(X_Api_Key) == str else None
|
||||
|
||||
# we have to specify the wallet id here, because postgres and sqlite return internal payments in different order
|
||||
# and get_standalone_payment otherwise just fetches the first one, causing unpredictable results
|
||||
wallet = await get_wallet_for_key(X_Api_Key) if isinstance(X_Api_Key, str) else None
|
||||
wallet = wallet if wallet and not wallet.deleted else None
|
||||
# we have to specify the wallet id here, because postgres and sqlite return
|
||||
# internal payments in different order and get_standalone_payment otherwise
|
||||
# just fetches the first one, causing unpredictable results
|
||||
payment = await get_standalone_payment(
|
||||
payment_hash, wallet_id=wallet.id if wallet else None
|
||||
)
|
||||
@@ -469,12 +566,12 @@ async def api_payment(payment_hash, X_Api_Key: Optional[str] = Header(None)):
|
||||
return {"paid": not payment.pending, "preimage": payment.preimage}
|
||||
|
||||
|
||||
@core_app.get("/api/v1/lnurlscan/{code}")
|
||||
@api_router.get("/api/v1/lnurlscan/{code}")
|
||||
async def api_lnurlscan(code: str, wallet: WalletTypeInfo = Depends(get_key_type)):
|
||||
try:
|
||||
url = lnurl.decode(code)
|
||||
url = str(lnurl_decode(code))
|
||||
domain = urlparse(url).netloc
|
||||
except:
|
||||
except Exception:
|
||||
# parse internet identifier (user@domain.com)
|
||||
name_domain = code.split("@")
|
||||
if len(name_domain) == 2 and len(name_domain[1].split(".")) >= 2:
|
||||
@@ -499,10 +596,13 @@ async def api_lnurlscan(code: str, wallet: WalletTypeInfo = Depends(get_key_type
|
||||
params.update(callback=url) # with k1 already in it
|
||||
|
||||
lnurlauth_key = wallet.wallet.lnurlauth_key(domain)
|
||||
assert lnurlauth_key.verifying_key
|
||||
params.update(pubkey=lnurlauth_key.verifying_key.to_string("compressed").hex())
|
||||
else:
|
||||
async with httpx.AsyncClient() as client:
|
||||
headers = {"User-Agent": settings.user_agent}
|
||||
async with httpx.AsyncClient(headers=headers, follow_redirects=True) as client:
|
||||
r = await client.get(url, timeout=5)
|
||||
r.raise_for_status()
|
||||
if r.is_error:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.SERVICE_UNAVAILABLE,
|
||||
@@ -564,7 +664,7 @@ async def api_lnurlscan(code: str, wallet: WalletTypeInfo = Depends(get_key_type
|
||||
if k == "text/plain":
|
||||
params.update(description=v)
|
||||
if k in ("image/jpeg;base64", "image/png;base64"):
|
||||
data_uri = "data:" + k + "," + v
|
||||
data_uri = f"data:{k},{v}"
|
||||
params.update(image=data_uri)
|
||||
if k in ("text/email", "text/identifier"):
|
||||
params.update(targetUser=v)
|
||||
@@ -582,45 +682,28 @@ async def api_lnurlscan(code: str, wallet: WalletTypeInfo = Depends(get_key_type
|
||||
return params
|
||||
|
||||
|
||||
class DecodePayment(BaseModel):
|
||||
data: str
|
||||
|
||||
|
||||
@core_app.post("/api/v1/payments/decode", status_code=HTTPStatus.OK)
|
||||
async def api_payments_decode(data: DecodePayment, response: Response):
|
||||
@api_router.post("/api/v1/payments/decode", status_code=HTTPStatus.OK)
|
||||
async def api_payments_decode(data: DecodePayment) -> JSONResponse:
|
||||
payment_str = data.data
|
||||
try:
|
||||
if payment_str[:5] == "LNURL":
|
||||
url = lnurl.decode(payment_str)
|
||||
return {"domain": url}
|
||||
url = str(lnurl_decode(payment_str))
|
||||
return JSONResponse({"domain": url})
|
||||
else:
|
||||
invoice = bolt11.decode(payment_str)
|
||||
return {
|
||||
"payment_hash": invoice.payment_hash,
|
||||
"amount_msat": invoice.amount_msat,
|
||||
"description": invoice.description,
|
||||
"description_hash": invoice.description_hash,
|
||||
"payee": invoice.payee,
|
||||
"date": invoice.date,
|
||||
"expiry": invoice.expiry,
|
||||
"secret": invoice.secret,
|
||||
"route_hints": invoice.route_hints,
|
||||
"min_final_cltv_expiry": invoice.min_final_cltv_expiry,
|
||||
}
|
||||
except:
|
||||
response.status_code = HTTPStatus.BAD_REQUEST
|
||||
return {"message": "Failed to decode"}
|
||||
return JSONResponse(invoice.data)
|
||||
except Exception as exc:
|
||||
return JSONResponse(
|
||||
{"message": f"Failed to decode: {str(exc)}"},
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
class Callback(BaseModel):
|
||||
callback: str = Query(...)
|
||||
|
||||
|
||||
@core_app.post("/api/v1/lnurlauth")
|
||||
@api_router.post("/api/v1/lnurlauth")
|
||||
async def api_perform_lnurlauth(
|
||||
callback: Callback, wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||
data: CreateLnurlAuth, wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||
):
|
||||
err = await perform_lnurlauth(callback.callback, wallet=wallet)
|
||||
err = await perform_lnurlauth(data.callback, wallet=wallet)
|
||||
if err:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.SERVICE_UNAVAILABLE, detail=err.reason
|
||||
@@ -628,18 +711,18 @@ async def api_perform_lnurlauth(
|
||||
return ""
|
||||
|
||||
|
||||
@core_app.get("/api/v1/currencies")
|
||||
@api_router.get("/api/v1/currencies")
|
||||
async def api_list_currencies_available():
|
||||
if len(settings.lnbits_allowed_currencies) > 0:
|
||||
return [
|
||||
item
|
||||
for item in currencies.keys()
|
||||
if item.upper() in settings.lnbits_allowed_currencies
|
||||
]
|
||||
return list(currencies.keys())
|
||||
|
||||
|
||||
class ConversionData(BaseModel):
|
||||
from_: str = Field("sat", alias="from")
|
||||
amount: float
|
||||
to: str = Query("usd")
|
||||
|
||||
|
||||
@core_app.post("/api/v1/conversion")
|
||||
@api_router.post("/api/v1/conversion")
|
||||
async def api_fiat_as_sats(data: ConversionData):
|
||||
output = {}
|
||||
if data.from_ == "sat":
|
||||
@@ -657,8 +740,8 @@ async def api_fiat_as_sats(data: ConversionData):
|
||||
return output
|
||||
|
||||
|
||||
@core_app.get("/api/v1/qrcode/{data}", response_class=StreamingResponse)
|
||||
async def img(request: Request, data):
|
||||
@api_router.get("/api/v1/qrcode/{data}", response_class=StreamingResponse)
|
||||
async def img(data):
|
||||
qr = pyqrcode.create(data)
|
||||
stream = BytesIO()
|
||||
qr.svg(stream, scale=3)
|
||||
@@ -678,31 +761,9 @@ async def img(request: Request, data):
|
||||
)
|
||||
|
||||
|
||||
@core_app.get("/api/v1/audit", dependencies=[Depends(check_admin)])
|
||||
async def api_auditor():
|
||||
WALLET = get_wallet_class()
|
||||
total_balance = await get_total_balance()
|
||||
error_message, node_balance = await WALLET.status()
|
||||
|
||||
if not error_message:
|
||||
delta = node_balance - total_balance
|
||||
else:
|
||||
node_balance, delta = None, None
|
||||
|
||||
return {
|
||||
"node_balance_msats": int(node_balance),
|
||||
"lnbits_balance_msats": int(total_balance),
|
||||
"delta_msats": int(delta),
|
||||
"timestamp": int(time.time()),
|
||||
}
|
||||
|
||||
|
||||
##################UNIVERSAL WEBSOCKET MANAGER########################
|
||||
|
||||
|
||||
@core_app.websocket("/api/v1/ws/{item_id}")
|
||||
@api_router.websocket("/api/v1/ws/{item_id}")
|
||||
async def websocket_connect(websocket: WebSocket, item_id: str):
|
||||
await websocketManager.connect(websocket)
|
||||
await websocketManager.connect(websocket, item_id)
|
||||
try:
|
||||
while True:
|
||||
await websocket.receive_text()
|
||||
@@ -710,27 +771,29 @@ async def websocket_connect(websocket: WebSocket, item_id: str):
|
||||
websocketManager.disconnect(websocket)
|
||||
|
||||
|
||||
@core_app.post("/api/v1/ws/{item_id}")
|
||||
@api_router.post("/api/v1/ws/{item_id}")
|
||||
async def websocket_update_post(item_id: str, data: str):
|
||||
try:
|
||||
await websocketUpdater(item_id, data)
|
||||
return {"sent": True, "data": data}
|
||||
except:
|
||||
except Exception:
|
||||
return {"sent": False, "data": data}
|
||||
|
||||
|
||||
@core_app.get("/api/v1/ws/{item_id}/{data}")
|
||||
@api_router.get("/api/v1/ws/{item_id}/{data}")
|
||||
async def websocket_update_get(item_id: str, data: str):
|
||||
try:
|
||||
await websocketUpdater(item_id, data)
|
||||
return {"sent": True, "data": data}
|
||||
except:
|
||||
except Exception:
|
||||
return {"sent": False, "data": data}
|
||||
|
||||
|
||||
@core_app.post("/api/v1/extension")
|
||||
@api_router.post("/api/v1/extension")
|
||||
async def api_install_extension(
|
||||
data: CreateExtension, user: User = Depends(check_admin)
|
||||
data: CreateExtension,
|
||||
user: User = Depends(check_admin),
|
||||
access_token: Optional[str] = Depends(check_access_token),
|
||||
):
|
||||
release = await InstallableExtension.get_extension_release(
|
||||
data.ext_id, data.source_repo, data.archive
|
||||
@@ -739,6 +802,12 @@ async def api_install_extension(
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Release not found"
|
||||
)
|
||||
|
||||
if not release.is_version_compatible:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST, detail="Incompatible extension version"
|
||||
)
|
||||
|
||||
ext_info = InstallableExtension(
|
||||
id=data.ext_id, name=data.ext_id, installed_release=release, icon=release.icon
|
||||
)
|
||||
@@ -756,7 +825,7 @@ async def api_install_extension(
|
||||
await add_installed_extension(ext_info)
|
||||
|
||||
# call stop while the old routes are still active
|
||||
await stop_extension_background_work(data.ext_id, user.id)
|
||||
await stop_extension_background_work(data.ext_id, user.id, access_token)
|
||||
|
||||
if data.ext_id not in settings.lnbits_deactivated_extensions:
|
||||
settings.lnbits_deactivated_extensions += [data.ext_id]
|
||||
@@ -774,13 +843,19 @@ async def api_install_extension(
|
||||
ext_info.clean_extension_files()
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to install extension.",
|
||||
detail=(
|
||||
f"Failed to install extension {ext_info.id} "
|
||||
f"({ext_info.installed_version})."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@core_app.delete("/api/v1/extension/{ext_id}")
|
||||
async def api_uninstall_extension(ext_id: str, user: User = Depends(check_admin)):
|
||||
|
||||
@api_router.delete("/api/v1/extension/{ext_id}")
|
||||
async def api_uninstall_extension(
|
||||
ext_id: str,
|
||||
user: User = Depends(check_admin),
|
||||
access_token: Optional[str] = Depends(check_access_token),
|
||||
):
|
||||
installable_extensions = await InstallableExtension.get_installable_extensions()
|
||||
|
||||
extensions = [e for e in installable_extensions if e.id == ext_id]
|
||||
@@ -798,12 +873,15 @@ async def api_uninstall_extension(ext_id: str, user: User = Depends(check_admin)
|
||||
if installed_ext and ext_id in installed_ext.dependencies:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail=f"Cannot uninstall. Extension '{installed_ext.name}' depends on this one.",
|
||||
detail=(
|
||||
f"Cannot uninstall. Extension '{installed_ext.name}' "
|
||||
"depends on this one."
|
||||
),
|
||||
)
|
||||
|
||||
try:
|
||||
# call stop while the old routes are still active
|
||||
await stop_extension_background_work(ext_id, user.id)
|
||||
await stop_extension_background_work(ext_id, user.id, access_token)
|
||||
|
||||
if ext_id not in settings.lnbits_deactivated_extensions:
|
||||
settings.lnbits_deactivated_extensions += [ext_id]
|
||||
@@ -812,14 +890,17 @@ async def api_uninstall_extension(ext_id: str, user: User = Depends(check_admin)
|
||||
ext_info.clean_extension_files()
|
||||
await delete_installed_extension(ext_id=ext_info.id)
|
||||
|
||||
logger.success(f"Extension '{ext_id}' uninstalled.")
|
||||
except Exception as ex:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(ex)
|
||||
)
|
||||
|
||||
|
||||
@core_app.get("/api/v1/extension/{ext_id}/releases")
|
||||
async def get_extension_releases(ext_id: str, user: User = Depends(check_admin)):
|
||||
@api_router.get(
|
||||
"/api/v1/extension/{ext_id}/releases", dependencies=[Depends(check_admin)]
|
||||
)
|
||||
async def get_extension_releases(ext_id: str):
|
||||
try:
|
||||
extension_releases: List[
|
||||
ExtensionRelease
|
||||
@@ -833,73 +914,48 @@ async def get_extension_releases(ext_id: str, user: User = Depends(check_admin))
|
||||
)
|
||||
|
||||
|
||||
############################TINYURL##################################
|
||||
|
||||
|
||||
@core_app.post("/api/v1/tinyurl")
|
||||
async def api_create_tinyurl(
|
||||
url: str, endless: bool = False, wallet: WalletTypeInfo = Depends(get_key_type)
|
||||
):
|
||||
tinyurls = await get_tinyurl_by_url(url)
|
||||
@api_router.get(
|
||||
"/api/v1/extension/release/{org}/{repo}/{tag_name}",
|
||||
dependencies=[Depends(check_admin)],
|
||||
)
|
||||
async def get_extension_release(org: str, repo: str, tag_name: str):
|
||||
try:
|
||||
for tinyurl in tinyurls:
|
||||
if tinyurl:
|
||||
if tinyurl.wallet == wallet.wallet.inkey:
|
||||
return tinyurl
|
||||
return await create_tinyurl(url, endless, wallet.wallet.inkey)
|
||||
except:
|
||||
config = await fetch_github_release_config(org, repo, tag_name)
|
||||
if not config:
|
||||
return {}
|
||||
|
||||
return {
|
||||
"min_lnbits_version": config.min_lnbits_version,
|
||||
"is_version_compatible": config.is_version_compatible(),
|
||||
"warning": config.warning,
|
||||
}
|
||||
except Exception as ex:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST, detail="Unable to create tinyurl"
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(ex)
|
||||
)
|
||||
|
||||
|
||||
@core_app.get("/api/v1/tinyurl/{tinyurl_id}")
|
||||
async def api_get_tinyurl(
|
||||
tinyurl_id: str, wallet: WalletTypeInfo = Depends(get_key_type)
|
||||
):
|
||||
@api_router.delete(
|
||||
"/api/v1/extension/{ext_id}/db",
|
||||
dependencies=[Depends(check_admin)],
|
||||
)
|
||||
async def delete_extension_db(ext_id: str):
|
||||
try:
|
||||
tinyurl = await get_tinyurl(tinyurl_id)
|
||||
if tinyurl:
|
||||
if tinyurl.wallet == wallet.wallet.inkey:
|
||||
return tinyurl
|
||||
db_version = (await get_dbversions()).get(ext_id, None)
|
||||
if not db_version:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail=f"Unknown extension id: {ext_id}",
|
||||
)
|
||||
await drop_extension_db(ext_id=ext_id)
|
||||
await delete_dbversion(ext_id=ext_id)
|
||||
logger.success(f"Database removed for extension '{ext_id}'")
|
||||
except HTTPException as ex:
|
||||
logger.error(ex)
|
||||
raise ex
|
||||
except Exception as ex:
|
||||
logger.error(ex)
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.FORBIDDEN, detail="Wrong key provided."
|
||||
)
|
||||
except:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Unable to fetch tinyurl"
|
||||
)
|
||||
|
||||
|
||||
@core_app.delete("/api/v1/tinyurl/{tinyurl_id}")
|
||||
async def api_delete_tinyurl(
|
||||
tinyurl_id: str, wallet: WalletTypeInfo = Depends(get_key_type)
|
||||
):
|
||||
try:
|
||||
tinyurl = await get_tinyurl(tinyurl_id)
|
||||
if tinyurl:
|
||||
if tinyurl.wallet == wallet.wallet.inkey:
|
||||
await delete_tinyurl(tinyurl_id)
|
||||
return {"deleted": True}
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.FORBIDDEN, detail="Wrong key provided."
|
||||
)
|
||||
except:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST, detail="Unable to delete"
|
||||
)
|
||||
|
||||
|
||||
@core_app.get("/t/{tinyurl_id}")
|
||||
async def api_tinyurl(tinyurl_id: str):
|
||||
try:
|
||||
tinyurl = await get_tinyurl(tinyurl_id)
|
||||
if tinyurl:
|
||||
response = RedirectResponse(url=tinyurl.url)
|
||||
return response
|
||||
else:
|
||||
return
|
||||
except:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="unable to find tinyurl"
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
detail=f"Cannot delete data for extension '{ext_id}'",
|
||||
)
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
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")
|
||||
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", samesite="none", secure=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", samesite="none", secure=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 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,44 +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:
|
||||
user = await get_user(user.id) # type: ignore
|
||||
|
||||
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:
|
||||
@@ -110,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: {
|
||||
@@ -136,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(),
|
||||
@@ -162,94 +182,84 @@ 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:
|
||||
user = await get_user((await create_account()).id)
|
||||
logger.info(f"Create user {user.id}") # type: ignore
|
||||
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: # type: ignore
|
||||
wallet = user.wallets[0] # type: ignore
|
||||
else:
|
||||
wallet = await create_wallet(user_id=user.id, wallet_name=wallet_name) # type: ignore
|
||||
logger.info(
|
||||
f"Created new wallet {wallet_name if wallet_name else '(no name)'} for user {user.id}" # type: ignore
|
||||
)
|
||||
|
||||
return RedirectResponse(
|
||||
f"/wallet?usr={user.id}&wal={wallet.id}", # type: ignore
|
||||
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) # type: ignore
|
||||
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(), # type: ignore
|
||||
"wallet": userwallet.dict(),
|
||||
"user": user.dict(),
|
||||
"wallet": user_wallet.dict(),
|
||||
"service_fee": settings.lnbits_service_fee,
|
||||
"web_manifest": f"/manifest/{user.id}.webmanifest", # type: ignore
|
||||
"service_fee_max": settings.lnbits_service_fee_max,
|
||||
"web_manifest": f"/manifest/{user.id}.webmanifest",
|
||||
},
|
||||
)
|
||||
resp.set_cookie(
|
||||
"lnbits_last_active_wallet", wallet_id, samesite="none", secure=True
|
||||
)
|
||||
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."}
|
||||
|
||||
@@ -259,27 +269,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())
|
||||
@@ -291,49 +313,35 @@ 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)
|
||||
user_wallet_ids = [u.id for u in user.wallets] # type: ignore
|
||||
|
||||
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) # type: ignore
|
||||
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]), # type: ignore
|
||||
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):
|
||||
async with db.connect() as conn:
|
||||
account = await create_account(conn=conn)
|
||||
user = await get_user(account.id, conn=conn)
|
||||
wallet = await create_wallet(user_id=user.id, conn=conn) # type: ignore
|
||||
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
|
||||
@@ -341,18 +349,20 @@ async def lnurlwallet(request: Request):
|
||||
)
|
||||
|
||||
return RedirectResponse(
|
||||
f"/wallet?usr={user.id}&wal={wallet.id}", # type: ignore
|
||||
f"/wallet?usr={user.id}&wal={wallet.id}",
|
||||
status_code=status.HTTP_307_TEMPORARY_REDIRECT,
|
||||
)
|
||||
|
||||
|
||||
@core_html_routes.get("/service-worker.js", response_class=FileResponse)
|
||||
@generic_router.get("/service-worker.js", response_class=FileResponse)
|
||||
async def service_worker():
|
||||
return FileResponse("lnbits/core/static/js/service-worker.js")
|
||||
return FileResponse(Path("lnbits", "static", "js", "service-worker.js"))
|
||||
|
||||
|
||||
@core_html_routes.get("/manifest/{usr}.webmanifest")
|
||||
async def manifest(usr: str):
|
||||
@generic_router.get("/manifest/{usr}.webmanifest")
|
||||
async def manifest(request: Request, usr: str):
|
||||
host = urlparse(str(request.url)).netloc
|
||||
|
||||
user = await get_user(usr)
|
||||
if not user:
|
||||
raise HTTPException(status_code=HTTPStatus.NOT_FOUND)
|
||||
@@ -362,14 +372,51 @@ 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",
|
||||
},
|
||||
],
|
||||
"start_url": "/wallet?usr=" + usr + "&wal=" + user.wallets[0].id,
|
||||
"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",
|
||||
"description": "Bitcoin Lightning Wallet",
|
||||
"display": "standalone",
|
||||
@@ -380,14 +427,60 @@ async def manifest(usr: str):
|
||||
"name": wallet.name,
|
||||
"short_name": wallet.name,
|
||||
"description": wallet.name,
|
||||
"url": "/wallet?usr=" + usr + "&wal=" + wallet.id,
|
||||
"url": f"/wallet?usr={usr}&wal={wallet.id}",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/static/images/maskable_icon_x96.png",
|
||||
"sizes": "96x96",
|
||||
"type": "image/png",
|
||||
}
|
||||
],
|
||||
}
|
||||
for wallet in user.wallets
|
||||
],
|
||||
"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)
|
||||
@@ -402,10 +495,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,189 @@
|
||||
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."
|
||||
)
|
||||
@@ -40,19 +39,18 @@ async def api_public_payment_longpolling(payment_hash):
|
||||
|
||||
response = None
|
||||
|
||||
async def payment_info_receiver(cancel_scope):
|
||||
async for payment in payment_queue.get():
|
||||
async def payment_info_receiver():
|
||||
for payment in await payment_queue.get():
|
||||
if payment.payment_hash == payment_hash:
|
||||
nonlocal response
|
||||
response = {"status": "paid"}
|
||||
cancel_scope.cancel()
|
||||
|
||||
async def timeouter(cancel_scope):
|
||||
await asyncio.sleep(45)
|
||||
cancel_scope.cancel()
|
||||
|
||||
asyncio.create_task(payment_info_receiver())
|
||||
asyncio.create_task(timeouter())
|
||||
cancel_scope = asyncio.create_task(payment_info_receiver())
|
||||
asyncio.create_task(timeouter(cancel_scope))
|
||||
|
||||
if response:
|
||||
return response
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
from http import HTTPStatus
|
||||
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
Depends,
|
||||
HTTPException,
|
||||
)
|
||||
from starlette.responses import RedirectResponse
|
||||
|
||||
from lnbits.decorators import (
|
||||
WalletTypeInfo,
|
||||
get_key_type,
|
||||
)
|
||||
|
||||
from ..crud import (
|
||||
create_tinyurl,
|
||||
delete_tinyurl,
|
||||
get_tinyurl,
|
||||
get_tinyurl_by_url,
|
||||
)
|
||||
|
||||
tinyurl_router = APIRouter()
|
||||
|
||||
|
||||
@tinyurl_router.post(
|
||||
"/api/v1/tinyurl",
|
||||
name="Tinyurl",
|
||||
description="creates a tinyurl",
|
||||
)
|
||||
async def api_create_tinyurl(
|
||||
url: str, endless: bool = False, wallet: WalletTypeInfo = Depends(get_key_type)
|
||||
):
|
||||
tinyurls = await get_tinyurl_by_url(url)
|
||||
try:
|
||||
for tinyurl in tinyurls:
|
||||
if tinyurl:
|
||||
if tinyurl.wallet == wallet.wallet.inkey:
|
||||
return tinyurl
|
||||
return await create_tinyurl(url, endless, wallet.wallet.inkey)
|
||||
except Exception:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST, detail="Unable to create tinyurl"
|
||||
)
|
||||
|
||||
|
||||
@tinyurl_router.get(
|
||||
"/api/v1/tinyurl/{tinyurl_id}",
|
||||
name="Tinyurl",
|
||||
description="get a tinyurl by id",
|
||||
)
|
||||
async def api_get_tinyurl(
|
||||
tinyurl_id: str, wallet: WalletTypeInfo = Depends(get_key_type)
|
||||
):
|
||||
try:
|
||||
tinyurl = await get_tinyurl(tinyurl_id)
|
||||
if tinyurl:
|
||||
if tinyurl.wallet == wallet.wallet.inkey:
|
||||
return tinyurl
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.FORBIDDEN, detail="Wrong key provided."
|
||||
)
|
||||
except Exception:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Unable to fetch tinyurl"
|
||||
)
|
||||
|
||||
|
||||
@tinyurl_router.delete(
|
||||
"/api/v1/tinyurl/{tinyurl_id}",
|
||||
name="Tinyurl",
|
||||
description="delete a tinyurl by id",
|
||||
)
|
||||
async def api_delete_tinyurl(
|
||||
tinyurl_id: str, wallet: WalletTypeInfo = Depends(get_key_type)
|
||||
):
|
||||
try:
|
||||
tinyurl = await get_tinyurl(tinyurl_id)
|
||||
if tinyurl:
|
||||
if tinyurl.wallet == wallet.wallet.inkey:
|
||||
await delete_tinyurl(tinyurl_id)
|
||||
return {"deleted": True}
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.FORBIDDEN, detail="Wrong key provided."
|
||||
)
|
||||
except Exception:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST, detail="Unable to delete"
|
||||
)
|
||||
|
||||
|
||||
@tinyurl_router.get(
|
||||
"/t/{tinyurl_id}",
|
||||
name="Tinyurl",
|
||||
description="redirects a tinyurl by id",
|
||||
)
|
||||
async def api_tinyurl(tinyurl_id: str):
|
||||
tinyurl = await get_tinyurl(tinyurl_id)
|
||||
if tinyurl:
|
||||
response = RedirectResponse(url=tinyurl.url)
|
||||
return response
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="unable to find tinyurl"
|
||||
)
|
||||
@@ -0,0 +1,60 @@
|
||||
import base64
|
||||
import json
|
||||
from http import HTTPStatus
|
||||
from urllib.parse import unquote, urlparse
|
||||
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
Depends,
|
||||
Request,
|
||||
)
|
||||
|
||||
from lnbits.core.models import (
|
||||
CreateWebPushSubscription,
|
||||
WebPushSubscription,
|
||||
)
|
||||
from lnbits.decorators import (
|
||||
WalletTypeInfo,
|
||||
require_admin_key,
|
||||
)
|
||||
|
||||
from ..crud import (
|
||||
create_webpush_subscription,
|
||||
delete_webpush_subscription,
|
||||
get_webpush_subscription,
|
||||
)
|
||||
|
||||
webpush_router = APIRouter(prefix="/api/v1/webpush", tags=["webpush"])
|
||||
|
||||
|
||||
@webpush_router.post("/", status_code=HTTPStatus.CREATED)
|
||||
async def api_create_webpush_subscription(
|
||||
request: Request,
|
||||
data: CreateWebPushSubscription,
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||
) -> WebPushSubscription:
|
||||
subscription = json.loads(data.subscription)
|
||||
endpoint = subscription["endpoint"]
|
||||
host = urlparse(str(request.url)).netloc
|
||||
|
||||
subscription = await get_webpush_subscription(endpoint, wallet.wallet.user)
|
||||
if subscription:
|
||||
return subscription
|
||||
else:
|
||||
return await create_webpush_subscription(
|
||||
endpoint,
|
||||
wallet.wallet.user,
|
||||
data.subscription,
|
||||
host,
|
||||
)
|
||||
|
||||
|
||||
@webpush_router.delete("/", status_code=HTTPStatus.OK)
|
||||
async def api_delete_webpush_subscription(
|
||||
request: Request,
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||
):
|
||||
endpoint = unquote(
|
||||
base64.b64decode(str(request.query_params.get("endpoint"))).decode("utf-8")
|
||||
)
|
||||
await delete_webpush_subscription(endpoint, wallet.wallet.user)
|
||||
@@ -1,12 +1,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import datetime
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Optional
|
||||
from enum import Enum
|
||||
from sqlite3 import Row
|
||||
from typing import Any, Generic, List, Literal, Optional, Type, TypeVar
|
||||
|
||||
from loguru import logger
|
||||
from pydantic import BaseModel, ValidationError, root_validator
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy_aio.base import AsyncConnection
|
||||
from sqlalchemy_aio.strategy import ASYNCIO_STRATEGY
|
||||
@@ -17,6 +22,48 @@ POSTGRES = "POSTGRES"
|
||||
COCKROACH = "COCKROACH"
|
||||
SQLITE = "SQLITE"
|
||||
|
||||
if settings.lnbits_database_url:
|
||||
database_uri = settings.lnbits_database_url
|
||||
|
||||
if database_uri.startswith("cockroachdb://"):
|
||||
DB_TYPE = COCKROACH
|
||||
else:
|
||||
DB_TYPE = POSTGRES
|
||||
|
||||
from psycopg2.extensions import DECIMAL, new_type, register_type
|
||||
|
||||
def _parse_timestamp(value, _):
|
||||
if value is None:
|
||||
return None
|
||||
f = "%Y-%m-%d %H:%M:%S.%f"
|
||||
if "." not in value:
|
||||
f = "%Y-%m-%d %H:%M:%S"
|
||||
return time.mktime(datetime.datetime.strptime(value, f).timetuple())
|
||||
|
||||
register_type(
|
||||
new_type(
|
||||
DECIMAL.values,
|
||||
"DEC2FLOAT",
|
||||
lambda value, curs: float(value) if value is not None else None,
|
||||
)
|
||||
)
|
||||
|
||||
register_type(new_type((1184, 1114), "TIMESTAMP2INT", _parse_timestamp))
|
||||
else:
|
||||
if not os.path.isdir(settings.lnbits_data_folder):
|
||||
os.mkdir(settings.lnbits_data_folder)
|
||||
logger.info(f"Created {settings.lnbits_data_folder}")
|
||||
DB_TYPE = SQLITE
|
||||
|
||||
|
||||
def compat_timestamp_placeholder():
|
||||
if DB_TYPE == POSTGRES:
|
||||
return "to_timestamp(?)"
|
||||
elif DB_TYPE == COCKROACH:
|
||||
return "cast(? AS timestamp)"
|
||||
else:
|
||||
return "?"
|
||||
|
||||
|
||||
class Compat:
|
||||
type: Optional[str] = "<inherited>"
|
||||
@@ -44,6 +91,12 @@ class Compat:
|
||||
return "(strftime('%s', 'now'))"
|
||||
return "<nothing>"
|
||||
|
||||
@property
|
||||
def timestamp_column_default(self) -> str:
|
||||
if self.type in {POSTGRES, COCKROACH}:
|
||||
return self.timestamp_now
|
||||
return "NULL"
|
||||
|
||||
@property
|
||||
def serial_primary_key(self) -> str:
|
||||
if self.type in {POSTGRES, COCKROACH}:
|
||||
@@ -66,6 +119,10 @@ class Compat:
|
||||
return "BIGINT"
|
||||
return "INT"
|
||||
|
||||
@property
|
||||
def timestamp_placeholder(self) -> str:
|
||||
return compat_timestamp_placeholder()
|
||||
|
||||
|
||||
class Connection(Compat):
|
||||
def __init__(self, conn: AsyncConnection, txn, typ, name, schema):
|
||||
@@ -85,17 +142,21 @@ class Connection(Compat):
|
||||
# strip html
|
||||
CLEANR = re.compile("<.*?>|&([a-z0-9]+|#[0-9]{1,6}|#x[0-9a-f]{1,6});")
|
||||
|
||||
def cleanhtml(raw_html):
|
||||
if isinstance(raw_html, str):
|
||||
cleantext = re.sub(CLEANR, "", raw_html)
|
||||
return cleantext
|
||||
else:
|
||||
return raw_html
|
||||
|
||||
# tuple to list and back to tuple
|
||||
value_list = [values] if isinstance(values, str) else list(values)
|
||||
values = tuple([cleanhtml(l) for l in value_list])
|
||||
return values
|
||||
raw_values = [values] if isinstance(values, str) else list(values)
|
||||
values = []
|
||||
for raw_value in raw_values:
|
||||
if isinstance(raw_value, str):
|
||||
values.append(re.sub(CLEANR, "", raw_value))
|
||||
elif isinstance(raw_value, datetime.datetime):
|
||||
ts = raw_value.timestamp()
|
||||
if self.type == SQLITE:
|
||||
values.append(int(ts))
|
||||
else:
|
||||
values.append(ts)
|
||||
else:
|
||||
values.append(raw_value)
|
||||
return tuple(values)
|
||||
|
||||
async def fetchall(self, query: str, values: tuple = ()) -> list:
|
||||
result = await self.conn.execute(
|
||||
@@ -111,6 +172,51 @@ class Connection(Compat):
|
||||
await result.close()
|
||||
return row
|
||||
|
||||
async def fetch_page(
|
||||
self,
|
||||
query: str,
|
||||
where: Optional[List[str]] = None,
|
||||
values: Optional[List[str]] = None,
|
||||
filters: Optional[Filters] = None,
|
||||
model: Optional[Type[TRowModel]] = None,
|
||||
) -> Page[TRowModel]:
|
||||
if not filters:
|
||||
filters = Filters()
|
||||
clause = filters.where(where)
|
||||
parsed_values = filters.values(values)
|
||||
|
||||
rows = await self.fetchall(
|
||||
f"""
|
||||
{query}
|
||||
{clause}
|
||||
{filters.order_by()}
|
||||
{filters.pagination()}
|
||||
""",
|
||||
parsed_values,
|
||||
)
|
||||
if rows:
|
||||
# no need for extra query if no pagination is specified
|
||||
if filters.offset or filters.limit:
|
||||
count = await self.fetchone(
|
||||
f"""
|
||||
SELECT COUNT(*) FROM (
|
||||
{query}
|
||||
{clause}
|
||||
) as count
|
||||
""",
|
||||
parsed_values,
|
||||
)
|
||||
count = int(count[0])
|
||||
else:
|
||||
count = len(rows)
|
||||
else:
|
||||
count = 0
|
||||
|
||||
return Page(
|
||||
data=[model.from_row(row) for row in rows] if model else rows,
|
||||
total=count,
|
||||
)
|
||||
|
||||
async def execute(self, query: str, values: tuple = ()):
|
||||
return await self.conn.execute(
|
||||
self.rewrite_query(query), self.rewrite_values(values)
|
||||
@@ -120,61 +226,17 @@ class Connection(Compat):
|
||||
class Database(Compat):
|
||||
def __init__(self, db_name: str):
|
||||
self.name = db_name
|
||||
self.schema = self.name
|
||||
self.type = DB_TYPE
|
||||
|
||||
if settings.lnbits_database_url:
|
||||
if DB_TYPE == SQLITE:
|
||||
self.path = os.path.join(
|
||||
settings.lnbits_data_folder, f"{self.name}.sqlite3"
|
||||
)
|
||||
database_uri = f"sqlite:///{self.path}"
|
||||
else:
|
||||
database_uri = settings.lnbits_database_url
|
||||
|
||||
if database_uri.startswith("cockroachdb://"):
|
||||
self.type = COCKROACH
|
||||
else:
|
||||
self.type = POSTGRES
|
||||
|
||||
import psycopg2
|
||||
|
||||
def _parse_timestamp(value, _):
|
||||
if value is None:
|
||||
return None
|
||||
f = "%Y-%m-%d %H:%M:%S.%f"
|
||||
if "." not in value:
|
||||
f = "%Y-%m-%d %H:%M:%S"
|
||||
return time.mktime(datetime.datetime.strptime(value, f).timetuple())
|
||||
|
||||
psycopg2.extensions.register_type(
|
||||
psycopg2.extensions.new_type(
|
||||
psycopg2.extensions.DECIMAL.values,
|
||||
"DEC2FLOAT",
|
||||
lambda value, curs: float(value) if value is not None else None,
|
||||
)
|
||||
)
|
||||
psycopg2.extensions.register_type(
|
||||
psycopg2.extensions.new_type(
|
||||
(1082, 1083, 1266),
|
||||
"DATE2INT",
|
||||
lambda value, curs: time.mktime(value.timetuple())
|
||||
if value is not None
|
||||
else None,
|
||||
)
|
||||
)
|
||||
|
||||
psycopg2.extensions.register_type(
|
||||
psycopg2.extensions.new_type(
|
||||
(1184, 1114), "TIMESTAMP2INT", _parse_timestamp
|
||||
)
|
||||
)
|
||||
else:
|
||||
if os.path.isdir(settings.lnbits_data_folder):
|
||||
self.path = os.path.join(
|
||||
settings.lnbits_data_folder, f"{self.name}.sqlite3"
|
||||
)
|
||||
database_uri = f"sqlite:///{self.path}"
|
||||
self.type = SQLITE
|
||||
else:
|
||||
raise NotADirectoryError(
|
||||
f"LNBITS_DATA_FOLDER named {settings.lnbits_data_folder} was not created"
|
||||
f" - please 'mkdir {settings.lnbits_data_folder}' and try again"
|
||||
)
|
||||
logger.trace(f"database {self.type} added for {self.name}")
|
||||
self.schema = self.name
|
||||
if self.name.startswith("ext_"):
|
||||
self.schema = self.name[4:]
|
||||
else:
|
||||
@@ -183,11 +245,13 @@ class Database(Compat):
|
||||
self.engine = create_engine(database_uri, strategy=ASYNCIO_STRATEGY)
|
||||
self.lock = asyncio.Lock()
|
||||
|
||||
logger.trace(f"database {self.type} added for {self.name}")
|
||||
|
||||
@asynccontextmanager
|
||||
async def connect(self):
|
||||
await self.lock.acquire()
|
||||
try:
|
||||
async with self.engine.connect() as conn:
|
||||
async with self.engine.connect() as conn: # type: ignore
|
||||
async with conn.begin() as txn:
|
||||
wconn = Connection(conn, txn, self.type, self.name, self.schema)
|
||||
|
||||
@@ -217,6 +281,17 @@ class Database(Compat):
|
||||
await result.close()
|
||||
return row
|
||||
|
||||
async def fetch_page(
|
||||
self,
|
||||
query: str,
|
||||
where: Optional[List[str]] = None,
|
||||
values: Optional[List[str]] = None,
|
||||
filters: Optional[Filters] = None,
|
||||
model: Optional[Type[TRowModel]] = None,
|
||||
) -> Page[TRowModel]:
|
||||
async with self.connect() as conn:
|
||||
return await conn.fetch_page(query, where, values, filters, model)
|
||||
|
||||
async def execute(self, query: str, values: tuple = ()):
|
||||
async with self.connect() as conn:
|
||||
return await conn.execute(query, values)
|
||||
@@ -224,3 +299,196 @@ class Database(Compat):
|
||||
@asynccontextmanager
|
||||
async def reuse_conn(self, conn: Connection):
|
||||
yield conn
|
||||
|
||||
@classmethod
|
||||
async def clean_ext_db_files(cls, ext_id: str) -> bool:
|
||||
"""
|
||||
If the extension DB is stored directly on the filesystem (like SQLite) then
|
||||
delete the files and return True. Otherwise do nothing and return False.
|
||||
"""
|
||||
|
||||
if DB_TYPE == SQLITE:
|
||||
db_file = os.path.join(settings.lnbits_data_folder, f"ext_{ext_id}.sqlite3")
|
||||
if os.path.isfile(db_file):
|
||||
os.remove(db_file)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
class Operator(Enum):
|
||||
GT = "gt"
|
||||
LT = "lt"
|
||||
EQ = "eq"
|
||||
NE = "ne"
|
||||
GE = "ge"
|
||||
LE = "le"
|
||||
INCLUDE = "in"
|
||||
EXCLUDE = "ex"
|
||||
|
||||
@property
|
||||
def as_sql(self):
|
||||
if self == Operator.EQ:
|
||||
return "="
|
||||
elif self == Operator.NE:
|
||||
return "!="
|
||||
elif self == Operator.INCLUDE:
|
||||
return "IN"
|
||||
elif self == Operator.EXCLUDE:
|
||||
return "NOT IN"
|
||||
elif self == Operator.GT:
|
||||
return ">"
|
||||
elif self == Operator.LT:
|
||||
return "<"
|
||||
elif self == Operator.GE:
|
||||
return ">="
|
||||
elif self == Operator.LE:
|
||||
return "<="
|
||||
else:
|
||||
raise ValueError("Unknown SQL Operator")
|
||||
|
||||
|
||||
class FromRowModel(BaseModel):
|
||||
@classmethod
|
||||
def from_row(cls, row: Row):
|
||||
return cls(**dict(row))
|
||||
|
||||
|
||||
class FilterModel(BaseModel):
|
||||
__search_fields__: List[str] = []
|
||||
__sort_fields__: Optional[List[str]] = None
|
||||
|
||||
|
||||
T = TypeVar("T")
|
||||
TModel = TypeVar("TModel", bound=BaseModel)
|
||||
TRowModel = TypeVar("TRowModel", bound=FromRowModel)
|
||||
TFilterModel = TypeVar("TFilterModel", bound=FilterModel)
|
||||
|
||||
|
||||
class Page(BaseModel, Generic[T]):
|
||||
data: list[T]
|
||||
total: int
|
||||
|
||||
|
||||
class Filter(BaseModel, Generic[TFilterModel]):
|
||||
field: str
|
||||
op: Operator = Operator.EQ
|
||||
values: list[Any]
|
||||
|
||||
model: Optional[Type[TFilterModel]]
|
||||
|
||||
@classmethod
|
||||
def parse_query(cls, key: str, raw_values: list[Any], model: Type[TFilterModel]):
|
||||
# Key format:
|
||||
# key[operator]
|
||||
# e.g. name[eq]
|
||||
if key.endswith("]"):
|
||||
split = key[:-1].split("[")
|
||||
if len(split) != 2:
|
||||
raise ValueError("Invalid key")
|
||||
field = split[0]
|
||||
op = Operator(split[1])
|
||||
else:
|
||||
field = key
|
||||
op = Operator("eq")
|
||||
|
||||
if field in model.__fields__:
|
||||
compare_field = model.__fields__[field]
|
||||
values = []
|
||||
for raw_value in raw_values:
|
||||
validated, errors = compare_field.validate(raw_value, {}, loc="none")
|
||||
if errors:
|
||||
raise ValidationError(errors=[errors], model=model)
|
||||
values.append(validated)
|
||||
else:
|
||||
raise ValueError("Unknown filter field")
|
||||
|
||||
return cls(field=field, op=op, values=values, model=model)
|
||||
|
||||
@property
|
||||
def statement(self):
|
||||
if self.model and self.model.__fields__[self.field].type_ == datetime.datetime:
|
||||
placeholder = compat_timestamp_placeholder()
|
||||
else:
|
||||
placeholder = "?"
|
||||
if self.op in (Operator.INCLUDE, Operator.EXCLUDE):
|
||||
placeholders = ", ".join([placeholder] * len(self.values))
|
||||
stmt = [f"{self.field} {self.op.as_sql} ({placeholders})"]
|
||||
else:
|
||||
stmt = [f"{self.field} {self.op.as_sql} {placeholder}"] * len(self.values)
|
||||
return " OR ".join(stmt)
|
||||
|
||||
|
||||
class Filters(BaseModel, Generic[TFilterModel]):
|
||||
"""
|
||||
Generic helper class for filtering and sorting data.
|
||||
For usage in an api endpoint, use the `parse_filters` dependency.
|
||||
|
||||
When constructing this class manually always make sure to pass a model so that
|
||||
the values can be validated. Otherwise, make sure to validate the inputs manually.
|
||||
"""
|
||||
|
||||
filters: List[Filter[TFilterModel]] = []
|
||||
search: Optional[str] = None
|
||||
|
||||
offset: Optional[int] = None
|
||||
limit: Optional[int] = None
|
||||
|
||||
sortby: Optional[str] = None
|
||||
direction: Optional[Literal["asc", "desc"]] = None
|
||||
|
||||
model: Optional[Type[TFilterModel]] = None
|
||||
|
||||
@root_validator(pre=True)
|
||||
def validate_sortby(cls, values):
|
||||
sortby = values.get("sortby")
|
||||
model = values.get("model")
|
||||
if sortby and model:
|
||||
model = values["model"]
|
||||
# if no sort fields are specified explicitly all fields are allowed
|
||||
allowed = model.__sort_fields__ or model.__fields__
|
||||
if sortby not in allowed:
|
||||
raise ValueError("Invalid sort field")
|
||||
return values
|
||||
|
||||
def pagination(self) -> str:
|
||||
stmt = ""
|
||||
if self.limit:
|
||||
stmt += f"LIMIT {self.limit} "
|
||||
if self.offset:
|
||||
stmt += f"OFFSET {self.offset}"
|
||||
return stmt
|
||||
|
||||
def where(self, where_stmts: Optional[List[str]] = None) -> str:
|
||||
if not where_stmts:
|
||||
where_stmts = []
|
||||
if self.filters:
|
||||
for filter in self.filters:
|
||||
where_stmts.append(filter.statement)
|
||||
if self.search and self.model:
|
||||
if DB_TYPE == POSTGRES:
|
||||
where_stmts.append(
|
||||
f"lower(concat({', '.join(self.model.__search_fields__)})) LIKE ?"
|
||||
)
|
||||
elif DB_TYPE == SQLITE:
|
||||
where_stmts.append(
|
||||
f"lower({'||'.join(self.model.__search_fields__)}) LIKE ?"
|
||||
)
|
||||
if where_stmts:
|
||||
return "WHERE " + " AND ".join(where_stmts)
|
||||
return ""
|
||||
|
||||
def order_by(self) -> str:
|
||||
if self.sortby:
|
||||
return f"ORDER BY {self.sortby} {self.direction or 'asc'}"
|
||||
return ""
|
||||
|
||||
def values(self, values: Optional[List[str]] = None) -> tuple:
|
||||
if not values:
|
||||
values = []
|
||||
if self.filters:
|
||||
for filter in self.filters:
|
||||
values.extend(filter.values)
|
||||
if self.search and self.model:
|
||||
values.append(f"%{self.search}%")
|
||||
return tuple(values)
|
||||
|
||||
@@ -1,40 +1,54 @@
|
||||
from http import HTTPStatus
|
||||
from typing import Annotated, Literal, Optional, Type, Union
|
||||
|
||||
from fastapi import Security, status
|
||||
from fastapi import Cookie, Depends, Query, Request, Security
|
||||
from fastapi.exceptions import HTTPException
|
||||
from fastapi.openapi.models import APIKey, APIKeyIn
|
||||
from fastapi.security.api_key import APIKeyHeader, APIKeyQuery
|
||||
from fastapi.security import APIKeyHeader, APIKeyQuery, OAuth2PasswordBearer
|
||||
from fastapi.security.base import SecurityBase
|
||||
from jose import ExpiredSignatureError, JWTError, jwt
|
||||
from loguru import logger
|
||||
from pydantic.types import UUID4
|
||||
from starlette.requests import Request
|
||||
|
||||
from lnbits.core.crud import get_user, get_wallet_for_key
|
||||
from lnbits.core.models import User, Wallet
|
||||
from lnbits.requestvars import g
|
||||
from lnbits.settings import settings
|
||||
from lnbits.core.crud import (
|
||||
get_account,
|
||||
get_account_by_email,
|
||||
get_account_by_username,
|
||||
get_user,
|
||||
get_wallet_for_key,
|
||||
)
|
||||
from lnbits.core.models import User, WalletType, WalletTypeInfo
|
||||
from lnbits.db import Filter, Filters, TFilterModel
|
||||
from lnbits.settings import AuthMethods, settings
|
||||
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="api/v1/auth", auto_error=False)
|
||||
|
||||
|
||||
# TODO: fix type ignores
|
||||
class KeyChecker(SecurityBase):
|
||||
def __init__(
|
||||
self, scheme_name: str = None, auto_error: bool = True, api_key: str = None
|
||||
self,
|
||||
scheme_name: Optional[str] = None,
|
||||
auto_error: bool = True,
|
||||
api_key: Optional[str] = None,
|
||||
):
|
||||
self.scheme_name = scheme_name or self.__class__.__name__
|
||||
self.auto_error = auto_error
|
||||
self._key_type = "invoice"
|
||||
self._key_type = WalletType.invoice
|
||||
self._api_key = api_key
|
||||
if api_key:
|
||||
key = APIKey(
|
||||
**{"in": APIKeyIn.query},
|
||||
**{"in": APIKeyIn.query}, # type: ignore
|
||||
name="X-API-KEY",
|
||||
description="Wallet API Key - QUERY",
|
||||
)
|
||||
else:
|
||||
key = APIKey(
|
||||
**{"in": APIKeyIn.header},
|
||||
**{"in": APIKeyIn.header}, # type: ignore
|
||||
name="X-API-KEY",
|
||||
description="Wallet API Key - HEADER",
|
||||
)
|
||||
self.wallet = None # type: ignore
|
||||
self.wallet = None
|
||||
self.model: APIKey = key
|
||||
|
||||
async def __call__(self, request: Request):
|
||||
@@ -44,16 +58,16 @@ class KeyChecker(SecurityBase):
|
||||
if self._api_key
|
||||
else request.headers.get("X-API-KEY") or request.query_params["api-key"]
|
||||
)
|
||||
# FIXME: Find another way to validate the key. A fetch from DB should be avoided here.
|
||||
# Also, we should not return the wallet here - thats silly.
|
||||
# Possibly store it in a Redis DB
|
||||
self.wallet = await get_wallet_for_key(key_value, self._key_type) # type: ignore
|
||||
if not self.wallet:
|
||||
# FIXME: Find another way to validate the key. A fetch from DB should be
|
||||
# avoided here. Also, we should not return the wallet here - thats
|
||||
# silly. Possibly store it in a Redis DB
|
||||
wallet = await get_wallet_for_key(key_value, self._key_type)
|
||||
if not wallet or wallet.deleted:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.UNAUTHORIZED,
|
||||
detail="Invalid key or expired key.",
|
||||
detail="Invalid key or wallet.",
|
||||
)
|
||||
|
||||
self.wallet = wallet # type: ignore
|
||||
except KeyError:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST, detail="`X-API-KEY` header missing."
|
||||
@@ -70,10 +84,13 @@ class WalletInvoiceKeyChecker(KeyChecker):
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, scheme_name: str = None, auto_error: bool = True, api_key: str = None
|
||||
self,
|
||||
scheme_name: Optional[str] = None,
|
||||
auto_error: bool = True,
|
||||
api_key: Optional[str] = None,
|
||||
):
|
||||
super().__init__(scheme_name, auto_error, api_key)
|
||||
self._key_type = "invoice"
|
||||
self._key_type = WalletType.invoice
|
||||
|
||||
|
||||
class WalletAdminKeyChecker(KeyChecker):
|
||||
@@ -86,19 +103,13 @@ class WalletAdminKeyChecker(KeyChecker):
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, scheme_name: str = None, auto_error: bool = True, api_key: str = None
|
||||
self,
|
||||
scheme_name: Optional[str] = None,
|
||||
auto_error: bool = True,
|
||||
api_key: Optional[str] = None,
|
||||
):
|
||||
super().__init__(scheme_name, auto_error, api_key)
|
||||
self._key_type = "admin"
|
||||
|
||||
|
||||
class WalletTypeInfo:
|
||||
wallet_type: int
|
||||
wallet: Wallet
|
||||
|
||||
def __init__(self, wallet_type: int, wallet: Wallet) -> None:
|
||||
self.wallet_type = wallet_type
|
||||
self.wallet = wallet
|
||||
self._key_type = WalletType.admin
|
||||
|
||||
|
||||
api_key_header = APIKeyHeader(
|
||||
@@ -118,11 +129,6 @@ async def get_key_type(
|
||||
api_key_header: str = Security(api_key_header),
|
||||
api_key_query: str = Security(api_key_query),
|
||||
) -> WalletTypeInfo:
|
||||
# 0: admin
|
||||
# 1: invoice
|
||||
# 2: invalid
|
||||
pathname = r["path"].split("/")[1]
|
||||
|
||||
token = api_key_header or api_key_query
|
||||
|
||||
if not token:
|
||||
@@ -131,38 +137,40 @@ async def get_key_type(
|
||||
detail="Invoice (or Admin) key required.",
|
||||
)
|
||||
|
||||
for typenr, WalletChecker in zip(
|
||||
[0, 1], [WalletAdminKeyChecker, WalletInvoiceKeyChecker]
|
||||
for wallet_type, WalletChecker in zip(
|
||||
[WalletType.admin, WalletType.invoice],
|
||||
[WalletAdminKeyChecker, WalletInvoiceKeyChecker],
|
||||
):
|
||||
try:
|
||||
checker = WalletChecker(api_key=token)
|
||||
await checker.__call__(r)
|
||||
wallet = WalletTypeInfo(typenr, checker.wallet) # type: ignore
|
||||
if wallet is None or wallet.wallet is None:
|
||||
if checker.wallet is None:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Wallet does not exist."
|
||||
)
|
||||
wallet = WalletTypeInfo(wallet_type, checker.wallet)
|
||||
if (
|
||||
wallet.wallet.user != settings.super_user
|
||||
and wallet.wallet.user not in settings.lnbits_admin_users
|
||||
) and (
|
||||
settings.lnbits_admin_extensions
|
||||
and pathname in settings.lnbits_admin_extensions
|
||||
and r["path"].split("/")[1] in settings.lnbits_admin_extensions
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.FORBIDDEN,
|
||||
detail="User not authorized for this extension.",
|
||||
)
|
||||
return wallet
|
||||
except HTTPException as e:
|
||||
if e.status_code == HTTPStatus.BAD_REQUEST:
|
||||
except HTTPException as exc:
|
||||
if exc.status_code == HTTPStatus.BAD_REQUEST:
|
||||
raise
|
||||
elif e.status_code == HTTPStatus.UNAUTHORIZED:
|
||||
# we pass this in case it is not an invoice key, nor an admin key, and then return NOT_FOUND at the end of this block
|
||||
elif exc.status_code == HTTPStatus.UNAUTHORIZED:
|
||||
# we pass this in case it is not an invoice key, nor an admin key,
|
||||
# and then return NOT_FOUND at the end of this block
|
||||
pass
|
||||
else:
|
||||
raise
|
||||
except:
|
||||
except Exception:
|
||||
raise
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Wallet does not exist."
|
||||
@@ -174,7 +182,6 @@ async def require_admin_key(
|
||||
api_key_header: str = Security(api_key_header),
|
||||
api_key_query: str = Security(api_key_query),
|
||||
):
|
||||
|
||||
token = api_key_header or api_key_query
|
||||
|
||||
if not token:
|
||||
@@ -189,7 +196,7 @@ async def require_admin_key(
|
||||
# If wallet type is not admin then return the unauthorized status
|
||||
# This also covers when the user passes an invalid key type
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED, detail="Admin key required."
|
||||
status_code=HTTPStatus.UNAUTHORIZED, detail="Admin key required."
|
||||
)
|
||||
else:
|
||||
return wallet
|
||||
@@ -200,7 +207,6 @@ async def require_invoice_key(
|
||||
api_key_header: str = Security(api_key_header),
|
||||
api_key_query: str = Security(api_key_query),
|
||||
):
|
||||
|
||||
token = api_key_header or api_key_query
|
||||
|
||||
if not token:
|
||||
@@ -211,58 +217,116 @@ async def require_invoice_key(
|
||||
|
||||
wallet = await get_key_type(r, token)
|
||||
|
||||
if wallet.wallet_type > 1:
|
||||
# If wallet type is not invoice then return the unauthorized status
|
||||
# This also covers when the user passes an invalid key type
|
||||
if (
|
||||
wallet.wallet_type != WalletType.admin
|
||||
and wallet.wallet_type != WalletType.invoice
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
status_code=HTTPStatus.UNAUTHORIZED,
|
||||
detail="Invoice (or Admin) key required.",
|
||||
)
|
||||
else:
|
||||
return wallet
|
||||
|
||||
|
||||
async def check_user_exists(usr: UUID4) -> User:
|
||||
g().user = await get_user(usr.hex)
|
||||
|
||||
if not g().user:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="User does not exist."
|
||||
)
|
||||
|
||||
if (
|
||||
len(settings.lnbits_allowed_users) > 0
|
||||
and g().user.id not in settings.lnbits_allowed_users
|
||||
and g().user.id not in settings.lnbits_admin_users
|
||||
and g().user.id != settings.super_user
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.UNAUTHORIZED, detail="User not authorized."
|
||||
)
|
||||
|
||||
return g().user
|
||||
async def check_access_token(
|
||||
header_access_token: Annotated[Union[str, None], Depends(oauth2_scheme)],
|
||||
cookie_access_token: Annotated[Union[str, None], Cookie()] = None,
|
||||
) -> Optional[str]:
|
||||
return header_access_token or cookie_access_token
|
||||
|
||||
|
||||
async def check_admin(usr: UUID4) -> User:
|
||||
user = await check_user_exists(usr)
|
||||
async def check_user_exists(
|
||||
r: Request,
|
||||
access_token: Annotated[Optional[str], Depends(check_access_token)],
|
||||
usr: Optional[UUID4] = None,
|
||||
) -> User:
|
||||
if access_token:
|
||||
account = await _get_account_from_token(access_token)
|
||||
elif usr and settings.is_auth_method_allowed(AuthMethods.user_id_only):
|
||||
account = await get_account(usr.hex)
|
||||
else:
|
||||
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Missing user ID or access token.")
|
||||
|
||||
if not account or not settings.is_user_allowed(account.id):
|
||||
raise HTTPException(HTTPStatus.UNAUTHORIZED, "User not allowed.")
|
||||
|
||||
user = await get_user(account.id)
|
||||
assert user, "User not found for account."
|
||||
|
||||
if not user.admin and r["path"].split("/")[1] in settings.lnbits_admin_extensions:
|
||||
raise HTTPException(HTTPStatus.FORBIDDEN, "User not authorized for extension.")
|
||||
|
||||
return user
|
||||
|
||||
|
||||
async def check_admin(user: Annotated[User, Depends(check_user_exists)]) -> User:
|
||||
if user.id != settings.super_user and user.id not in settings.lnbits_admin_users:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.UNAUTHORIZED,
|
||||
detail="User not authorized. No admin privileges.",
|
||||
HTTPStatus.UNAUTHORIZED, "User not authorized. No admin privileges."
|
||||
)
|
||||
user.admin = True
|
||||
user.super_user = False
|
||||
if user.id == settings.super_user:
|
||||
user.super_user = True
|
||||
|
||||
return user
|
||||
|
||||
|
||||
async def check_super_user(usr: UUID4) -> User:
|
||||
user = await check_admin(usr)
|
||||
async def check_super_user(user: Annotated[User, Depends(check_user_exists)]) -> User:
|
||||
if user.id != settings.super_user:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.UNAUTHORIZED,
|
||||
detail="User not authorized. No super user privileges.",
|
||||
HTTPStatus.UNAUTHORIZED, "User not authorized. No super user privileges."
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
def parse_filters(model: Type[TFilterModel]):
|
||||
"""
|
||||
Parses the query params as filters.
|
||||
:param model: model used for validation of filter values
|
||||
"""
|
||||
|
||||
def dependency(
|
||||
request: Request,
|
||||
limit: Optional[int] = None,
|
||||
offset: Optional[int] = None,
|
||||
sortby: Optional[str] = None,
|
||||
direction: Optional[Literal["asc", "desc"]] = None,
|
||||
search: Optional[str] = Query(None, description="Text based search"),
|
||||
):
|
||||
params = request.query_params
|
||||
filters = []
|
||||
for key in params.keys():
|
||||
try:
|
||||
filters.append(Filter.parse_query(key, params.getlist(key), model))
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
return Filters(
|
||||
filters=filters,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
sortby=sortby,
|
||||
direction=direction,
|
||||
search=search,
|
||||
model=model,
|
||||
)
|
||||
|
||||
return dependency
|
||||
|
||||
|
||||
async def _get_account_from_token(access_token):
|
||||
try:
|
||||
payload = jwt.decode(access_token, settings.auth_secret_key, "HS256")
|
||||
if "sub" in payload and payload.get("sub"):
|
||||
return await get_account_by_username(str(payload.get("sub")))
|
||||
if "usr" in payload and payload.get("usr"):
|
||||
return await get_account(str(payload.get("usr")))
|
||||
if "email" in payload and payload.get("email"):
|
||||
return await get_account_by_email(str(payload.get("email")))
|
||||
|
||||
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Data missing for access token.")
|
||||
except ExpiredSignatureError:
|
||||
raise HTTPException(
|
||||
HTTPStatus.UNAUTHORIZED, "Session expired.", {"token-expired": "true"}
|
||||
)
|
||||
except JWTError as e:
|
||||
logger.debug(e)
|
||||
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid access token.")
|
||||
|
||||
@@ -3,20 +3,166 @@ import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import urllib.request
|
||||
import zipfile
|
||||
from http import HTTPStatus
|
||||
from pathlib import Path
|
||||
from typing import Any, List, NamedTuple, Optional, Tuple
|
||||
from urllib import request
|
||||
|
||||
import httpx
|
||||
from fastapi.exceptions import HTTPException
|
||||
from fastapi import HTTPException
|
||||
from loguru import logger
|
||||
from packaging import version
|
||||
from pydantic import BaseModel
|
||||
|
||||
from lnbits.settings import settings
|
||||
|
||||
|
||||
class ExplicitRelease(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
version: str
|
||||
archive: str
|
||||
hash: str
|
||||
dependencies: List[str] = []
|
||||
repo: Optional[str]
|
||||
icon: Optional[str]
|
||||
short_description: Optional[str]
|
||||
min_lnbits_version: Optional[str]
|
||||
html_url: Optional[str] # todo: release_url
|
||||
warning: Optional[str]
|
||||
info_notification: Optional[str]
|
||||
critical_notification: Optional[str]
|
||||
|
||||
def is_version_compatible(self):
|
||||
if not self.min_lnbits_version:
|
||||
return True
|
||||
return version_parse(self.min_lnbits_version) <= version_parse(settings.version)
|
||||
|
||||
|
||||
class GitHubRelease(BaseModel):
|
||||
id: str
|
||||
organisation: str
|
||||
repository: str
|
||||
|
||||
|
||||
class Manifest(BaseModel):
|
||||
featured: List[str] = []
|
||||
extensions: List["ExplicitRelease"] = []
|
||||
repos: List["GitHubRelease"] = []
|
||||
|
||||
|
||||
class GitHubRepoRelease(BaseModel):
|
||||
name: str
|
||||
tag_name: str
|
||||
zipball_url: str
|
||||
html_url: str
|
||||
|
||||
|
||||
class GitHubRepo(BaseModel):
|
||||
stargazers_count: str
|
||||
html_url: str
|
||||
default_branch: str
|
||||
|
||||
|
||||
class ExtensionConfig(BaseModel):
|
||||
name: str
|
||||
short_description: str
|
||||
tile: str = ""
|
||||
warning: Optional[str] = ""
|
||||
min_lnbits_version: Optional[str]
|
||||
|
||||
def is_version_compatible(self):
|
||||
if not self.min_lnbits_version:
|
||||
return True
|
||||
return version_parse(self.min_lnbits_version) <= version_parse(settings.version)
|
||||
|
||||
|
||||
def download_url(url, save_path):
|
||||
with request.urlopen(url) as dl_file:
|
||||
with open(save_path, "wb") as out_file:
|
||||
out_file.write(dl_file.read())
|
||||
|
||||
|
||||
def file_hash(filename):
|
||||
h = hashlib.sha256()
|
||||
b = bytearray(128 * 1024)
|
||||
mv = memoryview(b)
|
||||
with open(filename, "rb", buffering=0) as f:
|
||||
while n := f.readinto(mv):
|
||||
h.update(mv[:n])
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
async def fetch_github_repo_info(
|
||||
org: str, repository: str
|
||||
) -> Tuple[GitHubRepo, GitHubRepoRelease, ExtensionConfig]:
|
||||
repo_url = f"https://api.github.com/repos/{org}/{repository}"
|
||||
error_msg = "Cannot fetch extension repo"
|
||||
repo = await github_api_get(repo_url, error_msg)
|
||||
github_repo = GitHubRepo.parse_obj(repo)
|
||||
|
||||
lates_release_url = (
|
||||
f"https://api.github.com/repos/{org}/{repository}/releases/latest"
|
||||
)
|
||||
error_msg = "Cannot fetch extension releases"
|
||||
latest_release: Any = await github_api_get(lates_release_url, error_msg)
|
||||
|
||||
config_url = f"https://raw.githubusercontent.com/{org}/{repository}/{github_repo.default_branch}/config.json"
|
||||
error_msg = "Cannot fetch config for extension"
|
||||
config = await github_api_get(config_url, error_msg)
|
||||
|
||||
return (
|
||||
github_repo,
|
||||
GitHubRepoRelease.parse_obj(latest_release),
|
||||
ExtensionConfig.parse_obj(config),
|
||||
)
|
||||
|
||||
|
||||
async def fetch_manifest(url) -> Manifest:
|
||||
error_msg = "Cannot fetch extensions manifest"
|
||||
manifest = await github_api_get(url, error_msg)
|
||||
return Manifest.parse_obj(manifest)
|
||||
|
||||
|
||||
async def fetch_github_releases(org: str, repo: str) -> List[GitHubRepoRelease]:
|
||||
releases_url = f"https://api.github.com/repos/{org}/{repo}/releases"
|
||||
error_msg = "Cannot fetch extension releases"
|
||||
releases = await github_api_get(releases_url, error_msg)
|
||||
return [GitHubRepoRelease.parse_obj(r) for r in releases]
|
||||
|
||||
|
||||
async def fetch_github_release_config(
|
||||
org: str, repo: str, tag_name: str
|
||||
) -> Optional[ExtensionConfig]:
|
||||
config_url = (
|
||||
f"https://raw.githubusercontent.com/{org}/{repo}/{tag_name}/config.json"
|
||||
)
|
||||
error_msg = "Cannot fetch GitHub extension config"
|
||||
config = await github_api_get(config_url, error_msg)
|
||||
return ExtensionConfig.parse_obj(config)
|
||||
|
||||
|
||||
async def github_api_get(url: str, error_msg: Optional[str]) -> Any:
|
||||
headers = {"User-Agent": settings.user_agent}
|
||||
if settings.lnbits_ext_github_token:
|
||||
headers["Authorization"] = f"Bearer {settings.lnbits_ext_github_token}"
|
||||
async with httpx.AsyncClient(headers=headers) as client:
|
||||
resp = await client.get(url)
|
||||
if resp.status_code != 200:
|
||||
logger.warning(f"{error_msg} ({url}): {resp.text}")
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
def icon_to_github_url(source_repo: str, path: Optional[str]) -> str:
|
||||
if not path:
|
||||
return ""
|
||||
_, _, *rest = path.split("/")
|
||||
tail = "/".join(rest)
|
||||
return f"https://github.com/{source_repo}/raw/main/{tail}"
|
||||
|
||||
|
||||
class Extension(NamedTuple):
|
||||
code: str
|
||||
is_valid: bool
|
||||
@@ -31,12 +177,19 @@ class Extension(NamedTuple):
|
||||
upgrade_hash: Optional[str] = ""
|
||||
|
||||
@property
|
||||
def module_name(self):
|
||||
return (
|
||||
f"lnbits.extensions.{self.code}"
|
||||
if self.upgrade_hash == ""
|
||||
else f"lnbits.upgrades.{self.code}-{self.upgrade_hash}.{self.code}"
|
||||
)
|
||||
def module_name(self) -> str:
|
||||
if self.is_upgrade_extension:
|
||||
if settings.has_default_extension_path:
|
||||
return f"lnbits.upgrades.{self.code}-{self.upgrade_hash}"
|
||||
return f"{self.code}-{self.upgrade_hash}"
|
||||
|
||||
if settings.has_default_extension_path:
|
||||
return f"lnbits.extensions.{self.code}"
|
||||
return self.code
|
||||
|
||||
@property
|
||||
def is_upgrade_extension(self) -> bool:
|
||||
return self.upgrade_hash != ""
|
||||
|
||||
@classmethod
|
||||
def from_installable_ext(cls, ext_info: "InstallableExtension") -> "Extension":
|
||||
@@ -53,9 +206,9 @@ class Extension(NamedTuple):
|
||||
|
||||
|
||||
class ExtensionManager:
|
||||
def __init__(self):
|
||||
p = Path(settings.lnbits_path, "extensions")
|
||||
os.makedirs(p, exist_ok=True)
|
||||
def __init__(self) -> None:
|
||||
p = Path(settings.lnbits_extensions_path, "extensions")
|
||||
Path(p).mkdir(parents=True, exist_ok=True)
|
||||
self._extension_folders: List[Path] = [f for f in p.iterdir() if f.is_dir()]
|
||||
|
||||
@property
|
||||
@@ -97,12 +250,15 @@ class ExtensionRelease(BaseModel):
|
||||
version: str
|
||||
archive: str
|
||||
source_repo: str
|
||||
is_github_release = False
|
||||
hash: Optional[str]
|
||||
html_url: Optional[str]
|
||||
description: Optional[str]
|
||||
details_html: Optional[str] = None
|
||||
icon: Optional[str]
|
||||
is_github_release: bool = False
|
||||
hash: Optional[str] = None
|
||||
min_lnbits_version: Optional[str] = None
|
||||
is_version_compatible: Optional[bool] = True
|
||||
html_url: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
warning: Optional[str] = None
|
||||
repo: Optional[str] = None
|
||||
icon: Optional[str] = None
|
||||
|
||||
@classmethod
|
||||
def from_github_release(
|
||||
@@ -115,10 +271,29 @@ class ExtensionRelease(BaseModel):
|
||||
archive=r.zipball_url,
|
||||
source_repo=source_repo,
|
||||
is_github_release=True,
|
||||
# description=r.body, # bad for JSON
|
||||
repo=f"https://github.com/{source_repo}",
|
||||
html_url=r.html_url,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_explicit_release(
|
||||
cls, source_repo: str, e: "ExplicitRelease"
|
||||
) -> "ExtensionRelease":
|
||||
return ExtensionRelease(
|
||||
name=e.name,
|
||||
version=e.version,
|
||||
archive=e.archive,
|
||||
hash=e.hash,
|
||||
source_repo=source_repo,
|
||||
description=e.short_description,
|
||||
min_lnbits_version=e.min_lnbits_version,
|
||||
is_version_compatible=e.is_version_compatible(),
|
||||
warning=e.warning,
|
||||
html_url=e.html_url,
|
||||
repo=e.repo,
|
||||
icon=e.icon,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def all_releases(cls, org: str, repo: str) -> List["ExtensionRelease"]:
|
||||
try:
|
||||
@@ -132,52 +307,6 @@ class ExtensionRelease(BaseModel):
|
||||
return []
|
||||
|
||||
|
||||
class ExplicitRelease(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
version: str
|
||||
archive: str
|
||||
hash: str
|
||||
dependencies: List[str] = []
|
||||
icon: Optional[str]
|
||||
short_description: Optional[str]
|
||||
html_url: Optional[str]
|
||||
details: Optional[str]
|
||||
info_notification: Optional[str]
|
||||
critical_notification: Optional[str]
|
||||
|
||||
|
||||
class GitHubRelease(BaseModel):
|
||||
id: str
|
||||
organisation: str
|
||||
repository: str
|
||||
|
||||
|
||||
class Manifest(BaseModel):
|
||||
featured: List[str] = []
|
||||
extensions: List["ExplicitRelease"] = []
|
||||
repos: List["GitHubRelease"] = []
|
||||
|
||||
|
||||
class GitHubRepoRelease(BaseModel):
|
||||
name: str
|
||||
tag_name: str
|
||||
zipball_url: str
|
||||
html_url: str
|
||||
|
||||
|
||||
class GitHubRepo(BaseModel):
|
||||
stargazers_count: str
|
||||
html_url: str
|
||||
default_branch: str
|
||||
|
||||
|
||||
class ExtensionConfig(BaseModel):
|
||||
name: str
|
||||
short_description: str
|
||||
tile: str = ""
|
||||
|
||||
|
||||
class InstallableExtension(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
@@ -187,8 +316,9 @@ class InstallableExtension(BaseModel):
|
||||
is_admin_only: bool = False
|
||||
stars: int = 0
|
||||
featured = False
|
||||
latest_release: Optional[ExtensionRelease]
|
||||
installed_release: Optional[ExtensionRelease]
|
||||
latest_release: Optional[ExtensionRelease] = None
|
||||
installed_release: Optional[ExtensionRelease] = None
|
||||
archive: Optional[str] = None
|
||||
|
||||
@property
|
||||
def hash(self) -> str:
|
||||
@@ -201,22 +331,26 @@ class InstallableExtension(BaseModel):
|
||||
return "not-installed"
|
||||
|
||||
@property
|
||||
def zip_path(self) -> str:
|
||||
extensions_data_dir = os.path.join(settings.lnbits_data_folder, "extensions")
|
||||
os.makedirs(extensions_data_dir, exist_ok=True)
|
||||
return os.path.join(extensions_data_dir, f"{self.id}.zip")
|
||||
def zip_path(self) -> Path:
|
||||
extensions_data_dir = Path(settings.lnbits_data_folder, "zips")
|
||||
Path(extensions_data_dir).mkdir(parents=True, exist_ok=True)
|
||||
return Path(extensions_data_dir, f"{self.id}.zip")
|
||||
|
||||
@property
|
||||
def ext_dir(self) -> str:
|
||||
return os.path.join("lnbits", "extensions", self.id)
|
||||
def ext_dir(self) -> Path:
|
||||
return Path(settings.lnbits_extensions_path, "extensions", self.id)
|
||||
|
||||
@property
|
||||
def ext_upgrade_dir(self) -> str:
|
||||
return os.path.join("lnbits", "upgrades", f"{self.id}-{self.hash}")
|
||||
def ext_upgrade_dir(self) -> Path:
|
||||
return Path(
|
||||
settings.lnbits_extensions_path, "upgrades", f"{self.id}-{self.hash}"
|
||||
)
|
||||
|
||||
@property
|
||||
def module_name(self) -> str:
|
||||
return f"lnbits.extensions.{self.id}"
|
||||
if settings.has_default_extension_path:
|
||||
return f"lnbits.extensions.{self.id}"
|
||||
return self.id
|
||||
|
||||
@property
|
||||
def module_installed(self) -> bool:
|
||||
@@ -224,21 +358,23 @@ class InstallableExtension(BaseModel):
|
||||
|
||||
@property
|
||||
def has_installed_version(self) -> bool:
|
||||
if not Path(self.ext_dir).is_dir():
|
||||
if not self.ext_dir.is_dir():
|
||||
return False
|
||||
config_file = os.path.join(self.ext_dir, "config.json")
|
||||
if not Path(config_file).is_file():
|
||||
return False
|
||||
with open(config_file, "r") as json_file:
|
||||
config_json = json.load(json_file)
|
||||
return config_json.get("is_installed") is True
|
||||
return Path(self.ext_dir, "config.json").is_file()
|
||||
|
||||
@property
|
||||
def installed_version(self) -> str:
|
||||
if self.installed_release:
|
||||
return self.installed_release.version
|
||||
return ""
|
||||
|
||||
def download_archive(self):
|
||||
logger.info(f"Downloading extension {self.name}.")
|
||||
logger.info(f"Downloading extension {self.name} ({self.installed_version}).")
|
||||
ext_zip_file = self.zip_path
|
||||
if os.path.isfile(ext_zip_file):
|
||||
if ext_zip_file.is_file():
|
||||
os.remove(ext_zip_file)
|
||||
try:
|
||||
assert self.installed_release, "installed_release is none."
|
||||
download_url(self.installed_release.archive, ext_zip_file)
|
||||
except Exception as ex:
|
||||
logger.warning(ex)
|
||||
@@ -250,7 +386,7 @@ class InstallableExtension(BaseModel):
|
||||
archive_hash = file_hash(ext_zip_file)
|
||||
if self.installed_release.hash and self.installed_release.hash != archive_hash:
|
||||
# remove downloaded archive
|
||||
if os.path.isfile(ext_zip_file):
|
||||
if ext_zip_file.is_file():
|
||||
os.remove(ext_zip_file)
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND,
|
||||
@@ -258,27 +394,28 @@ class InstallableExtension(BaseModel):
|
||||
)
|
||||
|
||||
def extract_archive(self):
|
||||
logger.info(f"Extracting extension {self.name}.")
|
||||
os.makedirs(os.path.join("lnbits", "upgrades"), exist_ok=True)
|
||||
shutil.rmtree(self.ext_upgrade_dir, True)
|
||||
with zipfile.ZipFile(self.zip_path, "r") as zip_ref:
|
||||
zip_ref.extractall(self.ext_upgrade_dir)
|
||||
generated_dir_name = os.listdir(self.ext_upgrade_dir)[0]
|
||||
os.rename(
|
||||
os.path.join(self.ext_upgrade_dir, generated_dir_name),
|
||||
os.path.join(self.ext_upgrade_dir, self.id),
|
||||
logger.info(f"Extracting extension {self.name} ({self.installed_version}).")
|
||||
Path(settings.lnbits_extensions_path, "upgrades").mkdir(
|
||||
parents=True, exist_ok=True
|
||||
)
|
||||
|
||||
tmp_dir = Path(settings.lnbits_data_folder, "unzip-temp", self.hash)
|
||||
shutil.rmtree(tmp_dir, True)
|
||||
|
||||
with zipfile.ZipFile(self.zip_path, "r") as zip_ref:
|
||||
zip_ref.extractall(tmp_dir)
|
||||
generated_dir_name = os.listdir(tmp_dir)[0]
|
||||
shutil.rmtree(self.ext_upgrade_dir, True)
|
||||
shutil.copytree(
|
||||
Path(tmp_dir, generated_dir_name),
|
||||
Path(self.ext_upgrade_dir),
|
||||
)
|
||||
shutil.rmtree(tmp_dir, True)
|
||||
|
||||
# Pre-packed extensions can be upgraded
|
||||
# Mark the extension as installed so we know it is not the pre-packed version
|
||||
with open(
|
||||
os.path.join(self.ext_upgrade_dir, self.id, "config.json"), "r+"
|
||||
) as json_file:
|
||||
with open(Path(self.ext_upgrade_dir, "config.json"), "r+") as json_file:
|
||||
config_json = json.load(json_file)
|
||||
config_json["is_installed"] = True
|
||||
json_file.seek(0)
|
||||
json.dump(config_json, json_file)
|
||||
json_file.truncate()
|
||||
|
||||
self.name = config_json.get("name")
|
||||
self.short_description = config_json.get("short_description")
|
||||
@@ -293,14 +430,14 @@ class InstallableExtension(BaseModel):
|
||||
)
|
||||
|
||||
shutil.rmtree(self.ext_dir, True)
|
||||
shutil.copytree(
|
||||
os.path.join(self.ext_upgrade_dir, self.id),
|
||||
os.path.join("lnbits", "extensions", self.id),
|
||||
)
|
||||
logger.success(f"Extension {self.name} installed.")
|
||||
shutil.copytree(Path(self.ext_upgrade_dir), Path(self.ext_dir))
|
||||
logger.success(f"Extension {self.name} ({self.installed_version}) installed.")
|
||||
|
||||
def nofiy_upgrade(self) -> None:
|
||||
"""Update the list of upgraded extensions. The middleware will perform redirects based on this"""
|
||||
"""
|
||||
Update the list of upgraded extensions. The middleware will perform
|
||||
redirects based on this
|
||||
"""
|
||||
|
||||
clean_upgraded_exts = list(
|
||||
filter(
|
||||
@@ -314,7 +451,7 @@ class InstallableExtension(BaseModel):
|
||||
|
||||
def clean_extension_files(self):
|
||||
# remove downloaded archive
|
||||
if os.path.isfile(self.zip_path):
|
||||
if self.zip_path.is_file():
|
||||
os.remove(self.zip_path)
|
||||
|
||||
# remove module from extensions
|
||||
@@ -322,6 +459,15 @@ class InstallableExtension(BaseModel):
|
||||
|
||||
shutil.rmtree(self.ext_upgrade_dir, True)
|
||||
|
||||
def check_latest_version(self, release: Optional[ExtensionRelease]):
|
||||
if not release:
|
||||
return
|
||||
if not self.latest_release:
|
||||
self.latest_release = release
|
||||
return
|
||||
if version_parse(self.latest_release.version) < version_parse(release.version):
|
||||
self.latest_release = release
|
||||
|
||||
@classmethod
|
||||
def from_row(cls, data: dict) -> "InstallableExtension":
|
||||
meta = json.loads(data["meta"])
|
||||
@@ -343,8 +489,7 @@ class InstallableExtension(BaseModel):
|
||||
id=github_release.id,
|
||||
name=config.name,
|
||||
short_description=config.short_description,
|
||||
version="0",
|
||||
stars=repo.stargazers_count,
|
||||
stars=int(repo.stargazers_count),
|
||||
icon=icon_to_github_url(
|
||||
f"{github_release.organisation}/{github_release.repository}",
|
||||
config.tile,
|
||||
@@ -363,7 +508,6 @@ class InstallableExtension(BaseModel):
|
||||
id=e.id,
|
||||
name=e.name,
|
||||
archive=e.archive,
|
||||
hash=e.hash,
|
||||
short_description=e.short_description,
|
||||
icon=e.icon,
|
||||
dependencies=e.dependencies,
|
||||
@@ -381,18 +525,30 @@ class InstallableExtension(BaseModel):
|
||||
manifest = await fetch_manifest(url)
|
||||
|
||||
for r in manifest.repos:
|
||||
if r.id in extension_id_list:
|
||||
continue
|
||||
ext = await InstallableExtension.from_github_release(r)
|
||||
if ext:
|
||||
ext.featured = ext.id in manifest.featured
|
||||
extension_list += [ext]
|
||||
extension_id_list += [ext.id]
|
||||
if not ext:
|
||||
continue
|
||||
existing_ext = next(
|
||||
(ee for ee in extension_list if ee.id == r.id), None
|
||||
)
|
||||
if existing_ext:
|
||||
existing_ext.check_latest_version(ext.latest_release)
|
||||
continue
|
||||
|
||||
ext.featured = ext.id in manifest.featured
|
||||
extension_list += [ext]
|
||||
extension_id_list += [ext.id]
|
||||
|
||||
for e in manifest.extensions:
|
||||
if e.id in extension_id_list:
|
||||
release = ExtensionRelease.from_explicit_release(url, e)
|
||||
existing_ext = next(
|
||||
(ee for ee in extension_list if ee.id == e.id), None
|
||||
)
|
||||
if existing_ext:
|
||||
existing_ext.check_latest_version(release)
|
||||
continue
|
||||
ext = InstallableExtension.from_explicit_release(e)
|
||||
ext.check_latest_version(release)
|
||||
ext.featured = ext.id in manifest.featured
|
||||
extension_list += [ext]
|
||||
extension_id_list += [e.id]
|
||||
@@ -418,17 +574,7 @@ class InstallableExtension(BaseModel):
|
||||
for e in manifest.extensions:
|
||||
if e.id == ext_id:
|
||||
extension_releases += [
|
||||
ExtensionRelease(
|
||||
name=e.name,
|
||||
version=e.version,
|
||||
archive=e.archive,
|
||||
hash=e.hash,
|
||||
source_repo=url,
|
||||
description=e.short_description,
|
||||
details_html=e.details,
|
||||
html_url=e.html_url,
|
||||
icon=e.icon,
|
||||
)
|
||||
ExtensionRelease.from_explicit_release(url, e)
|
||||
]
|
||||
|
||||
except Exception as e:
|
||||
@@ -458,86 +604,30 @@ class CreateExtension(BaseModel):
|
||||
source_repo: str
|
||||
|
||||
|
||||
def get_valid_extensions() -> List[Extension]:
|
||||
return [
|
||||
def get_valid_extensions(include_deactivated: Optional[bool] = True) -> List[Extension]:
|
||||
valid_extensions = [
|
||||
extension for extension in ExtensionManager().extensions if extension.is_valid
|
||||
]
|
||||
|
||||
if include_deactivated:
|
||||
return valid_extensions
|
||||
|
||||
def download_url(url, save_path):
|
||||
with urllib.request.urlopen(url) as dl_file:
|
||||
with open(save_path, "wb") as out_file:
|
||||
out_file.write(dl_file.read())
|
||||
if settings.lnbits_extensions_deactivate_all:
|
||||
return []
|
||||
|
||||
return [
|
||||
e
|
||||
for e in valid_extensions
|
||||
if e.code not in settings.lnbits_deactivated_extensions
|
||||
]
|
||||
|
||||
|
||||
def file_hash(filename):
|
||||
h = hashlib.sha256()
|
||||
b = bytearray(128 * 1024)
|
||||
mv = memoryview(b)
|
||||
with open(filename, "rb", buffering=0) as f:
|
||||
while n := f.readinto(mv):
|
||||
h.update(mv[:n])
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def icon_to_github_url(source_repo: str, path: Optional[str]) -> str:
|
||||
if not path:
|
||||
return ""
|
||||
_, _, *rest = path.split("/")
|
||||
tail = "/".join(rest)
|
||||
return f"https://github.com/{source_repo}/raw/main/{tail}"
|
||||
|
||||
|
||||
async def fetch_github_repo_info(
|
||||
org: str, repository: str
|
||||
) -> Tuple[GitHubRepo, GitHubRepoRelease, ExtensionConfig]:
|
||||
repo_url = f"https://api.github.com/repos/{org}/{repository}"
|
||||
error_msg = "Cannot fetch extension repo"
|
||||
repo = await gihub_api_get(repo_url, error_msg)
|
||||
github_repo = GitHubRepo.parse_obj(repo)
|
||||
|
||||
lates_release_url = (
|
||||
f"https://api.github.com/repos/{org}/{repository}/releases/latest"
|
||||
)
|
||||
error_msg = "Cannot fetch extension releases"
|
||||
latest_release: Any = await gihub_api_get(lates_release_url, error_msg)
|
||||
|
||||
config_url = f"https://raw.githubusercontent.com/{org}/{repository}/{github_repo.default_branch}/config.json"
|
||||
error_msg = "Cannot fetch config for extension"
|
||||
config = await gihub_api_get(config_url, error_msg)
|
||||
|
||||
return (
|
||||
github_repo,
|
||||
GitHubRepoRelease.parse_obj(latest_release),
|
||||
ExtensionConfig.parse_obj(config),
|
||||
)
|
||||
|
||||
|
||||
async def fetch_manifest(url) -> Manifest:
|
||||
error_msg = "Cannot fetch extensions manifest"
|
||||
manifest = await gihub_api_get(url, error_msg)
|
||||
return Manifest.parse_obj(manifest)
|
||||
|
||||
|
||||
async def fetch_github_releases(org: str, repo: str) -> List[GitHubRepoRelease]:
|
||||
releases_url = f"https://api.github.com/repos/{org}/{repo}/releases"
|
||||
error_msg = "Cannot fetch extension releases"
|
||||
releases = await gihub_api_get(releases_url, error_msg)
|
||||
return [GitHubRepoRelease.parse_obj(r) for r in releases]
|
||||
|
||||
|
||||
async def gihub_api_get(url: str, error_msg: Optional[str]) -> Any:
|
||||
async with httpx.AsyncClient() as client:
|
||||
headers = (
|
||||
{"Authorization": "Bearer " + settings.lnbits_ext_github_token}
|
||||
if settings.lnbits_ext_github_token
|
||||
else None
|
||||
)
|
||||
resp = await client.get(
|
||||
url,
|
||||
headers=headers,
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
logger.warning(f"{error_msg} ({url}): {resp.text}")
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
def version_parse(v: str):
|
||||
"""
|
||||
Wrapper for version.parse() that does not throw if the version is invalid.
|
||||
Instead it return the lowest possible version ("0.0.0")
|
||||
"""
|
||||
try:
|
||||
return version.parse(v)
|
||||
except Exception:
|
||||
return version.parse("0.0.0")
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
import glob
|
||||
import os
|
||||
from typing import Any, List, Optional
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any, List, Optional, Type
|
||||
|
||||
import jinja2
|
||||
import shortuuid # type: ignore
|
||||
import shortuuid
|
||||
from jose import jwt
|
||||
from pydantic import BaseModel
|
||||
from pydantic.schema import field_schema
|
||||
|
||||
from lnbits.jinja2_templating import Jinja2Templates
|
||||
from lnbits.nodes import get_node_class
|
||||
from lnbits.requestvars import g
|
||||
from lnbits.settings import settings
|
||||
from lnbits.utils.crypto import AESCipher
|
||||
|
||||
from .db import FilterModel
|
||||
from .extension_manager import get_valid_extensions
|
||||
|
||||
|
||||
@@ -16,70 +24,6 @@ def urlsafe_short_hash() -> str:
|
||||
return shortuuid.uuid()
|
||||
|
||||
|
||||
def get_js_vendored(prefer_minified: bool = False) -> List[str]:
|
||||
paths = get_vendored(".js", prefer_minified)
|
||||
|
||||
def sorter(key: str):
|
||||
if "moment@" in key:
|
||||
return 1
|
||||
if "vue@" in key:
|
||||
return 2
|
||||
if "vue-router@" in key:
|
||||
return 3
|
||||
if "polyfills" in key:
|
||||
return 4
|
||||
return 9
|
||||
|
||||
return sorted(paths, key=sorter)
|
||||
|
||||
|
||||
def get_css_vendored(prefer_minified: bool = False) -> List[str]:
|
||||
paths = get_vendored(".css", prefer_minified)
|
||||
|
||||
def sorter(key: str):
|
||||
if "quasar@" in key:
|
||||
return 1
|
||||
if "vue@" in key:
|
||||
return 2
|
||||
if "chart.js@" in key:
|
||||
return 100
|
||||
return 9
|
||||
|
||||
return sorted(paths, key=sorter)
|
||||
|
||||
|
||||
def get_vendored(ext: str, prefer_minified: bool = False) -> List[str]:
|
||||
paths: List[str] = []
|
||||
for path in glob.glob(
|
||||
os.path.join(settings.lnbits_path, "static/vendor/**"), recursive=True
|
||||
):
|
||||
if path.endswith(".min" + ext):
|
||||
# path is minified
|
||||
unminified = path.replace(".min" + ext, ext)
|
||||
if prefer_minified:
|
||||
paths.append(path)
|
||||
if unminified in paths:
|
||||
paths.remove(unminified)
|
||||
elif unminified not in paths:
|
||||
paths.append(path)
|
||||
|
||||
elif path.endswith(ext):
|
||||
# path is not minified
|
||||
minified = path.replace(ext, ".min" + ext)
|
||||
if not prefer_minified:
|
||||
paths.append(path)
|
||||
if minified in paths:
|
||||
paths.remove(minified)
|
||||
elif minified not in paths:
|
||||
paths.append(path)
|
||||
|
||||
return sorted(paths)
|
||||
|
||||
|
||||
def url_for_vendored(abspath: str) -> str:
|
||||
return "/" + os.path.relpath(abspath, settings.lnbits_path)
|
||||
|
||||
|
||||
def url_for(endpoint: str, external: Optional[bool] = False, **params: Any) -> str:
|
||||
base = g().base_url if external else ""
|
||||
url_params = "?"
|
||||
@@ -89,12 +33,20 @@ def url_for(endpoint: str, external: Optional[bool] = False, **params: Any) -> s
|
||||
return url
|
||||
|
||||
|
||||
def template_renderer(additional_folders: List = None) -> Jinja2Templates:
|
||||
def static_url_for(static: str, path: str) -> str:
|
||||
return f"/{static}/{path}?v={settings.server_startup_time}"
|
||||
|
||||
|
||||
def template_renderer(additional_folders: Optional[List] = None) -> Jinja2Templates:
|
||||
folders = ["lnbits/templates", "lnbits/core/templates"]
|
||||
if additional_folders:
|
||||
additional_folders += [
|
||||
Path(settings.lnbits_extensions_path, "extensions", f)
|
||||
for f in additional_folders
|
||||
]
|
||||
folders.extend(additional_folders)
|
||||
t = Jinja2Templates(loader=jinja2.FileSystemLoader(folders))
|
||||
t.env.globals["static_url_for"] = static_url_for
|
||||
|
||||
if settings.lnbits_ad_space_enabled:
|
||||
t.env.globals["AD_SPACE"] = settings.lnbits_ad_space.split(",")
|
||||
@@ -107,22 +59,33 @@ def template_renderer(additional_folders: List = None) -> Jinja2Templates:
|
||||
t.env.globals["SITE_TAGLINE"] = settings.lnbits_site_tagline
|
||||
t.env.globals["SITE_DESCRIPTION"] = settings.lnbits_site_description
|
||||
t.env.globals["LNBITS_THEME_OPTIONS"] = settings.lnbits_theme_options
|
||||
t.env.globals["LNBITS_VERSION"] = settings.lnbits_commit
|
||||
t.env.globals["LNBITS_QR_LOGO"] = settings.lnbits_qr_logo
|
||||
t.env.globals["LNBITS_VERSION"] = settings.version
|
||||
t.env.globals["LNBITS_NEW_ACCOUNTS_ALLOWED"] = settings.new_accounts_allowed
|
||||
t.env.globals["LNBITS_AUTH_METHODS"] = settings.auth_allowed_methods
|
||||
t.env.globals["LNBITS_ADMIN_UI"] = settings.lnbits_admin_ui
|
||||
t.env.globals["EXTENSIONS"] = [
|
||||
e
|
||||
for e in get_valid_extensions()
|
||||
if e.code not in settings.lnbits_deactivated_extensions
|
||||
]
|
||||
t.env.globals["LNBITS_SERVICE_FEE"] = settings.lnbits_service_fee
|
||||
t.env.globals["LNBITS_SERVICE_FEE_MAX"] = settings.lnbits_service_fee_max
|
||||
t.env.globals["LNBITS_SERVICE_FEE_WALLET"] = settings.lnbits_service_fee_wallet
|
||||
t.env.globals["LNBITS_NODE_UI"] = (
|
||||
settings.lnbits_node_ui and get_node_class() is not None
|
||||
)
|
||||
t.env.globals["LNBITS_NODE_UI_AVAILABLE"] = get_node_class() is not None
|
||||
t.env.globals["EXTENSIONS"] = get_valid_extensions(False)
|
||||
if settings.lnbits_custom_logo:
|
||||
t.env.globals["USE_CUSTOM_LOGO"] = settings.lnbits_custom_logo
|
||||
|
||||
if settings.debug:
|
||||
t.env.globals["VENDORED_JS"] = map(url_for_vendored, get_js_vendored())
|
||||
t.env.globals["VENDORED_CSS"] = map(url_for_vendored, get_css_vendored())
|
||||
if settings.bundle_assets:
|
||||
t.env.globals["INCLUDED_JS"] = ["bundle.min.js"]
|
||||
t.env.globals["INCLUDED_CSS"] = ["bundle.min.css"]
|
||||
else:
|
||||
t.env.globals["VENDORED_JS"] = ["/static/bundle.js"]
|
||||
t.env.globals["VENDORED_CSS"] = ["/static/bundle.css"]
|
||||
vendor_filepath = Path(settings.lnbits_path, "static", "vendor.json")
|
||||
with open(vendor_filepath) as vendor_file:
|
||||
vendor_files = json.loads(vendor_file.read())
|
||||
t.env.globals["INCLUDED_JS"] = vendor_files["js"]
|
||||
t.env.globals["INCLUDED_CSS"] = vendor_files["css"]
|
||||
|
||||
t.env.globals["WEBPUSH_PUBKEY"] = settings.lnbits_webpush_pubkey
|
||||
|
||||
return t
|
||||
|
||||
@@ -145,6 +108,93 @@ def get_current_extension_name() -> str:
|
||||
with open(config_path) as json_file:
|
||||
config = json.load(json_file)
|
||||
ext_name = config["name"]
|
||||
except:
|
||||
except Exception:
|
||||
ext_name = extension_director_name
|
||||
return ext_name
|
||||
|
||||
|
||||
def generate_filter_params_openapi(model: Type[FilterModel], keep_optional=False):
|
||||
"""
|
||||
Generate openapi documentation for Filters. This is intended to be used along
|
||||
parse_filters (see example)
|
||||
:param model: Filter model
|
||||
:param keep_optional: If false, all parameters will be optional,
|
||||
otherwise inferred from model
|
||||
"""
|
||||
fields = list(model.__fields__.values())
|
||||
params = []
|
||||
for field in fields:
|
||||
schema, _, _ = field_schema(field, model_name_map={})
|
||||
|
||||
description = "Supports Filtering"
|
||||
if (
|
||||
hasattr(model, "__search_fields__")
|
||||
and field.name in model.__search_fields__
|
||||
):
|
||||
description += ". Supports Search"
|
||||
|
||||
parameter = {
|
||||
"name": field.alias,
|
||||
"in": "query",
|
||||
"required": field.required if keep_optional else False,
|
||||
"schema": schema,
|
||||
"description": description,
|
||||
}
|
||||
params.append(parameter)
|
||||
|
||||
return {
|
||||
"parameters": params,
|
||||
}
|
||||
|
||||
|
||||
def insert_query(table_name: str, model: BaseModel) -> str:
|
||||
"""
|
||||
Generate an insert query with placeholders for a given table and model
|
||||
:param table_name: Name of the table
|
||||
:param model: Pydantic model
|
||||
"""
|
||||
placeholders = ", ".join(["?"] * len(model.dict().keys()))
|
||||
fields = ", ".join(model.dict().keys())
|
||||
return f"INSERT INTO {table_name} ({fields}) VALUES ({placeholders})"
|
||||
|
||||
|
||||
def update_query(table_name: str, model: BaseModel, where: str = "WHERE id = ?") -> str:
|
||||
"""
|
||||
Generate an update query with placeholders for a given table and model
|
||||
:param table_name: Name of the table
|
||||
:param model: Pydantic model
|
||||
:param where: Where string, default to `WHERE id = ?`
|
||||
"""
|
||||
query = ", ".join([f"{field} = ?" for field in model.dict().keys()])
|
||||
return f"UPDATE {table_name} SET {query} {where}"
|
||||
|
||||
|
||||
def is_valid_email_address(email: str) -> bool:
|
||||
email_regex = r"[A-Za-z0-9\._%+-]+@[A-Za-z0-9\.-]+\.[A-Za-z]{2,63}"
|
||||
return re.fullmatch(email_regex, email) is not None
|
||||
|
||||
|
||||
def is_valid_username(username: str) -> bool:
|
||||
username_regex = r"(?=[a-zA-Z0-9._]{2,20}$)(?!.*[_.]{2})[^_.].*[^_.]"
|
||||
return re.fullmatch(username_regex, username) is not None
|
||||
|
||||
|
||||
def create_access_token(data: dict):
|
||||
expire = datetime.utcnow() + timedelta(minutes=settings.auth_token_expire_minutes)
|
||||
to_encode = data.copy()
|
||||
to_encode.update({"exp": expire})
|
||||
return jwt.encode(to_encode, settings.auth_secret_key, "HS256")
|
||||
|
||||
|
||||
def encrypt_internal_message(m: Optional[str] = None) -> Optional[str]:
|
||||
"""Encrypt message with the internal secret key"""
|
||||
if not m:
|
||||
return None
|
||||
return AESCipher(key=settings.auth_secret_key).encrypt(m.encode())
|
||||
|
||||
|
||||
def decrypt_internal_message(m: Optional[str] = None) -> Optional[str]:
|
||||
"""Decrypt message with the internal secret key"""
|
||||
if not m:
|
||||
return None
|
||||
return AESCipher(key=settings.auth_secret_key).decrypt(m)
|
||||
|
||||