Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
f2849a6e68 | ||
|
|
3d55d98d33 | ||
|
|
dc2a7898f7 | ||
|
|
2787d5c6b6 | ||
|
|
39c1dcb942 | ||
|
|
be078a8c52 | ||
|
|
934fc0abbb | ||
|
|
81986c9b4d | ||
|
|
00b3ab94ba | ||
|
|
194b063311 | ||
|
|
235513f2ef | ||
|
|
0ca4b8dea1 | ||
|
|
83f04534e7 | ||
|
|
6cf1d97d5a | ||
|
|
8410776ffb | ||
|
|
e93fbd24be | ||
|
|
1e39e7dd63 | ||
|
|
aac392a053 | ||
|
|
c81d3e06c8 | ||
|
|
a2ce8fc955 | ||
|
|
a5edcc94ff | ||
|
|
061121d635 | ||
|
|
6f194fe2f8 | ||
|
|
61d809ebdb | ||
|
|
4df74c6896 | ||
|
|
f943ca7d51 | ||
|
|
f9695c0569 | ||
|
|
de445aa593 | ||
|
|
cbde5744aa | ||
|
|
a154ff3175 | ||
|
|
12014ee3e3 | ||
|
|
f7e43cdd95 | ||
|
|
0d5fef1cb9 | ||
|
|
84e369aad2 | ||
|
|
16a997663d | ||
|
|
1cd5c26392 | ||
|
|
c5e2bbc1c6 | ||
|
|
76ffb8a607 | ||
|
|
910727a7f4 | ||
|
|
bf097a3493 | ||
|
|
9f049c1952 | ||
|
|
51d71afe85 | ||
|
|
b31371a070 | ||
|
|
049b16a3fc | ||
|
|
cee4cfca43 | ||
|
|
81e4cfb6f4 | ||
|
|
bb26f2c7f0 | ||
|
|
0b7e4269be | ||
|
|
ca75b328a1 | ||
|
|
bb05703f2b | ||
|
|
ca33c276d3 | ||
|
|
6dade2edf3 | ||
|
|
5a8db02c60 | ||
|
|
4211998959 | ||
|
|
823956395b | ||
|
|
4494f473a6 | ||
|
|
451c8f7a9e | ||
|
|
c49b68600e | ||
|
|
d89de02864 | ||
|
|
1026b242ba | ||
|
|
b5be96836c | ||
|
|
b2c580268e | ||
|
|
1eb3df60fc | ||
|
|
967f46f82a | ||
|
|
dd14d2dc54 | ||
|
|
10da63f6d4 | ||
|
|
8637e7fd22 | ||
|
|
4345361847 | ||
|
|
61fb44a04e | ||
|
|
d32ad082c7 | ||
|
|
7ef4c18b56 | ||
|
|
ca28420e48 | ||
|
|
e3ac700005 | ||
|
|
a0f1f5d846 | ||
|
|
0b3f34fbbe | ||
|
|
6540cd9d76 | ||
|
|
6a5afe3025 | ||
|
|
ef3beaaccf | ||
|
|
0e1dbfcd26 | ||
|
|
61ed49761f | ||
|
|
38d72408fb | ||
|
|
6df8494ed6 | ||
|
|
755b032f17 | ||
|
|
e1d9bf31d1 | ||
|
|
7baa248204 | ||
|
|
5bc2646103 | ||
|
|
624a9ef60a | ||
|
|
891227b279 | ||
|
|
b9b84eda62 | ||
|
|
440818c219 | ||
|
|
c387e31330 | ||
|
|
e4206bc6ec | ||
|
|
7c4ef0ec0f | ||
|
|
2fe1f4b581 | ||
|
|
b938103a07 | ||
|
|
e1d4582345 | ||
|
|
7a57690bd7 | ||
|
|
f23efaf750 | ||
|
|
a3ed1af9d5 | ||
|
|
4431e9ce11 | ||
|
|
f9ee6041ee | ||
|
|
db97ef5543 | ||
|
|
3276347d5d | ||
|
|
a1a85d86ef | ||
|
|
cb475d7f88 | ||
|
|
77a6ac041c | ||
|
|
9840a8b154 | ||
|
|
82add2189a | ||
|
|
252ff65563 | ||
|
|
fd3f32ad51 | ||
|
|
592f2b6afa | ||
|
|
b99b413499 | ||
|
|
741f1a3daf | ||
|
|
c9e027ca21 | ||
|
|
b151004476 | ||
|
|
eb54d6fe92 | ||
|
|
96a66376bd | ||
|
|
538f44e6f0 | ||
|
|
9f6dc18be7 | ||
|
|
2fb8eb505a | ||
|
|
e0d9f2d764 | ||
|
|
f547b1f141 | ||
|
|
8bf4103523 | ||
|
|
05bb502bf5 | ||
|
|
9467a8da07 | ||
|
|
4593f7a942 | ||
|
|
479ca16118 | ||
|
|
16679c0d65 | ||
|
|
3bd68cb394 | ||
|
|
09eab69e12 | ||
|
|
b8a5cc60b8 | ||
|
|
c117ad6aa4 | ||
|
|
edc5531d24 | ||
|
|
77e0dc0288 | ||
|
|
d41afe4236 | ||
|
|
65ea2a05f9 | ||
|
|
97da27b6bb | ||
|
|
a55e72f66f | ||
|
|
d4d5cd887d | ||
|
|
67d246b675 | ||
|
|
04d6656b27 | ||
|
|
f03efd7a77 | ||
|
|
754db04d99 | ||
|
|
9f177dea98 | ||
|
|
970a0ce1d0 | ||
|
|
9d2008d45c | ||
|
|
bbbbb8b9af | ||
|
|
661fc6c625 | ||
|
|
7968c495ea | ||
|
|
d01c5dfdec | ||
|
|
0dca98ca2e | ||
|
|
8c4dfb5d28 | ||
|
|
0da9cb0d64 | ||
|
|
0dffb2c1a0 | ||
|
|
3ac6a98adc | ||
|
|
e7802c27ab | ||
|
|
865f4712c4 | ||
|
|
31ae40d44d | ||
|
|
d2d7bd6469 | ||
|
|
e20bb99811 | ||
|
|
2470497ce5 | ||
|
|
9b21e2028e | ||
|
|
b5c8633ab0 | ||
|
|
5f43ae4a2d | ||
|
|
deb9825391 | ||
|
|
5c5f414c25 | ||
|
|
c45c6b8ae8 | ||
|
|
58a7098be5 | ||
|
|
430698c763 | ||
|
|
0ff4c13346 | ||
|
|
24fbb9ef5d | ||
|
|
388cfc18fa | ||
|
|
a49a654137 | ||
|
|
967ce06ca5 | ||
|
|
3e42adc66f | ||
|
|
e05340269b | ||
|
|
8a72585f00 | ||
|
|
a02f147482 | ||
|
|
421214bc36 | ||
|
|
bc36e6bc4c | ||
|
|
86e8a3a315 | ||
|
|
eba7319808 | ||
|
|
f6bd8684d3 | ||
|
|
9ef4bd8fb9 | ||
|
|
50c613cdd4 | ||
|
|
a0cc55c5a1 | ||
|
|
9fa3e5c6cf | ||
|
|
906d91bcf1 | ||
|
|
35396d7e63 | ||
|
|
d29fcca6aa | ||
|
|
32f4c8aa17 | ||
|
|
15d7fbeace | ||
|
|
c8efe41e1a | ||
|
|
52e654af86 | ||
|
|
80a94aa9ce | ||
|
|
bdfbbb779e | ||
|
|
2aa569b4a8 | ||
|
|
817dd4083f | ||
|
|
aefbced978 | ||
|
|
4cfda287bf | ||
|
|
f1ef9a58fe | ||
|
|
b2a963638d | ||
|
|
ff3f86afa6 | ||
|
|
7ea3d6c245 | ||
|
|
f9d068cf2c | ||
|
|
0668fd79b6 | ||
|
|
f11044ae75 | ||
|
|
432ca98663 | ||
|
|
b1115e5b11 | ||
|
|
f22e5069df | ||
|
|
6846dd373f | ||
|
|
1c3a9d1881 | ||
|
|
e4479fcb78 | ||
|
|
39f0000fa5 | ||
|
|
7366200571 | ||
|
|
850169b3bd | ||
|
|
6276a7a4db | ||
|
|
a2fc34c08e | ||
|
|
1df9664937 | ||
|
|
1a20d35a13 | ||
|
|
75e8683fb1 | ||
|
|
9bf2c231f8 | ||
|
|
8d64c6d877 | ||
|
|
880c955208 | ||
|
|
925d4ccbe7 | ||
|
|
ad70e9c116 | ||
|
|
cc3dbaa1a7 | ||
|
|
fd71c82183 | ||
|
|
6bd2a071ec | ||
|
|
efa24584f3 | ||
|
|
2eef5028f9 | ||
|
|
50458901d6 | ||
|
|
c344a70de9 | ||
|
|
8bae8b06ea | ||
|
|
953bca0cf5 | ||
|
|
4af80858bc | ||
|
|
41a0a2d66e | ||
|
|
00c02e3c85 | ||
|
|
8ab4cb8c08 | ||
|
|
46faa62b8b | ||
|
|
a242a823fc | ||
|
|
1373b88169 | ||
|
|
9c105e8885 | ||
|
|
f27e5af639 | ||
|
|
b016804a5b | ||
|
|
dd3fc7c2bb | ||
|
|
9cca87f738 | ||
|
|
afe43b6bd4 | ||
|
|
b197a87eed | ||
|
|
302ed83051 | ||
|
|
994070a7a5 | ||
|
|
8971fb5f00 | ||
|
|
5ae1a22989 | ||
|
|
567c636b1d | ||
|
|
39add25b3b | ||
|
|
b57e808198 | ||
|
|
6b678186a2 | ||
|
|
ca1dcd6768 | ||
|
|
0b3324cd8f | ||
|
|
2dff48dcd0 | ||
|
|
5212320d8d | ||
|
|
e95c6ec3a6 | ||
|
|
b069fe17d8 | ||
|
|
4530400d33 | ||
|
|
f75529e1c2 | ||
|
|
53a95ccc11 | ||
|
|
5b6e11a621 | ||
|
|
fa5d824eea | ||
|
|
ea31e148ae | ||
|
|
9cf587a64a | ||
|
|
8857cb94b4 | ||
|
|
cf4c9baeeb | ||
|
|
223e92f9e2 | ||
|
|
a2284da4c3 | ||
|
|
30d23c2bef | ||
|
|
177d17ec10 | ||
|
|
43298dfcc7 | ||
|
|
77312d5e3c | ||
|
|
12790dec5d | ||
|
|
c2ca9403e4 | ||
|
|
e12211e4c5 | ||
|
|
9a002a2342 | ||
|
|
730017ed62 | ||
|
|
0b0efd99c1 | ||
|
|
776acdd857 | ||
|
|
478d74efc8 | ||
|
|
dea63ca2db | ||
|
|
37ec55a6e4 | ||
|
|
d8ba9448a2 | ||
|
|
c22b2b7dfc | ||
|
|
17888ebb49 | ||
|
|
496020e47a | ||
|
|
1fb4c1a3b6 | ||
|
|
0a7a589935 | ||
|
|
bb32112169 | ||
|
|
232a9e9885 | ||
|
|
db73a51c74 | ||
|
|
4d87246811 | ||
|
|
6773f33ad1 | ||
|
|
aa006b66d1 | ||
|
|
69b8dfa21f | ||
|
|
ce1639792a | ||
|
|
d3c05ef300 | ||
|
|
e52baab1c2 | ||
|
|
98735d79c7 | ||
|
|
1d2e994862 | ||
|
|
59bc9804ad | ||
|
|
72965c8d85 | ||
|
|
040cd390c8 | ||
|
|
3295c6bebe | ||
|
|
d89128fb93 | ||
|
|
f379b784cc | ||
|
|
12d32a4811 | ||
|
|
66d3e2fb2d | ||
|
|
5bcb8cb349 | ||
|
|
b957b506f9 | ||
|
|
b039ab1e6b | ||
|
|
a950af9944 | ||
|
|
975bbc2633 | ||
|
|
01b5536f3a | ||
|
|
0195c242c2 | ||
|
|
584b667637 | ||
|
|
03df382325 | ||
|
|
ce8d45c8c6 | ||
|
|
ebbfdbb6c9 | ||
|
|
86c86958ae | ||
|
|
77c17a2b63 | ||
|
|
6b360b927c | ||
|
|
12060b67ad | ||
|
|
d146a45b59 | ||
|
|
980267cb51 | ||
|
|
9d0cedfcb2 | ||
|
|
41ce316fc6 | ||
|
|
e8bed9cd90 | ||
|
|
2da466f0c8 | ||
|
|
dd0c97eefc | ||
|
|
86d45cdb50 | ||
|
|
27b308ae1a | ||
|
|
a73e8ae44d | ||
|
|
0b49541c15 | ||
|
|
eb88592473 | ||
|
|
c9bb5a660c | ||
|
|
97ea78f999 | ||
|
|
522bfcf4ae | ||
|
|
b4408e9d52 | ||
|
|
9a77a6e314 | ||
|
|
76fbb5ccbb | ||
|
|
496346b3ba | ||
|
|
38a132604b | ||
|
|
9bd1720d03 | ||
|
|
032a000da7 | ||
|
|
4efeae24d0 | ||
|
|
9d0fba7c15 | ||
|
|
e3ee04edc7 | ||
|
|
de929c6c70 | ||
|
|
84d2e93119 | ||
|
|
1ca0cf4313 | ||
|
|
0ab913e502 | ||
|
|
2edb9da387 | ||
|
|
795d05c109 | ||
|
|
e7a150e708 | ||
|
|
1b07768b76 | ||
|
|
3ed2b3cdeb | ||
|
|
cb6349fd76 | ||
|
|
cae71faf37 | ||
|
|
ece7a82b3d | ||
|
|
8f77c3f7bf | ||
|
|
59d8cff1da | ||
|
|
cb24a6a4f9 | ||
|
|
a2201c9d31 | ||
|
|
51c8e183d1 | ||
|
|
6ec1a668ae | ||
|
|
8c1479427d | ||
|
|
9dfeef15fd | ||
|
|
76cfaf93ad | ||
|
|
bdee9f46e3 | ||
|
|
691e175370 | ||
|
|
0d61db13ec | ||
|
|
a2d35f65c9 | ||
|
|
08be621529 | ||
|
|
a6e3b5e049 | ||
|
|
8f8ebbeeaa | ||
|
|
598d2a6939 | ||
|
|
3fbac927e9 | ||
|
|
6c41ac841b | ||
|
|
11815958a3 | ||
|
|
2d3ba2c457 | ||
|
|
66c908e600 | ||
|
|
331e93195d | ||
|
|
1f4f4d93ca |
@@ -3,7 +3,6 @@ data
|
||||
docker
|
||||
docs
|
||||
tests
|
||||
venv
|
||||
|
||||
lnbits/static/css/*
|
||||
lnbits/static/bundle.js
|
||||
|
||||
+10
-10
@@ -30,8 +30,13 @@ LNBITS_DEFAULT_WALLET_NAME="LNbits wallet"
|
||||
# Hides wallet api, extensions can choose to honor
|
||||
LNBITS_HIDE_API=false
|
||||
|
||||
# Disable extensions for all users, use "all" to disable all extensions
|
||||
LNBITS_DISABLED_EXTENSIONS="amilk"
|
||||
# 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://...
|
||||
@@ -41,7 +46,6 @@ LNBITS_DISABLED_EXTENSIONS="amilk"
|
||||
LNBITS_DATA_FOLDER="./data"
|
||||
# LNBITS_DATABASE_URL="postgres://user:password@host:port/databasename"
|
||||
|
||||
LNBITS_FORCE_HTTPS=true
|
||||
LNBITS_SERVICE_FEE="0.0"
|
||||
# value in millisats
|
||||
LNBITS_RESERVE_FEE_MIN=2000
|
||||
@@ -52,11 +56,11 @@ LNBITS_RESERVE_FEE_PERCENT=1.0
|
||||
LNBITS_SITE_TITLE="LNbits"
|
||||
LNBITS_SITE_TAGLINE="free and open-source lightning wallet"
|
||||
LNBITS_SITE_DESCRIPTION="Some description about your service, will display if title is not 'LNbits'"
|
||||
# Choose from bitcoin, mint, flamingo, freedom, salvador, autumn, monochrome, classic
|
||||
LNBITS_THEME_OPTIONS="classic, bitcoin, flamingo, freedom, mint, autumn, monochrome, salvador"
|
||||
# Choose from bitcoin, mint, flamingo, freedom, salvador, autumn, monochrome, classic, cyber
|
||||
LNBITS_THEME_OPTIONS="classic, bitcoin, flamingo, freedom, mint, autumn, monochrome, salvador, cyber"
|
||||
# LNBITS_CUSTOM_LOGO="https://lnbits.com/assets/images/logo/logo.svg"
|
||||
|
||||
# Choose from LNPayWallet, OpenNodeWallet, LntxbotWallet, ClicheWallet,
|
||||
# Choose from LNPayWallet, OpenNodeWallet, ClicheWallet,
|
||||
# LndWallet, LndRestWallet, CoreLightningWallet, EclairWallet,
|
||||
# LnTipsWallet, LNbitsWallet, SparkWallet, FakeWallet,
|
||||
LNBITS_BACKEND_WALLET_CLASS=VoidWallet
|
||||
@@ -104,10 +108,6 @@ LNPAY_API_KEY=LNPAY_API_KEY
|
||||
# Wallet Admin in Wallet Access Keys
|
||||
LNPAY_WALLET_KEY=LNPAY_ADMIN_KEY
|
||||
|
||||
# LntxbotWallet
|
||||
LNTXBOT_API_ENDPOINT=https://lntxbot.com/
|
||||
LNTXBOT_KEY=LNTXBOT_ADMIN_KEY
|
||||
|
||||
# OpenNodeWallet
|
||||
OPENNODE_API_ENDPOINT=https://api.opennode.com/
|
||||
OPENNODE_KEY=OPENNODE_ADMIN_KEY
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
[flake8]
|
||||
max-line-length = 150
|
||||
exclude = lnbits/wallets/lnd_grpc_files/, lnbits/extensions/
|
||||
ignore =
|
||||
# E203 whitespace before ':' black does not like it
|
||||
E203
|
||||
# E402: module level import not at top of file
|
||||
E402,
|
||||
# W503: line break before binary operator
|
||||
W503,
|
||||
# F821: undefined name - should be addressed in future PR
|
||||
F821,
|
||||
# E722 do not use bare 'except' - should be addressed in future PR
|
||||
E722,
|
||||
# flake8-requirements import checks
|
||||
I
|
||||
@@ -1,10 +1,9 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: "[BUG]"
|
||||
title: '[BUG]'
|
||||
labels: bug
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
@@ -12,6 +11,7 @@ A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
@@ -24,8 +24,9 @@ A clear and concise description of what you expected to happen.
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Desktop (please complete the following information):**
|
||||
- LNbits version: [e.g. 0.9.2 or commit hash]
|
||||
- Database [e.g. sqlite, postgres]
|
||||
|
||||
- LNbits version: [e.g. 0.9.2 or commit hash]
|
||||
- Database [e.g. sqlite, postgres]
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: "[Feature request]"
|
||||
title: '[Feature request]'
|
||||
labels: feature request
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
|
||||
@@ -4,7 +4,4 @@ about: Anything else that you need to say
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 2
|
||||
- run: git checkout HEAD^2
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
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,10 +1,6 @@
|
||||
name: formatting
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
checks:
|
||||
@@ -14,25 +10,24 @@ jobs:
|
||||
python-version: ["3.9"]
|
||||
poetry-version: ["1.3.1"]
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- 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 config virtualenvs.create false
|
||||
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: |
|
||||
npm install prettier
|
||||
make checkprettier
|
||||
run: make checkprettier
|
||||
|
||||
@@ -25,9 +25,9 @@ jobs:
|
||||
python-version: ["3.9"]
|
||||
poetry-version: ["1.3.1"]
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@v3
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v2
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Set up Poetry ${{ matrix.poetry-version }}
|
||||
@@ -36,7 +36,6 @@ jobs:
|
||||
poetry-version: ${{ matrix.poetry-version }}
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
poetry config virtualenvs.create false
|
||||
poetry install
|
||||
sudo apt install unzip
|
||||
- name: Run migrations
|
||||
|
||||
@@ -10,18 +10,18 @@ jobs:
|
||||
python-version: ["3.9"]
|
||||
poetry-version: ["1.3.1"]
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- 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 config virtualenvs.create false
|
||||
poetry install
|
||||
- name: Run tests
|
||||
run: poetry run mypy
|
||||
run: make mypy
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
run: echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_USERNAME }}" --password-stdin
|
||||
|
||||
- name: Checkout project
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Import environment variables
|
||||
id: import-env
|
||||
@@ -66,4 +66,4 @@ jobs:
|
||||
--cache-to "type=local,dest=/tmp/.buildx-cache" \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--tag ${{ secrets.DOCKER_USERNAME }}/lnbits-legend:latest \
|
||||
--output "type=registry" ./
|
||||
--output "type=registry" ./
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
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
|
||||
@@ -0,0 +1,28 @@
|
||||
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
|
||||
@@ -10,15 +10,16 @@ jobs:
|
||||
python-version: ["3.9"]
|
||||
poetry-version: ["1.3.1"]
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- 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 .
|
||||
@@ -29,7 +30,6 @@ jobs:
|
||||
sudo chmod -R a+rwx .
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
poetry config virtualenvs.create false
|
||||
poetry install
|
||||
- name: Run tests
|
||||
env:
|
||||
@@ -54,15 +54,16 @@ jobs:
|
||||
python-version: ["3.9"]
|
||||
poetry-version: ["1.3.1"]
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- 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 .
|
||||
@@ -73,7 +74,6 @@ jobs:
|
||||
sudo chmod -R a+rwx .
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
poetry config virtualenvs.create false
|
||||
poetry install
|
||||
- name: Run tests
|
||||
env:
|
||||
@@ -99,15 +99,16 @@ jobs:
|
||||
python-version: ["3.9"]
|
||||
poetry-version: ["1.3.1"]
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- 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 .
|
||||
@@ -118,7 +119,6 @@ jobs:
|
||||
sudo chmod -R a+rwx .
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
poetry config virtualenvs.create false
|
||||
poetry install
|
||||
- name: Run tests
|
||||
env:
|
||||
@@ -141,15 +141,16 @@ jobs:
|
||||
python-version: ["3.9"]
|
||||
poetry-version: ["1.3.1"]
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- 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 .
|
||||
@@ -184,15 +185,16 @@ jobs:
|
||||
python-version: ["3.9"]
|
||||
poetry-version: ["1.3.1"]
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- 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 .
|
||||
@@ -203,7 +205,6 @@ jobs:
|
||||
sudo chmod -R a+rwx .
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
poetry config virtualenvs.create false
|
||||
poetry install
|
||||
- name: Run tests
|
||||
env:
|
||||
@@ -219,4 +220,4 @@ jobs:
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v3
|
||||
with:
|
||||
file: ./coverage.xml
|
||||
file: ./coverage.xml
|
||||
|
||||
+12
-35
@@ -3,29 +3,6 @@ name: tests
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
venv-sqlite:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.9"]
|
||||
poetry-version: ["1.3.1"]
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v2
|
||||
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:
|
||||
@@ -33,18 +10,18 @@ jobs:
|
||||
python-version: ["3.9"]
|
||||
poetry-version: ["1.3.1"]
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- 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 config virtualenvs.create false
|
||||
poetry install
|
||||
- name: Run tests
|
||||
run: make test
|
||||
@@ -70,18 +47,18 @@ jobs:
|
||||
python-version: ["3.9"]
|
||||
poetry-version: ["1.3.1"]
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- 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 config virtualenvs.create false
|
||||
poetry install
|
||||
- name: Run tests
|
||||
env:
|
||||
|
||||
+13
-3
@@ -23,9 +23,9 @@ tests/data/*.sqlite3
|
||||
*.pyc
|
||||
*.env
|
||||
.env
|
||||
.venv
|
||||
venv
|
||||
.super_user
|
||||
|
||||
data
|
||||
*.sqlite3
|
||||
.pyre*
|
||||
|
||||
@@ -33,8 +33,18 @@ __bundle__
|
||||
|
||||
coverage.xml
|
||||
node_modules
|
||||
lnbits/static/bundle.*
|
||||
lnbits/static/bundle.js
|
||||
lnbits/static/bundle.css
|
||||
docker
|
||||
|
||||
# Nix
|
||||
*result*
|
||||
|
||||
# fly.io
|
||||
fly.toml
|
||||
|
||||
lnbits-backup.zip
|
||||
|
||||
# Ignore extensions (post installable extension PR)
|
||||
extensions/
|
||||
upgrades/
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
exclude: '^lnbits/static/bundle.*|^docs/.*|^lnbits/static/vendor/.*|^lnbits/extensions/.*|^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: 22.6.0
|
||||
hooks:
|
||||
- id: black
|
||||
- repo: https://github.com/pycqa/isort
|
||||
rev: 5.12.0
|
||||
hooks:
|
||||
- id: isort
|
||||
args: ['--profile', 'black']
|
||||
- repo: https://github.com/pre-commit/mirrors-prettier
|
||||
rev: '50c5478ed9e10bf360335449280cf2a67f4edb7a'
|
||||
hooks:
|
||||
- id: prettier
|
||||
args: ['lnbits']
|
||||
- repo: https://github.com/pycqa/flake8
|
||||
rev: 6.0.0
|
||||
hooks:
|
||||
- id: flake8
|
||||
entry: poetry run flake8
|
||||
- repo: https://github.com/pre-commit/mirrors-mypy
|
||||
rev: v0.971
|
||||
hooks:
|
||||
- id: mypy
|
||||
name: mypy
|
||||
entry: poetry run mypy
|
||||
args: [--ignore-missing-imports]
|
||||
additional_dependencies: [types-mock, types-protobuf]
|
||||
@@ -0,0 +1,12 @@
|
||||
**/.git
|
||||
**/.svn
|
||||
**/.hg
|
||||
**/node_modules
|
||||
|
||||
*.yml
|
||||
|
||||
**/lnbits/extensions/*
|
||||
|
||||
**/lnbits/static/vendor
|
||||
**/lnbits/static/bundle.*
|
||||
**/lnbits/static/css/*
|
||||
+10
-3
@@ -3,18 +3,25 @@ FROM python:3.10-slim
|
||||
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 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 mkdir data
|
||||
|
||||
RUN poetry config virtualenvs.create false
|
||||
RUN poetry install --only main
|
||||
RUN poetry run python build.py
|
||||
|
||||
ENV LNBITS_PORT="5000"
|
||||
ENV LNBITS_HOST="0.0.0.0"
|
||||
|
||||
@@ -1,28 +1,34 @@
|
||||
.PHONY: test
|
||||
|
||||
all: format check requirements.txt
|
||||
all: format check
|
||||
|
||||
format: prettier isort black
|
||||
|
||||
check: mypy checkprettier checkisort checkblack
|
||||
check: mypy pyright pylint flake8 checkisort checkblack checkprettier
|
||||
|
||||
prettier: $(shell find lnbits -name "*.js" -o -name ".html")
|
||||
./node_modules/.bin/prettier --write lnbits/static/js/*.js lnbits/core/static/js/*.js lnbits/extensions/*/templates/*/*.html ./lnbits/core/templates/core/*.html lnbits/templates/*.html lnbits/extensions/*/static/js/*.js lnbits/extensions/*/static/components/*/*.js lnbits/extensions/*/static/components/*/*.html
|
||||
prettier:
|
||||
poetry run ./node_modules/.bin/prettier --write lnbits
|
||||
|
||||
pyright:
|
||||
./node_modules/.bin/pyright
|
||||
poetry run ./node_modules/.bin/pyright
|
||||
|
||||
black:
|
||||
poetry run black .
|
||||
|
||||
flake8:
|
||||
poetry run flake8
|
||||
|
||||
mypy:
|
||||
poetry run mypy
|
||||
|
||||
isort:
|
||||
poetry run isort .
|
||||
|
||||
checkprettier: $(shell find lnbits -name "*.js" -o -name ".html")
|
||||
./node_modules/.bin/prettier --check lnbits/static/js/*.js lnbits/core/static/js/*.js lnbits/extensions/*/templates/*/*.html ./lnbits/core/templates/core/*.html lnbits/templates/*.html lnbits/extensions/*/static/js/*.js lnbits/extensions/*/static/components/*/*.js lnbits/extensions/*/static/components/*/*.html
|
||||
pylint:
|
||||
poetry run pylint *.py lnbits/ tools/ tests/
|
||||
|
||||
checkprettier:
|
||||
poetry run ./node_modules/.bin/prettier --check lnbits
|
||||
|
||||
checkblack:
|
||||
poetry run black --check .
|
||||
@@ -31,10 +37,6 @@ checkisort:
|
||||
poetry run isort --check-only .
|
||||
|
||||
test:
|
||||
BOLTZ_NETWORK="regtest" \
|
||||
BOLTZ_URL="http://127.0.0.1:9001" \
|
||||
BOLTZ_MEMPOOL_SPACE_URL="http://127.0.0.1:8080" \
|
||||
BOLTZ_MEMPOOL_SPACE_URL_WS="ws://127.0.0.1:8080" \
|
||||
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
|
||||
FAKE_WALLET_SECRET="ToTheMoon1" \
|
||||
LNBITS_DATA_FOLDER="./tests/data" \
|
||||
@@ -43,27 +45,11 @@ test:
|
||||
poetry run pytest
|
||||
|
||||
test-real-wallet:
|
||||
BOLTZ_NETWORK="regtest" \
|
||||
BOLTZ_URL="http://127.0.0.1:9001" \
|
||||
BOLTZ_MEMPOOL_SPACE_URL="http://127.0.0.1:8080" \
|
||||
BOLTZ_MEMPOOL_SPACE_URL_WS="ws://127.0.0.1:8080" \
|
||||
LNBITS_DATA_FOLDER="./tests/data" \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
DEBUG=true \
|
||||
poetry run pytest
|
||||
|
||||
test-venv:
|
||||
BOLTZ_NETWORK="regtest" \
|
||||
BOLTZ_URL="http://127.0.0.1:9001" \
|
||||
BOLTZ_MEMPOOL_SPACE_URL="http://127.0.0.1:8080" \
|
||||
BOLTZ_MEMPOOL_SPACE_URL_WS="ws://127.0.0.1:8080" \
|
||||
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
|
||||
@@ -85,3 +71,29 @@ migration:
|
||||
|
||||
bak:
|
||||
# LNBITS_DATABASE_URL=postgres://postgres:postgres@0.0.0.0:5432/postgres
|
||||
#
|
||||
|
||||
sass:
|
||||
npm run sass
|
||||
|
||||
bundle:
|
||||
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
|
||||
# increment serviceworker version
|
||||
sed -i -e "s/CACHE_VERSION =.*/CACHE_VERSION = $$(awk '/CACHE_VERSION =/ { print 1+$$4 }' lnbits/core/static/js/service-worker.js)/" \
|
||||
lnbits/core/static/js/service-worker.js
|
||||
|
||||
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
|
||||
|
||||
[![license-badge]](LICENSE)
|
||||
[![docs-badge]][docs]
|
||||
|
||||
|
||||

|
||||
|
||||
# LNbits v0.9 BETA, free and open-source Lightning wallet accounts system
|
||||
# LNbits v0.10 BETA, free and open-source Lightning wallet accounts system
|
||||
|
||||
(Join us on [https://t.me/lnbits](https://t.me/lnbits))
|
||||
|
||||
@@ -17,15 +15,15 @@ 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, lntxbot, LightningTipBot, and with more being added regularly.
|
||||
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.
|
||||
|
||||
See [docs.lnbits.org](https://docs.lnbits.org) for more detailed documentation.
|
||||
See [LNbits Wiki](https://github.com/lnbits/lnbits/wiki/) for more detailed documentation.
|
||||
|
||||
Checkout the LNbits [YouTube](https://www.youtube.com/playlist?list=PLPj3KCksGbSYG0ciIQUWJru1dWstPHshe) video series.
|
||||
|
||||
@@ -33,7 +31,7 @@ LNbits is inspired by all the great work of [opennode.com](https://www.opennode.
|
||||
|
||||
## Running LNbits
|
||||
|
||||
See the [install guide](docs/guide/installation.md) for details on installation and setup.
|
||||
See the [install guide](https://github.com/lnbits/lnbits/wiki/LNbits-Documentation#install-options) for details on installation and setup.
|
||||
|
||||
## LNbits as an account system
|
||||
|
||||
@@ -60,7 +58,7 @@ Example use would be an ATM, which utilises LNURL, if the user scans the QR with
|
||||
|
||||
## 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,8 +66,7 @@ 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://docs.lnbits.org/
|
||||
[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
|
||||
[github-mypy-badge]: https://github.com/lnbits/lnbits/workflows/mypy/badge.svg
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
import glob
|
||||
import os
|
||||
import subprocess
|
||||
import warnings
|
||||
from os import path
|
||||
from pathlib import Path
|
||||
from typing import Any, List, NamedTuple, Optional
|
||||
|
||||
LNBITS_PATH = path.dirname(path.realpath(__file__)) + "/lnbits"
|
||||
|
||||
|
||||
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(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, LNBITS_PATH)
|
||||
|
||||
|
||||
def transpile_scss():
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore")
|
||||
from scss.compiler import compile_string # type: ignore
|
||||
|
||||
with open(os.path.join(LNBITS_PATH, "static/scss/base.scss")) as scss:
|
||||
with open(os.path.join(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(LNBITS_PATH, "static/bundle.js")),
|
||||
(get_css_vendored, os.path.join(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)
|
||||
|
||||
|
||||
def build():
|
||||
transpile_scss()
|
||||
bundle_vendored()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
build()
|
||||
@@ -9,11 +9,14 @@ nav_order: 2
|
||||
Making extensions
|
||||
=================
|
||||
|
||||
Start off by copying the example extension in `lnbits/extensions/example` into your own:
|
||||
Start off by cloning the [example extension](https://github.com/lnbits/example) into your `lnbits/extensions` folder and renaming it 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 lnbits/extensions
|
||||
git clone https://github.com/lnbits/example.git --depth=1 mysuperplugin # Let's not use dashes or anything; it doesn't like those.
|
||||
cd 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.
|
||||
|
||||
@@ -28,18 +31,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
|
||||
|
||||
@@ -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 enviroment 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,14 +32,14 @@ There is also the possibility of posting the super user via webhook to another s
|
||||
|
||||
Admin Users
|
||||
===========
|
||||
enviroment variable: LNBITS_ADMIN_USERS, comma-seperated list of user ids
|
||||
Admin Users can change settings in the admin ui aswell, 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
|
||||
=============
|
||||
enviroment variable: LNBITS_ALLOWED_USERS, comma-seperated list of user ids
|
||||
By defining this users, LNbits will no longer be useable by the public, only defined users and admins can then access the LNbits frontend.
|
||||
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.
|
||||
|
||||
|
||||
How to activate
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
# Extension Install
|
||||
|
||||
Anyone can create an extension by following the [example extension](https://github.com/lnbits/example) and [making extensions](https://github.com/lnbits/lnbits/blob/main/docs/devs/extensions.md) dev guide.
|
||||
|
||||
Extensions can be installed by an admin user after the **LNbits** instance has been started.
|
||||
|
||||
## Configure Repositories
|
||||
|
||||
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/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).
|
||||
> **Warning**
|
||||
> Extensions can have bugs or malicious code, be careful what you install!!
|
||||
|
||||
## Install New Extension
|
||||
Only administrator users can install or upgrade extensions.
|
||||
|
||||
Go to `Manage Extensions` > `Add Remove Extensions`
|
||||

|
||||
|
||||
A list of extensions that can be installed is displayed:
|
||||

|
||||
|
||||
> **Note**
|
||||
> If the extension is installed from a GitHub repo, then the GitHub star count will be shown.
|
||||
|
||||
Click the `Manage` button in order to install a particular release of the extension.
|
||||

|
||||
|
||||
> **Note**
|
||||
> An extension can be listed in more than one repository. The admin user must select which repository it wants to install from.
|
||||
|
||||
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
|
||||
|
||||
|
||||
The extension has been installed but it cannot be accessed yet. In order to activate the extension toggle it in the `Activated` state.
|
||||
|
||||
Go to `Manage Extensions` (as admin user or regular user). Search for the extension and enable it.
|
||||
|
||||
|
||||
## Uninstall Extension
|
||||
On the `Install` page click `Manage` for the extension you want to uninstall:
|
||||

|
||||
|
||||
The installed release is highlighted in green.
|
||||
|
||||
Click the `Uninstall` button for the release or the one in the bottom.
|
||||
|
||||
Users will no longer be able to access the extension.
|
||||
|
||||
> **Note**
|
||||
> The database for the extension is not removed. If the extension is re-installed later, the data will be accessible.
|
||||
|
||||
## Manifest File
|
||||
The manifest file is just a `JSON` file that lists a collection of extensions that can be installed. This file is of the form:
|
||||
|
||||
```json
|
||||
{
|
||||
"extensions": [...]
|
||||
"repos": [...]
|
||||
}
|
||||
```
|
||||
|
||||
There are two ways to specify installable extensions:
|
||||
|
||||
### Explicit Release
|
||||
It goes under the `extensions` object and it is of the form:
|
||||
```json
|
||||
{
|
||||
"id": "lnurlp",
|
||||
"name": "LNURL Pay Links",
|
||||
"version": 1,
|
||||
"shortDescription": "Upgrade to version 111111111",
|
||||
"icon": "receipt",
|
||||
"details": "All charge names should be <code>111111111</code>. API panel must show: <br>",
|
||||
"archive": "https://github.com/lnbits/lnbits-extensions/raw/main/new/lnurlp/1/lnurlp.zip",
|
||||
"hash": "a22d02de6bf306a7a504cd344e032cc6d48837a1d4aeb569a55a57507bf9a43a",
|
||||
"htmlUrl": "https://github.com/lnbits/lnbits-extensions/tree/main/new/lnurlp/1",
|
||||
"infoNotification": "This is a very old version",
|
||||
"dependencies": ["other-ext-id"]
|
||||
}
|
||||
```
|
||||
|
||||
<details><summary>Fields Detailed Description</summary>
|
||||
|
||||
| Field | Type | | Description |
|
||||
|----------------------|---------------|-----------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| id | string | mandatory | The ID of the extension. Must be unique for each extension. It is also used as the path in the URL. |
|
||||
| name | string | mandatory | User friendly name for the extension. It will be displayed on the installation page. |
|
||||
| version | string | mandatory | Version of this release. [Semantic versioning](https://semver.org/) is recommended. |
|
||||
| shortDescription | string | optional | A few words about the extension. It will be displayed on the installation page. |
|
||||
| icon | string | optional | quasar valid icon name |
|
||||
| details | string (html) | optional | Details about this particular release |
|
||||
| archive | string | mandatory | URL to the `zip` file that contains the extension source-code |
|
||||
| hash | string | mandatory | The hash (`sha256`) of the `zip` file. The extension will not be installed if the hash is incorrect. |
|
||||
| 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.
|
||||
|
||||
</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:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "withdraw",
|
||||
"organisation": "lnbits",
|
||||
"repository": "withdraw-extension"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
|--------------|--------|-------------------------------------------------------|
|
||||
| id | string | The ID of the extension. Must be unique for each extension. It is also used as the path in the URL. |
|
||||
| organisation | string | The GitHub organisation (eg: `lnbits`) |
|
||||
| repository | string | The GitHub repository name (eg: `withdraw-extension`) |
|
||||
|
||||
The admin user will see all releases from the Github repository:
|
||||
|
||||

|
||||
|
||||
|
||||
+11
-11
@@ -33,7 +33,7 @@ allow-self-payment=1
|
||||
</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 importan to choose a source that has a good liquidity and good peers connected. If you use LNbits for public services your users´ payments can then flow happily in both directions. If you would like to fund your LNbits wallet via btc please see section Troubleshooting.</p>
|
||||
<p>There are several ways to run a LNbits instance funded from different sources. It is important to choose a source that has a good liquidity and good peers connected. If you use LNbits for public services your users´ payments can then flow happily in both directions. If you would like to fund your LNbits wallet via btc please see section Troubleshooting.</p>
|
||||
<p>The <a href="http://docs.lnbits.org/guide/wallets.html">LNbits manual</a> shows you which sources can be used and how to configure each: CLN, LND, LNPay, Cliche, OpenNode as well as bots.</p>
|
||||
</details>
|
||||
|
||||
@@ -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/lnbits/tree/main/lnbits/extensions/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="http://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>
|
||||
@@ -112,7 +112,7 @@ allow-self-payment=1
|
||||
<ul>
|
||||
<li>
|
||||
<details><summary>A - Via an external swap service</summary>
|
||||
<p>If the user do not have full acceess 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>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
|
||||
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>
|
||||
@@ -122,7 +122,7 @@ allow-self-payment=1
|
||||
<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 regularily drain all the sats from LN into onchain.
|
||||
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>
|
||||
@@ -157,10 +157,10 @@ allow-self-payment=1
|
||||
|
||||
|
||||
<details><summary>How can I use a LNbits lndhub account in other wallet apps?</summary>
|
||||
<p>Open your LNbits with the account / wallet you want to use, go to "manage extensions" and activate the LNDHUB extension.</p>
|
||||
<p>Open your LNbits with the account / wallet you want to use, go to "manage extensions" and activate the <a href="https://github.com/lnbits/lndhub">LNDHUB extension</a>.</p>
|
||||
<p>Then open the LNDHUB extension, choose the wallet you want to use and scan the QR code you want to use: "admin" or "invoice only", depending on the security level you want for that wallet.</p>
|
||||
<p>You can use <a href="https://zeusln.app">Zeus</a> or <a href="https://bluewallet.io">Bluewallet</a> as wallet apps for a lndhub account.</p>
|
||||
<p>Keep in mind: if your LNbits instance is Tor only, you must use also theose apps behind Tor and open the LNbits page through your Tor .onion address.</p>
|
||||
<p>Keep in mind: if your LNbits instance is Tor only, you must use also those apps behind Tor and open the LNbits page through your Tor .onion address.</p>
|
||||
</details>
|
||||
</ul>
|
||||
</ul>
|
||||
@@ -169,11 +169,11 @@ allow-self-payment=1
|
||||
<ul> <p>LNbits has all sorts of open APIs and tools to program and connect to a lot of different devices for a gazillion of use-cases. Let us know what you did with it ! Come to the <a href="https://t.me/makerbits">Makerbits Telegram Group</a> if you are interested in building or if you need help with a project - we got you!</p>
|
||||
|
||||
<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. It works based on BTCPayer server, LNTXBOT is not longer an option. 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>
|
||||
<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 👇 Ressources. It allows
|
||||
<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>
|
||||
<li>LNURLPoS Offline Lightning Payments. Passive interaction, sharing a secret as evidence</li>
|
||||
<li>OnChain For onchain payments. Generates an address and displays a link for verification</li>
|
||||
@@ -192,7 +192,7 @@ allow-self-payment=1
|
||||
</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 👇 Ressources.</p>
|
||||
<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>
|
||||
@@ -209,7 +209,7 @@ allow-self-payment=1
|
||||
<ul>
|
||||
<li><a href="https://t.me/makerbits'">MakerBits</a> - Telegram support group</li>
|
||||
<li><a href="https://ereignishorizont.xyz/">Instructions for LNpos, Switch, ATM, BTCticker</a> - guides in DE & EN</li>
|
||||
<li><a href="https://shop.lnbits.com/">LNbits shop</a> - eadymade hardware gimmicks from the community</li>
|
||||
<li><a href="https://shop.lnbits.com/">LNbits shop</a> - readymade hardware gimmicks from the community</li>
|
||||
<li><a href="https://github.com/cryptoteun/awesome-lnbits#hardware-projects-utilizing-lnbits">Collection of hardware projects using LNbits</a></li>
|
||||
</ul>
|
||||
</details>
|
||||
@@ -222,7 +222,7 @@ allow-self-payment=1
|
||||
</details>
|
||||
|
||||
<details><summary>Swapping ⚡️LN BTC to a BTC address</summary>
|
||||
<p>LNbits has two swap extensions integrated: <a href="https://github.com/lnbits/lnbits/tree/main/lnbits/extensions/boltz">Boltz</a> and <a href="https://github.com/lnbits/lnbits/tree/main/lnbits/extensions/deezy">Deezy</a>.</p>
|
||||
<p>LNbits has two swap extensions integrated: <a href="https://github.com/lnbits/boltz-extension/">Boltz</a> and <a href="hhttps://github.com/lnbits/deezy">Deezy</a>.</p>
|
||||
<p>For a merchant that uses LNbits to receive BTC payments through LN, this is very handy to move the received sats from LN channels into onchain wallets. It not only helps you HODLing but is also freeing up "space in your channels" so you are ready to receive more sats.</p>
|
||||
<p>Boltz has an option to setup an automated swap triggered by a certain amount received.</p>
|
||||
</details>
|
||||
|
||||
+74
-44
@@ -6,13 +6,13 @@ 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
|
||||
@@ -80,30 +80,8 @@ mkdir data
|
||||
LNBITS_DATA_FOLDER=data LNBITS_BACKEND_WALLET_CLASS=LNbitsWallet LNBITS_ENDPOINT=https://legend.lnbits.com LNBITS_KEY=7b1a78d6c78f48b09a202f2dcb2d22eb ./result/bin/lnbits --port 9000
|
||||
```
|
||||
|
||||
## Option 3: venv
|
||||
|
||||
```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 build.py
|
||||
```
|
||||
|
||||
#### 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
|
||||
@@ -122,7 +100,7 @@ mkdir data
|
||||
docker run --detach --publish 5000:5000 --name lnbits --volume ${PWD}/.env:/app/.env --volume ${PWD}/data/:/app/data lnbitsdocker/lnbits-legend
|
||||
```
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -174,8 +152,8 @@ kill_timeout = 30
|
||||
[env]
|
||||
HOST="127.0.0.1"
|
||||
PORT=5000
|
||||
LNBITS_FORCE_HTTPS=true
|
||||
FORWARDED_ALLOW_IPS="*"
|
||||
LNBITS_BASEURL="https://mylnbits.lnbits.org/"
|
||||
LNBITS_DATA_FOLDER="/data"
|
||||
|
||||
${PUT_YOUR_LNBITS_ENV_VARS_HERE}
|
||||
@@ -210,15 +188,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 +274,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 +294,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 +307,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 +332,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,16 +380,62 @@ 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.
|
||||
|
||||
We have to create a self-signed certificate using `mkcert`. Note that this certiciate is not "trusted" by most browsers but that's fine (since you know that you have created it) and encryption is always better than clear text.
|
||||
We have to create a self-signed certificate using `mkcert`. Note that this certificate is not "trusted" by most browsers but that's fine (since you know that you have created it) and encryption is always better than clear text.
|
||||
|
||||
#### Install mkcert
|
||||
You can find the install instructions for `mkcert` [here](https://github.com/FiloSottile/mkcert).
|
||||
@@ -433,7 +463,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
|
||||
```
|
||||
|
||||
|
||||
|
||||
+3
-10
@@ -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, lntxbot and OpenNode, with more being added regularly.
|
||||
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.
|
||||
|
||||
A backend wallet can be configured using the following LNbits environment variables:
|
||||
|
||||
@@ -47,7 +47,7 @@ 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,7 +57,7 @@ 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/
|
||||
@@ -65,13 +65,6 @@ For the invoice listener to work you have a publicly accessible URL in your LNbi
|
||||
- `LNPAY_WALLET_KEY`: waka_apiKey
|
||||
|
||||
|
||||
### lntxbot
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: **LntxbotWallet**
|
||||
- `LNTXBOT_API_ENDPOINT`: https://lntxbot.com/
|
||||
- `LNTXBOT_KEY`: lntxbotAdminApiKey
|
||||
|
||||
|
||||
### OpenNode
|
||||
|
||||
For the invoice to work you must have a publicly accessible URL in your LNbits. No manual webhook setting is necessary.
|
||||
|
||||
Generated
+12
-25
@@ -2,11 +2,11 @@
|
||||
"nodes": {
|
||||
"flake-utils": {
|
||||
"locked": {
|
||||
"lastModified": 1656928814,
|
||||
"narHash": "sha256-RIFfgBuKz6Hp89yRr7+NR5tzIAbn52h8vT6vXkYjZoM=",
|
||||
"lastModified": 1676283394,
|
||||
"narHash": "sha256-XX2f9c3iySLCw54rJ/CZs+ZK6IQy7GXNY4nSOyu2QG4=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "7e2a3b3dfd9af950a856d66b0a7d01e3c18aa249",
|
||||
"rev": "3db36a8b464d0c4532ba1c7dda728f4576d6d073",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -17,11 +17,11 @@
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1657114324,
|
||||
"narHash": "sha256-fWuaUNXrHcz/ciHRHlcSO92dvV3EVS0GJQUSBO5JIB4=",
|
||||
"lastModified": 1678470307,
|
||||
"narHash": "sha256-OEeMUr3ueLIXyW/OaFUX5jUdimyQwMg/7e+/Q0gC/QE=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "a5c867d9fe9e4380452628e8f171c26b69fa9d3d",
|
||||
"rev": "0c4800d579af4ed98ecc47d464a5e7b0870c4b1f",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -31,32 +31,19 @@
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs_2": {
|
||||
"locked": {
|
||||
"lastModified": 1657261001,
|
||||
"narHash": "sha256-sUZeuRYfhG59uD6xafM07bc7bAIkpcGq84Vj4B+cyms=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "0be91cefefde5701f8fa957904618a13e3bb51d8",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"poetry2nix": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": "nixpkgs_2"
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1657149754,
|
||||
"narHash": "sha256-iSnZoqwNDDVoO175whSuvl4sS9lAb/2zZ3Sa4ywo970=",
|
||||
"lastModified": 1678513806,
|
||||
"narHash": "sha256-bEto1lp9bIQ+DlJsXQyRxN5l6B/oy3Skb7DueYRJJBo=",
|
||||
"owner": "nix-community",
|
||||
"repo": "poetry2nix",
|
||||
"rev": "fc1930e011dea149db81863aac22fe701f36f1b5",
|
||||
"rev": "bf239d53fafb53cf439a72b3a50e86dd6a3984a5",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
{
|
||||
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";
|
||||
poetry2nix = {
|
||||
url = "github:nix-community/poetry2nix";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
};
|
||||
outputs = { self, nixpkgs, poetry2nix }@inputs:
|
||||
let
|
||||
@@ -17,6 +22,7 @@
|
||||
default = pkgs.mkShell {
|
||||
buildInputs = with pkgs; [
|
||||
nodePackages.prettier
|
||||
poetry
|
||||
];
|
||||
};
|
||||
});
|
||||
@@ -29,7 +35,6 @@
|
||||
default = self.packages.${system}.${projectName};
|
||||
${projectName} = pkgs.poetry2nix.mkPoetryApplication {
|
||||
projectDir = ./.;
|
||||
python = pkgs.python39;
|
||||
};
|
||||
});
|
||||
nixosModules = {
|
||||
|
||||
+180
-46
@@ -1,10 +1,14 @@
|
||||
import asyncio
|
||||
import glob
|
||||
import importlib
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import signal
|
||||
import sys
|
||||
import traceback
|
||||
from http import HTTPStatus
|
||||
from typing import Callable, List
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.exceptions import HTTPException, RequestValidationError
|
||||
@@ -14,20 +18,23 @@ from fastapi.responses import JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.core.crud import 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 .commands import migrate_databases
|
||||
from .core import core_app
|
||||
from .commands import db_versions, load_disabled_extension_list, migrate_databases
|
||||
from .core import (
|
||||
add_installed_extension,
|
||||
core_app,
|
||||
core_app_extra,
|
||||
update_installed_extension_state,
|
||||
)
|
||||
from .core.services import check_admin_settings
|
||||
from .core.views.generic import core_html_routes
|
||||
from .helpers import (
|
||||
get_css_vendored,
|
||||
get_js_vendored,
|
||||
get_valid_extensions,
|
||||
template_renderer,
|
||||
url_for_vendored,
|
||||
)
|
||||
from .extension_manager import Extension, InstallableExtension, get_valid_extensions
|
||||
from .helpers import template_renderer
|
||||
from .middleware import ExtensionsRedirectMiddleware, InstalledExtensionMiddleware
|
||||
from .requestvars import g
|
||||
from .tasks import (
|
||||
catch_everything_and_restart,
|
||||
@@ -45,6 +52,7 @@ def create_app() -> FastAPI:
|
||||
app = FastAPI(
|
||||
title="LNbits API",
|
||||
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",
|
||||
@@ -66,12 +74,18 @@ def create_app() -> FastAPI:
|
||||
|
||||
app.add_middleware(GZipMiddleware, minimum_size=1000)
|
||||
|
||||
# order of these two middlewares is important
|
||||
app.add_middleware(InstalledExtensionMiddleware)
|
||||
app.add_middleware(ExtensionsRedirectMiddleware)
|
||||
|
||||
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))
|
||||
|
||||
return app
|
||||
|
||||
|
||||
@@ -80,31 +94,129 @@ async def check_funding_source() -> None:
|
||||
original_sigint_handler = signal.getsignal(signal.SIGINT)
|
||||
|
||||
def signal_handler(signal, frame):
|
||||
logger.debug(f"SIGINT received, terminating LNbits.")
|
||||
logger.debug("SIGINT received, terminating LNbits.")
|
||||
sys.exit(1)
|
||||
|
||||
signal.signal(signal.SIGINT, signal_handler)
|
||||
|
||||
WALLET = get_wallet_class()
|
||||
|
||||
# fallback to void after 30 seconds of failures
|
||||
sleep_time = 5
|
||||
timeout = int(30 / sleep_time)
|
||||
|
||||
balance = 0
|
||||
retry_counter = 0
|
||||
|
||||
while True:
|
||||
try:
|
||||
error_message, balance = await WALLET.status()
|
||||
if not error_message:
|
||||
retry_counter = 0
|
||||
break
|
||||
|
||||
logger.error(
|
||||
f"The backend for {WALLET.__class__.__name__} isn't working properly: '{error_message}'",
|
||||
RuntimeWarning,
|
||||
)
|
||||
except:
|
||||
pass
|
||||
logger.info("Retrying connection to backend in 5 seconds...")
|
||||
await asyncio.sleep(5)
|
||||
|
||||
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")
|
||||
WALLET = get_wallet_class()
|
||||
break
|
||||
else:
|
||||
logger.warning(f"Retrying connection to backend in {sleep_time} seconds...")
|
||||
retry_counter += 1
|
||||
await asyncio.sleep(sleep_time)
|
||||
|
||||
signal.signal(signal.SIGINT, original_sigint_handler)
|
||||
|
||||
logger.info(
|
||||
f"✔️ Backend {WALLET.__class__.__name__} connected and with a balance of {balance} msat."
|
||||
)
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
shutil.rmtree(os.path.join("lnbits", "upgrades"), True)
|
||||
await load_disabled_extension_list()
|
||||
installed_extensions = await build_all_installed_extensions_list()
|
||||
|
||||
for ext in installed_extensions:
|
||||
try:
|
||||
installed = check_installed_extension(ext)
|
||||
if not installed:
|
||||
await restore_installed_extension(app, ext)
|
||||
logger.info(f"✔️ Successfully re-installed extension: {ext.id}")
|
||||
except Exception as e:
|
||||
logger.warning(e)
|
||||
logger.warning(f"Failed to re-install extension: {ext.id}")
|
||||
|
||||
|
||||
async def build_all_installed_extensions_list() -> List[InstallableExtension]:
|
||||
"""
|
||||
Returns a list of all the installed extensions plus the extensions that
|
||||
MUST be installed by default (see LNBITS_EXTENSIONS_DEFAULT_INSTALL).
|
||||
"""
|
||||
installed_extensions = await get_installed_extensions()
|
||||
|
||||
installed_extensions_ids = [e.id for e in installed_extensions]
|
||||
for ext_id in settings.lnbits_extensions_default_install:
|
||||
if ext_id in installed_extensions_ids:
|
||||
continue
|
||||
|
||||
ext_releases = await InstallableExtension.get_extension_releases(ext_id)
|
||||
release = ext_releases[0] if len(ext_releases) else 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
|
||||
|
||||
|
||||
def check_installed_extension(ext: InstallableExtension) -> bool:
|
||||
if ext.has_installed_version:
|
||||
return True
|
||||
|
||||
zip_files = glob.glob(
|
||||
os.path.join(settings.lnbits_data_folder, "extensions", "*.zip")
|
||||
)
|
||||
|
||||
if f"./{str(ext.zip_path)}" not in zip_files:
|
||||
ext.download_archive()
|
||||
ext.extract_archive()
|
||||
|
||||
return False
|
||||
|
||||
|
||||
async def restore_installed_extension(app: FastAPI, ext: InstallableExtension):
|
||||
extension = Extension.from_installable_ext(ext)
|
||||
register_ext_routes(app, extension)
|
||||
|
||||
current_version = (await db_versions()).get(ext.id, 0)
|
||||
await migrate_extension_database(extension, current_version)
|
||||
|
||||
# mount routes for the new version
|
||||
core_app_extra.register_new_ext_routes(extension)
|
||||
if extension.upgrade_hash:
|
||||
ext.nofiy_upgrade()
|
||||
|
||||
|
||||
def register_routes(app: FastAPI) -> None:
|
||||
"""Register FastAPI routes / LNbits extensions."""
|
||||
app.include_router(core_app)
|
||||
@@ -112,20 +224,7 @@ def register_routes(app: FastAPI) -> None:
|
||||
|
||||
for ext in get_valid_extensions():
|
||||
try:
|
||||
ext_module = importlib.import_module(f"lnbits.extensions.{ext.code}")
|
||||
ext_route = getattr(ext_module, f"{ext.code}_ext")
|
||||
|
||||
if hasattr(ext_module, f"{ext.code}_start"):
|
||||
ext_start_func = getattr(ext_module, f"{ext.code}_start")
|
||||
ext_start_func()
|
||||
|
||||
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"])
|
||||
|
||||
logger.trace(f"adding route for extension {ext_module}")
|
||||
app.include_router(ext_route)
|
||||
register_ext_routes(app, ext)
|
||||
except Exception as e:
|
||||
logger.error(str(e))
|
||||
raise ImportError(
|
||||
@@ -133,24 +232,67 @@ def register_routes(app: FastAPI) -> None:
|
||||
)
|
||||
|
||||
|
||||
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.
|
||||
def register_new_ext_routes_fn(ext: Extension):
|
||||
register_ext_routes(app, ext)
|
||||
|
||||
return register_new_ext_routes_fn
|
||||
|
||||
|
||||
def register_ext_routes(app: FastAPI, ext: Extension) -> None:
|
||||
"""Register FastAPI routes for extension."""
|
||||
ext_module = importlib.import_module(ext.module_name)
|
||||
|
||||
ext_route = getattr(ext_module, f"{ext.code}_ext")
|
||||
|
||||
if hasattr(ext_module, f"{ext.code}_start"):
|
||||
ext_start_func = getattr(ext_module, f"{ext.code}_start")
|
||||
ext_start_func()
|
||||
|
||||
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"])
|
||||
|
||||
if hasattr(ext_module, f"{ext.code}_redirect_paths"):
|
||||
ext_redirects = getattr(ext_module, f"{ext.code}_redirect_paths")
|
||||
settings.lnbits_extensions_redirects = [
|
||||
r for r in settings.lnbits_extensions_redirects if r["ext_id"] != ext.code
|
||||
]
|
||||
for r in ext_redirects:
|
||||
r["ext_id"] = ext.code
|
||||
settings.lnbits_extensions_redirects.append(r)
|
||||
|
||||
logger.trace(f"adding route for extension {ext_module}")
|
||||
|
||||
prefix = f"/upgrades/{ext.upgrade_hash}" if ext.upgrade_hash != "" else ""
|
||||
app.include_router(router=ext_route, prefix=prefix)
|
||||
|
||||
|
||||
def register_startup(app: FastAPI):
|
||||
@app.on_event("startup")
|
||||
async def lnbits_startup():
|
||||
|
||||
try:
|
||||
# 1. wait till migration is done
|
||||
# wait till migration is done
|
||||
await migrate_databases()
|
||||
|
||||
# 2. setup admin settings
|
||||
# setup admin settings
|
||||
await check_admin_settings()
|
||||
|
||||
log_server_info()
|
||||
|
||||
# 3. initialize WALLET
|
||||
# initialize WALLET
|
||||
set_wallet_class()
|
||||
|
||||
# 4. initialize funding source
|
||||
# initialize funding source
|
||||
await check_funding_source()
|
||||
|
||||
# check extensions after restart
|
||||
await check_installed_extensions(app)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(str(e))
|
||||
raise ImportError("Failed to run 'startup' event.")
|
||||
@@ -158,6 +300,8 @@ def register_startup(app: FastAPI):
|
||||
|
||||
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}")
|
||||
logger.info(f"Debug: {settings.debug}")
|
||||
@@ -180,19 +324,6 @@ def get_db_vendor_name():
|
||||
)
|
||||
|
||||
|
||||
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():
|
||||
@@ -297,12 +428,15 @@ class Formatter:
|
||||
self.padding = 0
|
||||
self.minimal_fmt: str = "<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: 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"
|
||||
)
|
||||
else:
|
||||
self.fmt: str = self.minimal_fmt
|
||||
|
||||
def format(self, record):
|
||||
function = "{function}".format(**record)
|
||||
function = "{function}".format(**record) # pylint: disable=C0209
|
||||
if function == "emit": # uvicorn logs
|
||||
return self.minimal_fmt
|
||||
return self.fmt
|
||||
|
||||
+45
-49
@@ -5,7 +5,6 @@ from decimal import Decimal
|
||||
from typing import List, NamedTuple, Optional
|
||||
|
||||
import bitstring
|
||||
import embit
|
||||
import secp256k1
|
||||
from bech32 import CHARSET, bech32_decode, bech32_encode
|
||||
from ecdsa import SECP256k1, VerifyingKey
|
||||
@@ -20,7 +19,7 @@ class Route(NamedTuple):
|
||||
cltv: int
|
||||
|
||||
|
||||
class Invoice(object):
|
||||
class Invoice:
|
||||
payment_hash: str
|
||||
amount_msat: int = 0
|
||||
description: Optional[str] = None
|
||||
@@ -67,11 +66,12 @@ def decode(pr: str) -> Invoice:
|
||||
invoice.amount_msat = _unshorten_amount(amountstr)
|
||||
|
||||
# pull out date
|
||||
invoice.date = data.read(35).uint
|
||||
date_bin = data.read(35)
|
||||
invoice.date = date_bin.uint # type: ignore
|
||||
|
||||
while data.pos != data.len:
|
||||
tag, tagdata, data = _pull_tagged(data)
|
||||
data_length = len(tagdata) / 5
|
||||
data_length = len(tagdata or []) / 5
|
||||
|
||||
if tag == "d":
|
||||
invoice.description = _trim_to_bytes(tagdata).decode()
|
||||
@@ -80,7 +80,7 @@ def decode(pr: str) -> Invoice:
|
||||
elif tag == "p" and data_length == 52:
|
||||
invoice.payment_hash = _trim_to_bytes(tagdata).hex()
|
||||
elif tag == "x":
|
||||
invoice.expiry = tagdata.uint
|
||||
invoice.expiry = tagdata.uint # type: ignore
|
||||
elif tag == "n":
|
||||
invoice.payee = _trim_to_bytes(tagdata).hex()
|
||||
# this won't work in most cases, we must extract the payee
|
||||
@@ -91,11 +91,11 @@ def decode(pr: str) -> Invoice:
|
||||
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,
|
||||
pubkey=s.read(264).tobytes().hex(), # type: ignore
|
||||
short_channel_id=_readable_scid(s.read(64).intbe), # type: ignore
|
||||
base_fee_msat=s.read(32).intbe, # type: ignore
|
||||
ppm_fee=s.read(32).intbe, # type: ignore
|
||||
cltv=s.read(16).intbe, # type: ignore
|
||||
)
|
||||
invoice.route_hints.append(route)
|
||||
|
||||
@@ -166,15 +166,13 @@ def lnencode(addr, privkey):
|
||||
amount = Decimal(str(addr.amount))
|
||||
# We can only send down to millisatoshi.
|
||||
if amount * 10**12 % 10:
|
||||
raise ValueError(
|
||||
"Cannot encode {}: too many decimal places".format(addr.amount)
|
||||
)
|
||||
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"
|
||||
hrp = f"ln{amount}0n"
|
||||
|
||||
# Start with the timestamp
|
||||
data = bitstring.pack("uint:35", addr.date)
|
||||
@@ -190,7 +188,7 @@ def lnencode(addr, privkey):
|
||||
# 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("Duplicate '{}' tag".format(k))
|
||||
raise ValueError(f"Duplicate '{k}' tag")
|
||||
|
||||
if k == "r":
|
||||
route = bitstring.BitArray()
|
||||
@@ -205,7 +203,8 @@ def lnencode(addr, privkey):
|
||||
)
|
||||
data += tagged("r", route)
|
||||
elif k == "f":
|
||||
data += encode_fallback(v, addr.currency)
|
||||
# NOTE: there was an error fallback here that's now removed
|
||||
continue
|
||||
elif k == "d":
|
||||
data += tagged_bytes("d", v.encode())
|
||||
elif k == "x":
|
||||
@@ -220,7 +219,7 @@ def lnencode(addr, privkey):
|
||||
data += tagged_bytes("n", v)
|
||||
else:
|
||||
# FIXME: Support unknown tags?
|
||||
raise ValueError("Unknown tag {}".format(k))
|
||||
raise ValueError(f"Unknown tag {k}")
|
||||
|
||||
tags_set.add(k)
|
||||
|
||||
@@ -230,7 +229,7 @@ def lnencode(addr, privkey):
|
||||
# both.
|
||||
if "d" in tags_set and "h" in tags_set:
|
||||
raise ValueError("Cannot include both 'd' and 'h'")
|
||||
if not "d" in tags_set and not "h" in tags_set:
|
||||
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).
|
||||
@@ -245,9 +244,15 @@ def lnencode(addr, privkey):
|
||||
return bech32_encode(hrp, bitarray_to_u5(data))
|
||||
|
||||
|
||||
class LnAddr(object):
|
||||
class LnAddr:
|
||||
def __init__(
|
||||
self, paymenthash=None, amount=None, currency="bc", tags=None, date=None
|
||||
self,
|
||||
paymenthash=None,
|
||||
amount=None,
|
||||
currency="bc",
|
||||
tags=None,
|
||||
date=None,
|
||||
fallback=None,
|
||||
):
|
||||
self.date = int(time.time()) if not date else int(date)
|
||||
self.tags = [] if not tags else tags
|
||||
@@ -255,16 +260,15 @@ class LnAddr(object):
|
||||
self.paymenthash = paymenthash
|
||||
self.signature = None
|
||||
self.pubkey = None
|
||||
self.fallback = fallback
|
||||
self.currency = currency
|
||||
self.amount = amount
|
||||
|
||||
def __str__(self):
|
||||
return "LnAddr[{}, amount={}{} tags=[{}]]".format(
|
||||
bytes.hex(self.pubkey.serialize()).decode(),
|
||||
self.amount,
|
||||
self.currency,
|
||||
", ".join([k + "=" + str(v) for k, v in self.tags]),
|
||||
)
|
||||
assert self.pubkey, "LnAddr, pubkey must be set"
|
||||
pubkey = bytes.hex(self.pubkey.serialize())
|
||||
tags = ", ".join([f"{k}={v}" for k, v in self.tags])
|
||||
return f"LnAddr[{pubkey}, amount={self.amount}{self.currency} tags=[{tags}]]"
|
||||
|
||||
|
||||
def shorten_amount(amount):
|
||||
@@ -272,6 +276,7 @@ def shorten_amount(amount):
|
||||
# Convert to pico initially
|
||||
amount = int(amount * 10**12)
|
||||
units = ["p", "n", "u", "m", ""]
|
||||
unit = ""
|
||||
for unit in units:
|
||||
if amount % 1000 == 0:
|
||||
amount //= 1000
|
||||
@@ -296,7 +301,7 @@ def _unshorten_amount(amount: str) -> int:
|
||||
# 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("Invalid amount '{}'".format(amount))
|
||||
raise ValueError(f"Invalid amount '{amount}'")
|
||||
|
||||
if unit in units:
|
||||
return int(int(amount[:-1]) * 100_000_000_000 / units[unit])
|
||||
@@ -310,32 +315,24 @@ def _pull_tagged(stream):
|
||||
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):
|
||||
def tagged(char, bits):
|
||||
# Tagged fields need to be zero-padded to 5 bits.
|
||||
while l.len % 5 != 0:
|
||||
l.append("0b0")
|
||||
while bits.len % 5 != 0:
|
||||
bits.append("0b0")
|
||||
return (
|
||||
bitstring.pack(
|
||||
"uint:5, uint:5, uint:5",
|
||||
CHARSET.find(char),
|
||||
(l.len / 5) / 32,
|
||||
(l.len / 5) % 32,
|
||||
(bits.len / 5) / 32,
|
||||
(bits.len / 5) % 32,
|
||||
)
|
||||
+ l
|
||||
+ bits
|
||||
)
|
||||
|
||||
|
||||
def tagged_bytes(char, l):
|
||||
return tagged(char, bitstring.BitArray(l))
|
||||
def tagged_bytes(char, bits):
|
||||
return tagged(char, bitstring.BitArray(bits))
|
||||
|
||||
|
||||
def _trim_to_bytes(barr):
|
||||
@@ -347,11 +344,10 @@ def _trim_to_bytes(barr):
|
||||
|
||||
|
||||
def _readable_scid(short_channel_id: int) -> str:
|
||||
return "{blockheight}x{transactionindex}x{outputindex}".format(
|
||||
blockheight=((short_channel_id >> 40) & 0xFFFFFF),
|
||||
transactionindex=((short_channel_id >> 16) & 0xFFFFFF),
|
||||
outputindex=(short_channel_id & 0xFFFF),
|
||||
)
|
||||
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:
|
||||
@@ -366,5 +362,5 @@ def bitarray_to_u5(barr):
|
||||
ret = []
|
||||
s = bitstring.ConstBitStream(barr)
|
||||
while s.pos != s.len:
|
||||
ret.append(s.read(5).uint)
|
||||
ret.append(s.read(5).uint) # type: ignore
|
||||
return ret
|
||||
|
||||
+22
-86
@@ -1,8 +1,4 @@
|
||||
import asyncio
|
||||
import importlib
|
||||
import os
|
||||
import re
|
||||
import warnings
|
||||
|
||||
import click
|
||||
from loguru import logger
|
||||
@@ -11,13 +7,10 @@ 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.helpers import migrate_extension_database, run_migration
|
||||
from .db import COCKROACH, POSTGRES, SQLITE
|
||||
from .helpers import (
|
||||
get_css_vendored,
|
||||
get_js_vendored,
|
||||
get_valid_extensions,
|
||||
url_for_vendored,
|
||||
)
|
||||
from .extension_manager import get_valid_extensions
|
||||
|
||||
|
||||
@click.command("migrate")
|
||||
@@ -25,98 +18,41 @@ 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 def set_migration_version(conn, db_name, version):
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO dbversions (db, version) VALUES (?, ?)
|
||||
ON CONFLICT (db) DO UPDATE SET version = ?
|
||||
""",
|
||||
(db_name, version, version),
|
||||
)
|
||||
|
||||
async def run_migration(db, migrations_module, db_name):
|
||||
for key, migrate in migrations_module.__dict__.items():
|
||||
match = match = matcher.match(key)
|
||||
if match:
|
||||
version = int(match.group(1))
|
||||
if version > current_versions.get(db_name, 0):
|
||||
logger.debug(f"running migration {db_name}.{version}")
|
||||
await migrate(db)
|
||||
|
||||
if db.schema == None:
|
||||
await set_migration_version(db, db_name, version)
|
||||
else:
|
||||
async with core_db.connect() as conn:
|
||||
await set_migration_version(conn, db_name, version)
|
||||
|
||||
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_name = 'dbversions'"
|
||||
"SELECT * FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'dbversions'"
|
||||
)
|
||||
|
||||
if not exists:
|
||||
await core_migrations.m000_create_migrations_table(conn)
|
||||
|
||||
rows = await (await conn.execute("SELECT * FROM dbversions")).fetchall()
|
||||
current_versions = {row["db"]: row["version"] for row in rows}
|
||||
matcher = re.compile(r"^m(\d\d\d)_")
|
||||
db_name = core_migrations.__name__.split(".")[-2]
|
||||
await run_migration(conn, core_migrations, db_name)
|
||||
current_versions = await get_dbversions(conn)
|
||||
core_version = current_versions.get("core", 0)
|
||||
await run_migration(conn, core_migrations, core_version)
|
||||
|
||||
for ext in get_valid_extensions():
|
||||
try:
|
||||
|
||||
module_str = (
|
||||
ext.migration_module or f"lnbits.extensions.{ext.code}.migrations"
|
||||
)
|
||||
ext_migrations = importlib.import_module(module_str)
|
||||
ext_db = importlib.import_module(f"lnbits.extensions.{ext.code}").db
|
||||
db_name = ext.db_name or module_str.split(".")[-2]
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
f"Please make sure that the extension `{ext.code}` has a migrations file."
|
||||
)
|
||||
|
||||
async with ext_db.connect() as ext_conn:
|
||||
await run_migration(ext_conn, ext_migrations, db_name)
|
||||
current_version = current_versions.get(ext.code, 0)
|
||||
await migrate_extension_database(ext, current_version)
|
||||
|
||||
logger.info("✔️ All migrations done.")
|
||||
|
||||
|
||||
async def db_versions():
|
||||
async with core_db.connect() as conn:
|
||||
current_versions = await get_dbversions(conn)
|
||||
return current_versions
|
||||
|
||||
|
||||
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
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
from fastapi.routing import APIRouter
|
||||
|
||||
from lnbits.core.models import CoreAppExtra
|
||||
from lnbits.db import Database
|
||||
|
||||
db = Database("database")
|
||||
|
||||
core_app: APIRouter = APIRouter()
|
||||
|
||||
from .views.admin_api import * # noqa
|
||||
from .views.api import * # noqa
|
||||
from .views.generic import * # noqa
|
||||
from .views.public_api import * # noqa
|
||||
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
|
||||
|
||||
+212
-54
@@ -2,23 +2,31 @@ import datetime
|
||||
import json
|
||||
from typing import Any, Dict, List, Optional
|
||||
from urllib.parse import urlparse
|
||||
from uuid import uuid4
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import shortuuid
|
||||
|
||||
from lnbits import bolt11
|
||||
from lnbits.db import COCKROACH, POSTGRES, Connection
|
||||
from lnbits.db import Connection, Filters, Page
|
||||
from lnbits.extension_manager import InstallableExtension
|
||||
from lnbits.settings import AdminSettings, EditableSettings, SuperSettings, settings
|
||||
|
||||
from . import db
|
||||
from .models import BalanceCheck, Payment, TinyURL, User, Wallet
|
||||
from .models import BalanceCheck, Payment, PaymentFilters, TinyURL, User, Wallet
|
||||
|
||||
# accounts
|
||||
# --------
|
||||
|
||||
|
||||
async def create_account(conn: Optional[Connection] = None) -> User:
|
||||
user_id = uuid4().hex
|
||||
async def create_account(
|
||||
conn: Optional[Connection] = None, user_id: Optional[str] = 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
|
||||
|
||||
await (conn or db).execute("INSERT INTO accounts (id) VALUES (?)", (user_id,))
|
||||
|
||||
new_account = await get_account(user_id=user_id, conn=conn)
|
||||
@@ -61,13 +69,106 @@ 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],
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
# extensions
|
||||
# -------
|
||||
|
||||
|
||||
async def add_installed_extension(
|
||||
ext: InstallableExtension,
|
||||
conn: Optional[Connection] = None,
|
||||
) -> None:
|
||||
meta = {
|
||||
"installed_release": dict(ext.installed_release)
|
||||
if ext.installed_release
|
||||
else None,
|
||||
"dependencies": ext.dependencies,
|
||||
}
|
||||
|
||||
version = ext.installed_release.version if ext.installed_release else ""
|
||||
|
||||
await (conn or db).execute(
|
||||
"""
|
||||
INSERT INTO installed_extensions (id, version, name, short_description, icon, stars, meta) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (id) DO
|
||||
UPDATE SET (version, name, active, short_description, icon, stars, meta) = (?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
ext.id,
|
||||
version,
|
||||
ext.name,
|
||||
ext.short_description,
|
||||
ext.icon,
|
||||
ext.stars,
|
||||
json.dumps(meta),
|
||||
version,
|
||||
ext.name,
|
||||
False,
|
||||
ext.short_description,
|
||||
ext.icon,
|
||||
ext.stars,
|
||||
json.dumps(meta),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def update_installed_extension_state(
|
||||
*, ext_id: str, active: bool, conn: Optional[Connection] = None
|
||||
) -> None:
|
||||
await (conn or db).execute(
|
||||
"""
|
||||
UPDATE installed_extensions SET active = ? WHERE id = ?
|
||||
""",
|
||||
(active, ext_id),
|
||||
)
|
||||
|
||||
|
||||
async def delete_installed_extension(
|
||||
*, ext_id: str, conn: Optional[Connection] = None
|
||||
) -> None:
|
||||
await (conn or db).execute(
|
||||
"""
|
||||
DELETE from installed_extensions WHERE id = ?
|
||||
""",
|
||||
(ext_id,),
|
||||
)
|
||||
|
||||
|
||||
async def get_installed_extension(ext_id: str, conn: Optional[Connection] = None):
|
||||
row = await (conn or db).fetchone(
|
||||
"SELECT * FROM installed_extensions WHERE id = ?",
|
||||
(ext_id,),
|
||||
)
|
||||
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
async def get_installed_extensions(
|
||||
conn: Optional[Connection] = None,
|
||||
) -> List["InstallableExtension"]:
|
||||
rows = await (conn or db).fetchall(
|
||||
"SELECT * FROM installed_extensions",
|
||||
(),
|
||||
)
|
||||
return [InstallableExtension.from_row(row) for row in rows]
|
||||
|
||||
|
||||
async def get_inactive_extensions(*, conn: Optional[Connection] = None) -> List[str]:
|
||||
inactive_extensions = await (conn or db).fetchall(
|
||||
"""SELECT id FROM installed_extensions WHERE NOT active""",
|
||||
(),
|
||||
)
|
||||
return [ext[0] for ext in inactive_extensions]
|
||||
|
||||
|
||||
async def update_user_extension(
|
||||
*, user_id: str, extension: str, active: bool, conn: Optional[Connection] = None
|
||||
) -> None:
|
||||
@@ -114,7 +215,7 @@ async def create_wallet(
|
||||
async def update_wallet(
|
||||
wallet_id: str, new_name: str, conn: Optional[Connection] = None
|
||||
) -> Optional[Wallet]:
|
||||
return await (conn or db).execute(
|
||||
await (conn or db).execute(
|
||||
"""
|
||||
UPDATE wallets SET
|
||||
name = ?
|
||||
@@ -122,6 +223,9 @@ async def update_wallet(
|
||||
""",
|
||||
(new_name, wallet_id),
|
||||
)
|
||||
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(
|
||||
@@ -246,7 +350,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,
|
||||
@@ -255,29 +359,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 != 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)
|
||||
if since is not None:
|
||||
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
|
||||
@@ -301,30 +399,54 @@ 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.
|
||||
"""
|
||||
|
||||
if not filters:
|
||||
filters = Filters()
|
||||
|
||||
if limit:
|
||||
filters.limit = limit
|
||||
if offset:
|
||||
filters.offset = 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(
|
||||
@@ -367,7 +489,6 @@ async def create_payment(
|
||||
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"
|
||||
@@ -427,7 +548,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] = []
|
||||
|
||||
@@ -512,6 +632,23 @@ 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
|
||||
# -------------
|
||||
|
||||
@@ -598,7 +735,7 @@ async def get_admin_settings(is_super_user: bool = False) -> Optional[AdminSetti
|
||||
row_dict = dict(sets)
|
||||
row_dict.pop("super_user")
|
||||
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,
|
||||
)
|
||||
@@ -610,18 +747,39 @@ async def delete_admin_settings():
|
||||
|
||||
|
||||
async def update_admin_settings(data: EditableSettings):
|
||||
await db.execute(f"UPDATE settings SET editable_settings = ?", (json.dumps(data),))
|
||||
await db.execute("UPDATE settings SET editable_settings = ?", (json.dumps(data),))
|
||||
|
||||
|
||||
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 = f"INSERT INTO settings (super_user, editable_settings) VALUES (?, ?)"
|
||||
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
|
||||
# --------------
|
||||
async def get_dbversions(conn: Optional[Connection] = None):
|
||||
rows = await (conn or db).fetchall("SELECT * FROM dbversions")
|
||||
return {row["db"]: row["version"] for row in rows}
|
||||
|
||||
|
||||
async def update_migration_version(conn, db_name, version):
|
||||
await (conn or db).execute(
|
||||
"""
|
||||
INSERT INTO dbversions (db, version) VALUES (?, ?)
|
||||
ON CONFLICT (db) DO UPDATE SET version = ?
|
||||
""",
|
||||
(db_name, version, version),
|
||||
)
|
||||
|
||||
|
||||
# tinyurl
|
||||
@@ -631,7 +789,7 @@ async def create_admin_settings(super_user: str, new_settings: dict):
|
||||
async def create_tinyurl(domain: str, endless: bool, wallet: str):
|
||||
tinyurl_id = shortuuid.uuid()[:8]
|
||||
await db.execute(
|
||||
f"INSERT INTO tiny_url (id, url, endless, wallet) VALUES (?, ?, ?, ?)",
|
||||
"INSERT INTO tiny_url (id, url, endless, wallet) VALUES (?, ?, ?, ?)",
|
||||
(
|
||||
tinyurl_id,
|
||||
domain,
|
||||
@@ -644,7 +802,7 @@ async def create_tinyurl(domain: str, endless: bool, wallet: str):
|
||||
|
||||
async def get_tinyurl(tinyurl_id: str) -> Optional[TinyURL]:
|
||||
row = await db.fetchone(
|
||||
f"SELECT * FROM tiny_url WHERE id = ?",
|
||||
"SELECT * FROM tiny_url WHERE id = ?",
|
||||
(tinyurl_id,),
|
||||
)
|
||||
return TinyURL.from_row(row) if row else None
|
||||
@@ -652,14 +810,14 @@ async def get_tinyurl(tinyurl_id: str) -> Optional[TinyURL]:
|
||||
|
||||
async def get_tinyurl_by_url(url: str) -> List[TinyURL]:
|
||||
rows = await db.fetchall(
|
||||
f"SELECT * FROM tiny_url WHERE url = ?",
|
||||
"SELECT * FROM tiny_url WHERE url = ?",
|
||||
(url,),
|
||||
)
|
||||
return [TinyURL.from_row(row) for row in rows]
|
||||
|
||||
|
||||
async def delete_tinyurl(tinyurl_id: str):
|
||||
row = await db.execute(
|
||||
f"DELETE FROM tiny_url WHERE id = ?",
|
||||
await db.execute(
|
||||
"DELETE FROM tiny_url WHERE id = ?",
|
||||
(tinyurl_id,),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import importlib
|
||||
import re
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
import httpx
|
||||
from loguru import logger
|
||||
|
||||
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
|
||||
|
||||
|
||||
async def migrate_extension_database(ext: Extension, current_version):
|
||||
try:
|
||||
ext_migrations = importlib.import_module(f"{ext.module_name}.migrations")
|
||||
ext_db = importlib.import_module(ext.module_name).db
|
||||
except ImportError as e:
|
||||
logger.error(e)
|
||||
raise ImportError(
|
||||
f"Please make sure that the extension `{ext.code}` has a migrations file."
|
||||
)
|
||||
|
||||
async with ext_db.connect() as ext_conn:
|
||||
await run_migration(ext_conn, ext_migrations, current_version)
|
||||
|
||||
|
||||
async def run_migration(db: Connection, migrations_module: Any, 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:
|
||||
version = int(match.group(1))
|
||||
if version > current_version:
|
||||
logger.debug(f"running migration {db_name}.{version}")
|
||||
print(f"running migration {db_name}.{version}")
|
||||
await migrate(db)
|
||||
|
||||
if db.schema is None:
|
||||
await update_migration_version(db, db_name, version)
|
||||
else:
|
||||
async with core_db.connect() as conn:
|
||||
await update_migration_version(conn, db_name, version)
|
||||
|
||||
|
||||
async def stop_extension_background_work(ext_id: str, user: str):
|
||||
"""
|
||||
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)
|
||||
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:
|
||||
raise ValueError("Invalid hex string for User ID.")
|
||||
|
||||
return UUID(hex=user_id[:32], version=4)
|
||||
@@ -107,7 +107,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 +168,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(
|
||||
@@ -283,3 +284,20 @@ async def m009_create_tinyurl_table(db):
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
async def m010_create_installed_extensions_table(db):
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS installed_extensions (
|
||||
id TEXT PRIMARY KEY,
|
||||
version TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
short_description TEXT,
|
||||
icon TEXT,
|
||||
stars INT NOT NULL DEFAULT 0,
|
||||
active BOOLEAN DEFAULT false,
|
||||
meta TEXT NOT NULL DEFAULT '{}'
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
+36
-5
@@ -4,17 +4,16 @@ import hmac
|
||||
import json
|
||||
import time
|
||||
from sqlite3 import Row
|
||||
from typing import Dict, List, Optional
|
||||
from typing import Callable, Dict, List, Optional
|
||||
|
||||
from ecdsa import SECP256k1, SigningKey
|
||||
from fastapi import Query
|
||||
from lnurl import encode as lnurl_encode
|
||||
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.settings import get_wallet_class, settings
|
||||
from lnbits.wallets.base import PaymentStatus
|
||||
|
||||
|
||||
@@ -76,8 +75,18 @@ 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 Payment(FromRowModel):
|
||||
checking_id: str
|
||||
pending: bool
|
||||
amount: int
|
||||
@@ -205,6 +214,24 @@ class Payment(BaseModel):
|
||||
await delete_payment(self.checking_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 BalanceCheck(BaseModel):
|
||||
wallet: str
|
||||
service: str
|
||||
@@ -215,6 +242,10 @@ class BalanceCheck(BaseModel):
|
||||
return cls(wallet=row["wallet"], service=row["service"], url=row["url"])
|
||||
|
||||
|
||||
class CoreAppExtra:
|
||||
register_new_ext_routes: Callable
|
||||
|
||||
|
||||
class TinyURL(BaseModel):
|
||||
id: str
|
||||
url: str
|
||||
|
||||
+77
-50
@@ -1,7 +1,7 @@
|
||||
import asyncio
|
||||
import json
|
||||
from io import BytesIO
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
from typing import Dict, List, Optional, Tuple, TypedDict
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import httpx
|
||||
@@ -13,11 +13,11 @@ from loguru import logger
|
||||
from lnbits import bolt11
|
||||
from lnbits.db import Connection
|
||||
from lnbits.decorators import WalletTypeInfo, require_admin_key
|
||||
from lnbits.helpers import url_for, urlsafe_short_hash
|
||||
from lnbits.requestvars import g
|
||||
from lnbits.helpers import url_for
|
||||
from lnbits.settings import (
|
||||
FAKE_WALLET,
|
||||
EditableSettings,
|
||||
SuperSettings,
|
||||
get_wallet_class,
|
||||
readonly_variables,
|
||||
send_admin_user_to_saas,
|
||||
@@ -28,12 +28,14 @@ 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_standalone_payment,
|
||||
get_super_settings,
|
||||
get_wallet,
|
||||
get_wallet_payment,
|
||||
@@ -41,13 +43,9 @@ from .crud import (
|
||||
update_payment_status,
|
||||
update_super_user,
|
||||
)
|
||||
from .helpers import to_valid_user_id
|
||||
from .models import Payment
|
||||
|
||||
try:
|
||||
from typing import TypedDict
|
||||
except ImportError: # pragma: nocover
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
|
||||
class PaymentFailure(Exception):
|
||||
pass
|
||||
@@ -70,6 +68,9 @@ 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.")
|
||||
|
||||
invoice_memo = None if description_hash else memo
|
||||
|
||||
# use the fake wallet if the invoice is for internal use only
|
||||
@@ -150,9 +151,25 @@ async def pay_invoice(
|
||||
extra=extra,
|
||||
)
|
||||
|
||||
# check_internal() returns the checking_id of the invoice we're waiting for
|
||||
# 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:
|
||||
# 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
|
||||
):
|
||||
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(
|
||||
@@ -166,16 +183,21 @@ async def pay_invoice(
|
||||
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:
|
||||
await create_payment(
|
||||
checking_id=temp_id,
|
||||
fee=-fee_reserve_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:
|
||||
@@ -213,22 +235,22 @@ async def pay_invoice(
|
||||
)
|
||||
|
||||
logger.debug(f"backend: pay_invoice finished {temp_id}")
|
||||
if payment.checking_id and payment.ok != False:
|
||||
if payment.checking_id and payment.ok is not False:
|
||||
# payment.ok can be True (paid) or None (pending)!
|
||||
logger.debug(f"updating payment {temp_id}")
|
||||
async with db.connect() as conn:
|
||||
await update_payment_details(
|
||||
checking_id=temp_id,
|
||||
pending=payment.ok != True,
|
||||
pending=payment.ok is not True,
|
||||
fee=payment.fee_msat,
|
||||
preimage=payment.preimage,
|
||||
new_checking_id=payment.checking_id,
|
||||
conn=conn,
|
||||
)
|
||||
logger.debug(f"payment successful {payment.checking_id}")
|
||||
elif payment.checking_id is None and payment.ok == False:
|
||||
elif payment.checking_id is None and payment.ok is False:
|
||||
# payment failed
|
||||
logger.warning(f"backend sent payment failure")
|
||||
logger.warning("backend sent payment failure")
|
||||
async with db.connect() as conn:
|
||||
logger.debug(f"deleting temporary payment {temp_id}")
|
||||
await delete_wallet_payment(temp_id, wallet_id, conn=conn)
|
||||
@@ -323,19 +345,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()
|
||||
@@ -343,16 +366,17 @@ 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:
|
||||
assert key.verifying_key, "LNURLauth verifying_key does not exist"
|
||||
r = await client.get(
|
||||
callback,
|
||||
params={
|
||||
@@ -383,7 +407,7 @@ 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
|
||||
@@ -397,24 +421,26 @@ def fee_reserve(amount_msat: int) -> int:
|
||||
|
||||
|
||||
async def update_wallet_balance(wallet_id: str, amount: int):
|
||||
internal_id = f"internal_{urlsafe_short_hash()}"
|
||||
payment = await create_payment(
|
||||
payment_hash, _ = await create_invoice(
|
||||
wallet_id=wallet_id,
|
||||
checking_id=internal_id,
|
||||
payment_request="admin_internal",
|
||||
payment_hash="admin_internal",
|
||||
amount=amount * 1000,
|
||||
amount=amount,
|
||||
memo="Admin top up",
|
||||
pending=False,
|
||||
internal=True,
|
||||
)
|
||||
# manually send this for now
|
||||
from lnbits.tasks import internal_invoice_queue
|
||||
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(internal_id)
|
||||
return payment
|
||||
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:
|
||||
@@ -430,15 +456,17 @@ async def check_admin_settings():
|
||||
update_cached_settings(settings_db.dict())
|
||||
|
||||
# printing settings for debugging
|
||||
logger.debug(f"Admin settings:")
|
||||
logger.debug("Admin settings:")
|
||||
for key, value in settings.dict(exclude_none=True).items():
|
||||
logger.debug(f"{key}: {value}")
|
||||
|
||||
admin_url = (
|
||||
f"http://{settings.host}:{settings.port}/wallet?usr={settings.super_user}"
|
||||
)
|
||||
admin_url = f"{settings.lnbits_baseurl}wallet?usr={settings.super_user}"
|
||||
logger.success(f"✔️ Access super user account at: {admin_url}")
|
||||
|
||||
# saving it to .super_user file
|
||||
with open(".super_user", "w") as file:
|
||||
file.write(settings.super_user)
|
||||
|
||||
# callback for saas
|
||||
if (
|
||||
settings.lnbits_saas_callback
|
||||
@@ -450,7 +478,7 @@ async def check_admin_settings():
|
||||
|
||||
def update_cached_settings(sets_dict: dict):
|
||||
for key, value in sets_dict.items():
|
||||
if not key in readonly_variables:
|
||||
if key not in readonly_variables:
|
||||
try:
|
||||
setattr(settings, key, value)
|
||||
except:
|
||||
@@ -459,13 +487,12 @@ def update_cached_settings(sets_dict: 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)
|
||||
if not account.wallets or len(account.wallets) == 0:
|
||||
await create_wallet(user_id=account.id)
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 8.7 KiB |
@@ -3,7 +3,9 @@ new Vue({
|
||||
data: function () {
|
||||
return {
|
||||
searchTerm: '',
|
||||
filteredExtensions: null
|
||||
filteredExtensions: [],
|
||||
maxStars: 5,
|
||||
user: null
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
@@ -32,5 +34,10 @@ new Vue({
|
||||
}
|
||||
}
|
||||
},
|
||||
created() {
|
||||
if (window.user) {
|
||||
this.user = LNbits.map.user(window.user)
|
||||
}
|
||||
},
|
||||
mixins: [windowMixin]
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// the cache version gets updated every time there is a new deployment
|
||||
const CACHE_VERSION = 1
|
||||
// update cache version every time there is a new deployment
|
||||
// so the service worker reinitializes the cache
|
||||
const CACHE_VERSION = 8
|
||||
const CURRENT_CACHE = `lnbits-${CACHE_VERSION}-`
|
||||
|
||||
const getApiKey = request => {
|
||||
|
||||
@@ -148,34 +148,39 @@ new Vue({
|
||||
{
|
||||
name: 'memo',
|
||||
align: 'left',
|
||||
label: 'Memo',
|
||||
label: this.$t('memo'),
|
||||
field: 'memo'
|
||||
},
|
||||
{
|
||||
name: 'date',
|
||||
name: 'time',
|
||||
align: 'left',
|
||||
label: 'Date',
|
||||
label: this.$t('date'),
|
||||
field: 'date',
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
name: 'sat',
|
||||
name: 'amount',
|
||||
align: 'right',
|
||||
label: 'Amount (' + LNBITS_DENOMINATION + ')',
|
||||
label: this.$t('amount') + ' (' + LNBITS_DENOMINATION + ')',
|
||||
field: 'sat',
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
name: 'fee',
|
||||
align: 'right',
|
||||
label: 'Fee (m' + LNBITS_DENOMINATION + ')',
|
||||
label: this.$t('fee') + ' (m' + LNBITS_DENOMINATION + ')',
|
||||
field: 'fee'
|
||||
}
|
||||
],
|
||||
pagination: {
|
||||
rowsPerPage: 10
|
||||
rowsPerPage: 10,
|
||||
page: 1,
|
||||
sortBy: 'time',
|
||||
descending: true,
|
||||
rowsNumber: 10
|
||||
},
|
||||
filter: null
|
||||
filter: null,
|
||||
loading: false
|
||||
},
|
||||
paymentsChart: {
|
||||
show: false
|
||||
@@ -266,7 +271,7 @@ new Vue({
|
||||
this.g.user.wallets[0].adminkey,
|
||||
{
|
||||
amount: credit,
|
||||
id: this.g.user.wallets[0].id
|
||||
id: this.g.wallet.id
|
||||
}
|
||||
)
|
||||
.then(response => {
|
||||
@@ -466,6 +471,16 @@ new Vue({
|
||||
return
|
||||
}
|
||||
|
||||
// BIP-21 support
|
||||
if (this.parse.data.request.toLowerCase().includes('lightning')) {
|
||||
this.parse.data.request = this.parse.data.request.split('lightning=')[1]
|
||||
|
||||
// fail safe to check there's nothing after the lightning= part
|
||||
if (this.parse.data.request.includes('&')) {
|
||||
this.parse.data.request = this.parse.data.request.split('&')[0]
|
||||
}
|
||||
}
|
||||
|
||||
let invoice
|
||||
try {
|
||||
invoice = decode(this.parse.data.request)
|
||||
@@ -510,7 +525,7 @@ new Vue({
|
||||
payInvoice: function () {
|
||||
let dismissPaymentMsg = this.$q.notify({
|
||||
timeout: 0,
|
||||
message: 'Processing payment...'
|
||||
message: this.$t('processing_payment')
|
||||
})
|
||||
|
||||
LNbits.api
|
||||
@@ -685,16 +700,35 @@ new Vue({
|
||||
LNbits.href.deleteWallet(walletId, user)
|
||||
})
|
||||
},
|
||||
fetchPayments: function () {
|
||||
return LNbits.api.getPayments(this.g.wallet).then(response => {
|
||||
this.payments = response.data
|
||||
.map(obj => {
|
||||
fetchPayments: function (props) {
|
||||
// Props are passed by qasar when pagination or sorting changes
|
||||
if (props) {
|
||||
this.paymentsTable.pagination = props.pagination
|
||||
}
|
||||
let pagination = this.paymentsTable.pagination
|
||||
this.paymentsTable.loading = true
|
||||
const query = {
|
||||
limit: pagination.rowsPerPage,
|
||||
offset: (pagination.page - 1) * pagination.rowsPerPage,
|
||||
sortby: pagination.sortBy ?? 'time',
|
||||
direction: pagination.descending ? 'desc' : 'asc'
|
||||
}
|
||||
if (this.paymentsTable.filter) {
|
||||
query.search = this.paymentsTable.filter
|
||||
}
|
||||
return LNbits.api
|
||||
.getPayments(this.g.wallet, query)
|
||||
.then(response => {
|
||||
this.paymentsTable.loading = false
|
||||
this.paymentsTable.pagination.rowsNumber = response.data.total
|
||||
this.payments = response.data.data.map(obj => {
|
||||
return LNbits.map.payment(obj)
|
||||
})
|
||||
.sort((a, b) => {
|
||||
return b.time - a.time
|
||||
})
|
||||
})
|
||||
})
|
||||
.catch(err => {
|
||||
this.paymentsTable.loading = false
|
||||
LNbits.utils.notifyApiError(err)
|
||||
})
|
||||
},
|
||||
fetchBalance: function () {
|
||||
LNbits.api.getWallet(this.g.wallet).then(response => {
|
||||
|
||||
@@ -9,6 +9,7 @@ from lnbits.tasks import SseListenersDict, register_invoice_listener
|
||||
from . import db
|
||||
from .crud import get_balance_notify
|
||||
from .models import Payment
|
||||
from .services import websocketUpdater
|
||||
|
||||
api_invoice_listeners: Dict[str, asyncio.Queue] = SseListenersDict(
|
||||
"api_invoice_listeners"
|
||||
@@ -37,6 +38,7 @@ async def wait_for_paid_invoices(invoice_paid_queue: asyncio.Queue):
|
||||
logger.trace("received invoice paid event")
|
||||
# send information to sse channel
|
||||
await dispatch_api_invoice_listeners(payment)
|
||||
await websocketUpdater(payment.wallet_id, payment.dict())
|
||||
|
||||
# dispatch webhook
|
||||
if payment.webhook and not payment.webhook_status:
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
filled
|
||||
v-model="formData.lightning_invoice_expiry"
|
||||
label="Invoice expiry (seconds)"
|
||||
mask="#######"
|
||||
mask="#######"
|
||||
>
|
||||
</q-input>
|
||||
</div>
|
||||
|
||||
@@ -33,22 +33,7 @@
|
||||
<br />
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<p>Miscelaneous</p>
|
||||
<q-item tag="label" v-ripple>
|
||||
<q-item-section>
|
||||
<q-item-label>Force HTTPS</q-item-label>
|
||||
<q-item-label caption>Prefer secure URLs</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section avatar>
|
||||
<q-toggle
|
||||
size="md"
|
||||
v-model="formData.lnbits_force_https"
|
||||
checked-icon="check"
|
||||
color="green"
|
||||
unchecked-icon="clear"
|
||||
/>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<p>Miscellaneous</p>
|
||||
<q-item tag="label" v-ripple>
|
||||
<q-item-section>
|
||||
<q-item-label>Hide API</q-item-label>
|
||||
@@ -69,6 +54,49 @@
|
||||
<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>
|
||||
|
||||
<div>
|
||||
<p>Extension Sources</p>
|
||||
<q-input
|
||||
filled
|
||||
v-model="formAddExtensionsManifest"
|
||||
@keydown.enter="addExtensionsManifest"
|
||||
type="text"
|
||||
label="Source URL (only use the official LNbits extension source, and sources you can trust)"
|
||||
hint="Repositories from where the extensions can be downloaded"
|
||||
>
|
||||
<q-btn @click="addExtensionsManifest" dense flat icon="add"></q-btn>
|
||||
</q-input>
|
||||
<div>
|
||||
{%raw%}
|
||||
<q-chip
|
||||
v-for="manifestUrl in formData.lnbits_extensions_manifests"
|
||||
:key="manifestUrl"
|
||||
removable
|
||||
@remove="removeExtensionsManifest(manifestUrl)"
|
||||
color="primary"
|
||||
text-color="white"
|
||||
>
|
||||
{{ manifestUrl }}
|
||||
</q-chip>
|
||||
{%endraw%}
|
||||
</div>
|
||||
<br />
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-tab-panel>
|
||||
|
||||
@@ -58,31 +58,5 @@
|
||||
</div>
|
||||
<br />
|
||||
</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.name)"
|
||||
></q-select>
|
||||
<br />
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<p>Disabled Extensions</p>
|
||||
<q-select
|
||||
filled
|
||||
v-model="formData.lnbits_disabled_extensions"
|
||||
:options="g.extensions.map(e => e.name)"
|
||||
multiple
|
||||
hint="Disable extensions *amilk disabled by default as resource heavy"
|
||||
label="Disable extensions"
|
||||
></q-select>
|
||||
<br />
|
||||
</div>
|
||||
</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,25 @@
|
||||
<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="theme"
|
||||
label="Theme"
|
||||
:label="$t('theme')"
|
||||
@update="val => tab = val.name"
|
||||
></q-tab>
|
||||
</q-tabs>
|
||||
@@ -113,7 +111,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 +121,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,15 +133,25 @@
|
||||
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>
|
||||
@@ -160,6 +168,7 @@
|
||||
'classic',
|
||||
'bitcoin',
|
||||
'flamingo',
|
||||
'cyber',
|
||||
'freedom',
|
||||
'mint',
|
||||
'autumn',
|
||||
@@ -169,6 +178,7 @@
|
||||
formData: {},
|
||||
formAddAdmin: '',
|
||||
formAddUser: '',
|
||||
formAddExtensionsManifest: '',
|
||||
isSuperUser: false,
|
||||
wallet: {},
|
||||
cancel: {},
|
||||
@@ -215,18 +225,6 @@
|
||||
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'
|
||||
}
|
||||
}
|
||||
],
|
||||
@@ -249,10 +247,6 @@
|
||||
value: null,
|
||||
label: 'Admin Macaroon'
|
||||
},
|
||||
lnd_grpc_invoice_macaroon: {
|
||||
value: null,
|
||||
label: 'Invoice Macaroon'
|
||||
},
|
||||
lnd_grpc_macaroon_encrypted: {
|
||||
value: null,
|
||||
label: 'Encrypted Macaroon'
|
||||
@@ -391,6 +385,27 @@
|
||||
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)
|
||||
@@ -448,7 +463,7 @@
|
||||
this.g.user.wallets[0].adminkey
|
||||
)
|
||||
.then(response => {
|
||||
this.isSuperUser = response.data.super_user || false
|
||||
this.isSuperUser = response.data.is_super_user || false
|
||||
this.settings = response.data
|
||||
this.formData = _.clone(this.settings)
|
||||
this.updateFundingData()
|
||||
@@ -459,7 +474,7 @@
|
||||
},
|
||||
updateSettings() {
|
||||
let data = _.omit(this.formData, [
|
||||
'super_user',
|
||||
'is_super_user',
|
||||
'lnbits_allowed_funding_sources'
|
||||
])
|
||||
LNbits.api
|
||||
@@ -512,19 +527,7 @@
|
||||
})
|
||||
},
|
||||
downloadBackup() {
|
||||
LNbits.api
|
||||
.request('GET', '/admin/api/v1/backup/?usr=' + this.g.user.id)
|
||||
.then(response => {
|
||||
this.$q.notify({
|
||||
type: 'positive',
|
||||
message:
|
||||
'Success! Database backup request, download starts soon!',
|
||||
icon: null
|
||||
})
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
window.open('/admin/api/v1/backup/?usr=' + this.g.user.id, '_blank')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<q-expansion-item
|
||||
group="extras"
|
||||
icon="swap_vertical_circle"
|
||||
label="API info"
|
||||
:label="$t('api_docs')"
|
||||
:content-inset-level="0.5"
|
||||
>
|
||||
<q-card-section>
|
||||
|
||||
@@ -3,7 +3,20 @@
|
||||
<script src="/core/static/js/extensions.js"></script>
|
||||
{% endblock %} {% block page %}
|
||||
<div class="row q-col-gutter-md q-mb-md">
|
||||
<div class="col-sm-3 col-xs-8 q-ml-auto">
|
||||
<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('')"
|
||||
:label="$t('manage_extensions')"
|
||||
></q-btn>
|
||||
</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 !== ''"
|
||||
@@ -14,7 +27,7 @@
|
||||
</q-input>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="!filteredExtensions.length" v-text="$t('no_extensions')"></p>
|
||||
<div class="row q-col-gutter-md">
|
||||
<div
|
||||
class="col-6 col-md-4 col-lg-3"
|
||||
@@ -22,18 +35,39 @@
|
||||
:key="extension.code"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<q-card-section style="min-height: 140px">
|
||||
<div class="row">
|
||||
<div class="col-3">
|
||||
<q-img
|
||||
v-if="extension.tile"
|
||||
:src="extension.tile"
|
||||
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">
|
||||
{% raw %}
|
||||
<div class="text-h5 gt-sm q-mt-sm q-mb-xs">
|
||||
<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
|
||||
@@ -59,12 +93,14 @@
|
||||
<div>
|
||||
<q-rating
|
||||
class="gt-sm"
|
||||
v-model="maxStars"
|
||||
disable
|
||||
size="2em"
|
||||
size="1.5em"
|
||||
:max="5"
|
||||
color="primary"
|
||||
></q-rating
|
||||
><q-rating
|
||||
></q-rating>
|
||||
<q-rating
|
||||
v-model="maxStars"
|
||||
class="lt-md"
|
||||
size="1.5em"
|
||||
:max="5"
|
||||
|
||||
@@ -12,24 +12,23 @@
|
||||
@click="processing"
|
||||
type="a"
|
||||
href="{{ url_for('core.lnurlwallet') }}?lightning={{ lnurl }}"
|
||||
>
|
||||
Press to claim bitcoin
|
||||
</q-btn>
|
||||
v-text="$t('press_to_claim')"
|
||||
></q-btn>
|
||||
{% else %}
|
||||
<q-form @submit="createWallet" class="q-gutter-md">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model="walletName"
|
||||
label="Name your {{SITE_TITLE}} wallet *"
|
||||
:label='$t("name_your_wallet", { name: "{{ SITE_TITLE }} *" })'
|
||||
></q-input>
|
||||
<q-btn
|
||||
unelevated
|
||||
color="primary"
|
||||
:disable="walletName == ''"
|
||||
type="submit"
|
||||
>Add a new wallet</q-btn
|
||||
>
|
||||
:label="$t('add_wallet')"
|
||||
></q-btn>
|
||||
</q-form>
|
||||
{% endif %}
|
||||
</q-card-section>
|
||||
@@ -40,27 +39,7 @@
|
||||
<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, lntxbot, 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>
|
||||
<p v-text="$t('lnbits_description')"></p>
|
||||
<div class="row q-mt-md q-gutter-sm">
|
||||
<q-btn
|
||||
outline
|
||||
@@ -69,8 +48,8 @@
|
||||
href="https://github.com/lnbits/lnbits"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>View project in GitHub</q-btn
|
||||
>
|
||||
:label="$t('view_github')"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
@@ -78,8 +57,8 @@
|
||||
href="https://legend.lnbits.com/paywall/GAqKguK5S8f6w5VNjS9DfK"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>Donate</q-btn
|
||||
>
|
||||
:label="$t('donate')"
|
||||
></q-btn>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else>{{SITE_DESCRIPTION | safe}}</p>
|
||||
@@ -94,7 +73,7 @@
|
||||
<q-btn
|
||||
flat
|
||||
color="secondary"
|
||||
label="Runs on"
|
||||
:label="$t('runs_on')"
|
||||
class="full-width"
|
||||
></q-btn>
|
||||
<div class="row">
|
||||
@@ -135,24 +114,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<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>
|
||||
<div class="col q-pl-md">
|
||||
<a href="https://t.me/lntxbot">
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '/static/images/lntxbot.png' : '/static/images/lntxbotl.png'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<a href="https://github.com/rootzoll/raspiblitz">
|
||||
@@ -180,7 +141,14 @@
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col q-pl-md"> </div>
|
||||
<div class="col q-pl-md">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,505 @@
|
||||
{% 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 %}
|
||||
@@ -37,8 +37,8 @@
|
||||
>
|
||||
<q-input
|
||||
filled
|
||||
label="{{LNBITS_DENOMINATION}} to credit"
|
||||
hint="Press Enter to credit account"
|
||||
:label='$t("credit_label", { denomination: "{{LNBITS_DENOMINATION}}"})'
|
||||
:hint="$t('credit_hint')"
|
||||
v-model="scope.value"
|
||||
dense
|
||||
autofocus
|
||||
@@ -63,8 +63,8 @@
|
||||
color="primary"
|
||||
class="full-width"
|
||||
@click="showParseDialog"
|
||||
>Paste Request</q-btn
|
||||
>
|
||||
:label="$t('paste_request')"
|
||||
></q-btn>
|
||||
</div>
|
||||
<div class="col">
|
||||
<q-btn
|
||||
@@ -72,8 +72,8 @@
|
||||
color="primary"
|
||||
class="full-width"
|
||||
@click="showReceiveDialog"
|
||||
>Create Invoice</q-btn
|
||||
>
|
||||
:label="$t('create_invoice')"
|
||||
></q-btn>
|
||||
</div>
|
||||
<div class="col">
|
||||
<q-btn
|
||||
@@ -81,8 +81,11 @@
|
||||
color="secondary"
|
||||
icon="photo_camera"
|
||||
@click="showCamera"
|
||||
>scan
|
||||
<q-tooltip>Use camera to scan an invoice/QR</q-tooltip>
|
||||
:label="$t('scan')"
|
||||
>
|
||||
<q-tooltip
|
||||
>{% raw %}{{$t('camera_tooltip')}}{% endraw %}</q-tooltip
|
||||
>
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
@@ -92,12 +95,18 @@
|
||||
<q-card-section>
|
||||
<div class="row items-center no-wrap q-mb-sm">
|
||||
<div class="col">
|
||||
<h5 class="text-subtitle1 q-my-none">Transactions</h5>
|
||||
<h5
|
||||
class="text-subtitle1 q-my-none"
|
||||
:v-text="$t('transactions')"
|
||||
></h5>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<q-btn flat color="grey" @click="exportCSV"
|
||||
>Export to CSV</q-btn
|
||||
>
|
||||
<q-btn
|
||||
flat
|
||||
color="grey"
|
||||
@click="exportCSV"
|
||||
:label="$t('export_csv')"
|
||||
></q-btn>
|
||||
<!--<q-btn v-if="pendingPaymentsExist" dense flat round icon="update" color="grey" @click="checkPendingPayments">
|
||||
<q-tooltip>Check pending</q-tooltip>
|
||||
</q-btn>-->
|
||||
@@ -109,30 +118,33 @@
|
||||
color="grey"
|
||||
@click="showChart"
|
||||
>
|
||||
<q-tooltip>Show chart</q-tooltip>
|
||||
<q-tooltip
|
||||
>{% raw %}{{$t('chart_tooltip')}}{% endraw %}</q-tooltip
|
||||
>
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
<q-input
|
||||
v-if="payments.length > 10"
|
||||
filled
|
||||
dense
|
||||
clearable
|
||||
v-model="paymentsTable.filter"
|
||||
debounce="300"
|
||||
placeholder="Search by tag, memo, amount"
|
||||
:placeholder="$t('search_by_tag_memo_amount')"
|
||||
class="q-mb-md"
|
||||
>
|
||||
</q-input>
|
||||
<q-table
|
||||
dense
|
||||
flat
|
||||
:data="filteredPayments"
|
||||
:data="payments"
|
||||
:row-key="paymentTableRowKey"
|
||||
:columns="paymentsTable.columns"
|
||||
:pagination.sync="paymentsTable.pagination"
|
||||
no-data-label="No transactions made yet"
|
||||
:no-data-label="$t('no_transactions')"
|
||||
:filter="paymentsTable.filter"
|
||||
:loading="paymentsTable.loading"
|
||||
@request="fetchPayments"
|
||||
>
|
||||
{% raw %}
|
||||
<template v-slot:header="props">
|
||||
@@ -159,7 +171,7 @@
|
||||
color="grey"
|
||||
@click="props.expand = !props.expand"
|
||||
>
|
||||
<q-tooltip>Pending</q-tooltip>
|
||||
<q-tooltip>{{$t('pending')}}</q-tooltip>
|
||||
</q-icon>
|
||||
</q-td>
|
||||
<q-td
|
||||
@@ -181,14 +193,14 @@
|
||||
</q-badge>
|
||||
{{ props.row.memo }}
|
||||
</q-td>
|
||||
<q-td auto-width key="date" :props="props">
|
||||
<q-td auto-width key="time" :props="props">
|
||||
<q-tooltip>{{ props.row.date }}</q-tooltip>
|
||||
{{ props.row.dateFrom }}
|
||||
</q-td>
|
||||
{% endraw %}
|
||||
<q-td
|
||||
auto-width
|
||||
key="sat"
|
||||
key="amount"
|
||||
v-if="'{{LNBITS_DENOMINATION}}' != 'sats'"
|
||||
:props="props"
|
||||
>{% raw %} {{
|
||||
@@ -196,7 +208,7 @@
|
||||
}}
|
||||
</q-td>
|
||||
|
||||
<q-td auto-width key="sat" v-else :props="props">
|
||||
<q-td auto-width key="amount" v-else :props="props">
|
||||
{{ props.row.fsat }}
|
||||
</q-td>
|
||||
<q-td auto-width key="fee" :props="props">
|
||||
@@ -209,7 +221,7 @@
|
||||
<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>
|
||||
Invoice waiting to be paid
|
||||
<span v-text="$t('invoice_waiting')"></span>
|
||||
<lnbits-payment-details
|
||||
:payment="props.row"
|
||||
></lnbits-payment-details>
|
||||
@@ -232,15 +244,15 @@
|
||||
outline
|
||||
color="grey"
|
||||
@click="copyText(props.row.bolt11)"
|
||||
>Copy invoice</q-btn
|
||||
>
|
||||
:label="$t('copy_invoice')"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
v-close-popup
|
||||
flat
|
||||
color="grey"
|
||||
class="q-ml-auto"
|
||||
>Close</q-btn
|
||||
>
|
||||
:label="$t('close')"
|
||||
></q-btn>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="props.row.isPaid && props.row.isIn">
|
||||
@@ -249,7 +261,7 @@
|
||||
:name="'call_received'"
|
||||
:color="'green'"
|
||||
></q-icon>
|
||||
Payment Received
|
||||
<span v-text="$t('payment_received')"></span>
|
||||
<lnbits-payment-details
|
||||
:payment="props.row"
|
||||
></lnbits-payment-details>
|
||||
@@ -260,14 +272,14 @@
|
||||
:name="'call_made'"
|
||||
:color="'pink'"
|
||||
></q-icon>
|
||||
Payment Sent
|
||||
<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>
|
||||
Outgoing payment pending
|
||||
<span v-text="$t('outgoing_payment_pending')"></span>
|
||||
<lnbits-payment-details
|
||||
:payment="props.row"
|
||||
></lnbits-payment-details>
|
||||
@@ -304,26 +316,17 @@
|
||||
<q-expansion-item
|
||||
group="extras"
|
||||
icon="crop_free"
|
||||
label="Drain Funds"
|
||||
:label="$t('drain_funds')"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section class="text-center">
|
||||
<p>
|
||||
This is an LNURL-withdraw QR code for slurping
|
||||
everything from this wallet. Do not share with anyone.
|
||||
</p>
|
||||
<a href="lightning:{{wallet.lnurlwithdraw_full}}">
|
||||
<qrcode
|
||||
value="lightning:{{wallet.lnurlwithdraw_full}}"
|
||||
:options="{width:240}"
|
||||
></qrcode>
|
||||
</a>
|
||||
<p>
|
||||
It is compatible with <code>balanceCheck</code> and
|
||||
<code>balanceNotify</code> so your wallet may keep
|
||||
pulling the funds continuously from here after the first
|
||||
withdraw.
|
||||
</p>
|
||||
<p v-text="$('drain_funds_desc')"></p>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
@@ -333,15 +336,11 @@
|
||||
<q-expansion-item
|
||||
group="extras"
|
||||
icon="settings_cell"
|
||||
label="Export to Phone with QR Code"
|
||||
:label="$t('export_to_phone')"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section class="text-center">
|
||||
<p>
|
||||
This QR code contains your wallet URL with full access.
|
||||
You can scan it from your phone to open your wallet from
|
||||
there.
|
||||
</p>
|
||||
<p v-text="$t('export_to_phone_desc')"></p>
|
||||
<qrcode
|
||||
:value="'{{request.base_url}}' +'wallet?usr={{user.id}}&wal={{wallet.id}}'"
|
||||
:options="{width:240}"
|
||||
@@ -353,7 +352,7 @@
|
||||
<q-expansion-item
|
||||
group="extras"
|
||||
icon="edit"
|
||||
label="Rename wallet"
|
||||
:label="$t('rename_wallet')"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
@@ -371,9 +370,9 @@
|
||||
unelevated
|
||||
class="q-mt-sm"
|
||||
color="primary"
|
||||
:label="$t('update_name')"
|
||||
@click="updateWalletName()"
|
||||
>Update name</q-btn
|
||||
>
|
||||
></q-btn>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
@@ -381,20 +380,17 @@
|
||||
<q-expansion-item
|
||||
group="extras"
|
||||
icon="remove_circle"
|
||||
label="Delete wallet"
|
||||
:label="$t('delete_wallet')"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<p>
|
||||
This whole wallet will be deleted, the funds will be
|
||||
<strong>UNRECOVERABLE</strong>.
|
||||
</p>
|
||||
<p v-text="$t('delete_wallet_desc')"></p>
|
||||
<q-btn
|
||||
unelevated
|
||||
color="red-10"
|
||||
@click="deleteWallet('{{ wallet.id }}', '{{ user.id }}')"
|
||||
>Delete wallet</q-btn
|
||||
>
|
||||
:label="$t('delete_wallet')"
|
||||
></q-btn>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
@@ -445,7 +441,7 @@
|
||||
filled
|
||||
dense
|
||||
v-model.number="receive.data.amount"
|
||||
label="Amount ({{LNBITS_DENOMINATION}}) *"
|
||||
:label="$('amount')+' ({{LNBITS_DENOMINATION}}) *'"
|
||||
mask="#.##"
|
||||
fill-mask="0"
|
||||
reverse-fill-mask
|
||||
@@ -459,7 +455,7 @@
|
||||
dense
|
||||
v-model="receive.unit"
|
||||
type="text"
|
||||
label="Unit"
|
||||
:label="$t('unit')"
|
||||
:options="receive.units"
|
||||
></q-select>
|
||||
<q-input
|
||||
@@ -467,7 +463,7 @@
|
||||
filled
|
||||
dense
|
||||
v-model.number="receive.data.amount"
|
||||
:label="'Amount (' + receive.unit + ') *'"
|
||||
:label="$t('amount') + ' (' + receive.unit + ') *'"
|
||||
:mask="receive.unit != 'sat' ? '#.##' : '#'"
|
||||
fill-mask="0"
|
||||
reverse-fill-mask
|
||||
@@ -482,7 +478,7 @@
|
||||
filled
|
||||
dense
|
||||
v-model.trim="receive.data.memo"
|
||||
label="Memo"
|
||||
:label="$t('memo')"
|
||||
></q-input>
|
||||
{% raw %}
|
||||
<div v-if="receive.status == 'pending'" class="row q-mt-lg">
|
||||
@@ -493,13 +489,17 @@
|
||||
type="submit"
|
||||
>
|
||||
<span v-if="receive.lnurl">
|
||||
Withdraw from {{receive.lnurl.domain}}
|
||||
{{$t('withdraw_from')}} {{receive.lnurl.domain}}
|
||||
</span>
|
||||
<span v-else> Create invoice </span>
|
||||
<span v-else v-text="$t('create_invoice')"></span>
|
||||
</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-spinner
|
||||
v-if="receive.status == 'loading'"
|
||||
@@ -521,12 +521,19 @@
|
||||
</a>
|
||||
</div>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn outline color="grey" @click="copyText(receive.paymentReq)"
|
||||
>Copy invoice</q-btn
|
||||
>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
|
||||
>Close</q-btn
|
||||
>
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
@click="copyText(receive.paymentReq)"
|
||||
:label="$t('copy_invoice')"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
v-close-popup
|
||||
flat
|
||||
color="grey"
|
||||
class="q-ml-auto"
|
||||
:label="$t('close')"
|
||||
></q-btn>
|
||||
</div>
|
||||
</q-card>
|
||||
{% endraw %}
|
||||
@@ -545,24 +552,42 @@
|
||||
</h6>
|
||||
<q-separator class="q-my-sm"></q-separator>
|
||||
<p class="text-wrap">
|
||||
<strong>Description:</strong> {{ parse.invoice.description }}<br />
|
||||
<strong v-text="$t('description')">:</strong> {{
|
||||
parse.invoice.description }}<br />
|
||||
<strong>Expire date:</strong> {{ parse.invoice.expireDate }}<br />
|
||||
<strong>Hash:</strong> {{ parse.invoice.hash }}
|
||||
</p>
|
||||
{% endraw %}
|
||||
<div v-if="canPay" class="row q-mt-lg">
|
||||
<q-btn unelevated color="primary" @click="payInvoice">Pay</q-btn>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
|
||||
>Cancel</q-btn
|
||||
>
|
||||
<q-btn
|
||||
unelevated
|
||||
color="primary"
|
||||
@click="payInvoice"
|
||||
:label="$t('pay')"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
v-close-popup
|
||||
flat
|
||||
color="grey"
|
||||
class="q-ml-auto"
|
||||
:label="$t('cancel')"
|
||||
></q-btn>
|
||||
</div>
|
||||
<div v-else class="row q-mt-lg">
|
||||
<q-btn unelevated disabled color="yellow" text-color="black"
|
||||
>Not enough funds!</q-btn
|
||||
>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
|
||||
>Cancel</q-btn
|
||||
>
|
||||
<q-btn
|
||||
:label="$t('not_enough_funds')"
|
||||
unelevated
|
||||
disabled
|
||||
color="yellow"
|
||||
text-color="black"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
v-close-popup
|
||||
flat
|
||||
color="grey"
|
||||
class="q-ml-auto"
|
||||
:label="$t('cancel')"
|
||||
></q-btn>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="parse.lnurlauth">
|
||||
@@ -584,9 +609,13 @@
|
||||
</p>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn unelevated color="primary" type="submit">Login</q-btn>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
|
||||
>Cancel</q-btn
|
||||
>
|
||||
<q-btn
|
||||
:label="$t('cancel')"
|
||||
v-close-popup
|
||||
flat
|
||||
color="grey"
|
||||
class="q-ml-auto"
|
||||
></q-btn>
|
||||
</div>
|
||||
</q-form>
|
||||
{% endraw %}
|
||||
@@ -657,9 +686,13 @@
|
||||
<q-btn unelevated color="primary" type="submit"
|
||||
>Send {{LNBITS_DENOMINATION}}</q-btn
|
||||
>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
|
||||
>Cancel</q-btn
|
||||
>
|
||||
<q-btn
|
||||
:label="$t('cancel')"
|
||||
v-close-popup
|
||||
flat
|
||||
color="grey"
|
||||
class="q-ml-auto"
|
||||
></q-btn>
|
||||
</div>
|
||||
</q-form>
|
||||
{% endraw %}
|
||||
@@ -675,7 +708,7 @@
|
||||
dense
|
||||
v-model.trim="parse.data.request"
|
||||
type="textarea"
|
||||
label="Paste an invoice, payment request or lnurl code *"
|
||||
:label="$t('paste_invoice_label')"
|
||||
>
|
||||
</q-input>
|
||||
<div class="row q-mt-lg">
|
||||
@@ -684,11 +717,15 @@
|
||||
color="primary"
|
||||
:disable="parse.data.request == ''"
|
||||
type="submit"
|
||||
>Read</q-btn
|
||||
>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
|
||||
>Cancel</q-btn
|
||||
>
|
||||
:label="$t('read')"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
v-close-popup
|
||||
flat
|
||||
color="grey"
|
||||
class="q-ml-auto"
|
||||
:label="$t('cancel')"
|
||||
></q-btn>
|
||||
</div>
|
||||
</q-form>
|
||||
<div v-else>
|
||||
@@ -700,8 +737,13 @@
|
||||
></qrcode-stream>
|
||||
</q-responsive>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn @click="closeCamera" flat color="grey" class="q-ml-auto">
|
||||
Cancel
|
||||
<q-btn
|
||||
:label="$t('cancel')"
|
||||
@click="closeCamera"
|
||||
flat
|
||||
color="grey"
|
||||
class="q-ml-auto"
|
||||
>
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
@@ -719,9 +761,13 @@
|
||||
></qrcode-stream>
|
||||
</div>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn @click="closeCamera" flat color="grey" class="q-ml-auto"
|
||||
>Cancel</q-btn
|
||||
>
|
||||
<q-btn
|
||||
@click="closeCamera"
|
||||
flat
|
||||
color="grey"
|
||||
class="q-ml-auto"
|
||||
:label="$t('cancel')"
|
||||
></q-btn>
|
||||
</div>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
@@ -755,31 +801,21 @@
|
||||
<q-dialog v-model="disclaimerDialog.show">
|
||||
<q-card class="q-pa-lg">
|
||||
<h6 class="q-my-md text-primary">Warning</h6>
|
||||
<p>
|
||||
Login functionality to be released in a future update, for now,
|
||||
<strong
|
||||
>make sure you bookmark this page for future access to your
|
||||
wallet</strong
|
||||
>!
|
||||
</p>
|
||||
<p>
|
||||
This service is in BETA, and we hold no responsibility for people
|
||||
losing access to funds. {% if service_fee > 0 %} To encourage you to
|
||||
run your own LNbits installation, any balance on {% raw %}{{
|
||||
disclaimerDialog.location.host }}{% endraw %} will incur a charge of
|
||||
<strong>{{ service_fee }}% service fee</strong> per week. {% endif
|
||||
%}
|
||||
</p>
|
||||
<p v-text="$t('disclaimer_dialog')"></p>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
@click="copyText(disclaimerDialog.location.href)"
|
||||
>Copy wallet URL</q-btn
|
||||
>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
|
||||
>I understand</q-btn
|
||||
>
|
||||
:label="$t('copy_wallet_url')"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
v-close-popup
|
||||
flat
|
||||
color="grey"
|
||||
class="q-ml-auto"
|
||||
:label="$t('i_understand')"
|
||||
></q-btn>
|
||||
</div>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import os
|
||||
import time
|
||||
from http import HTTPStatus
|
||||
from shutil import make_archive
|
||||
from subprocess import Popen
|
||||
from typing import Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from fastapi import Body, Depends
|
||||
from fastapi.responses import FileResponse
|
||||
from starlette.exceptions import HTTPException
|
||||
|
||||
from lnbits.core.crud import get_wallet
|
||||
@@ -9,7 +15,7 @@ from lnbits.core.models import User
|
||||
from lnbits.core.services import update_cached_settings, update_wallet_balance
|
||||
from lnbits.decorators import check_admin, check_super_user
|
||||
from lnbits.server import server_restart
|
||||
from lnbits.settings import AdminSettings, EditableSettings
|
||||
from lnbits.settings import AdminSettings, EditableSettings, settings
|
||||
|
||||
from .. import core_app
|
||||
from ..crud import delete_admin_settings, get_admin_settings, update_admin_settings
|
||||
@@ -26,11 +32,14 @@ async def api_get_settings(
|
||||
@core_app.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: EditableSettings, 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())
|
||||
return {"status": "Success"}
|
||||
|
||||
|
||||
@@ -72,3 +81,43 @@ async def api_topup_balance(
|
||||
await update_wallet_balance(wallet_id=id, amount=int(amount))
|
||||
|
||||
return {"status": "Success"}
|
||||
|
||||
|
||||
@core_app.get(
|
||||
"/admin/api/v1/backup/",
|
||||
status_code=HTTPStatus.OK,
|
||||
dependencies=[Depends(check_super_user)],
|
||||
response_class=FileResponse,
|
||||
)
|
||||
async def api_download_backup() -> FileResponse:
|
||||
last_filename = "lnbits-backup"
|
||||
filename = f"lnbits-backup-{int(time.time())}.zip"
|
||||
db_url = settings.lnbits_database_url
|
||||
pg_backup_filename = f"{settings.lnbits_data_folder}/lnbits-database.dmp"
|
||||
is_pg = db_url and db_url.startswith("postgres://")
|
||||
|
||||
if is_pg:
|
||||
p = urlparse(db_url)
|
||||
command = (
|
||||
f"pg_dump --host={p.hostname} "
|
||||
f'--dbname={p.path.replace("/", "")} '
|
||||
f"--username={p.username} "
|
||||
f"--no-password "
|
||||
f"--format=c "
|
||||
f"--file={pg_backup_filename}"
|
||||
)
|
||||
proc = Popen(
|
||||
command, shell=True, env={**os.environ, "PGPASSWORD": p.password or ""}
|
||||
)
|
||||
proc.wait()
|
||||
|
||||
make_archive(last_filename, "zip", settings.lnbits_data_folder)
|
||||
|
||||
# cleanup pg_dump file
|
||||
if is_pg:
|
||||
proc = Popen(f"rm {pg_backup_filename}", shell=True)
|
||||
proc.wait()
|
||||
|
||||
return FileResponse(
|
||||
path=f"{last_filename}.zip", filename=filename, media_type="application/zip"
|
||||
)
|
||||
|
||||
+185
-25
@@ -5,7 +5,7 @@ import time
|
||||
import uuid
|
||||
from http import HTTPStatus
|
||||
from io import BytesIO
|
||||
from typing import Dict, Optional, Tuple, Union
|
||||
from typing import Dict, List, Optional, Tuple, Union
|
||||
from urllib.parse import ParseResult, parse_qs, urlencode, urlparse, urlunparse
|
||||
|
||||
import async_timeout
|
||||
@@ -29,15 +29,28 @@ from sse_starlette.sse import EventSourceResponse
|
||||
from starlette.responses import RedirectResponse, StreamingResponse
|
||||
|
||||
from lnbits import bolt11, lnurl
|
||||
from lnbits.core.models import Payment, Wallet
|
||||
from lnbits.core.helpers import (
|
||||
migrate_extension_database,
|
||||
stop_extension_background_work,
|
||||
)
|
||||
from lnbits.core.models import Payment, PaymentFilters, User, Wallet
|
||||
from lnbits.db import Filters, Page
|
||||
from lnbits.decorators import (
|
||||
WalletTypeInfo,
|
||||
check_admin,
|
||||
get_key_type,
|
||||
parse_filters,
|
||||
require_admin_key,
|
||||
require_invoice_key,
|
||||
)
|
||||
from lnbits.helpers import url_for
|
||||
from lnbits.extension_manager import (
|
||||
CreateExtension,
|
||||
Extension,
|
||||
ExtensionRelease,
|
||||
InstallableExtension,
|
||||
get_valid_extensions,
|
||||
)
|
||||
from lnbits.helpers import generate_filter_params_openapi, url_for
|
||||
from lnbits.settings import get_wallet_class, settings
|
||||
from lnbits.utils.exchange_rates import (
|
||||
currencies,
|
||||
@@ -45,11 +58,15 @@ from lnbits.utils.exchange_rates import (
|
||||
satoshis_amount_as_fiat,
|
||||
)
|
||||
|
||||
from .. import core_app, db
|
||||
from .. import core_app, core_app_extra, db
|
||||
from ..crud import (
|
||||
add_installed_extension,
|
||||
create_tinyurl,
|
||||
delete_installed_extension,
|
||||
delete_tinyurl,
|
||||
get_dbversions,
|
||||
get_payments,
|
||||
get_payments_paginated,
|
||||
get_standalone_payment,
|
||||
get_tinyurl,
|
||||
get_tinyurl_by_url,
|
||||
@@ -100,20 +117,25 @@ async def api_update_wallet(
|
||||
}
|
||||
|
||||
|
||||
@core_app.get("/api/v1/payments")
|
||||
@core_app.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(
|
||||
limit: Optional[int] = None,
|
||||
offset: Optional[int] = None,
|
||||
wallet: WalletTypeInfo = Depends(get_key_type),
|
||||
filters: Filters = Depends(parse_filters(PaymentFilters)),
|
||||
):
|
||||
pendingPayments = await get_payments(
|
||||
pending_payments = await get_payments(
|
||||
wallet_id=wallet.wallet.id,
|
||||
pending=True,
|
||||
exclude_uncheckable=True,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
filters=filters,
|
||||
)
|
||||
for payment in pendingPayments:
|
||||
for payment in pending_payments:
|
||||
await check_transaction_status(
|
||||
wallet_id=payment.wallet_id, payment_hash=payment.payment_hash
|
||||
)
|
||||
@@ -121,11 +143,41 @@ async def api_payments(
|
||||
wallet_id=wallet.wallet.id,
|
||||
pending=True,
|
||||
complete=True,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
filters=filters,
|
||||
)
|
||||
|
||||
|
||||
@core_app.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)),
|
||||
):
|
||||
pending = await get_payments_paginated(
|
||||
wallet_id=wallet.wallet.id,
|
||||
pending=True,
|
||||
exclude_uncheckable=True,
|
||||
filters=filters,
|
||||
)
|
||||
for payment in pending.data:
|
||||
await check_transaction_status(
|
||||
wallet_id=payment.wallet_id, payment_hash=payment.payment_hash
|
||||
)
|
||||
page = await get_payments_paginated(
|
||||
wallet_id=wallet.wallet.id,
|
||||
pending=True,
|
||||
complete=True,
|
||||
filters=filters,
|
||||
)
|
||||
return page
|
||||
|
||||
|
||||
class CreateInvoiceData(BaseModel):
|
||||
out: Optional[bool] = True
|
||||
amount: float = Query(None, ge=0)
|
||||
@@ -173,7 +225,7 @@ async def api_payments_create_invoice(data: CreateInvoiceData, wallet: Wallet):
|
||||
|
||||
async with db.connect() as conn:
|
||||
try:
|
||||
payment_hash, payment_request = await create_invoice(
|
||||
_, payment_request = await create_invoice(
|
||||
wallet_id=wallet.id,
|
||||
amount=amount,
|
||||
memo=memo,
|
||||
@@ -376,7 +428,7 @@ async def subscribe_wallet_invoices(request: Request, wallet: Wallet):
|
||||
async with async_timeout.timeout(1):
|
||||
payment: Payment = await payment_queue.get()
|
||||
if payment.wallet_id == this_wallet_id:
|
||||
logger.debug("sse listener: payment receieved", payment)
|
||||
logger.debug("sse listener: payment received", payment)
|
||||
await send_queue.put(("payment-received", payment))
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
@@ -391,8 +443,7 @@ async def subscribe_wallet_invoices(request: Request, wallet: Wallet):
|
||||
typ, data = await send_queue.get()
|
||||
if data:
|
||||
jdata = json.dumps(dict(data.dict(), pending=False))
|
||||
|
||||
yield dict(data=jdata, event=typ)
|
||||
yield dict(data=jdata, event=typ)
|
||||
except asyncio.CancelledError:
|
||||
logger.debug(f"removing listener for wallet {uid}")
|
||||
api_invoice_listeners.pop(uid)
|
||||
@@ -411,11 +462,12 @@ async def api_payments_sse(
|
||||
)
|
||||
|
||||
|
||||
# TODO: refactor this route into a public and admin one
|
||||
@core_app.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
|
||||
wallet = await get_wallet_for_key(X_Api_Key) if type(X_Api_Key) == str else None # type: ignore
|
||||
|
||||
# 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
|
||||
@@ -485,6 +537,7 @@ 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:
|
||||
@@ -549,10 +602,10 @@ async def api_lnurlscan(code: str, wallet: WalletTypeInfo = Depends(get_key_type
|
||||
for [k, v] in metadata:
|
||||
if k == "text/plain":
|
||||
params.update(description=v)
|
||||
if k == "image/jpeg;base64" or k == "image/png;base64":
|
||||
data_uri = "data:" + k + "," + v
|
||||
if k in ("image/jpeg;base64", "image/png;base64"):
|
||||
data_uri = f"data:{k},{v}"
|
||||
params.update(image=data_uri)
|
||||
if k == "text/email" or k == "text/identifier":
|
||||
if k in ("text/email", "text/identifier"):
|
||||
params.update(targetUser=v)
|
||||
params.update(commentAllowed=data.get("commentAllowed", 0))
|
||||
|
||||
@@ -673,7 +726,7 @@ async def api_auditor():
|
||||
if not error_message:
|
||||
delta = node_balance - total_balance
|
||||
else:
|
||||
node_balance, delta = None, None
|
||||
node_balance, delta = 0, 0
|
||||
|
||||
return {
|
||||
"node_balance_msats": int(node_balance),
|
||||
@@ -683,7 +736,7 @@ async def api_auditor():
|
||||
}
|
||||
|
||||
|
||||
##################UNIVERSAL WEBSOCKET MANAGER########################
|
||||
# UNIVERSAL WEBSOCKET MANAGER
|
||||
|
||||
|
||||
@core_app.websocket("/api/v1/ws/{item_id}")
|
||||
@@ -691,7 +744,7 @@ async def websocket_connect(websocket: WebSocket, item_id: str):
|
||||
await websocketManager.connect(websocket)
|
||||
try:
|
||||
while True:
|
||||
data = await websocket.receive_text()
|
||||
await websocket.receive_text()
|
||||
except WebSocketDisconnect:
|
||||
websocketManager.disconnect(websocket)
|
||||
|
||||
@@ -714,7 +767,114 @@ async def websocket_update_get(item_id: str, data: str):
|
||||
return {"sent": False, "data": data}
|
||||
|
||||
|
||||
############################TINYURL##################################
|
||||
@core_app.post("/api/v1/extension")
|
||||
async def api_install_extension(
|
||||
data: CreateExtension, user: User = Depends(check_admin)
|
||||
):
|
||||
release = await InstallableExtension.get_extension_release(
|
||||
data.ext_id, data.source_repo, data.archive
|
||||
)
|
||||
if not release:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Release not found"
|
||||
)
|
||||
|
||||
ext_info = InstallableExtension(
|
||||
id=data.ext_id, name=data.ext_id, installed_release=release, icon=release.icon
|
||||
)
|
||||
|
||||
ext_info.download_archive()
|
||||
|
||||
try:
|
||||
ext_info.extract_archive()
|
||||
|
||||
extension = Extension.from_installable_ext(ext_info)
|
||||
|
||||
db_version = (await get_dbversions()).get(data.ext_id, 0)
|
||||
await migrate_extension_database(extension, db_version)
|
||||
|
||||
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)
|
||||
|
||||
if data.ext_id not in settings.lnbits_deactivated_extensions:
|
||||
settings.lnbits_deactivated_extensions += [data.ext_id]
|
||||
|
||||
# mount routes for the new version
|
||||
core_app_extra.register_new_ext_routes(extension)
|
||||
|
||||
if extension.upgrade_hash:
|
||||
ext_info.nofiy_upgrade()
|
||||
|
||||
return extension
|
||||
|
||||
except Exception as ex:
|
||||
logger.warning(ex)
|
||||
ext_info.clean_extension_files()
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to install extension.",
|
||||
)
|
||||
|
||||
|
||||
@core_app.delete("/api/v1/extension/{ext_id}")
|
||||
async def api_uninstall_extension(ext_id: str, user: User = Depends(check_admin)):
|
||||
installable_extensions = await InstallableExtension.get_installable_extensions()
|
||||
|
||||
extensions = [e for e in installable_extensions if e.id == ext_id]
|
||||
if len(extensions) == 0:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail=f"Unknown extension id: {ext_id}",
|
||||
)
|
||||
|
||||
# check that other extensions do not depend on this one
|
||||
for valid_ext_id in list(map(lambda e: e.code, get_valid_extensions())):
|
||||
installed_ext = next(
|
||||
(ext for ext in installable_extensions if ext.id == valid_ext_id), None
|
||||
)
|
||||
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.",
|
||||
)
|
||||
|
||||
try:
|
||||
# call stop while the old routes are still active
|
||||
await stop_extension_background_work(ext_id, user.id)
|
||||
|
||||
if ext_id not in settings.lnbits_deactivated_extensions:
|
||||
settings.lnbits_deactivated_extensions += [ext_id]
|
||||
|
||||
for ext_info in extensions:
|
||||
ext_info.clean_extension_files()
|
||||
await delete_installed_extension(ext_id=ext_info.id)
|
||||
|
||||
except Exception as ex:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(ex)
|
||||
)
|
||||
|
||||
|
||||
@core_app.get(
|
||||
"/api/v1/extension/{ext_id}/releases", dependencies=[Depends(check_admin)]
|
||||
)
|
||||
async def get_extension_releases(ext_id: str):
|
||||
try:
|
||||
extension_releases: List[
|
||||
ExtensionRelease
|
||||
] = await InstallableExtension.get_extension_releases(ext_id)
|
||||
|
||||
return extension_releases
|
||||
|
||||
except Exception as ex:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(ex)
|
||||
)
|
||||
|
||||
|
||||
# TINYURL
|
||||
|
||||
|
||||
@core_app.post("/api/v1/tinyurl")
|
||||
|
||||
+159
-47
@@ -1,6 +1,6 @@
|
||||
import asyncio
|
||||
from http import HTTPStatus
|
||||
from typing import Optional
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import Depends, Query, Request, status
|
||||
from fastapi.exceptions import HTTPException
|
||||
@@ -11,19 +11,23 @@ from pydantic.types import UUID4
|
||||
from starlette.responses import HTMLResponse, JSONResponse
|
||||
|
||||
from lnbits.core import db
|
||||
from lnbits.core.helpers import to_valid_user_id
|
||||
from lnbits.core.models import User
|
||||
from lnbits.decorators import check_admin, check_user_exists
|
||||
from lnbits.helpers import template_renderer, url_for
|
||||
from lnbits.settings import get_wallet_class, settings
|
||||
|
||||
from ...helpers import get_valid_extensions
|
||||
from ...extension_manager import InstallableExtension, get_valid_extensions
|
||||
from ..crud import (
|
||||
create_account,
|
||||
create_wallet,
|
||||
delete_wallet,
|
||||
get_balance_check,
|
||||
get_inactive_extensions,
|
||||
get_installed_extensions,
|
||||
get_user,
|
||||
save_balance_notify,
|
||||
update_installed_extension_state,
|
||||
update_user_extension,
|
||||
)
|
||||
from ..services import pay_invoice, redeem_lnurl_withdraw
|
||||
@@ -61,42 +65,106 @@ async def extensions(
|
||||
enable: str = Query(None),
|
||||
disable: str = Query(None),
|
||||
):
|
||||
extension_to_enable = enable
|
||||
extension_to_disable = disable
|
||||
|
||||
if extension_to_enable and extension_to_disable:
|
||||
raise HTTPException(
|
||||
HTTPStatus.BAD_REQUEST, "You can either `enable` or `disable` an extension."
|
||||
)
|
||||
|
||||
# check if extension exists
|
||||
if extension_to_enable or extension_to_disable:
|
||||
ext = extension_to_enable or extension_to_disable
|
||||
if ext not in [e.code for e in get_valid_extensions()]:
|
||||
raise HTTPException(
|
||||
HTTPStatus.BAD_REQUEST, f"Extension '{ext}' doesn't exist."
|
||||
)
|
||||
|
||||
if extension_to_enable:
|
||||
logger.info(f"Enabling extension: {extension_to_enable} for user {user.id}")
|
||||
await update_user_extension(
|
||||
user_id=user.id, extension=extension_to_enable, active=True
|
||||
)
|
||||
elif extension_to_disable:
|
||||
logger.info(f"Disabling extension: {extension_to_disable} for user {user.id}")
|
||||
await update_user_extension(
|
||||
user_id=user.id, extension=extension_to_disable, active=False
|
||||
)
|
||||
await toggle_extension(enable, disable, user.id)
|
||||
|
||||
# Update user as his extensions have been updated
|
||||
if extension_to_enable or extension_to_disable:
|
||||
user = await get_user(user.id) # type: ignore
|
||||
if enable or disable:
|
||||
updated_user = await get_user(user.id)
|
||||
assert updated_user, "User does not exist."
|
||||
user = updated_user
|
||||
|
||||
return template_renderer().TemplateResponse(
|
||||
"core/extensions.html", {"request": request, "user": user.dict()}
|
||||
)
|
||||
|
||||
|
||||
@core_html_routes.get(
|
||||
"/install", name="install.extensions", response_class=HTMLResponse
|
||||
)
|
||||
async def extensions_install(
|
||||
request: Request,
|
||||
user: User = Depends(check_user_exists),
|
||||
activate: str = Query(None),
|
||||
deactivate: str = Query(None),
|
||||
):
|
||||
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 += [
|
||||
e for e in installed_exts if e.id not in installed_exts_ids
|
||||
]
|
||||
|
||||
for e in installable_exts:
|
||||
installed_ext = next((ie for ie in installed_exts if e.id == ie.id), None)
|
||||
if installed_ext:
|
||||
e.installed_release = installed_ext.installed_release
|
||||
# use the installed extension values
|
||||
e.name = installed_ext.name
|
||||
e.short_description = installed_ext.short_description
|
||||
e.icon = installed_ext.icon
|
||||
|
||||
except Exception as ex:
|
||||
logger.warning(ex)
|
||||
installable_exts = []
|
||||
|
||||
try:
|
||||
ext_id = activate or deactivate
|
||||
if ext_id and user.admin:
|
||||
if deactivate and deactivate not in settings.lnbits_deactivated_extensions:
|
||||
settings.lnbits_deactivated_extensions += [deactivate]
|
||||
elif activate:
|
||||
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()))
|
||||
inactive_extensions = await get_inactive_extensions()
|
||||
extensions = list(
|
||||
map(
|
||||
lambda ext: {
|
||||
"id": ext.id,
|
||||
"name": ext.name,
|
||||
"icon": ext.icon,
|
||||
"shortDescription": ext.short_description,
|
||||
"stars": ext.stars,
|
||||
"isFeatured": ext.featured,
|
||||
"dependencies": ext.dependencies,
|
||||
"isInstalled": ext.id in installed_exts_ids,
|
||||
"isAvailable": ext.id in all_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,
|
||||
},
|
||||
installable_exts,
|
||||
)
|
||||
)
|
||||
|
||||
return template_renderer().TemplateResponse(
|
||||
"core/install.html",
|
||||
{
|
||||
"request": request,
|
||||
"user": user.dict(),
|
||||
"extensions": extensions,
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(e)
|
||||
raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e))
|
||||
|
||||
|
||||
@core_html_routes.get(
|
||||
"/wallet",
|
||||
response_class=HTMLResponse,
|
||||
@@ -121,8 +189,10 @@ async def wallet(
|
||||
wallet_name = nme
|
||||
|
||||
if not user_id:
|
||||
user = await get_user((await create_account()).id)
|
||||
logger.info(f"Create user {user.id}") # type: ignore
|
||||
new_user = await create_account()
|
||||
user = await get_user(new_user.id)
|
||||
assert user, "Newly created user has to exist."
|
||||
logger.info(f"Create user {user.id}")
|
||||
else:
|
||||
user = await get_user(user_id)
|
||||
if not user:
|
||||
@@ -144,23 +214,23 @@ async def wallet(
|
||||
user.super_user = True
|
||||
|
||||
if not wallet_id:
|
||||
if user.wallets and not wallet_name: # type: ignore
|
||||
wallet = user.wallets[0] # type: ignore
|
||||
if user.wallets and not wallet_name:
|
||||
wallet = user.wallets[0]
|
||||
else:
|
||||
wallet = await create_wallet(user_id=user.id, wallet_name=wallet_name) # type: ignore
|
||||
wallet = await create_wallet(user_id=user.id, wallet_name=wallet_name)
|
||||
logger.info(
|
||||
f"Created new wallet {wallet_name if wallet_name else '(no name)'} for user {user.id}" # type: ignore
|
||||
f"Created new wallet {wallet_name if wallet_name else '(no name)'} for user {user.id}"
|
||||
)
|
||||
|
||||
return RedirectResponse(
|
||||
f"/wallet?usr={user.id}&wal={wallet.id}", # type: ignore
|
||||
f"/wallet?usr={user.id}&wal={wallet.id}",
|
||||
status_code=status.HTTP_307_TEMPORARY_REDIRECT,
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f"Access {'user '+ user.id + ' ' if user else ''} {'wallet ' + wallet_name if wallet_name else ''}"
|
||||
)
|
||||
userwallet = user.get_wallet(wallet_id) # type: ignore
|
||||
userwallet = user.get_wallet(wallet_id)
|
||||
if not userwallet:
|
||||
return template_renderer().TemplateResponse(
|
||||
"error.html", {"request": request, "err": "Wallet not found"}
|
||||
@@ -170,10 +240,10 @@ async def wallet(
|
||||
"core/wallet.html",
|
||||
{
|
||||
"request": request,
|
||||
"user": user.dict(), # type: ignore
|
||||
"user": user.dict(),
|
||||
"wallet": userwallet.dict(),
|
||||
"service_fee": settings.lnbits_service_fee,
|
||||
"web_manifest": f"/manifest/{user.id}.webmanifest", # type: ignore
|
||||
"web_manifest": f"/manifest/{user.id}.webmanifest",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -229,18 +299,21 @@ async def lnurl_full_withdraw_callback(request: Request):
|
||||
@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 not user:
|
||||
raise HTTPException(HTTPStatus.FORBIDDEN, "User not found.")
|
||||
|
||||
user_wallet_ids = [u.id for u in user.wallets]
|
||||
|
||||
if wal not in user_wallet_ids:
|
||||
raise HTTPException(HTTPStatus.FORBIDDEN, "Not your wallet.")
|
||||
else:
|
||||
await delete_wallet(user_id=user.id, wallet_id=wal) # type: ignore
|
||||
await delete_wallet(user_id=user.id, wallet_id=wal)
|
||||
user_wallet_ids.remove(wal)
|
||||
logger.debug("Deleted wallet {wal} of user {user.id}")
|
||||
|
||||
if user_wallet_ids:
|
||||
return RedirectResponse(
|
||||
url_for("/wallet", usr=user.id, wal=user_wallet_ids[0]), # type: ignore
|
||||
url_for("/wallet", usr=user.id, wal=user_wallet_ids[0]),
|
||||
status_code=status.HTTP_307_TEMPORARY_REDIRECT,
|
||||
)
|
||||
|
||||
@@ -263,7 +336,8 @@ 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)
|
||||
|
||||
asyncio.create_task(
|
||||
redeem_lnurl_withdraw(
|
||||
@@ -276,7 +350,7 @@ 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,
|
||||
)
|
||||
|
||||
@@ -304,7 +378,7 @@ async def manifest(usr: str):
|
||||
"sizes": "900x900",
|
||||
}
|
||||
],
|
||||
"start_url": "/wallet?usr=" + usr + "&wal=" + user.wallets[0].id,
|
||||
"start_url": f"/wallet?usr={usr}&wal={user.wallets[0].id}",
|
||||
"background_color": "#1F2234",
|
||||
"description": "Bitcoin Lightning Wallet",
|
||||
"display": "standalone",
|
||||
@@ -315,7 +389,7 @@ 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}",
|
||||
}
|
||||
for wallet in user.wallets
|
||||
],
|
||||
@@ -324,6 +398,9 @@ async def manifest(usr: str):
|
||||
|
||||
@core_html_routes.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)
|
||||
|
||||
WALLET = get_wallet_class()
|
||||
_, balance = await WALLET.status()
|
||||
|
||||
@@ -336,3 +413,38 @@ async def index(request: Request, user: User = Depends(check_admin)):
|
||||
"balance": balance,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@core_html_routes.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(
|
||||
HTTPStatus.BAD_REQUEST, "You can either `enable` or `disable` an extension."
|
||||
)
|
||||
|
||||
# check if extension exists
|
||||
if extension_to_enable or extension_to_disable:
|
||||
ext = extension_to_enable or extension_to_disable
|
||||
if ext not in [e.code for e in get_valid_extensions()]:
|
||||
raise HTTPException(
|
||||
HTTPStatus.BAD_REQUEST, f"Extension '{ext}' doesn't exist."
|
||||
)
|
||||
|
||||
if extension_to_enable:
|
||||
logger.info(f"Enabling extension: {extension_to_enable} for user {user_id}")
|
||||
await update_user_extension(
|
||||
user_id=user_id, extension=extension_to_enable, active=True
|
||||
)
|
||||
elif extension_to_disable:
|
||||
logger.info(f"Disabling extension: {extension_to_disable} for user {user_id}")
|
||||
await update_user_extension(
|
||||
user_id=user_id, extension=extension_to_disable, active=False
|
||||
)
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import asyncio
|
||||
import datetime
|
||||
from http import HTTPStatus
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from fastapi import HTTPException
|
||||
from loguru import logger
|
||||
from starlette.requests import Request
|
||||
|
||||
from lnbits import bolt11
|
||||
|
||||
@@ -14,14 +12,6 @@ from ..crud import get_standalone_payment
|
||||
from ..tasks import api_invoice_listeners
|
||||
|
||||
|
||||
@core_app.get("/.well-known/lnurlp/{username}")
|
||||
async def lnaddress(username: str, request: Request):
|
||||
from lnbits.extensions.lnaddress.lnurl import lnurl_response # type: ignore
|
||||
|
||||
domain = urlparse(str(request.url)).netloc
|
||||
return await lnurl_response(username, domain, request)
|
||||
|
||||
|
||||
@core_app.get("/public/v1/payment/{payment_hash}")
|
||||
async def api_public_payment_longpolling(payment_hash):
|
||||
payment = await get_standalone_payment(payment_hash)
|
||||
@@ -50,19 +40,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
|
||||
|
||||
+319
-64
@@ -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
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy_aio.base import AsyncConnection
|
||||
from sqlalchemy_aio.strategy import ASYNCIO_STRATEGY
|
||||
@@ -17,6 +22,51 @@ 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(
|
||||
(1082, 1083, 1266),
|
||||
"DATE2INT",
|
||||
lambda value, curs: time.mktime(value.timetuple())
|
||||
if value is not None
|
||||
else None,
|
||||
)
|
||||
)
|
||||
|
||||
register_type(new_type((1184, 1114), "TIMESTAMP2INT", _parse_timestamp))
|
||||
else:
|
||||
if os.path.isdir(settings.lnbits_data_folder):
|
||||
DB_TYPE = SQLITE
|
||||
else:
|
||||
raise NotADirectoryError(
|
||||
f"LNBITS_DATA_FOLDER named {settings.lnbits_data_folder} was not created"
|
||||
f" - please 'mkdir {settings.lnbits_data_folder}' and try again"
|
||||
)
|
||||
|
||||
|
||||
class Compat:
|
||||
type: Optional[str] = "<inherited>"
|
||||
@@ -66,6 +116,16 @@ class Compat:
|
||||
return "BIGINT"
|
||||
return "INT"
|
||||
|
||||
@classmethod
|
||||
@property
|
||||
def timestamp_placeholder(cls):
|
||||
if DB_TYPE == POSTGRES:
|
||||
return "to_timestamp(?)"
|
||||
elif DB_TYPE == COCKROACH:
|
||||
return "cast(? AS timestamp)"
|
||||
else:
|
||||
return "?"
|
||||
|
||||
|
||||
class Connection(Compat):
|
||||
def __init__(self, conn: AsyncConnection, txn, typ, name, schema):
|
||||
@@ -85,17 +145,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 +175,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 +229,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 +248,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 +284,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 +302,180 @@ class Database(Compat):
|
||||
@asynccontextmanager
|
||||
async def reuse_conn(self, conn: Connection):
|
||||
yield conn
|
||||
|
||||
|
||||
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] = []
|
||||
|
||||
|
||||
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
|
||||
nested: Optional[List[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_names = split[0].split(".")
|
||||
op = Operator(split[1])
|
||||
else:
|
||||
field_names = key.split(".")
|
||||
op = Operator("eq")
|
||||
|
||||
field = field_names[0]
|
||||
nested = field_names[1:]
|
||||
|
||||
if field in model.__fields__:
|
||||
compare_field = model.__fields__[field]
|
||||
values = []
|
||||
for raw_value in raw_values:
|
||||
# If there is a nested field, pydantic expects a dict, so the raw value is turned into a dict before
|
||||
# and the converted value is extracted afterwards
|
||||
for name in reversed(nested):
|
||||
raw_value = {name: raw_value}
|
||||
|
||||
validated, errors = compare_field.validate(raw_value, {}, loc="none")
|
||||
if errors:
|
||||
raise ValidationError(errors=[errors], model=model)
|
||||
|
||||
for name in nested:
|
||||
if isinstance(validated, dict):
|
||||
validated = validated[name]
|
||||
else:
|
||||
validated = getattr(validated, name)
|
||||
|
||||
values.append(validated)
|
||||
else:
|
||||
raise ValueError("Unknown filter field")
|
||||
|
||||
return cls(field=field, op=op, nested=nested, values=values, model=model)
|
||||
|
||||
@property
|
||||
def statement(self):
|
||||
accessor = self.field
|
||||
if self.nested:
|
||||
for name in self.nested:
|
||||
accessor = f"({accessor} ->> '{name}')"
|
||||
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"{accessor} {self.op.as_sql} ({placeholders})"]
|
||||
else:
|
||||
stmt = [f"{accessor} {self.op.as_sql} {placeholder}"] * len(self.values)
|
||||
return " OR ".join(stmt)
|
||||
|
||||
|
||||
class Filters(BaseModel, Generic[TFilterModel]):
|
||||
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
|
||||
|
||||
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({f', '.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)
|
||||
|
||||
+55
-11
@@ -1,22 +1,27 @@
|
||||
from http import HTTPStatus
|
||||
from typing import Literal, Optional, Type
|
||||
|
||||
from fastapi import Security, status
|
||||
from fastapi import Query, Request, Security, status
|
||||
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
|
||||
from fastapi.security.base import SecurityBase
|
||||
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.db import Filter, Filters, TFilterModel
|
||||
from lnbits.requestvars import g
|
||||
from lnbits.settings import settings
|
||||
|
||||
|
||||
# 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
|
||||
@@ -24,13 +29,13 @@ class KeyChecker(SecurityBase):
|
||||
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",
|
||||
)
|
||||
@@ -70,7 +75,10 @@ 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"
|
||||
@@ -86,7 +94,10 @@ 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"
|
||||
@@ -174,7 +185,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:
|
||||
@@ -200,7 +210,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:
|
||||
@@ -245,7 +254,7 @@ async def check_user_exists(usr: UUID4) -> User:
|
||||
|
||||
async def check_admin(usr: UUID4) -> User:
|
||||
user = await check_user_exists(usr)
|
||||
if user.id != settings.super_user and not user.id in settings.lnbits_admin_users:
|
||||
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.",
|
||||
@@ -266,3 +275,38 @@ async def check_super_user(usr: UUID4) -> User:
|
||||
detail="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
|
||||
|
||||
@@ -0,0 +1,534 @@
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
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 import HTTPException
|
||||
from loguru import logger
|
||||
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] = []
|
||||
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 = ""
|
||||
|
||||
|
||||
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 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 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
|
||||
is_admin_only: bool
|
||||
name: Optional[str] = None
|
||||
short_description: Optional[str] = None
|
||||
tile: Optional[str] = None
|
||||
contributors: Optional[List[str]] = None
|
||||
hidden: bool = False
|
||||
migration_module: Optional[str] = None
|
||||
db_name: Optional[str] = None
|
||||
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}"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_installable_ext(cls, ext_info: "InstallableExtension") -> "Extension":
|
||||
return Extension(
|
||||
code=ext_info.id,
|
||||
is_valid=True,
|
||||
is_admin_only=False, # todo: is admin only
|
||||
name=ext_info.name,
|
||||
upgrade_hash=ext_info.hash if ext_info.module_installed else "",
|
||||
)
|
||||
|
||||
|
||||
# All subdirectories in the current directory, not recursive.
|
||||
|
||||
|
||||
class ExtensionManager:
|
||||
def __init__(self):
|
||||
p = Path(settings.lnbits_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
|
||||
def extensions(self) -> List[Extension]:
|
||||
output: List[Extension] = []
|
||||
|
||||
for extension_folder in self._extension_folders:
|
||||
extension_code = extension_folder.parts[-1]
|
||||
try:
|
||||
with open(extension_folder / "config.json") as json_file:
|
||||
config = json.load(json_file)
|
||||
is_valid = True
|
||||
is_admin_only = extension_code in settings.lnbits_admin_extensions
|
||||
except Exception:
|
||||
config = {}
|
||||
is_valid = False
|
||||
is_admin_only = False
|
||||
|
||||
output.append(
|
||||
Extension(
|
||||
extension_code,
|
||||
is_valid,
|
||||
is_admin_only,
|
||||
config.get("name"),
|
||||
config.get("short_description"),
|
||||
config.get("tile"),
|
||||
config.get("contributors"),
|
||||
config.get("hidden") or False,
|
||||
config.get("migration_module"),
|
||||
config.get("db_name"),
|
||||
)
|
||||
)
|
||||
|
||||
return output
|
||||
|
||||
|
||||
class ExtensionRelease(BaseModel):
|
||||
name: str
|
||||
version: str
|
||||
archive: str
|
||||
source_repo: str
|
||||
is_github_release: bool = False
|
||||
hash: Optional[str] = None
|
||||
html_url: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
details_html: Optional[str] = None
|
||||
icon: Optional[str] = None
|
||||
|
||||
@classmethod
|
||||
def from_github_release(
|
||||
cls, source_repo: str, r: "GitHubRepoRelease"
|
||||
) -> "ExtensionRelease":
|
||||
return ExtensionRelease(
|
||||
name=r.name,
|
||||
description=r.name,
|
||||
version=r.tag_name,
|
||||
archive=r.zipball_url,
|
||||
source_repo=source_repo,
|
||||
is_github_release=True,
|
||||
# description=r.body, # bad for JSON
|
||||
html_url=r.html_url,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def all_releases(cls, org: str, repo: str) -> List["ExtensionRelease"]:
|
||||
try:
|
||||
github_releases = await fetch_github_releases(org, repo)
|
||||
return [
|
||||
ExtensionRelease.from_github_release(f"{org}/{repo}", r)
|
||||
for r in github_releases
|
||||
]
|
||||
except Exception as e:
|
||||
logger.warning(e)
|
||||
return []
|
||||
|
||||
|
||||
class InstallableExtension(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
short_description: Optional[str] = None
|
||||
icon: Optional[str] = None
|
||||
dependencies: List[str] = []
|
||||
is_admin_only: bool = False
|
||||
stars: int = 0
|
||||
featured = False
|
||||
latest_release: Optional[ExtensionRelease] = None
|
||||
installed_release: Optional[ExtensionRelease] = None
|
||||
archive: Optional[str] = None
|
||||
|
||||
@property
|
||||
def hash(self) -> str:
|
||||
if self.installed_release:
|
||||
if self.installed_release.hash:
|
||||
return self.installed_release.hash
|
||||
m = hashlib.sha256()
|
||||
m.update(f"{self.installed_release.archive}".encode())
|
||||
return m.hexdigest()
|
||||
return "not-installed"
|
||||
|
||||
@property
|
||||
def zip_path(self) -> Path:
|
||||
extensions_data_dir = Path(settings.lnbits_data_folder, "extensions")
|
||||
Path(extensions_data_dir).mkdir(parents=True, exist_ok=True)
|
||||
return Path(extensions_data_dir, f"{self.id}.zip")
|
||||
|
||||
@property
|
||||
def ext_dir(self) -> Path:
|
||||
return Path(settings.lnbits_path, "extensions", self.id)
|
||||
|
||||
@property
|
||||
def ext_upgrade_dir(self) -> Path:
|
||||
return Path("lnbits", "upgrades", f"{self.id}-{self.hash}")
|
||||
|
||||
@property
|
||||
def module_name(self) -> str:
|
||||
return f"lnbits.extensions.{self.id}"
|
||||
|
||||
@property
|
||||
def module_installed(self) -> bool:
|
||||
return self.module_name in sys.modules
|
||||
|
||||
@property
|
||||
def has_installed_version(self) -> bool:
|
||||
if not self.ext_dir.is_dir():
|
||||
return False
|
||||
return Path(self.ext_dir, "config.json").is_file()
|
||||
|
||||
def download_archive(self):
|
||||
logger.info(f"Downloading extension {self.name}.")
|
||||
ext_zip_file = self.zip_path
|
||||
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)
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND,
|
||||
detail="Cannot fetch extension archive file",
|
||||
)
|
||||
|
||||
archive_hash = file_hash(ext_zip_file)
|
||||
if self.installed_release.hash and self.installed_release.hash != archive_hash:
|
||||
# remove downloaded archive
|
||||
if ext_zip_file.is_file():
|
||||
os.remove(ext_zip_file)
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND,
|
||||
detail="File hash missmatch. Will not install.",
|
||||
)
|
||||
|
||||
def extract_archive(self):
|
||||
logger.info(f"Extracting extension {self.name}.")
|
||||
Path("lnbits", "upgrades").mkdir(parents=True, exist_ok=True)
|
||||
shutil.rmtree(self.ext_upgrade_dir, True)
|
||||
with zipfile.ZipFile(self.zip_path, "r") as zip_ref:
|
||||
zip_ref.extractall(self.ext_upgrade_dir)
|
||||
generated_dir_name = os.listdir(self.ext_upgrade_dir)[0]
|
||||
os.rename(
|
||||
Path(self.ext_upgrade_dir, generated_dir_name),
|
||||
Path(self.ext_upgrade_dir, self.id),
|
||||
)
|
||||
|
||||
# Pre-packed extensions can be upgraded
|
||||
# Mark the extension as installed so we know it is not the pre-packed version
|
||||
with open(
|
||||
Path(self.ext_upgrade_dir, self.id, "config.json"), "r+"
|
||||
) as json_file:
|
||||
config_json = json.load(json_file)
|
||||
|
||||
self.name = config_json.get("name")
|
||||
self.short_description = config_json.get("short_description")
|
||||
|
||||
if (
|
||||
self.installed_release
|
||||
and self.installed_release.is_github_release
|
||||
and config_json.get("tile")
|
||||
):
|
||||
self.icon = icon_to_github_url(
|
||||
self.installed_release.source_repo, config_json.get("tile")
|
||||
)
|
||||
|
||||
shutil.rmtree(self.ext_dir, True)
|
||||
shutil.copytree(
|
||||
Path(self.ext_upgrade_dir, self.id),
|
||||
Path(settings.lnbits_path, "extensions", self.id),
|
||||
)
|
||||
logger.success(f"Extension {self.name} installed.")
|
||||
|
||||
def nofiy_upgrade(self) -> None:
|
||||
"""Update the list of upgraded extensions. The middleware will perform redirects based on this"""
|
||||
|
||||
clean_upgraded_exts = list(
|
||||
filter(
|
||||
lambda old_ext: not old_ext.endswith(f"/{self.id}"),
|
||||
settings.lnbits_upgraded_extensions,
|
||||
)
|
||||
)
|
||||
settings.lnbits_upgraded_extensions = clean_upgraded_exts + [
|
||||
f"{self.hash}/{self.id}"
|
||||
]
|
||||
|
||||
def clean_extension_files(self):
|
||||
# remove downloaded archive
|
||||
if self.zip_path.is_file():
|
||||
os.remove(self.zip_path)
|
||||
|
||||
# remove module from extensions
|
||||
shutil.rmtree(self.ext_dir, True)
|
||||
|
||||
shutil.rmtree(self.ext_upgrade_dir, True)
|
||||
|
||||
@classmethod
|
||||
def from_row(cls, data: dict) -> "InstallableExtension":
|
||||
meta = json.loads(data["meta"])
|
||||
ext = InstallableExtension(**data)
|
||||
if "installed_release" in meta:
|
||||
ext.installed_release = ExtensionRelease(**meta["installed_release"])
|
||||
return ext
|
||||
|
||||
@classmethod
|
||||
async def from_github_release(
|
||||
cls, github_release: GitHubRelease
|
||||
) -> Optional["InstallableExtension"]:
|
||||
try:
|
||||
repo, latest_release, config = await fetch_github_repo_info(
|
||||
github_release.organisation, github_release.repository
|
||||
)
|
||||
|
||||
return InstallableExtension(
|
||||
id=github_release.id,
|
||||
name=config.name,
|
||||
short_description=config.short_description,
|
||||
stars=int(repo.stargazers_count),
|
||||
icon=icon_to_github_url(
|
||||
f"{github_release.organisation}/{github_release.repository}",
|
||||
config.tile,
|
||||
),
|
||||
latest_release=ExtensionRelease.from_github_release(
|
||||
repo.html_url, latest_release
|
||||
),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(e)
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def from_explicit_release(cls, e: ExplicitRelease) -> "InstallableExtension":
|
||||
return InstallableExtension(
|
||||
id=e.id,
|
||||
name=e.name,
|
||||
archive=e.archive,
|
||||
short_description=e.short_description,
|
||||
icon=e.icon,
|
||||
dependencies=e.dependencies,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def get_installable_extensions(
|
||||
cls,
|
||||
) -> List["InstallableExtension"]:
|
||||
extension_list: List[InstallableExtension] = []
|
||||
extension_id_list: List[str] = []
|
||||
|
||||
for url in settings.lnbits_extensions_manifests:
|
||||
try:
|
||||
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]
|
||||
|
||||
for e in manifest.extensions:
|
||||
if e.id in extension_id_list:
|
||||
continue
|
||||
ext = InstallableExtension.from_explicit_release(e)
|
||||
ext.featured = ext.id in manifest.featured
|
||||
extension_list += [ext]
|
||||
extension_id_list += [e.id]
|
||||
except Exception as e:
|
||||
logger.warning(f"Manifest {url} failed with '{str(e)}'")
|
||||
|
||||
return extension_list
|
||||
|
||||
@classmethod
|
||||
async def get_extension_releases(cls, ext_id: str) -> List["ExtensionRelease"]:
|
||||
extension_releases: List[ExtensionRelease] = []
|
||||
|
||||
for url in settings.lnbits_extensions_manifests:
|
||||
try:
|
||||
manifest = await fetch_manifest(url)
|
||||
for r in manifest.repos:
|
||||
if r.id == ext_id:
|
||||
repo_releases = await ExtensionRelease.all_releases(
|
||||
r.organisation, r.repository
|
||||
)
|
||||
extension_releases += repo_releases
|
||||
|
||||
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,
|
||||
)
|
||||
]
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Manifest {url} failed with '{str(e)}'")
|
||||
|
||||
return extension_releases
|
||||
|
||||
@classmethod
|
||||
async def get_extension_release(
|
||||
cls, ext_id: str, source_repo: str, archive: str
|
||||
) -> Optional["ExtensionRelease"]:
|
||||
all_releases: List[
|
||||
ExtensionRelease
|
||||
] = await InstallableExtension.get_extension_releases(ext_id)
|
||||
selected_release = [
|
||||
r
|
||||
for r in all_releases
|
||||
if r.archive == archive and r.source_repo == source_repo
|
||||
]
|
||||
|
||||
return selected_release[0] if len(selected_release) != 0 else None
|
||||
|
||||
|
||||
class CreateExtension(BaseModel):
|
||||
ext_id: str
|
||||
archive: str
|
||||
source_repo: str
|
||||
|
||||
|
||||
def get_valid_extensions() -> List[Extension]:
|
||||
return [
|
||||
extension for extension in ExtensionManager().extensions if extension.is_valid
|
||||
]
|
||||
@@ -1,21 +0,0 @@
|
||||
# Bleskomat Extension for lnbits
|
||||
|
||||
This extension allows you to connect a Bleskomat ATM to an lnbits wallet. It will work with both the [open-source DIY Bleskomat ATM project](https://github.com/samotari/bleskomat) as well as the [commercial Bleskomat ATM](https://www.bleskomat.com/).
|
||||
|
||||
|
||||
## Connect Your Bleskomat ATM
|
||||
|
||||
* Click the "Add Bleskomat" button on this page to begin.
|
||||
* Choose a wallet. This will be the wallet that is used to pay satoshis to your ATM customers.
|
||||
* Choose the fiat currency. This should match the fiat currency that your ATM accepts.
|
||||
* Pick an exchange rate provider. This is the API that will be used to query the fiat to satoshi exchange rate at the time your customer attempts to withdraw their funds.
|
||||
* Set your ATM's fee percentage.
|
||||
* Click the "Done" button.
|
||||
* Find the new Bleskomat in the list and then click the export icon to download a new configuration file for your ATM.
|
||||
* Copy the configuration file ("bleskomat.conf") to your ATM's SD card.
|
||||
* Restart Your Bleskomat ATM. It should automatically reload the configurations from the SD card.
|
||||
|
||||
|
||||
## How Does It Work?
|
||||
|
||||
Since the Bleskomat ATMs are designed to be offline, a cryptographic signing scheme is used to verify that the URL was generated by an authorized device. When one of your customers inserts fiat money into the device, a signed URL (lnurl-withdraw) is created and displayed as a QR code. Your customer scans the QR code with their lnurl-supporting mobile app, their mobile app communicates with the web API of lnbits to verify the signature, the fiat currency amount is converted to sats, the customer accepts the withdrawal, and finally lnbits will pay the customer from your lnbits wallet.
|
||||
@@ -1,26 +0,0 @@
|
||||
from fastapi import APIRouter
|
||||
from starlette.staticfiles import StaticFiles
|
||||
|
||||
from lnbits.db import Database
|
||||
from lnbits.helpers import template_renderer
|
||||
|
||||
db = Database("ext_bleskomat")
|
||||
|
||||
bleskomat_static_files = [
|
||||
{
|
||||
"path": "/bleskomat/static",
|
||||
"app": StaticFiles(packages=[("lnbits", "extensions/bleskomat/static")]),
|
||||
"name": "bleskomat_static",
|
||||
}
|
||||
]
|
||||
|
||||
bleskomat_ext: APIRouter = APIRouter(prefix="/bleskomat", tags=["Bleskomat"])
|
||||
|
||||
|
||||
def bleskomat_renderer():
|
||||
return template_renderer(["lnbits/extensions/bleskomat/templates"])
|
||||
|
||||
|
||||
from .lnurl_api import * # noqa
|
||||
from .views import * # noqa
|
||||
from .views_api import * # noqa
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"name": "Bleskomat",
|
||||
"short_description": "Connect a Bleskomat ATM to an lnbits",
|
||||
"tile": "/bleskomat/static/image/bleskomat.png",
|
||||
"contributors": ["chill117"]
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
import secrets
|
||||
import time
|
||||
from typing import List, Optional, Union
|
||||
from uuid import uuid4
|
||||
|
||||
from . import db
|
||||
from .helpers import generate_bleskomat_lnurl_hash
|
||||
from .models import Bleskomat, BleskomatLnurl, CreateBleskomat
|
||||
|
||||
|
||||
async def create_bleskomat(data: CreateBleskomat, wallet_id: str) -> Bleskomat:
|
||||
bleskomat_id = uuid4().hex
|
||||
api_key_id = secrets.token_hex(8)
|
||||
api_key_secret = secrets.token_hex(32)
|
||||
api_key_encoding = "hex"
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO bleskomat.bleskomats (id, wallet, api_key_id, api_key_secret, api_key_encoding, name, fiat_currency, exchange_rate_provider, fee)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
bleskomat_id,
|
||||
wallet_id,
|
||||
api_key_id,
|
||||
api_key_secret,
|
||||
api_key_encoding,
|
||||
data.name,
|
||||
data.fiat_currency,
|
||||
data.exchange_rate_provider,
|
||||
data.fee,
|
||||
),
|
||||
)
|
||||
bleskomat = await get_bleskomat(bleskomat_id)
|
||||
assert bleskomat, "Newly created bleskomat couldn't be retrieved"
|
||||
return bleskomat
|
||||
|
||||
|
||||
async def get_bleskomat(bleskomat_id: str) -> Optional[Bleskomat]:
|
||||
row = await db.fetchone(
|
||||
"SELECT * FROM bleskomat.bleskomats WHERE id = ?", (bleskomat_id,)
|
||||
)
|
||||
return Bleskomat(**row) if row else None
|
||||
|
||||
|
||||
async def get_bleskomat_by_api_key_id(api_key_id: str) -> Optional[Bleskomat]:
|
||||
row = await db.fetchone(
|
||||
"SELECT * FROM bleskomat.bleskomats WHERE api_key_id = ?", (api_key_id,)
|
||||
)
|
||||
return Bleskomat(**row) if row else None
|
||||
|
||||
|
||||
async def get_bleskomats(wallet_ids: Union[str, List[str]]) -> List[Bleskomat]:
|
||||
if isinstance(wallet_ids, str):
|
||||
wallet_ids = [wallet_ids]
|
||||
q = ",".join(["?"] * len(wallet_ids))
|
||||
rows = await db.fetchall(
|
||||
f"SELECT * FROM bleskomat.bleskomats WHERE wallet IN ({q})", (*wallet_ids,)
|
||||
)
|
||||
return [Bleskomat(**row) for row in rows]
|
||||
|
||||
|
||||
async def update_bleskomat(bleskomat_id: str, **kwargs) -> Optional[Bleskomat]:
|
||||
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
|
||||
await db.execute(
|
||||
f"UPDATE bleskomat.bleskomats SET {q} WHERE id = ?",
|
||||
(*kwargs.values(), bleskomat_id),
|
||||
)
|
||||
row = await db.fetchone(
|
||||
"SELECT * FROM bleskomat.bleskomats WHERE id = ?", (bleskomat_id,)
|
||||
)
|
||||
return Bleskomat(**row) if row else None
|
||||
|
||||
|
||||
async def delete_bleskomat(bleskomat_id: str) -> None:
|
||||
await db.execute("DELETE FROM bleskomat.bleskomats WHERE id = ?", (bleskomat_id,))
|
||||
|
||||
|
||||
async def create_bleskomat_lnurl(
|
||||
*, bleskomat: Bleskomat, secret: str, tag: str, params: str, uses: int = 1
|
||||
) -> BleskomatLnurl:
|
||||
bleskomat_lnurl_id = uuid4().hex
|
||||
hash = generate_bleskomat_lnurl_hash(secret)
|
||||
now = int(time.time())
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO bleskomat.bleskomat_lnurls (id, bleskomat, wallet, hash, tag, params, api_key_id, initial_uses, remaining_uses, created_time, updated_time)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
bleskomat_lnurl_id,
|
||||
bleskomat.id,
|
||||
bleskomat.wallet,
|
||||
hash,
|
||||
tag,
|
||||
params,
|
||||
bleskomat.api_key_id,
|
||||
uses,
|
||||
uses,
|
||||
now,
|
||||
now,
|
||||
),
|
||||
)
|
||||
bleskomat_lnurl = await get_bleskomat_lnurl(secret)
|
||||
assert bleskomat_lnurl, "Newly created bleskomat LNURL couldn't be retrieved"
|
||||
return bleskomat_lnurl
|
||||
|
||||
|
||||
async def get_bleskomat_lnurl(secret: str) -> Optional[BleskomatLnurl]:
|
||||
hash = generate_bleskomat_lnurl_hash(secret)
|
||||
row = await db.fetchone(
|
||||
"SELECT * FROM bleskomat.bleskomat_lnurls WHERE hash = ?", (hash,)
|
||||
)
|
||||
return BleskomatLnurl(**row) if row else None
|
||||
@@ -1,86 +0,0 @@
|
||||
import json
|
||||
import os
|
||||
from typing import Callable, Dict, Union
|
||||
|
||||
import httpx
|
||||
|
||||
fiat_currencies = json.load(
|
||||
open(
|
||||
os.path.join(
|
||||
os.path.dirname(os.path.realpath(__file__)), "fiat_currencies.json"
|
||||
),
|
||||
"r",
|
||||
)
|
||||
)
|
||||
|
||||
exchange_rate_providers: dict[
|
||||
str, dict[str, Union[str, Callable[[dict, dict], str]]]
|
||||
] = {
|
||||
"bitfinex": {
|
||||
"name": "Bitfinex",
|
||||
"domain": "bitfinex.com",
|
||||
"api_url": "https://api.bitfinex.com/v1/pubticker/{from}{to}",
|
||||
"getter": lambda data, replacements: data["last_price"],
|
||||
},
|
||||
"bitstamp": {
|
||||
"name": "Bitstamp",
|
||||
"domain": "bitstamp.net",
|
||||
"api_url": "https://www.bitstamp.net/api/v2/ticker/{from}{to}/",
|
||||
"getter": lambda data, replacements: data["last"],
|
||||
},
|
||||
"coinbase": {
|
||||
"name": "Coinbase",
|
||||
"domain": "coinbase.com",
|
||||
"api_url": "https://api.coinbase.com/v2/exchange-rates?currency={FROM}",
|
||||
"getter": lambda data, replacements: data["data"]["rates"][replacements["TO"]],
|
||||
},
|
||||
"coinmate": {
|
||||
"name": "CoinMate",
|
||||
"domain": "coinmate.io",
|
||||
"api_url": "https://coinmate.io/api/ticker?currencyPair={FROM}_{TO}",
|
||||
"getter": lambda data, replacements: data["data"]["last"],
|
||||
},
|
||||
"kraken": {
|
||||
"name": "Kraken",
|
||||
"domain": "kraken.com",
|
||||
"api_url": "https://api.kraken.com/0/public/Ticker?pair=XBT{TO}",
|
||||
"getter": lambda data, replacements: data["result"][
|
||||
"XXBTZ" + replacements["TO"]
|
||||
]["c"][0],
|
||||
},
|
||||
}
|
||||
|
||||
exchange_rate_providers_serializable = {}
|
||||
for ref, exchange_rate_provider in exchange_rate_providers.items():
|
||||
exchange_rate_provider_serializable = {}
|
||||
for key, value in exchange_rate_provider.items():
|
||||
if not callable(value):
|
||||
exchange_rate_provider_serializable[key] = value
|
||||
exchange_rate_providers_serializable[ref] = exchange_rate_provider_serializable
|
||||
|
||||
|
||||
async def fetch_fiat_exchange_rate(currency: str, provider: str):
|
||||
|
||||
replacements = {
|
||||
"FROM": "BTC",
|
||||
"from": "btc",
|
||||
"TO": currency.upper(),
|
||||
"to": currency.lower(),
|
||||
}
|
||||
|
||||
api_url_or_none = exchange_rate_providers[provider]["api_url"]
|
||||
if api_url_or_none is not None:
|
||||
api_url = str(api_url_or_none)
|
||||
for key in replacements.keys():
|
||||
api_url = api_url.replace("{" + key + "}", replacements[key])
|
||||
async with httpx.AsyncClient() as client:
|
||||
r = await client.get(api_url)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
else:
|
||||
data = {}
|
||||
getter = exchange_rate_providers[provider]["getter"]
|
||||
print(getter)
|
||||
if callable(getter):
|
||||
rate = float(getter(data, replacements))
|
||||
return rate
|
||||
@@ -1,166 +0,0 @@
|
||||
{
|
||||
"AED": "United Arab Emirates Dirham",
|
||||
"AFN": "Afghan Afghani",
|
||||
"ALL": "Albanian Lek",
|
||||
"AMD": "Armenian Dram",
|
||||
"ANG": "Netherlands Antillean Gulden",
|
||||
"AOA": "Angolan Kwanza",
|
||||
"ARS": "Argentine Peso",
|
||||
"AUD": "Australian Dollar",
|
||||
"AWG": "Aruban Florin",
|
||||
"AZN": "Azerbaijani Manat",
|
||||
"BAM": "Bosnia and Herzegovina Convertible Mark",
|
||||
"BBD": "Barbadian Dollar",
|
||||
"BDT": "Bangladeshi Taka",
|
||||
"BGN": "Bulgarian Lev",
|
||||
"BHD": "Bahraini Dinar",
|
||||
"BIF": "Burundian Franc",
|
||||
"BMD": "Bermudian Dollar",
|
||||
"BND": "Brunei Dollar",
|
||||
"BOB": "Bolivian Boliviano",
|
||||
"BRL": "Brazilian Real",
|
||||
"BSD": "Bahamian Dollar",
|
||||
"BTN": "Bhutanese Ngultrum",
|
||||
"BWP": "Botswana Pula",
|
||||
"BYN": "Belarusian Ruble",
|
||||
"BYR": "Belarusian Ruble",
|
||||
"BZD": "Belize Dollar",
|
||||
"CAD": "Canadian Dollar",
|
||||
"CDF": "Congolese Franc",
|
||||
"CHF": "Swiss Franc",
|
||||
"CLF": "Unidad de Fomento",
|
||||
"CLP": "Chilean Peso",
|
||||
"CNH": "Chinese Renminbi Yuan Offshore",
|
||||
"CNY": "Chinese Renminbi Yuan",
|
||||
"COP": "Colombian Peso",
|
||||
"CRC": "Costa Rican Colón",
|
||||
"CUC": "Cuban Convertible Peso",
|
||||
"CVE": "Cape Verdean Escudo",
|
||||
"CZK": "Czech Koruna",
|
||||
"DJF": "Djiboutian Franc",
|
||||
"DKK": "Danish Krone",
|
||||
"DOP": "Dominican Peso",
|
||||
"DZD": "Algerian Dinar",
|
||||
"EGP": "Egyptian Pound",
|
||||
"ERN": "Eritrean Nakfa",
|
||||
"ETB": "Ethiopian Birr",
|
||||
"EUR": "Euro",
|
||||
"FJD": "Fijian Dollar",
|
||||
"FKP": "Falkland Pound",
|
||||
"GBP": "British Pound",
|
||||
"GEL": "Georgian Lari",
|
||||
"GGP": "Guernsey Pound",
|
||||
"GHS": "Ghanaian Cedi",
|
||||
"GIP": "Gibraltar Pound",
|
||||
"GMD": "Gambian Dalasi",
|
||||
"GNF": "Guinean Franc",
|
||||
"GTQ": "Guatemalan Quetzal",
|
||||
"GYD": "Guyanese Dollar",
|
||||
"HKD": "Hong Kong Dollar",
|
||||
"HNL": "Honduran Lempira",
|
||||
"HRK": "Croatian Kuna",
|
||||
"HTG": "Haitian Gourde",
|
||||
"HUF": "Hungarian Forint",
|
||||
"IDR": "Indonesian Rupiah",
|
||||
"ILS": "Israeli New Sheqel",
|
||||
"IMP": "Isle of Man Pound",
|
||||
"INR": "Indian Rupee",
|
||||
"IQD": "Iraqi Dinar",
|
||||
"ISK": "Icelandic Króna",
|
||||
"JEP": "Jersey Pound",
|
||||
"JMD": "Jamaican Dollar",
|
||||
"JOD": "Jordanian Dinar",
|
||||
"JPY": "Japanese Yen",
|
||||
"KES": "Kenyan Shilling",
|
||||
"KGS": "Kyrgyzstani Som",
|
||||
"KHR": "Cambodian Riel",
|
||||
"KMF": "Comorian Franc",
|
||||
"KRW": "South Korean Won",
|
||||
"KWD": "Kuwaiti Dinar",
|
||||
"KYD": "Cayman Islands Dollar",
|
||||
"KZT": "Kazakhstani Tenge",
|
||||
"LAK": "Lao Kip",
|
||||
"LBP": "Lebanese Pound",
|
||||
"LKR": "Sri Lankan Rupee",
|
||||
"LRD": "Liberian Dollar",
|
||||
"LSL": "Lesotho Loti",
|
||||
"LYD": "Libyan Dinar",
|
||||
"MAD": "Moroccan Dirham",
|
||||
"MDL": "Moldovan Leu",
|
||||
"MGA": "Malagasy Ariary",
|
||||
"MKD": "Macedonian Denar",
|
||||
"MMK": "Myanmar Kyat",
|
||||
"MNT": "Mongolian Tögrög",
|
||||
"MOP": "Macanese Pataca",
|
||||
"MRO": "Mauritanian Ouguiya",
|
||||
"MUR": "Mauritian Rupee",
|
||||
"MVR": "Maldivian Rufiyaa",
|
||||
"MWK": "Malawian Kwacha",
|
||||
"MXN": "Mexican Peso",
|
||||
"MYR": "Malaysian Ringgit",
|
||||
"MZN": "Mozambican Metical",
|
||||
"NAD": "Namibian Dollar",
|
||||
"NGN": "Nigerian Naira",
|
||||
"NIO": "Nicaraguan Córdoba",
|
||||
"NOK": "Norwegian Krone",
|
||||
"NPR": "Nepalese Rupee",
|
||||
"NZD": "New Zealand Dollar",
|
||||
"OMR": "Omani Rial",
|
||||
"PAB": "Panamanian Balboa",
|
||||
"PEN": "Peruvian Sol",
|
||||
"PGK": "Papua New Guinean Kina",
|
||||
"PHP": "Philippine Peso",
|
||||
"PKR": "Pakistani Rupee",
|
||||
"PLN": "Polish Złoty",
|
||||
"PYG": "Paraguayan Guaraní",
|
||||
"QAR": "Qatari Riyal",
|
||||
"RON": "Romanian Leu",
|
||||
"RSD": "Serbian Dinar",
|
||||
"RUB": "Russian Ruble",
|
||||
"RWF": "Rwandan Franc",
|
||||
"SAR": "Saudi Riyal",
|
||||
"SBD": "Solomon Islands Dollar",
|
||||
"SCR": "Seychellois Rupee",
|
||||
"SEK": "Swedish Krona",
|
||||
"SGD": "Singapore Dollar",
|
||||
"SHP": "Saint Helenian Pound",
|
||||
"SLL": "Sierra Leonean Leone",
|
||||
"SOS": "Somali Shilling",
|
||||
"SRD": "Surinamese Dollar",
|
||||
"SSP": "South Sudanese Pound",
|
||||
"STD": "São Tomé and Príncipe Dobra",
|
||||
"SVC": "Salvadoran Colón",
|
||||
"SZL": "Swazi Lilangeni",
|
||||
"THB": "Thai Baht",
|
||||
"TJS": "Tajikistani Somoni",
|
||||
"TMT": "Turkmenistani Manat",
|
||||
"TND": "Tunisian Dinar",
|
||||
"TOP": "Tongan Paʻanga",
|
||||
"TRY": "Turkish Lira",
|
||||
"TTD": "Trinidad and Tobago Dollar",
|
||||
"TWD": "New Taiwan Dollar",
|
||||
"TZS": "Tanzanian Shilling",
|
||||
"UAH": "Ukrainian Hryvnia",
|
||||
"UGX": "Ugandan Shilling",
|
||||
"USD": "US Dollar",
|
||||
"UYU": "Uruguayan Peso",
|
||||
"UZS": "Uzbekistan Som",
|
||||
"VEF": "Venezuelan Bolívar",
|
||||
"VES": "Venezuelan Bolívar Soberano",
|
||||
"VND": "Vietnamese Đồng",
|
||||
"VUV": "Vanuatu Vatu",
|
||||
"WST": "Samoan Tala",
|
||||
"XAF": "Central African Cfa Franc",
|
||||
"XAG": "Silver (Troy Ounce)",
|
||||
"XAU": "Gold (Troy Ounce)",
|
||||
"XCD": "East Caribbean Dollar",
|
||||
"XDR": "Special Drawing Rights",
|
||||
"XOF": "West African Cfa Franc",
|
||||
"XPD": "Palladium",
|
||||
"XPF": "Cfp Franc",
|
||||
"XPT": "Platinum",
|
||||
"YER": "Yemeni Rial",
|
||||
"ZAR": "South African Rand",
|
||||
"ZMW": "Zambian Kwacha",
|
||||
"ZWL": "Zimbabwean Dollar"
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
from http import HTTPStatus
|
||||
from typing import Dict
|
||||
from urllib import parse
|
||||
|
||||
from fastapi import Request
|
||||
|
||||
|
||||
def generate_bleskomat_lnurl_hash(secret: str):
|
||||
m = hashlib.sha256()
|
||||
m.update(f"{secret}".encode())
|
||||
return m.hexdigest()
|
||||
|
||||
|
||||
def generate_bleskomat_lnurl_signature(
|
||||
payload: str, api_key_secret: str, api_key_encoding: str = "hex"
|
||||
):
|
||||
if api_key_encoding == "hex":
|
||||
key = bytes.fromhex(api_key_secret)
|
||||
elif api_key_encoding == "base64":
|
||||
key = base64.b64decode(api_key_secret)
|
||||
else:
|
||||
key = bytes.fromhex(api_key_secret)
|
||||
return hmac.new(key=key, msg=payload.encode(), digestmod=hashlib.sha256).hexdigest()
|
||||
|
||||
|
||||
def generate_bleskomat_lnurl_secret(api_key_id: str, signature: str):
|
||||
# The secret is not randomly generated by the server.
|
||||
# Instead it is the hash of the API key ID and signature concatenated together.
|
||||
m = hashlib.sha256()
|
||||
m.update(f"{api_key_id}-{signature}".encode())
|
||||
return m.hexdigest()
|
||||
|
||||
|
||||
def get_callback_url(req: Request):
|
||||
return req.url_for("bleskomat.api_bleskomat_lnurl")
|
||||
|
||||
|
||||
def is_supported_lnurl_subprotocol(tag: str) -> bool:
|
||||
return tag == "withdrawRequest"
|
||||
|
||||
|
||||
class LnurlHttpError(Exception):
|
||||
def __init__(
|
||||
self,
|
||||
message: str = "",
|
||||
http_status: HTTPStatus = HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
):
|
||||
self.message = message
|
||||
self.http_status = http_status
|
||||
super().__init__(self.message)
|
||||
|
||||
|
||||
class LnurlValidationError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def prepare_lnurl_params(tag: str, query: dict) -> dict:
|
||||
params: dict = {}
|
||||
if not is_supported_lnurl_subprotocol(tag):
|
||||
raise LnurlValidationError(f'Unsupported subprotocol: "{tag}"')
|
||||
if tag == "withdrawRequest":
|
||||
params["minWithdrawable"] = float(query["minWithdrawable"])
|
||||
params["maxWithdrawable"] = float(query["maxWithdrawable"])
|
||||
params["defaultDescription"] = query["defaultDescription"]
|
||||
if not params["minWithdrawable"] > 0:
|
||||
raise LnurlValidationError('"minWithdrawable" must be greater than zero')
|
||||
if not params["maxWithdrawable"] >= params["minWithdrawable"]:
|
||||
raise LnurlValidationError(
|
||||
'"maxWithdrawable" must be greater than or equal to "minWithdrawable"'
|
||||
)
|
||||
return params
|
||||
|
||||
|
||||
encode_uri_component_safe_chars = (
|
||||
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.!~*'()"
|
||||
)
|
||||
|
||||
|
||||
def query_to_signing_payload(query: Dict[str, str]) -> str:
|
||||
# Sort the query by key, then stringify it to create the payload.
|
||||
sorted_keys = sorted(query.keys(), key=str.lower)
|
||||
payload = []
|
||||
for key in sorted_keys:
|
||||
if not key == "signature":
|
||||
encoded_key = parse.quote(key, safe=encode_uri_component_safe_chars)
|
||||
encoded_value = parse.quote(
|
||||
query[key], safe=encode_uri_component_safe_chars
|
||||
)
|
||||
payload.append(f"{encoded_key}={encoded_value}")
|
||||
return "&".join(payload)
|
||||
|
||||
|
||||
unshorten_rules: dict[str, dict] = {
|
||||
"query": {"n": "nonce", "s": "signature", "t": "tag"},
|
||||
"tags": {
|
||||
"c": "channelRequest",
|
||||
"l": "login",
|
||||
"p": "payRequest",
|
||||
"w": "withdrawRequest",
|
||||
},
|
||||
"params": {
|
||||
"channelRequest": {"pl": "localAmt", "pp": "pushAmt"},
|
||||
"login": {},
|
||||
"payRequest": {"pn": "minSendable", "px": "maxSendable", "pm": "metadata"},
|
||||
"withdrawRequest": {
|
||||
"pn": "minWithdrawable",
|
||||
"px": "maxWithdrawable",
|
||||
"pd": "defaultDescription",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def unshorten_lnurl_query(query: dict) -> Dict[str, str]:
|
||||
new_query = {}
|
||||
rules = unshorten_rules
|
||||
if "tag" in query:
|
||||
tag = query["tag"]
|
||||
elif "t" in query:
|
||||
tag = query["t"]
|
||||
else:
|
||||
raise LnurlValidationError('Missing required query parameter: "tag"')
|
||||
# Unshorten tag:
|
||||
if tag in rules["tags"]:
|
||||
long_tag = rules["tags"][tag]
|
||||
new_query["tag"] = long_tag
|
||||
tag = long_tag
|
||||
if not tag in rules["params"]:
|
||||
raise LnurlValidationError(f'Unknown tag: "{tag}"')
|
||||
for key in query:
|
||||
if key in rules["params"][str(tag)]:
|
||||
short_param_key = key
|
||||
long_param_key = rules["params"][str(tag)][short_param_key]
|
||||
if short_param_key in query:
|
||||
new_query[long_param_key] = query[short_param_key]
|
||||
else:
|
||||
new_query[long_param_key] = query[long_param_key]
|
||||
elif key in rules["query"]:
|
||||
# Unshorten general keys:
|
||||
short_key = key
|
||||
long_key = rules["query"][short_key]
|
||||
if not long_key in new_query:
|
||||
if short_key in query:
|
||||
new_query[long_key] = query[short_key]
|
||||
else:
|
||||
new_query[long_key] = query[str(long_key)]
|
||||
else:
|
||||
# Keep unknown key/value pairs unchanged:
|
||||
new_query[key] = query[key]
|
||||
return new_query
|
||||
@@ -1,132 +0,0 @@
|
||||
import json
|
||||
import math
|
||||
from http import HTTPStatus
|
||||
|
||||
from loguru import logger
|
||||
from starlette.requests import Request
|
||||
|
||||
from . import bleskomat_ext
|
||||
from .crud import (
|
||||
create_bleskomat_lnurl,
|
||||
get_bleskomat_by_api_key_id,
|
||||
get_bleskomat_lnurl,
|
||||
)
|
||||
from .exchange_rates import fetch_fiat_exchange_rate
|
||||
from .helpers import (
|
||||
LnurlHttpError,
|
||||
LnurlValidationError,
|
||||
generate_bleskomat_lnurl_secret,
|
||||
generate_bleskomat_lnurl_signature,
|
||||
prepare_lnurl_params,
|
||||
query_to_signing_payload,
|
||||
unshorten_lnurl_query,
|
||||
)
|
||||
|
||||
|
||||
# Handles signed URL from Bleskomat ATMs and "action" callback of auto-generated LNURLs.
|
||||
@bleskomat_ext.get("/u", name="bleskomat.api_bleskomat_lnurl")
|
||||
async def api_bleskomat_lnurl(req: Request):
|
||||
try:
|
||||
query = dict(req.query_params)
|
||||
|
||||
# Unshorten query if "s" is used instead of "signature".
|
||||
if "s" in query:
|
||||
query = unshorten_lnurl_query(query)
|
||||
|
||||
if "signature" in query:
|
||||
|
||||
# Signature provided.
|
||||
# Use signature to verify that the URL was generated by an authorized device.
|
||||
# Later validate parameters, auto-generate LNURL, reply with LNURL response object.
|
||||
signature = query["signature"]
|
||||
|
||||
# The API key ID, nonce, and tag should be present in the query string.
|
||||
for field in ["id", "nonce", "tag"]:
|
||||
if not field in query:
|
||||
raise LnurlHttpError(
|
||||
f'Failed API key signature check: Missing "{field}"',
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
)
|
||||
|
||||
# URL signing scheme is described here:
|
||||
# https://github.com/chill117/lnurl-node#how-to-implement-url-signing-scheme
|
||||
payload = query_to_signing_payload(query)
|
||||
api_key_id = query["id"]
|
||||
bleskomat = await get_bleskomat_by_api_key_id(api_key_id)
|
||||
if not bleskomat:
|
||||
raise LnurlHttpError("Unknown API key", HTTPStatus.BAD_REQUEST)
|
||||
api_key_secret = bleskomat.api_key_secret
|
||||
api_key_encoding = bleskomat.api_key_encoding
|
||||
expected_signature = generate_bleskomat_lnurl_signature(
|
||||
payload, api_key_secret, api_key_encoding
|
||||
)
|
||||
if signature != expected_signature:
|
||||
raise LnurlHttpError("Invalid API key signature", HTTPStatus.FORBIDDEN)
|
||||
|
||||
# Signature is valid.
|
||||
# In the case of signed URLs, the secret is deterministic based on the API key ID and signature.
|
||||
secret = generate_bleskomat_lnurl_secret(api_key_id, signature)
|
||||
lnurl = await get_bleskomat_lnurl(secret)
|
||||
if not lnurl:
|
||||
try:
|
||||
tag = query["tag"]
|
||||
params = prepare_lnurl_params(tag, query)
|
||||
if "f" in query:
|
||||
rate = await fetch_fiat_exchange_rate(
|
||||
currency=query["f"],
|
||||
provider=bleskomat.exchange_rate_provider,
|
||||
)
|
||||
# Convert fee (%) to decimal:
|
||||
fee = float(bleskomat.fee) / 100
|
||||
if tag == "withdrawRequest":
|
||||
for key in ["minWithdrawable", "maxWithdrawable"]:
|
||||
amount_sats = int(
|
||||
math.floor((params[key] / rate) * 1e8)
|
||||
)
|
||||
fee_sats = int(math.floor(amount_sats * fee))
|
||||
amount_sats_less_fee = amount_sats - fee_sats
|
||||
# Convert to msats:
|
||||
params[key] = int(amount_sats_less_fee * 1e3)
|
||||
except LnurlValidationError as e:
|
||||
raise LnurlHttpError(str(e), HTTPStatus.BAD_REQUEST)
|
||||
# Create a new LNURL using the query parameters provided in the signed URL.
|
||||
json_params = json.JSONEncoder().encode(params)
|
||||
lnurl = await create_bleskomat_lnurl(
|
||||
bleskomat=bleskomat,
|
||||
secret=secret,
|
||||
tag=tag,
|
||||
params=json_params,
|
||||
uses=1,
|
||||
)
|
||||
|
||||
# Reply with LNURL response object.
|
||||
return lnurl.get_info_response_object(secret, req)
|
||||
|
||||
# No signature provided.
|
||||
# Treat as "action" callback.
|
||||
|
||||
if not "k1" in query:
|
||||
raise LnurlHttpError("Missing secret", HTTPStatus.BAD_REQUEST)
|
||||
|
||||
secret = query["k1"]
|
||||
lnurl = await get_bleskomat_lnurl(secret)
|
||||
if not lnurl:
|
||||
raise LnurlHttpError("Invalid secret", HTTPStatus.BAD_REQUEST)
|
||||
|
||||
if not lnurl.has_uses_remaining():
|
||||
raise LnurlHttpError(
|
||||
"Maximum number of uses already reached", HTTPStatus.BAD_REQUEST
|
||||
)
|
||||
|
||||
try:
|
||||
await lnurl.execute_action(query)
|
||||
except LnurlValidationError as e:
|
||||
raise LnurlHttpError(str(e), HTTPStatus.BAD_REQUEST)
|
||||
|
||||
except LnurlHttpError as e:
|
||||
return {"status": "ERROR", "reason": str(e)}
|
||||
except Exception as e:
|
||||
logger.error(str(e))
|
||||
return {"status": "ERROR", "reason": "Unexpected error"}
|
||||
|
||||
return {"status": "OK"}
|
||||
@@ -1,37 +0,0 @@
|
||||
async def m001_initial(db):
|
||||
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE bleskomat.bleskomats (
|
||||
id TEXT PRIMARY KEY,
|
||||
wallet TEXT NOT NULL,
|
||||
api_key_id TEXT NOT NULL,
|
||||
api_key_secret TEXT NOT NULL,
|
||||
api_key_encoding TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
fiat_currency TEXT NOT NULL,
|
||||
exchange_rate_provider TEXT NOT NULL,
|
||||
fee TEXT NOT NULL,
|
||||
UNIQUE(api_key_id)
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE bleskomat.bleskomat_lnurls (
|
||||
id TEXT PRIMARY KEY,
|
||||
bleskomat TEXT NOT NULL,
|
||||
wallet TEXT NOT NULL,
|
||||
hash TEXT NOT NULL,
|
||||
tag TEXT NOT NULL,
|
||||
params TEXT NOT NULL,
|
||||
api_key_id TEXT NOT NULL,
|
||||
initial_uses INTEGER DEFAULT 1,
|
||||
remaining_uses INTEGER DEFAULT 0,
|
||||
created_time INTEGER,
|
||||
updated_time INTEGER,
|
||||
UNIQUE(hash)
|
||||
);
|
||||
"""
|
||||
)
|
||||
@@ -1,142 +0,0 @@
|
||||
import json
|
||||
import time
|
||||
from typing import Dict
|
||||
|
||||
from fastapi import Query, Request
|
||||
from loguru import logger
|
||||
from pydantic import BaseModel, validator
|
||||
|
||||
from lnbits import bolt11
|
||||
from lnbits.core.services import PaymentFailure, pay_invoice
|
||||
|
||||
from . import db
|
||||
from .exchange_rates import exchange_rate_providers, fiat_currencies
|
||||
from .helpers import LnurlValidationError, get_callback_url
|
||||
|
||||
|
||||
class CreateBleskomat(BaseModel):
|
||||
name: str = Query(...)
|
||||
fiat_currency: str = Query(...)
|
||||
exchange_rate_provider: str = Query(...)
|
||||
fee: str = Query(...)
|
||||
|
||||
@validator("fiat_currency")
|
||||
def allowed_fiat_currencies(cls, v):
|
||||
if v not in fiat_currencies.keys():
|
||||
raise ValueError("Not allowed currency")
|
||||
return v
|
||||
|
||||
@validator("exchange_rate_provider")
|
||||
def allowed_providers(cls, v):
|
||||
if v not in exchange_rate_providers.keys():
|
||||
raise ValueError("Not allowed provider")
|
||||
return v
|
||||
|
||||
@validator("fee")
|
||||
def fee_type(cls, v):
|
||||
if not isinstance(v, (str, float, int)):
|
||||
raise ValueError("Fee type not allowed")
|
||||
return v
|
||||
|
||||
|
||||
class Bleskomat(BaseModel):
|
||||
id: str
|
||||
wallet: str
|
||||
api_key_id: str
|
||||
api_key_secret: str
|
||||
api_key_encoding: str
|
||||
name: str
|
||||
fiat_currency: str
|
||||
exchange_rate_provider: str
|
||||
fee: str
|
||||
|
||||
|
||||
class BleskomatLnurl(BaseModel):
|
||||
id: str
|
||||
bleskomat: str
|
||||
wallet: str
|
||||
hash: str
|
||||
tag: str
|
||||
params: str
|
||||
api_key_id: str
|
||||
initial_uses: int
|
||||
remaining_uses: int
|
||||
created_time: int
|
||||
updated_time: int
|
||||
|
||||
def has_uses_remaining(self) -> bool:
|
||||
# When initial uses is 0 then the LNURL has unlimited uses.
|
||||
return self.initial_uses == 0 or self.remaining_uses > 0
|
||||
|
||||
def get_info_response_object(self, secret: str, req: Request) -> Dict[str, str]:
|
||||
tag = self.tag
|
||||
params = json.loads(self.params)
|
||||
response = {"tag": tag}
|
||||
if tag == "withdrawRequest":
|
||||
for key in ["minWithdrawable", "maxWithdrawable", "defaultDescription"]:
|
||||
response[key] = params[key]
|
||||
response["callback"] = get_callback_url(req)
|
||||
response["k1"] = secret
|
||||
return response
|
||||
|
||||
def validate_action(self, query) -> None:
|
||||
tag = self.tag
|
||||
params = json.loads(self.params)
|
||||
# Perform tag-specific checks.
|
||||
if tag == "withdrawRequest":
|
||||
for field in ["pr"]:
|
||||
if not field in query:
|
||||
raise LnurlValidationError(f'Missing required parameter: "{field}"')
|
||||
# Check the bolt11 invoice(s) provided.
|
||||
pr = query["pr"]
|
||||
if "," in pr:
|
||||
raise LnurlValidationError("Multiple payment requests not supported")
|
||||
try:
|
||||
invoice = bolt11.decode(pr)
|
||||
except ValueError:
|
||||
raise LnurlValidationError(
|
||||
'Invalid parameter ("pr"): Lightning payment request expected'
|
||||
)
|
||||
if invoice.amount_msat < params["minWithdrawable"]:
|
||||
raise LnurlValidationError(
|
||||
'Amount in invoice must be greater than or equal to "minWithdrawable"'
|
||||
)
|
||||
if invoice.amount_msat > params["maxWithdrawable"]:
|
||||
raise LnurlValidationError(
|
||||
'Amount in invoice must be less than or equal to "maxWithdrawable"'
|
||||
)
|
||||
else:
|
||||
raise LnurlValidationError(f'Unknown subprotocol: "{tag}"')
|
||||
|
||||
async def execute_action(self, query):
|
||||
self.validate_action(query)
|
||||
used = False
|
||||
async with db.connect() as conn:
|
||||
if self.initial_uses > 0:
|
||||
used = await self.use(conn)
|
||||
if not used:
|
||||
raise LnurlValidationError("Maximum number of uses already reached")
|
||||
tag = self.tag
|
||||
if tag == "withdrawRequest":
|
||||
try:
|
||||
await pay_invoice(
|
||||
wallet_id=self.wallet, payment_request=query["pr"]
|
||||
)
|
||||
except (ValueError, PermissionError, PaymentFailure) as e:
|
||||
raise LnurlValidationError("Failed to pay invoice: " + str(e))
|
||||
except Exception as e:
|
||||
logger.error(str(e))
|
||||
raise LnurlValidationError("Unexpected error")
|
||||
|
||||
async def use(self, conn) -> bool:
|
||||
now = int(time.time())
|
||||
result = await conn.execute(
|
||||
"""
|
||||
UPDATE bleskomat.bleskomat_lnurls
|
||||
SET remaining_uses = remaining_uses - 1, updated_time = ?
|
||||
WHERE id = ?
|
||||
AND remaining_uses > 0
|
||||
""",
|
||||
(now, self.id),
|
||||
)
|
||||
return result.rowcount > 0
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 26 KiB |
@@ -1,216 +0,0 @@
|
||||
/* global Vue, VueQrcode, _, Quasar, LOCALE, windowMixin, LNbits */
|
||||
|
||||
Vue.component(VueQrcode.name, VueQrcode)
|
||||
|
||||
var mapBleskomat = function (obj) {
|
||||
obj._data = _.clone(obj)
|
||||
return obj
|
||||
}
|
||||
|
||||
var defaultValues = {
|
||||
name: 'My Bleskomat',
|
||||
fiat_currency: 'EUR',
|
||||
exchange_rate_provider: 'coinbase',
|
||||
fee: '0.00'
|
||||
}
|
||||
|
||||
new Vue({
|
||||
el: '#vue',
|
||||
mixins: [windowMixin],
|
||||
data: function () {
|
||||
return {
|
||||
checker: null,
|
||||
bleskomats: [],
|
||||
bleskomatsTable: {
|
||||
columns: [
|
||||
{
|
||||
name: 'api_key_id',
|
||||
align: 'left',
|
||||
label: 'API Key ID',
|
||||
field: 'api_key_id'
|
||||
},
|
||||
{
|
||||
name: 'name',
|
||||
align: 'left',
|
||||
label: 'Name',
|
||||
field: 'name'
|
||||
},
|
||||
{
|
||||
name: 'fiat_currency',
|
||||
align: 'left',
|
||||
label: 'Fiat Currency',
|
||||
field: 'fiat_currency'
|
||||
},
|
||||
{
|
||||
name: 'exchange_rate_provider',
|
||||
align: 'left',
|
||||
label: 'Exchange Rate Provider',
|
||||
field: 'exchange_rate_provider'
|
||||
},
|
||||
{
|
||||
name: 'fee',
|
||||
align: 'left',
|
||||
label: 'Fee (%)',
|
||||
field: 'fee'
|
||||
}
|
||||
],
|
||||
pagination: {
|
||||
rowsPerPage: 10
|
||||
}
|
||||
},
|
||||
formDialog: {
|
||||
show: false,
|
||||
fiatCurrencies: _.keys(window.bleskomat_vars.fiat_currencies),
|
||||
exchangeRateProviders: _.keys(
|
||||
window.bleskomat_vars.exchange_rate_providers
|
||||
),
|
||||
data: _.clone(defaultValues)
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
sortedBleskomats: function () {
|
||||
return this.bleskomats.sort(function (a, b) {
|
||||
// Sort by API Key ID alphabetically.
|
||||
var apiKeyId_A = a.api_key_id.toLowerCase()
|
||||
var apiKeyId_B = b.api_key_id.toLowerCase()
|
||||
return apiKeyId_A < apiKeyId_B ? -1 : apiKeyId_A > apiKeyId_B ? 1 : 0
|
||||
})
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getBleskomats: function () {
|
||||
var self = this
|
||||
LNbits.api
|
||||
.request(
|
||||
'GET',
|
||||
'/bleskomat/api/v1/bleskomats?all_wallets=true',
|
||||
this.g.user.wallets[0].adminkey
|
||||
)
|
||||
.then(function (response) {
|
||||
self.bleskomats = response.data.map(function (obj) {
|
||||
return mapBleskomat(obj)
|
||||
})
|
||||
})
|
||||
.catch(function (error) {
|
||||
clearInterval(self.checker)
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
closeFormDialog: function () {
|
||||
this.formDialog.data = _.clone(defaultValues)
|
||||
},
|
||||
exportConfigFile: function (bleskomatId) {
|
||||
var bleskomat = _.findWhere(this.bleskomats, {id: bleskomatId})
|
||||
var fieldToKey = {
|
||||
api_key_id: 'apiKey.id',
|
||||
api_key_secret: 'apiKey.key',
|
||||
api_key_encoding: 'apiKey.encoding',
|
||||
fiat_currency: 'fiatCurrency'
|
||||
}
|
||||
var lines = _.chain(bleskomat)
|
||||
.map(function (value, field) {
|
||||
var key = fieldToKey[field] || null
|
||||
return key ? [key, value].join('=') : null
|
||||
})
|
||||
.compact()
|
||||
.value()
|
||||
lines.push('callbackUrl=' + window.bleskomat_vars.callback_url)
|
||||
lines.push('shorten=true')
|
||||
var content = lines.join('\n')
|
||||
var status = Quasar.utils.exportFile(
|
||||
'bleskomat.conf',
|
||||
content,
|
||||
'text/plain'
|
||||
)
|
||||
if (status !== true) {
|
||||
Quasar.plugins.Notify.create({
|
||||
message: 'Browser denied file download...',
|
||||
color: 'negative',
|
||||
icon: null
|
||||
})
|
||||
}
|
||||
},
|
||||
openUpdateDialog: function (bleskomatId) {
|
||||
var bleskomat = _.findWhere(this.bleskomats, {id: bleskomatId})
|
||||
this.formDialog.data = _.clone(bleskomat._data)
|
||||
this.formDialog.show = true
|
||||
},
|
||||
sendFormData: function () {
|
||||
var wallet = _.findWhere(this.g.user.wallets, {
|
||||
id: this.formDialog.data.wallet
|
||||
})
|
||||
var data = _.omit(this.formDialog.data, 'wallet')
|
||||
if (data.id) {
|
||||
this.updateBleskomat(wallet, data)
|
||||
} else {
|
||||
this.createBleskomat(wallet, data)
|
||||
}
|
||||
},
|
||||
updateBleskomat: function (wallet, data) {
|
||||
var self = this
|
||||
LNbits.api
|
||||
.request(
|
||||
'PUT',
|
||||
'/bleskomat/api/v1/bleskomat/' + data.id,
|
||||
wallet.adminkey,
|
||||
_.pick(data, 'name', 'fiat_currency', 'exchange_rate_provider', 'fee')
|
||||
)
|
||||
.then(function (response) {
|
||||
self.bleskomats = _.reject(self.bleskomats, function (obj) {
|
||||
return obj.id === data.id
|
||||
})
|
||||
self.bleskomats.push(mapBleskomat(response.data))
|
||||
self.formDialog.show = false
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
createBleskomat: function (wallet, data) {
|
||||
var self = this
|
||||
LNbits.api
|
||||
.request('POST', '/bleskomat/api/v1/bleskomat', wallet.adminkey, data)
|
||||
.then(function (response) {
|
||||
self.bleskomats.push(mapBleskomat(response.data))
|
||||
self.formDialog.show = false
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
deleteBleskomat: function (bleskomatId) {
|
||||
var self = this
|
||||
var bleskomat = _.findWhere(this.bleskomats, {id: bleskomatId})
|
||||
LNbits.utils
|
||||
.confirmDialog(
|
||||
'Are you sure you want to delete "' + bleskomat.name + '"?'
|
||||
)
|
||||
.onOk(function () {
|
||||
LNbits.api
|
||||
.request(
|
||||
'DELETE',
|
||||
'/bleskomat/api/v1/bleskomat/' + bleskomatId,
|
||||
_.findWhere(self.g.user.wallets, {id: bleskomat.wallet}).adminkey
|
||||
)
|
||||
.then(function (response) {
|
||||
self.bleskomats = _.reject(self.bleskomats, function (obj) {
|
||||
return obj.id === bleskomatId
|
||||
})
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
})
|
||||
}
|
||||
},
|
||||
created: function () {
|
||||
if (this.g.user.wallets.length) {
|
||||
var getBleskomats = this.getBleskomats
|
||||
getBleskomats()
|
||||
this.checker = setInterval(function () {
|
||||
getBleskomats()
|
||||
}, 20000)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -1,68 +0,0 @@
|
||||
<q-expansion-item
|
||||
group="extras"
|
||||
icon="swap_vertical_circle"
|
||||
label="Setup guide"
|
||||
:content-inset-level="0.5"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<p>
|
||||
This extension allows you to connect a Bleskomat ATM to an lnbits
|
||||
wallet. It will work with both the
|
||||
<a class="text-secondary" href="https://github.com/samotari/bleskomat"
|
||||
>open-source DIY Bleskomat ATM project</a
|
||||
>
|
||||
as well as the
|
||||
<a class="text-secondary" href="https://www.bleskomat.com/"
|
||||
>commercial Bleskomat ATM</a
|
||||
>.
|
||||
</p>
|
||||
<h5 class="text-subtitle1 q-my-none">Connect Your Bleskomat ATM</h5>
|
||||
<div>
|
||||
<ol>
|
||||
<li>Click the "Add Bleskomat" button on this page to begin.</li>
|
||||
<li>
|
||||
Choose a wallet. This will be the wallet that is used to pay
|
||||
satoshis to your ATM customers.
|
||||
</li>
|
||||
<li>
|
||||
Choose the fiat currency. This should match the fiat currency that
|
||||
your ATM accepts.
|
||||
</li>
|
||||
<li>
|
||||
Pick an exchange rate provider. This is the API that will be used to
|
||||
query the fiat to satoshi exchange rate at the time your customer
|
||||
attempts to withdraw their funds.
|
||||
</li>
|
||||
<li>Set your ATM's fee percentage.</li>
|
||||
<li>Click the "Done" button.</li>
|
||||
<li>
|
||||
Find the new Bleskomat in the list and then click the export icon to
|
||||
download a new configuration file for your ATM.
|
||||
</li>
|
||||
<li>
|
||||
Copy the configuration file ("bleskomat.conf") to your ATM's SD
|
||||
card.
|
||||
</li>
|
||||
<li>
|
||||
Restart Your Bleskomat ATM. It should automatically reload the
|
||||
configurations from the SD card.
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
<h5 class="text-subtitle1 q-my-none">How does it work?</h5>
|
||||
<p>
|
||||
Since the Bleskomat ATMs are designed to be offline, a cryptographic
|
||||
signing scheme is used to verify that the URL was generated by an
|
||||
authorized device. When one of your customers inserts fiat money into
|
||||
the device, a signed URL (lnurl-withdraw) is created and displayed as a
|
||||
QR code. Your customer scans the QR code with their lnurl-supporting
|
||||
mobile app, their mobile app communicates with the web API of lnbits to
|
||||
verify the signature, the fiat currency amount is converted to sats, the
|
||||
customer accepts the withdrawal, and finally lnbits will pay the
|
||||
customer from your lnbits wallet.
|
||||
</p>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
<q-btn flat label="Swagger API" type="a" href="../docs#/Bleskomat"></q-btn>
|
||||
</q-expansion-item>
|
||||
@@ -1,180 +0,0 @@
|
||||
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
|
||||
%} {% block scripts %} {{ window_vars(user) }}
|
||||
<script>
|
||||
{% if bleskomat_vars %}
|
||||
window.bleskomat_vars = {{ bleskomat_vars | tojson | safe }}
|
||||
{% endif %}
|
||||
</script>
|
||||
<script src="/bleskomat/static/js/index.js"></script>
|
||||
{% endblock %} {% block page %}
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 col-md-7 q-gutter-y-md">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<q-btn unelevated color="primary" @click="formDialog.show = true"
|
||||
>Add Bleskomat</q-btn
|
||||
>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<div class="row items-center no-wrap q-mb-md">
|
||||
<div class="col">
|
||||
<h5 class="text-subtitle1 q-my-none">Bleskomats</h5>
|
||||
</div>
|
||||
</div>
|
||||
<q-table
|
||||
dense
|
||||
flat
|
||||
:data="sortedBleskomats"
|
||||
row-key="id"
|
||||
:columns="bleskomatsTable.columns"
|
||||
:pagination.sync="bleskomatsTable.pagination"
|
||||
>
|
||||
{% raw %}
|
||||
<template v-slot:header="props">
|
||||
<q-tr :props="props">
|
||||
<q-th auto-width></q-th>
|
||||
<q-th v-for="col in props.cols" :key="col.name" :props="props">
|
||||
{{ col.label }}
|
||||
</q-th>
|
||||
<q-th auto-width></q-th>
|
||||
</q-tr>
|
||||
</template>
|
||||
<template v-slot:body="props">
|
||||
<q-tr :props="props">
|
||||
<q-td auto-width>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
size="xs"
|
||||
icon="file_download"
|
||||
color="orange"
|
||||
@click="exportConfigFile(props.row.id)"
|
||||
>
|
||||
<q-tooltip content-class="bg-accent"
|
||||
>Export Configuration</q-tooltip
|
||||
>
|
||||
</q-btn>
|
||||
</q-td>
|
||||
<q-td v-for="col in props.cols" :key="col.name" :props="props">
|
||||
{{ col.value }}
|
||||
</q-td>
|
||||
<q-td auto-width>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
size="xs"
|
||||
@click="openUpdateDialog(props.row.id)"
|
||||
icon="edit"
|
||||
color="light-blue"
|
||||
>
|
||||
<q-tooltip content-class="bg-accent">Edit</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
size="xs"
|
||||
@click="deleteBleskomat(props.row.id)"
|
||||
icon="cancel"
|
||||
color="pink"
|
||||
>
|
||||
<q-tooltip content-class="bg-accent">Delete</q-tooltip>
|
||||
</q-btn>
|
||||
</q-td>
|
||||
</q-tr>
|
||||
</template>
|
||||
{% endraw %}
|
||||
</q-table>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-5 q-gutter-y-md">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<h6 class="text-subtitle1 q-my-none">
|
||||
{{SITE_TITLE}} Bleskomat extension
|
||||
</h6>
|
||||
</q-card-section>
|
||||
<q-card-section class="q-pa-none">
|
||||
<q-separator></q-separator>
|
||||
<q-list> {% include "bleskomat/_api_docs.html" %} </q-list>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
|
||||
<q-dialog v-model="formDialog.show" position="top" @hide="closeFormDialog">
|
||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<q-form @submit="sendFormData" class="q-gutter-md">
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
emit-value
|
||||
v-model="formDialog.data.wallet"
|
||||
:options="g.user.walletOptions"
|
||||
label="Wallet *"
|
||||
>
|
||||
</q-select>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialog.data.name"
|
||||
type="text"
|
||||
label="Name *"
|
||||
></q-input>
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
v-model="formDialog.data.fiat_currency"
|
||||
:options="formDialog.fiatCurrencies"
|
||||
label="Fiat Currency *"
|
||||
>
|
||||
</q-select>
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
v-model="formDialog.data.exchange_rate_provider"
|
||||
:options="formDialog.exchangeRateProviders"
|
||||
label="Exchange Rate Provider *"
|
||||
>
|
||||
</q-select>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.number="formDialog.data.fee"
|
||||
type="string"
|
||||
:default="0.00"
|
||||
label="Fee (%) *"
|
||||
></q-input>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
v-if="formDialog.data.id"
|
||||
unelevated
|
||||
color="primary"
|
||||
type="submit"
|
||||
>Update Bleskomat</q-btn
|
||||
>
|
||||
<q-btn
|
||||
v-else
|
||||
unelevated
|
||||
color="primary"
|
||||
:disable="
|
||||
formDialog.data.wallet == null ||
|
||||
formDialog.data.name == null ||
|
||||
formDialog.data.fiat_currency == null ||
|
||||
formDialog.data.exchange_rate_provider == null ||
|
||||
formDialog.data.fee == null"
|
||||
type="submit"
|
||||
>Add Bleskomat</q-btn
|
||||
>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
|
||||
>Cancel</q-btn
|
||||
>
|
||||
</div>
|
||||
</q-form>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,25 +0,0 @@
|
||||
from fastapi import Depends, Request
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from starlette.responses import HTMLResponse
|
||||
|
||||
from lnbits.core.models import User
|
||||
from lnbits.decorators import check_user_exists
|
||||
|
||||
from . import bleskomat_ext, bleskomat_renderer
|
||||
from .exchange_rates import exchange_rate_providers_serializable, fiat_currencies
|
||||
from .helpers import get_callback_url
|
||||
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
|
||||
@bleskomat_ext.get("/", response_class=HTMLResponse)
|
||||
async def index(req: Request, user: User = Depends(check_user_exists)):
|
||||
bleskomat_vars = {
|
||||
"callback_url": get_callback_url(req),
|
||||
"exchange_rate_providers": exchange_rate_providers_serializable,
|
||||
"fiat_currencies": fiat_currencies,
|
||||
}
|
||||
return bleskomat_renderer().TemplateResponse(
|
||||
"bleskomat/index.html",
|
||||
{"request": req, "user": user.dict(), "bleskomat_vars": bleskomat_vars},
|
||||
)
|
||||
@@ -1,100 +0,0 @@
|
||||
from http import HTTPStatus
|
||||
|
||||
from fastapi import Depends, Query
|
||||
from loguru import logger
|
||||
from starlette.exceptions import HTTPException
|
||||
|
||||
from lnbits.core.crud import get_user
|
||||
from lnbits.decorators import WalletTypeInfo, require_admin_key
|
||||
|
||||
from . import bleskomat_ext
|
||||
from .crud import (
|
||||
create_bleskomat,
|
||||
delete_bleskomat,
|
||||
get_bleskomat,
|
||||
get_bleskomats,
|
||||
update_bleskomat,
|
||||
)
|
||||
from .exchange_rates import fetch_fiat_exchange_rate
|
||||
from .models import CreateBleskomat
|
||||
|
||||
|
||||
@bleskomat_ext.get("/api/v1/bleskomats")
|
||||
async def api_bleskomats(
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||
all_wallets: bool = Query(False),
|
||||
):
|
||||
wallet_ids = [wallet.wallet.id]
|
||||
|
||||
if all_wallets:
|
||||
user = await get_user(wallet.wallet.user)
|
||||
wallet_ids = user.wallet_ids if user else []
|
||||
|
||||
return [bleskomat.dict() for bleskomat in await get_bleskomats(wallet_ids)]
|
||||
|
||||
|
||||
@bleskomat_ext.get("/api/v1/bleskomat/{bleskomat_id}")
|
||||
async def api_bleskomat_retrieve(
|
||||
bleskomat_id, wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||
):
|
||||
bleskomat = await get_bleskomat(bleskomat_id)
|
||||
|
||||
if not bleskomat or bleskomat.wallet != wallet.wallet.id:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND,
|
||||
detail="Bleskomat configuration not found.",
|
||||
)
|
||||
|
||||
return bleskomat.dict()
|
||||
|
||||
|
||||
@bleskomat_ext.post("/api/v1/bleskomat")
|
||||
@bleskomat_ext.put("/api/v1/bleskomat/{bleskomat_id}")
|
||||
async def api_bleskomat_create_or_update(
|
||||
data: CreateBleskomat,
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||
bleskomat_id=None,
|
||||
):
|
||||
fiat_currency = data.fiat_currency
|
||||
exchange_rate_provider = data.exchange_rate_provider
|
||||
try:
|
||||
await fetch_fiat_exchange_rate(
|
||||
currency=fiat_currency, provider=exchange_rate_provider
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
detail=f'Failed to fetch BTC/{fiat_currency} currency pair from "{exchange_rate_provider}"',
|
||||
)
|
||||
|
||||
if bleskomat_id:
|
||||
bleskomat = await get_bleskomat(bleskomat_id)
|
||||
if not bleskomat or bleskomat.wallet != wallet.wallet.id:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND,
|
||||
detail="Bleskomat configuration not found.",
|
||||
)
|
||||
|
||||
bleskomat = await update_bleskomat(bleskomat_id, **data.dict())
|
||||
else:
|
||||
bleskomat = await create_bleskomat(wallet_id=wallet.wallet.id, data=data)
|
||||
|
||||
assert bleskomat
|
||||
return bleskomat.dict()
|
||||
|
||||
|
||||
@bleskomat_ext.delete("/api/v1/bleskomat/{bleskomat_id}")
|
||||
async def api_bleskomat_delete(
|
||||
bleskomat_id, wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||
):
|
||||
bleskomat = await get_bleskomat(bleskomat_id)
|
||||
|
||||
if not bleskomat or bleskomat.wallet != wallet.wallet.id:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND,
|
||||
detail="Bleskomat configuration not found.",
|
||||
)
|
||||
|
||||
await delete_bleskomat(bleskomat_id)
|
||||
return "", HTTPStatus.NO_CONTENT
|
||||
@@ -1,88 +0,0 @@
|
||||
# Bolt cards (NXP NTAG) Extension
|
||||
|
||||
This extension allows you to link your Bolt Card (or other compatible NXP NTAG device) with a LNbits instance and use it in a more secure way than a static LNURLw. A technology called [Secure Unique NFC](https://mishka-scan.com/blog/secure-unique-nfc) is utilized in this workflow.
|
||||
|
||||
<a class="text-secondary" href="https://www.youtube.com/watch?v=wJ7QLFTRjK0">Tutorial</a>
|
||||
|
||||
**Disclaimer:** ***Use this only if you either know what you are doing or are a reckless lightning pioneer. Only you are responsible for all your sats, cards and other devices. Always backup all your card keys!***
|
||||
|
||||
|
||||
***In order to use this extension you need to be able to setup your own card.*** That means writing a URL template pointing to your LNbits instance, configuring some SUN (SDM) settings and optionally changing the card's keys. There's a [guide](https://www.whitewolftech.com/articles/payment-card/) to set it up with a card reader connected to your computer. It can be done (without setting the keys) with [TagWriter app by NXP](https://play.google.com/store/apps/details?id=com.nxp.nfc.tagwriter) Android app. Last but not least, an OSS android app by name [Boltcard NFC Card Creator](https://github.com/boltcard/bolt-nfc-android-app) is being developed for these purposes. It's available from Google Play [here](https://play.google.com/store/apps/details?id=com.lightningnfcapp).
|
||||
|
||||
## About the keys
|
||||
|
||||
Up to five 16-byte keys can be stored on the card, numbered from 00 to 04. In the empty state they all should be set to zeros (00000000000000000000000000000000). For this extension only two keys need to be set, but for the security reasons all five keys should be changed from default (empty) state. The keys directly needed by this extension are:
|
||||
|
||||
- One for encrypting the card UID and the counter (p parameter), let's called it meta key, key #01 or K1.
|
||||
|
||||
- One for calculating CMAC (c parameter), let's called it file key, key #02 or K2.
|
||||
|
||||
The key #00, K0 (also know as auth key) is used as authentification key. It is not directly needed by this extension, but should be filled in order to write the keys in cooperation with Boltcard NFC Card Creator. In this case also K3 is set to same value as K1 and K4 as K2, so all keys are changed from default values. Keep that in your mind in case you ever need to reset the keys manually.
|
||||
|
||||
***Always backup all keys that you're trying to write on the card. Without them you may not be able to change them in the future!***
|
||||
|
||||
|
||||
## Setting the card - Boltcard NFC Card Creator (easy way)
|
||||
Updated for v0.1.3
|
||||
|
||||
- Add new card in the extension.
|
||||
- Set a max sats per transaction. Any transaction greater than this amount will be rejected.
|
||||
- Set a max sats per day. After the card spends this amount of sats in a day, additional transactions will be rejected.
|
||||
- Set a card name. This is just for your reference inside LNbits.
|
||||
- Set the card UID. This is the unique identifier on your NFC card and is 7 bytes.
|
||||
- If on an Android device with a newish version of Chrome, you can click the icon next to the input and tap your card to autofill this field.
|
||||
- Otherwise read it with the Android app (Advanced -> Read NFC) and paste it to the field.
|
||||
- Advanced Options
|
||||
- Card Keys (k0, k1, k2) will be automatically generated if not explicitly set.
|
||||
- Set to 16 bytes of 0s (00000000000000000000000000000000) to leave the keys in default (empty) state (this is unsecure).
|
||||
- GENERATE KEY button fill the keys randomly.
|
||||
- Click CREATE CARD button
|
||||
- Click the QR code button next to a card to view its details. Backup the keys now! They'll be comfortable in your password manager.
|
||||
- Now you can scan the QR code with the Android app (Create Bolt Card -> SCAN QR CODE).
|
||||
- Or you can Click the "KEYS / AUTH LINK" button to copy the auth URL to the clipboard. Then paste it into the Android app (Create Bolt Card -> PASTE AUTH URL).
|
||||
- Click WRITE CARD NOW and approach the NFC card to set it up. DO NOT REMOVE THE CARD PREMATURELY!
|
||||
|
||||
## Erasing the card - Boltcard NFC Card Creator
|
||||
Updated for v0.1.3
|
||||
|
||||
Since v0.1.2 of Boltcard NFC Card Creator it is possible not only reset the keys but also disable the SUN function and do the complete erase so the card can be use again as a static tag (or set as a new Bolt Card, ofc).
|
||||
|
||||
- Click the QR code button next to a card to view its details and select WIPE
|
||||
- OR click the red cross icon on the right side to reach the same
|
||||
- In the android app (Advanced -> Reset Keys)
|
||||
- Click SCAN QR CODE to scan the QR
|
||||
- Or click WIPE DATA in LNbits to copy and paste in to the app (PASTE KEY JSON)
|
||||
- Click RESET CARD NOW and approach the NFC card to erase it. DO NOT REMOVE THE CARD PREMATURELY!
|
||||
- Now if there is all success the card can be safely delete from LNbits (but keep the keys backuped anyway; batter safe than brick).
|
||||
|
||||
If you somehow find yourself in some non-standard state (for instance only k3 and k4 remains filled after previous unsuccessful reset), then you need edit the key fields manually (for instance leave k0-k2 to zeroes and provide the right k3 and k4).
|
||||
|
||||
## Setting the card - computer (hard way)
|
||||
|
||||
Follow the guide.
|
||||
|
||||
The URI should be `lnurlw://YOUR-DOMAIN.COM/boltcards/api/v1/scan/{YOUR_card_external_id}?p=00000000000000000000000000000000&c=0000000000000000`
|
||||
|
||||
Then fill up the card parameters in the extension. Card Auth key (K0) can be filled in the extension just for the record. Initical counter can be 0.
|
||||
|
||||
## Setting the card - android NXP app (hard way)
|
||||
- If you don't know the card ID, use NXP TagInfo app to find it out.
|
||||
- In the TagWriter app tap Write tags
|
||||
- New Data Set > Link
|
||||
- Set URI type to Custom URL
|
||||
- URL should look like lnurlw://YOUR_LNBITS_DOMAIN/boltcards/api/v1/scan/{YOUR_card_external_id}?p=00000000000000000000000000000000&c=0000000000000000
|
||||
- click Configure mirroring options
|
||||
- Select Card Type NTAG 424 DNA
|
||||
- Check Enable SDM Mirroring
|
||||
- Select SDM Meta Read Access Right to 01
|
||||
- Check Enable UID Mirroring
|
||||
- Check Enable Counter Mirroring
|
||||
- Set SDM Counter Retrieval Key to 0E
|
||||
- Set PICC Data Offset to immediately after e=
|
||||
- Set Derivation Key for CMAC Calculation to 00
|
||||
- Set SDM MAC Input Offset to immediately after c=
|
||||
- Set SDM MAC Offset to immediately after c=
|
||||
- Save & Write
|
||||
- Scan with compatible Wallet
|
||||
|
||||
This app afaik cannot change the keys. If you cannot change them any other way, leave them empty in the extension dialog and remember you're not secured. Card Auth key (K0) can be omitted anyway. Initical counter can be 0.
|
||||
@@ -1,37 +0,0 @@
|
||||
import asyncio
|
||||
|
||||
from fastapi import APIRouter
|
||||
from starlette.staticfiles import StaticFiles
|
||||
|
||||
from lnbits.db import Database
|
||||
from lnbits.helpers import template_renderer
|
||||
from lnbits.tasks import catch_everything_and_restart
|
||||
|
||||
db = Database("ext_boltcards")
|
||||
|
||||
boltcards_static_files = [
|
||||
{
|
||||
"path": "/boltcards/static",
|
||||
"app": StaticFiles(packages=[("lnbits", "extensions/boltcards/static")]),
|
||||
"name": "boltcards_static",
|
||||
}
|
||||
]
|
||||
|
||||
boltcards_ext: APIRouter = APIRouter(prefix="/boltcards", tags=["boltcards"])
|
||||
|
||||
|
||||
def boltcards_renderer():
|
||||
return template_renderer(["lnbits/extensions/boltcards/templates"])
|
||||
|
||||
|
||||
from .lnurl import * # noqa
|
||||
from .tasks import * # noqa
|
||||
|
||||
|
||||
def boltcards_start():
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.create_task(catch_everything_and_restart(wait_for_paid_invoices))
|
||||
|
||||
|
||||
from .views import * # noqa
|
||||
from .views_api import * # noqa
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"name": "Bolt Cards",
|
||||
"short_description": "Self custody Bolt Cards with one time LNURLw",
|
||||
"tile": "/boltcards/static/image/boltcard.png",
|
||||
"contributors": ["iwarpbtc", "arcbtc", "leesalminen"]
|
||||
}
|
||||
@@ -1,278 +0,0 @@
|
||||
import secrets
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from lnbits.helpers import urlsafe_short_hash
|
||||
|
||||
from . import db
|
||||
from .models import Card, CreateCardData, Hit, Refund
|
||||
|
||||
|
||||
async def create_card(data: CreateCardData, wallet_id: str) -> Card:
|
||||
card_id = urlsafe_short_hash().upper()
|
||||
extenal_id = urlsafe_short_hash().lower()
|
||||
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO boltcards.cards (
|
||||
id,
|
||||
uid,
|
||||
external_id,
|
||||
wallet,
|
||||
card_name,
|
||||
counter,
|
||||
tx_limit,
|
||||
daily_limit,
|
||||
enable,
|
||||
k0,
|
||||
k1,
|
||||
k2,
|
||||
otp
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
card_id,
|
||||
data.uid.upper(),
|
||||
extenal_id,
|
||||
wallet_id,
|
||||
data.card_name,
|
||||
data.counter,
|
||||
data.tx_limit,
|
||||
data.daily_limit,
|
||||
True,
|
||||
data.k0,
|
||||
data.k1,
|
||||
data.k2,
|
||||
secrets.token_hex(16),
|
||||
),
|
||||
)
|
||||
card = await get_card(card_id)
|
||||
assert card, "Newly created card couldn't be retrieved"
|
||||
return card
|
||||
|
||||
|
||||
async def update_card(card_id: str, **kwargs) -> Optional[Card]:
|
||||
if "is_unique" in kwargs:
|
||||
kwargs["is_unique"] = int(kwargs["is_unique"])
|
||||
if "uid" in kwargs:
|
||||
kwargs["uid"] = kwargs["uid"].upper()
|
||||
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
|
||||
await db.execute(
|
||||
f"UPDATE boltcards.cards SET {q} WHERE id = ?",
|
||||
(*kwargs.values(), card_id),
|
||||
)
|
||||
row = await db.fetchone("SELECT * FROM boltcards.cards WHERE id = ?", (card_id,))
|
||||
return Card(**row) if row else None
|
||||
|
||||
|
||||
async def get_cards(wallet_ids: List[str]) -> List[Card]:
|
||||
if len(wallet_ids) == 0:
|
||||
return []
|
||||
|
||||
q = ",".join(["?"] * len(wallet_ids))
|
||||
rows = await db.fetchall(
|
||||
f"SELECT * FROM boltcards.cards WHERE wallet IN ({q})", (*wallet_ids,)
|
||||
)
|
||||
|
||||
return [Card(**row) for row in rows]
|
||||
|
||||
|
||||
async def get_card(card_id: str) -> Optional[Card]:
|
||||
row = await db.fetchone("SELECT * FROM boltcards.cards WHERE id = ?", (card_id,))
|
||||
if not row:
|
||||
return None
|
||||
|
||||
card = dict(**row)
|
||||
|
||||
return Card.parse_obj(card)
|
||||
|
||||
|
||||
async def get_card_by_uid(card_uid: str) -> Optional[Card]:
|
||||
row = await db.fetchone(
|
||||
"SELECT * FROM boltcards.cards WHERE uid = ?", (card_uid.upper(),)
|
||||
)
|
||||
if not row:
|
||||
return None
|
||||
|
||||
card = dict(**row)
|
||||
|
||||
return Card.parse_obj(card)
|
||||
|
||||
|
||||
async def get_card_by_external_id(external_id: str) -> Optional[Card]:
|
||||
row = await db.fetchone(
|
||||
"SELECT * FROM boltcards.cards WHERE external_id = ?", (external_id.lower(),)
|
||||
)
|
||||
if not row:
|
||||
return None
|
||||
|
||||
card = dict(**row)
|
||||
|
||||
return Card.parse_obj(card)
|
||||
|
||||
|
||||
async def get_card_by_otp(otp: str) -> Optional[Card]:
|
||||
row = await db.fetchone("SELECT * FROM boltcards.cards WHERE otp = ?", (otp,))
|
||||
if not row:
|
||||
return None
|
||||
|
||||
card = dict(**row)
|
||||
|
||||
return Card.parse_obj(card)
|
||||
|
||||
|
||||
async def delete_card(card_id: str) -> None:
|
||||
# Delete cards
|
||||
await db.execute("DELETE FROM boltcards.cards WHERE id = ?", (card_id,))
|
||||
# Delete hits
|
||||
hits = await get_hits([card_id])
|
||||
for hit in hits:
|
||||
await db.execute("DELETE FROM boltcards.hits WHERE id = ?", (hit.id,))
|
||||
# Delete refunds
|
||||
refunds = await get_refunds([hit.id])
|
||||
for refund in refunds:
|
||||
await db.execute(
|
||||
"DELETE FROM boltcards.refunds WHERE id = ?", (refund.hit_id,)
|
||||
)
|
||||
|
||||
|
||||
async def update_card_counter(counter: int, id: str):
|
||||
await db.execute(
|
||||
"UPDATE boltcards.cards SET counter = ? WHERE id = ?",
|
||||
(counter, id),
|
||||
)
|
||||
|
||||
|
||||
async def enable_disable_card(enable: bool, id: str) -> Optional[Card]:
|
||||
await db.execute(
|
||||
"UPDATE boltcards.cards SET enable = ? WHERE id = ?",
|
||||
(enable, id),
|
||||
)
|
||||
return await get_card(id)
|
||||
|
||||
|
||||
async def update_card_otp(otp: str, id: str):
|
||||
await db.execute(
|
||||
"UPDATE boltcards.cards SET otp = ? WHERE id = ?",
|
||||
(otp, id),
|
||||
)
|
||||
|
||||
|
||||
async def get_hit(hit_id: str) -> Optional[Hit]:
|
||||
row = await db.fetchone(f"SELECT * FROM boltcards.hits WHERE id = ?", (hit_id,))
|
||||
if not row:
|
||||
return None
|
||||
|
||||
hit = dict(**row)
|
||||
|
||||
return Hit.parse_obj(hit)
|
||||
|
||||
|
||||
async def get_hits(cards_ids: List[str]) -> List[Hit]:
|
||||
if len(cards_ids) == 0:
|
||||
return []
|
||||
|
||||
q = ",".join(["?"] * len(cards_ids))
|
||||
rows = await db.fetchall(
|
||||
f"SELECT * FROM boltcards.hits WHERE card_id IN ({q})", (*cards_ids,)
|
||||
)
|
||||
|
||||
return [Hit(**row) for row in rows]
|
||||
|
||||
|
||||
async def get_hits_today(card_id: str) -> List[Hit]:
|
||||
rows = await db.fetchall(
|
||||
f"SELECT * FROM boltcards.hits WHERE card_id = ?",
|
||||
(card_id,),
|
||||
)
|
||||
updatedrow = []
|
||||
for row in rows:
|
||||
if datetime.now().date() == datetime.fromtimestamp(row.time).date():
|
||||
updatedrow.append(row)
|
||||
|
||||
return [Hit(**row) for row in updatedrow]
|
||||
|
||||
|
||||
async def spend_hit(id: str, amount: int):
|
||||
await db.execute(
|
||||
"UPDATE boltcards.hits SET spent = ?, amount = ? WHERE id = ?",
|
||||
(True, amount, id),
|
||||
)
|
||||
return await get_hit(id)
|
||||
|
||||
|
||||
async def create_hit(card_id, ip, useragent, old_ctr, new_ctr) -> Hit:
|
||||
hit_id = urlsafe_short_hash()
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO boltcards.hits (
|
||||
id,
|
||||
card_id,
|
||||
ip,
|
||||
spent,
|
||||
useragent,
|
||||
old_ctr,
|
||||
new_ctr,
|
||||
amount
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
hit_id,
|
||||
card_id,
|
||||
ip,
|
||||
False,
|
||||
useragent,
|
||||
old_ctr,
|
||||
new_ctr,
|
||||
0,
|
||||
),
|
||||
)
|
||||
hit = await get_hit(hit_id)
|
||||
assert hit, "Newly recorded hit couldn't be retrieved"
|
||||
return hit
|
||||
|
||||
|
||||
async def create_refund(hit_id, refund_amount) -> Refund:
|
||||
refund_id = urlsafe_short_hash()
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO boltcards.refunds (
|
||||
id,
|
||||
hit_id,
|
||||
refund_amount
|
||||
)
|
||||
VALUES (?, ?, ?)
|
||||
""",
|
||||
(
|
||||
refund_id,
|
||||
hit_id,
|
||||
refund_amount,
|
||||
),
|
||||
)
|
||||
refund = await get_refund(refund_id)
|
||||
assert refund, "Newly recorded hit couldn't be retrieved"
|
||||
return refund
|
||||
|
||||
|
||||
async def get_refund(refund_id: str) -> Optional[Refund]:
|
||||
row = await db.fetchone(
|
||||
f"SELECT * FROM boltcards.refunds WHERE id = ?", (refund_id,)
|
||||
)
|
||||
if not row:
|
||||
return None
|
||||
refund = dict(**row)
|
||||
return Refund.parse_obj(refund)
|
||||
|
||||
|
||||
async def get_refunds(hits_ids: List[str]) -> List[Refund]:
|
||||
if len(hits_ids) == 0:
|
||||
return []
|
||||
|
||||
q = ",".join(["?"] * len(hits_ids))
|
||||
rows = await db.fetchall(
|
||||
f"SELECT * FROM boltcards.refunds WHERE hit_id IN ({q})", (*hits_ids,)
|
||||
)
|
||||
|
||||
return [Refund(**row) for row in rows]
|
||||
@@ -1,221 +0,0 @@
|
||||
import json
|
||||
import secrets
|
||||
from http import HTTPStatus
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from fastapi import HTTPException, Query, Request
|
||||
from lnurl import encode as lnurl_encode
|
||||
from lnurl.types import LnurlPayMetadata
|
||||
from starlette.responses import HTMLResponse
|
||||
|
||||
from lnbits import bolt11
|
||||
from lnbits.core.services import create_invoice
|
||||
from lnbits.core.views.api import pay_invoice
|
||||
|
||||
from . import boltcards_ext
|
||||
from .crud import (
|
||||
create_hit,
|
||||
get_card,
|
||||
get_card_by_external_id,
|
||||
get_card_by_otp,
|
||||
get_hit,
|
||||
get_hits_today,
|
||||
spend_hit,
|
||||
update_card_counter,
|
||||
update_card_otp,
|
||||
)
|
||||
from .nxp424 import decryptSUN, getSunMAC
|
||||
|
||||
###############LNURLWITHDRAW#################
|
||||
|
||||
# /boltcards/api/v1/scan?p=00000000000000000000000000000000&c=0000000000000000
|
||||
@boltcards_ext.get("/api/v1/scan/{external_id}")
|
||||
async def api_scan(p, c, request: Request, external_id: str = Query(None)):
|
||||
# some wallets send everything as lower case, no bueno
|
||||
p = p.upper()
|
||||
c = c.upper()
|
||||
card = None
|
||||
counter = b""
|
||||
card = await get_card_by_external_id(external_id)
|
||||
if not card:
|
||||
return {"status": "ERROR", "reason": "No card."}
|
||||
if not card.enable:
|
||||
return {"status": "ERROR", "reason": "Card is disabled."}
|
||||
try:
|
||||
card_uid, counter = decryptSUN(bytes.fromhex(p), bytes.fromhex(card.k1))
|
||||
if card.uid.upper() != card_uid.hex().upper():
|
||||
return {"status": "ERROR", "reason": "Card UID mis-match."}
|
||||
if c != getSunMAC(card_uid, counter, bytes.fromhex(card.k2)).hex().upper():
|
||||
return {"status": "ERROR", "reason": "CMAC does not check."}
|
||||
except:
|
||||
return {"status": "ERROR", "reason": "Error decrypting card."}
|
||||
|
||||
ctr_int = int.from_bytes(counter, "little")
|
||||
|
||||
if ctr_int <= card.counter:
|
||||
return {"status": "ERROR", "reason": "This link is already used."}
|
||||
|
||||
await update_card_counter(ctr_int, card.id)
|
||||
|
||||
# gathering some info for hit record
|
||||
assert request.client
|
||||
ip = request.client.host
|
||||
if "x-real-ip" in request.headers:
|
||||
ip = request.headers["x-real-ip"]
|
||||
elif "x-forwarded-for" in request.headers:
|
||||
ip = request.headers["x-forwarded-for"]
|
||||
|
||||
agent = request.headers["user-agent"] if "user-agent" in request.headers else ""
|
||||
todays_hits = await get_hits_today(card.id)
|
||||
|
||||
hits_amount = 0
|
||||
for hit in todays_hits:
|
||||
hits_amount = hits_amount + hit.amount
|
||||
if hits_amount > card.daily_limit:
|
||||
return {"status": "ERROR", "reason": "Max daily limit spent."}
|
||||
hit = await create_hit(card.id, ip, agent, card.counter, ctr_int)
|
||||
lnurlpay = lnurl_encode(request.url_for("boltcards.lnurlp_response", hit_id=hit.id))
|
||||
return {
|
||||
"tag": "withdrawRequest",
|
||||
"callback": request.url_for("boltcards.lnurl_callback", hitid=hit.id),
|
||||
"k1": hit.id,
|
||||
"minWithdrawable": 1 * 1000,
|
||||
"maxWithdrawable": card.tx_limit * 1000,
|
||||
"defaultDescription": f"Boltcard (refund address lnurl://{lnurlpay})",
|
||||
}
|
||||
|
||||
|
||||
@boltcards_ext.get(
|
||||
"/api/v1/lnurl/cb/{hitid}",
|
||||
status_code=HTTPStatus.OK,
|
||||
name="boltcards.lnurl_callback",
|
||||
)
|
||||
async def lnurl_callback(
|
||||
pr: str = Query(None),
|
||||
k1: str = Query(None),
|
||||
):
|
||||
if not k1:
|
||||
return {"status": "ERROR", "reason": "Missing K1 token"}
|
||||
|
||||
hit = await get_hit(k1)
|
||||
|
||||
if not hit:
|
||||
return {
|
||||
"status": "ERROR",
|
||||
"reason": "Record not found for this charge (bad k1)",
|
||||
}
|
||||
if hit.spent:
|
||||
return {"status": "ERROR", "reason": "Payment already claimed"}
|
||||
if not pr:
|
||||
return {"status": "ERROR", "reason": "Missing payment request"}
|
||||
|
||||
try:
|
||||
invoice = bolt11.decode(pr)
|
||||
except:
|
||||
return {"status": "ERROR", "reason": "Failed to decode payment request"}
|
||||
|
||||
card = await get_card(hit.card_id)
|
||||
assert card
|
||||
hit = await spend_hit(id=hit.id, amount=int(invoice.amount_msat / 1000))
|
||||
assert hit
|
||||
try:
|
||||
await pay_invoice(
|
||||
wallet_id=card.wallet,
|
||||
payment_request=pr,
|
||||
max_sat=card.tx_limit,
|
||||
extra={"tag": "boltcard", "tag": hit.id},
|
||||
)
|
||||
return {"status": "OK"}
|
||||
except Exception as exc:
|
||||
return {"status": "ERROR", "reason": f"Payment failed - {exc}"}
|
||||
|
||||
|
||||
# /boltcards/api/v1/auth?a=00000000000000000000000000000000
|
||||
@boltcards_ext.get("/api/v1/auth")
|
||||
async def api_auth(a, request: Request):
|
||||
if a == "00000000000000000000000000000000":
|
||||
response = {"k0": "0" * 32, "k1": "1" * 32, "k2": "2" * 32}
|
||||
return response
|
||||
|
||||
card = await get_card_by_otp(a)
|
||||
if not card:
|
||||
raise HTTPException(
|
||||
detail="Card does not exist.", status_code=HTTPStatus.NOT_FOUND
|
||||
)
|
||||
|
||||
new_otp = secrets.token_hex(16)
|
||||
await update_card_otp(new_otp, card.id)
|
||||
|
||||
lnurlw_base = (
|
||||
f"{urlparse(str(request.url)).netloc}/boltcards/api/v1/scan/{card.external_id}"
|
||||
)
|
||||
|
||||
response = {
|
||||
"card_name": card.card_name,
|
||||
"id": str(1),
|
||||
"k0": card.k0,
|
||||
"k1": card.k1,
|
||||
"k2": card.k2,
|
||||
"k3": card.k1,
|
||||
"k4": card.k2,
|
||||
"lnurlw_base": "lnurlw://" + lnurlw_base,
|
||||
"protocol_name": "new_bolt_card_response",
|
||||
"protocol_version": str(1),
|
||||
}
|
||||
|
||||
return response
|
||||
|
||||
|
||||
###############LNURLPAY REFUNDS#################
|
||||
|
||||
|
||||
@boltcards_ext.get(
|
||||
"/api/v1/lnurlp/{hit_id}",
|
||||
response_class=HTMLResponse,
|
||||
name="boltcards.lnurlp_response",
|
||||
)
|
||||
async def lnurlp_response(req: Request, hit_id: str = Query(None)):
|
||||
hit = await get_hit(hit_id)
|
||||
assert hit
|
||||
card = await get_card(hit.card_id)
|
||||
assert card
|
||||
if not hit:
|
||||
return {"status": "ERROR", "reason": f"LNURL-pay record not found."}
|
||||
if not card.enable:
|
||||
return {"status": "ERROR", "reason": "Card is disabled."}
|
||||
payResponse = {
|
||||
"tag": "payRequest",
|
||||
"callback": req.url_for("boltcards.lnurlp_callback", hit_id=hit_id),
|
||||
"metadata": LnurlPayMetadata(json.dumps([["text/plain", "Refund"]])),
|
||||
"minSendable": 1 * 1000,
|
||||
"maxSendable": card.tx_limit * 1000,
|
||||
}
|
||||
return json.dumps(payResponse)
|
||||
|
||||
|
||||
@boltcards_ext.get(
|
||||
"/api/v1/lnurlp/cb/{hit_id}",
|
||||
response_class=HTMLResponse,
|
||||
name="boltcards.lnurlp_callback",
|
||||
)
|
||||
async def lnurlp_callback(hit_id: str = Query(None), amount: str = Query(None)):
|
||||
hit = await get_hit(hit_id)
|
||||
assert hit
|
||||
card = await get_card(hit.card_id)
|
||||
assert card
|
||||
if not hit:
|
||||
return {"status": "ERROR", "reason": f"LNURL-pay record not found."}
|
||||
|
||||
_, payment_request = await create_invoice(
|
||||
wallet_id=card.wallet,
|
||||
amount=int(int(amount) / 1000),
|
||||
memo=f"Refund {hit_id}",
|
||||
unhashed_description=LnurlPayMetadata(
|
||||
json.dumps([["text/plain", "Refund"]])
|
||||
).encode(),
|
||||
extra={"refund": hit_id},
|
||||
)
|
||||
|
||||
payResponse = {"pr": payment_request, "routes": []}
|
||||
|
||||
return json.dumps(payResponse)
|
||||
@@ -1,60 +0,0 @@
|
||||
from lnbits.helpers import urlsafe_short_hash
|
||||
|
||||
|
||||
async def m001_initial(db):
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE boltcards.cards (
|
||||
id TEXT PRIMARY KEY UNIQUE,
|
||||
wallet TEXT NOT NULL,
|
||||
card_name TEXT NOT NULL,
|
||||
uid TEXT NOT NULL UNIQUE,
|
||||
external_id TEXT NOT NULL UNIQUE,
|
||||
counter INT NOT NULL DEFAULT 0,
|
||||
tx_limit TEXT NOT NULL,
|
||||
daily_limit TEXT NOT NULL,
|
||||
enable BOOL NOT NULL,
|
||||
k0 TEXT NOT NULL DEFAULT '00000000000000000000000000000000',
|
||||
k1 TEXT NOT NULL DEFAULT '00000000000000000000000000000000',
|
||||
k2 TEXT NOT NULL DEFAULT '00000000000000000000000000000000',
|
||||
prev_k0 TEXT NOT NULL DEFAULT '00000000000000000000000000000000',
|
||||
prev_k1 TEXT NOT NULL DEFAULT '00000000000000000000000000000000',
|
||||
prev_k2 TEXT NOT NULL DEFAULT '00000000000000000000000000000000',
|
||||
otp TEXT NOT NULL DEFAULT '',
|
||||
time TIMESTAMP NOT NULL DEFAULT """
|
||||
+ db.timestamp_now
|
||||
+ """
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
await db.execute(
|
||||
f"""
|
||||
CREATE TABLE boltcards.hits (
|
||||
id TEXT PRIMARY KEY UNIQUE,
|
||||
card_id TEXT NOT NULL,
|
||||
ip TEXT NOT NULL,
|
||||
spent BOOL NOT NULL DEFAULT True,
|
||||
useragent TEXT,
|
||||
old_ctr INT NOT NULL DEFAULT 0,
|
||||
new_ctr INT NOT NULL DEFAULT 0,
|
||||
amount {db.big_int} NOT NULL,
|
||||
time TIMESTAMP NOT NULL DEFAULT """
|
||||
+ db.timestamp_now
|
||||
+ """
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
await db.execute(
|
||||
f"""
|
||||
CREATE TABLE boltcards.refunds (
|
||||
id TEXT PRIMARY KEY UNIQUE,
|
||||
hit_id TEXT NOT NULL,
|
||||
refund_amount {db.big_int} NOT NULL,
|
||||
time TIMESTAMP NOT NULL DEFAULT """
|
||||
+ db.timestamp_now
|
||||
+ """
|
||||
);
|
||||
"""
|
||||
)
|
||||
@@ -1,83 +0,0 @@
|
||||
import json
|
||||
from sqlite3 import Row
|
||||
|
||||
from fastapi import Query, Request
|
||||
from lnurl import Lnurl
|
||||
from lnurl import encode as lnurl_encode
|
||||
from lnurl.types import LnurlPayMetadata
|
||||
from pydantic import BaseModel
|
||||
|
||||
ZERO_KEY = "00000000000000000000000000000000"
|
||||
|
||||
|
||||
class Card(BaseModel):
|
||||
id: str
|
||||
wallet: str
|
||||
card_name: str
|
||||
uid: str
|
||||
external_id: str
|
||||
counter: int
|
||||
tx_limit: int
|
||||
daily_limit: int
|
||||
enable: bool
|
||||
k0: str
|
||||
k1: str
|
||||
k2: str
|
||||
prev_k0: str
|
||||
prev_k1: str
|
||||
prev_k2: str
|
||||
otp: str
|
||||
time: int
|
||||
|
||||
@classmethod
|
||||
def from_row(cls, row: Row) -> "Card":
|
||||
return cls(**dict(row))
|
||||
|
||||
def lnurl(self, req: Request) -> Lnurl:
|
||||
url = req.url_for("boltcard.lnurl_response", device_id=self.id, _external=True)
|
||||
return lnurl_encode(url)
|
||||
|
||||
async def lnurlpay_metadata(self) -> LnurlPayMetadata:
|
||||
return LnurlPayMetadata(json.dumps([["text/plain", self.card_name]]))
|
||||
|
||||
|
||||
class CreateCardData(BaseModel):
|
||||
card_name: str = Query(...)
|
||||
uid: str = Query(...)
|
||||
counter: int = Query(0)
|
||||
tx_limit: int = Query(0)
|
||||
daily_limit: int = Query(0)
|
||||
enable: bool = Query(True)
|
||||
k0: str = Query(ZERO_KEY)
|
||||
k1: str = Query(ZERO_KEY)
|
||||
k2: str = Query(ZERO_KEY)
|
||||
prev_k0: str = Query(ZERO_KEY)
|
||||
prev_k1: str = Query(ZERO_KEY)
|
||||
prev_k2: str = Query(ZERO_KEY)
|
||||
|
||||
|
||||
class Hit(BaseModel):
|
||||
id: str
|
||||
card_id: str
|
||||
ip: str
|
||||
spent: bool
|
||||
useragent: str
|
||||
old_ctr: int
|
||||
new_ctr: int
|
||||
amount: int
|
||||
time: int
|
||||
|
||||
@classmethod
|
||||
def from_row(cls, row: Row) -> "Hit":
|
||||
return cls(**dict(row))
|
||||
|
||||
|
||||
class Refund(BaseModel):
|
||||
id: str
|
||||
hit_id: str
|
||||
refund_amount: int
|
||||
time: int
|
||||
|
||||
@classmethod
|
||||
def from_row(cls, row: Row) -> "Refund":
|
||||
return cls(**dict(row))
|
||||
@@ -1,36 +0,0 @@
|
||||
# https://www.nxp.com/docs/en/application-note/AN12196.pdf
|
||||
from typing import Tuple
|
||||
|
||||
from Cryptodome.Cipher import AES
|
||||
from Cryptodome.Hash import CMAC
|
||||
|
||||
SV2 = "3CC300010080"
|
||||
|
||||
|
||||
def myCMAC(key: bytes, msg: bytes = b"") -> bytes:
|
||||
cobj = CMAC.new(key, ciphermod=AES)
|
||||
if msg != b"":
|
||||
cobj.update(msg)
|
||||
return cobj.digest()
|
||||
|
||||
|
||||
def decryptSUN(sun: bytes, key: bytes) -> Tuple[bytes, bytes]:
|
||||
IVbytes = b"\x00" * 16
|
||||
|
||||
cipher = AES.new(key, AES.MODE_CBC, IVbytes)
|
||||
sun_plain = cipher.decrypt(sun)
|
||||
|
||||
UID = sun_plain[1:8]
|
||||
counter = sun_plain[8:11]
|
||||
|
||||
return UID, counter
|
||||
|
||||
|
||||
def getSunMAC(UID: bytes, counter: bytes, key: bytes) -> bytes:
|
||||
sv2prefix = bytes.fromhex(SV2)
|
||||
sv2bytes = sv2prefix + UID + counter
|
||||
|
||||
mac1 = myCMAC(key, sv2bytes)
|
||||
mac2 = myCMAC(mac1)
|
||||
|
||||
return mac2[1::2]
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 33 KiB |
@@ -1,456 +0,0 @@
|
||||
Vue.component(VueQrcode.name, VueQrcode)
|
||||
|
||||
const mapCards = obj => {
|
||||
obj.date = Quasar.utils.date.formatDate(
|
||||
new Date(obj.time * 1000),
|
||||
'YYYY-MM-DD HH:mm'
|
||||
)
|
||||
|
||||
return obj
|
||||
}
|
||||
|
||||
new Vue({
|
||||
el: '#vue',
|
||||
mixins: [windowMixin],
|
||||
data: function () {
|
||||
return {
|
||||
toggleAdvanced: false,
|
||||
nfcTagReading: false,
|
||||
lnurlLink: `${window.location.host}/boltcards/api/v1/scan/`,
|
||||
cards: [],
|
||||
hits: [],
|
||||
refunds: [],
|
||||
cardDialog: {
|
||||
show: false,
|
||||
data: {
|
||||
counter: 1,
|
||||
k0: '',
|
||||
k1: '',
|
||||
k2: '',
|
||||
uid: '',
|
||||
card_name: ''
|
||||
},
|
||||
temp: {}
|
||||
},
|
||||
cardsTable: {
|
||||
columns: [
|
||||
{
|
||||
name: 'card_name',
|
||||
align: 'left',
|
||||
label: 'Card name',
|
||||
field: 'card_name'
|
||||
},
|
||||
{
|
||||
name: 'counter',
|
||||
align: 'left',
|
||||
label: 'Counter',
|
||||
field: 'counter'
|
||||
},
|
||||
{
|
||||
name: 'wallet',
|
||||
align: 'left',
|
||||
label: 'Wallet',
|
||||
field: 'wallet'
|
||||
},
|
||||
{
|
||||
name: 'tx_limit',
|
||||
align: 'left',
|
||||
label: 'Max tx',
|
||||
field: 'tx_limit'
|
||||
},
|
||||
{
|
||||
name: 'daily_limit',
|
||||
align: 'left',
|
||||
label: 'Daily tx limit',
|
||||
field: 'daily_limit'
|
||||
}
|
||||
],
|
||||
pagination: {
|
||||
rowsPerPage: 10
|
||||
}
|
||||
},
|
||||
refundsTable: {
|
||||
columns: [
|
||||
{
|
||||
name: 'hit_id',
|
||||
align: 'left',
|
||||
label: 'Hit ID',
|
||||
field: 'hit_id'
|
||||
},
|
||||
{
|
||||
name: 'refund_amount',
|
||||
align: 'left',
|
||||
label: 'Refund Amount',
|
||||
field: 'refund_amount'
|
||||
},
|
||||
{
|
||||
name: 'date',
|
||||
align: 'left',
|
||||
label: 'Time',
|
||||
field: 'date'
|
||||
}
|
||||
],
|
||||
pagination: {
|
||||
rowsPerPage: 10,
|
||||
sortBy: 'date',
|
||||
descending: true
|
||||
}
|
||||
},
|
||||
hitsTable: {
|
||||
columns: [
|
||||
{
|
||||
name: 'card_name',
|
||||
align: 'left',
|
||||
label: 'Card name',
|
||||
field: 'card_name'
|
||||
},
|
||||
{
|
||||
name: 'amount',
|
||||
align: 'left',
|
||||
label: 'Amount',
|
||||
field: 'amount'
|
||||
},
|
||||
{
|
||||
name: 'old_ctr',
|
||||
align: 'left',
|
||||
label: 'Old counter',
|
||||
field: 'old_ctr'
|
||||
},
|
||||
{
|
||||
name: 'new_ctr',
|
||||
align: 'left',
|
||||
label: 'New counter',
|
||||
field: 'new_ctr'
|
||||
},
|
||||
{
|
||||
name: 'date',
|
||||
align: 'left',
|
||||
label: 'Time',
|
||||
field: 'date'
|
||||
},
|
||||
{
|
||||
name: 'ip',
|
||||
align: 'left',
|
||||
label: 'IP',
|
||||
field: 'ip'
|
||||
},
|
||||
{
|
||||
name: 'useragent',
|
||||
align: 'left',
|
||||
label: 'User agent',
|
||||
field: 'useragent'
|
||||
}
|
||||
],
|
||||
pagination: {
|
||||
rowsPerPage: 10,
|
||||
sortBy: 'date',
|
||||
descending: true
|
||||
}
|
||||
},
|
||||
qrCodeDialog: {
|
||||
show: false,
|
||||
wipe: false,
|
||||
data: null
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
readNfcTag: function () {
|
||||
try {
|
||||
const self = this
|
||||
|
||||
if (typeof NDEFReader == 'undefined') {
|
||||
throw {
|
||||
toString: function () {
|
||||
return 'NFC not supported on this device or browser.'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ndef = new NDEFReader()
|
||||
|
||||
const readerAbortController = new AbortController()
|
||||
readerAbortController.signal.onabort = event => {
|
||||
console.log('All NFC Read operations have been aborted.')
|
||||
}
|
||||
|
||||
this.nfcTagReading = true
|
||||
this.$q.notify({
|
||||
message: 'Tap your NFC tag to copy its UID here.'
|
||||
})
|
||||
|
||||
return ndef.scan({signal: readerAbortController.signal}).then(() => {
|
||||
ndef.onreadingerror = () => {
|
||||
self.nfcTagReading = false
|
||||
|
||||
this.$q.notify({
|
||||
type: 'negative',
|
||||
message: 'There was an error reading this NFC tag.'
|
||||
})
|
||||
|
||||
readerAbortController.abort()
|
||||
}
|
||||
|
||||
ndef.onreading = ({message, serialNumber}) => {
|
||||
//Decode NDEF data from tag
|
||||
var self = this
|
||||
self.cardDialog.data.uid = serialNumber
|
||||
.toUpperCase()
|
||||
.replaceAll(':', '')
|
||||
this.$q.notify({
|
||||
type: 'positive',
|
||||
message: 'NFC tag read successfully.'
|
||||
})
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
this.nfcTagReading = false
|
||||
this.$q.notify({
|
||||
type: 'negative',
|
||||
message: error
|
||||
? error.toString()
|
||||
: 'An unexpected error has occurred.'
|
||||
})
|
||||
}
|
||||
},
|
||||
getCards: function () {
|
||||
var self = this
|
||||
|
||||
LNbits.api
|
||||
.request(
|
||||
'GET',
|
||||
'/boltcards/api/v1/cards?all_wallets=true',
|
||||
this.g.user.wallets[0].inkey
|
||||
)
|
||||
.then(function (response) {
|
||||
self.cards = response.data.map(function (obj) {
|
||||
return mapCards(obj)
|
||||
})
|
||||
})
|
||||
.then(function () {
|
||||
self.getHits()
|
||||
})
|
||||
},
|
||||
getHits: function () {
|
||||
var self = this
|
||||
LNbits.api
|
||||
.request(
|
||||
'GET',
|
||||
'/boltcards/api/v1/hits?all_wallets=true',
|
||||
this.g.user.wallets[0].inkey
|
||||
)
|
||||
.then(function (response) {
|
||||
self.hits = response.data.map(function (obj) {
|
||||
obj.card_name = self.cards.find(d => d.id == obj.card_id).card_name
|
||||
return mapCards(obj)
|
||||
})
|
||||
})
|
||||
},
|
||||
getRefunds: function () {
|
||||
var self = this
|
||||
LNbits.api
|
||||
.request(
|
||||
'GET',
|
||||
'/boltcards/api/v1/refunds?all_wallets=true',
|
||||
this.g.user.wallets[0].inkey
|
||||
)
|
||||
.then(function (response) {
|
||||
self.refunds = response.data.map(function (obj) {
|
||||
return mapCards(obj)
|
||||
})
|
||||
})
|
||||
},
|
||||
openQrCodeDialog(cardId, wipe) {
|
||||
var card = _.findWhere(this.cards, {id: cardId})
|
||||
this.qrCodeDialog.data = {
|
||||
id: card.id,
|
||||
link: window.location.origin + '/boltcards/api/v1/auth?a=' + card.otp,
|
||||
name: card.card_name,
|
||||
uid: card.uid,
|
||||
external_id: card.external_id,
|
||||
k0: card.k0,
|
||||
k1: card.k1,
|
||||
k2: card.k2,
|
||||
k3: card.k1,
|
||||
k4: card.k2
|
||||
}
|
||||
this.qrCodeDialog.data_wipe = JSON.stringify({
|
||||
action: 'wipe',
|
||||
k0: card.k0,
|
||||
k1: card.k1,
|
||||
k2: card.k2,
|
||||
k3: card.k1,
|
||||
k4: card.k2,
|
||||
uid: card.uid,
|
||||
version: 1
|
||||
})
|
||||
this.qrCodeDialog.wipe = wipe
|
||||
this.qrCodeDialog.show = true
|
||||
},
|
||||
addCardOpen: function () {
|
||||
this.cardDialog.show = true
|
||||
this.generateKeys()
|
||||
},
|
||||
generateKeys: function () {
|
||||
var self = this
|
||||
const genRanHex = size =>
|
||||
[...Array(size)]
|
||||
.map(() => Math.floor(Math.random() * 16).toString(16))
|
||||
.join('')
|
||||
|
||||
debugcard =
|
||||
typeof this.cardDialog.data.card_name === 'string' &&
|
||||
this.cardDialog.data.card_name.search('debug') > -1
|
||||
|
||||
self.cardDialog.data.k0 = debugcard
|
||||
? '11111111111111111111111111111111'
|
||||
: genRanHex(32)
|
||||
|
||||
self.cardDialog.data.k1 = debugcard
|
||||
? '22222222222222222222222222222222'
|
||||
: genRanHex(32)
|
||||
|
||||
self.cardDialog.data.k2 = debugcard
|
||||
? '33333333333333333333333333333333'
|
||||
: genRanHex(32)
|
||||
},
|
||||
closeFormDialog: function () {
|
||||
this.cardDialog.data = {}
|
||||
},
|
||||
sendFormData: function () {
|
||||
let wallet = _.findWhere(this.g.user.wallets, {
|
||||
id: this.cardDialog.data.wallet
|
||||
})
|
||||
let data = this.cardDialog.data
|
||||
if (data.id) {
|
||||
this.updateCard(wallet, data)
|
||||
} else {
|
||||
this.createCard(wallet, data)
|
||||
}
|
||||
},
|
||||
createCard: function (wallet, data) {
|
||||
var self = this
|
||||
|
||||
LNbits.api
|
||||
.request('POST', '/boltcards/api/v1/cards', wallet.adminkey, data)
|
||||
.then(function (response) {
|
||||
self.cards.push(mapCards(response.data))
|
||||
self.cardDialog.show = false
|
||||
self.cardDialog.data = {}
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
updateCardDialog: function (formId) {
|
||||
var card = _.findWhere(this.cards, {id: formId})
|
||||
this.cardDialog.data = _.clone(card)
|
||||
|
||||
this.cardDialog.temp.k0 = this.cardDialog.data.k0
|
||||
this.cardDialog.temp.k1 = this.cardDialog.data.k1
|
||||
this.cardDialog.temp.k2 = this.cardDialog.data.k2
|
||||
|
||||
this.cardDialog.show = true
|
||||
},
|
||||
updateCard: function (wallet, data) {
|
||||
var self = this
|
||||
|
||||
if (
|
||||
this.cardDialog.temp.k0 != data.k0 ||
|
||||
this.cardDialog.temp.k1 != data.k1 ||
|
||||
this.cardDialog.temp.k2 != data.k2
|
||||
) {
|
||||
data.prev_k0 = this.cardDialog.temp.k0
|
||||
data.prev_k1 = this.cardDialog.temp.k1
|
||||
data.prev_k2 = this.cardDialog.temp.k2
|
||||
}
|
||||
|
||||
LNbits.api
|
||||
.request(
|
||||
'PUT',
|
||||
'/boltcards/api/v1/cards/' + data.id,
|
||||
wallet.adminkey,
|
||||
data
|
||||
)
|
||||
.then(function (response) {
|
||||
self.cards = _.reject(self.cards, function (obj) {
|
||||
return obj.id == data.id
|
||||
})
|
||||
self.cards.push(mapCards(response.data))
|
||||
self.cardDialog.show = false
|
||||
self.cardDialog.data = {}
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
enableCard: function (wallet, card_id, enable) {
|
||||
var self = this
|
||||
let fullWallet = _.findWhere(self.g.user.wallets, {
|
||||
id: wallet
|
||||
})
|
||||
LNbits.api
|
||||
.request(
|
||||
'GET',
|
||||
'/boltcards/api/v1/cards/enable/' + card_id + '/' + enable,
|
||||
fullWallet.adminkey
|
||||
)
|
||||
.then(function (response) {
|
||||
console.log(response.data)
|
||||
self.cards = _.reject(self.cards, function (obj) {
|
||||
return obj.id == response.data.id
|
||||
})
|
||||
self.cards.push(mapCards(response.data))
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
deleteCard: function (cardId) {
|
||||
let self = this
|
||||
let cards = _.findWhere(this.cards, {id: cardId})
|
||||
|
||||
Quasar.utils.exportFile(
|
||||
cards.card_name + '.json',
|
||||
this.qrCodeDialog.data_wipe,
|
||||
'application/json'
|
||||
)
|
||||
|
||||
LNbits.utils
|
||||
.confirmDialog(
|
||||
"Are you sure you want to delete this card? Without access to the card keys you won't be able to reset them in the future!"
|
||||
)
|
||||
.onOk(function () {
|
||||
LNbits.api
|
||||
.request(
|
||||
'DELETE',
|
||||
'/boltcards/api/v1/cards/' + cardId,
|
||||
_.findWhere(self.g.user.wallets, {id: cards.wallet}).adminkey
|
||||
)
|
||||
.then(function (response) {
|
||||
self.cards = _.reject(self.cards, function (obj) {
|
||||
return obj.id == cardId
|
||||
})
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
})
|
||||
},
|
||||
exportCardsCSV: function () {
|
||||
LNbits.utils.exportCSV(this.cardsTable.columns, this.cards)
|
||||
},
|
||||
exportHitsCSV: function () {
|
||||
LNbits.utils.exportCSV(this.hitsTable.columns, this.hits)
|
||||
},
|
||||
exportRefundsCSV: function () {
|
||||
LNbits.utils.exportCSV(this.refundsTable.columns, this.refunds)
|
||||
}
|
||||
},
|
||||
created: function () {
|
||||
if (this.g.user.wallets.length) {
|
||||
this.getCards()
|
||||
this.getRefunds()
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -1,47 +0,0 @@
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
from lnbits.core import db as core_db
|
||||
from lnbits.core.models import Payment
|
||||
from lnbits.helpers import get_current_extension_name
|
||||
from lnbits.tasks import register_invoice_listener
|
||||
|
||||
from .crud import create_refund, get_hit
|
||||
|
||||
|
||||
async def wait_for_paid_invoices():
|
||||
invoice_queue = asyncio.Queue()
|
||||
register_invoice_listener(invoice_queue, get_current_extension_name())
|
||||
|
||||
while True:
|
||||
payment = await invoice_queue.get()
|
||||
await on_invoice_paid(payment)
|
||||
|
||||
|
||||
async def on_invoice_paid(payment: Payment) -> None:
|
||||
|
||||
if not payment.extra.get("refund"):
|
||||
return
|
||||
|
||||
if payment.extra.get("wh_status"):
|
||||
# this webhook has already been sent
|
||||
return
|
||||
|
||||
hit = await get_hit(str(payment.extra.get("refund")))
|
||||
|
||||
if hit:
|
||||
await create_refund(hit_id=hit.id, refund_amount=(payment.amount / 1000))
|
||||
await mark_webhook_sent(payment, 1)
|
||||
|
||||
|
||||
async def mark_webhook_sent(payment: Payment, status: int) -> None:
|
||||
|
||||
payment.extra["wh_status"] = status
|
||||
|
||||
await core_db.execute(
|
||||
"""
|
||||
UPDATE apipayments SET extra = ?
|
||||
WHERE hash = ?
|
||||
""",
|
||||
(json.dumps(payment.extra), payment.payment_hash),
|
||||
)
|
||||
@@ -1,22 +0,0 @@
|
||||
<q-expansion-item
|
||||
group="extras"
|
||||
icon="swap_vertical_circle"
|
||||
label="About Bolt Cards"
|
||||
:content-inset-level="0.5"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<h5 class="text-subtitle1 q-my-none">Be your own card association</h5>
|
||||
<p>
|
||||
Manage your Bolt Cards self custodian way<br />
|
||||
|
||||
<a
|
||||
class="text-secondary"
|
||||
href="https://github.com/lnbits/lnbits/tree/main/lnbits/extensions/boltcards"
|
||||
>More details</a
|
||||
>
|
||||
<br />
|
||||
</p>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
@@ -1,474 +0,0 @@
|
||||
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
|
||||
%} {% block page %}
|
||||
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 col-md-8 col-lg-7 q-gutter-y-md">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<div class="row items-center no-wrap q-mb-md">
|
||||
<div class="col">
|
||||
<div class="row justify-start" style="width: 150px">
|
||||
<div class="col">
|
||||
<h5 class="text-subtitle1 q-my-none">Cards</h5>
|
||||
</div>
|
||||
<div class="col">
|
||||
<q-btn
|
||||
round
|
||||
size="sm"
|
||||
icon="add"
|
||||
unelevated
|
||||
color="primary"
|
||||
@click="addCardOpen"
|
||||
>
|
||||
<q-tooltip>Add card</q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<q-btn flat color="grey" @click="exportCardsCSV"
|
||||
>Export to CSV</q-btn
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<q-table
|
||||
dense
|
||||
flat
|
||||
:data="cards"
|
||||
row-key="id"
|
||||
:columns="cardsTable.columns"
|
||||
:pagination.sync="cardsTable.pagination"
|
||||
>
|
||||
{% raw %}
|
||||
<template v-slot:header="props">
|
||||
<q-tr :props="props">
|
||||
<q-th auto-width></q-th>
|
||||
<q-th v-for="col in props.cols" :key="col.name" :props="props">
|
||||
{{ col.label }}
|
||||
</q-th>
|
||||
<q-th auto-width></q-th>
|
||||
<q-th auto-width></q-th>
|
||||
<q-th auto-width></q-th>
|
||||
</q-tr>
|
||||
</template>
|
||||
<template v-slot:body="props">
|
||||
<q-tr :props="props">
|
||||
<q-td auto-width>
|
||||
<q-btn
|
||||
unelevated
|
||||
dense
|
||||
icon="qr_code"
|
||||
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
|
||||
@click="openQrCodeDialog(props.row.id, false)"
|
||||
>
|
||||
<q-tooltip>Card key credentials</q-tooltip>
|
||||
</q-btn>
|
||||
</q-td>
|
||||
<q-td v-for="col in props.cols" :key="col.name" :props="props">
|
||||
{{ col.value }}
|
||||
</q-td>
|
||||
<q-td auto-width>
|
||||
<q-btn
|
||||
v-if="props.row.enable"
|
||||
dense
|
||||
@click="enableCard(props.row.wallet, props.row.id, false)"
|
||||
color="pink"
|
||||
>DISABLE</q-btn
|
||||
>
|
||||
<q-btn
|
||||
v-else
|
||||
dense
|
||||
@click="enableCard(props.row.wallet, props.row.id, true)"
|
||||
color="green"
|
||||
>ENABLE
|
||||
</q-btn>
|
||||
</q-td>
|
||||
<q-td auto-width>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
size="xs"
|
||||
@click="updateCardDialog(props.row.id)"
|
||||
icon="edit"
|
||||
color="light-blue"
|
||||
>
|
||||
<q-tooltip>Edit card</q-tooltip>
|
||||
</q-btn>
|
||||
</q-td>
|
||||
<q-td auto-width>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
size="xs"
|
||||
@click="openQrCodeDialog(props.row.id, true)"
|
||||
icon="cancel"
|
||||
color="pink"
|
||||
>
|
||||
<q-tooltip
|
||||
>Deleting card will also delete all records</q-tooltip
|
||||
>
|
||||
</q-btn>
|
||||
</q-td>
|
||||
</q-tr>
|
||||
</template>
|
||||
{% endraw %}
|
||||
</q-table>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<div class="row items-center no-wrap q-mb-md">
|
||||
<div class="col">
|
||||
<h5 class="text-subtitle1 q-my-none">Hits</h5>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<q-btn flat color="grey" @click="exportCardsCSV"
|
||||
>Export to CSV</q-btn
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<q-table
|
||||
dense
|
||||
flat
|
||||
:data="hits"
|
||||
row-key="id"
|
||||
:columns="hitsTable.columns"
|
||||
:pagination.sync="hitsTable.pagination"
|
||||
>
|
||||
{% raw %}
|
||||
<template v-slot:header="props">
|
||||
<q-tr :props="props">
|
||||
<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 v-for="col in props.cols" :key="col.name" :props="props">
|
||||
{{ col.value }}
|
||||
</q-td>
|
||||
</q-tr>
|
||||
</template>
|
||||
{% endraw %}
|
||||
</q-table>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<div class="row items-center no-wrap q-mb-md">
|
||||
<div class="col">
|
||||
<h5 class="text-subtitle1 q-my-none">Refunds</h5>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<q-btn flat color="grey" @click="exportRefundsCSV"
|
||||
>Export to CSV</q-btn
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<q-table
|
||||
dense
|
||||
flat
|
||||
:data="refunds"
|
||||
row-key="id"
|
||||
:columns="refundsTable.columns"
|
||||
:pagination.sync="refundsTable.pagination"
|
||||
>
|
||||
{% raw %}
|
||||
<template v-slot:header="props">
|
||||
<q-tr :props="props">
|
||||
<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 v-for="col in props.cols" :key="col.name" :props="props">
|
||||
{{ col.value }}
|
||||
</q-td>
|
||||
</q-tr>
|
||||
</template>
|
||||
{% endraw %}
|
||||
</q-table>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
<div class="col-12 col-md-4 col-lg-5 q-gutter-y-md">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<h6 class="text-subtitle1 q-my-none">
|
||||
{{SITE_TITLE}} Bolt Cards extension
|
||||
</h6>
|
||||
</q-card-section>
|
||||
<q-card-section class="q-pa-none">
|
||||
<q-separator></q-separator>
|
||||
<q-list> {% include "boltcards/_api_docs.html" %} </q-list>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
<q-dialog v-model="cardDialog.show" position="top" @hide="closeFormDialog">
|
||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<q-form @submit="sendFormData" class="q-gutter-md">
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
emit-value
|
||||
v-model="cardDialog.data.wallet"
|
||||
:options="g.user.walletOptions"
|
||||
:disable="cardDialog.data.id != null"
|
||||
label="Wallet *"
|
||||
>
|
||||
</q-select>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
emit-value
|
||||
v-model.trim="cardDialog.data.tx_limit"
|
||||
type="number"
|
||||
label="Max transaction (sats)"
|
||||
class="q-pr-sm"
|
||||
></q-input>
|
||||
</div>
|
||||
<div class="col">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
emit-value
|
||||
v-model.trim="cardDialog.data.daily_limit"
|
||||
type="number"
|
||||
label="Daily limit (sats)"
|
||||
></q-input>
|
||||
</div>
|
||||
</div>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
emit-value
|
||||
v-model.trim="cardDialog.data.card_name"
|
||||
type="text"
|
||||
label="Card name "
|
||||
>
|
||||
</q-input>
|
||||
<div class="row">
|
||||
<div class="col-10">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
emit-value
|
||||
v-model.trim="cardDialog.data.uid"
|
||||
type="text"
|
||||
label="Card UID "
|
||||
>
|
||||
<q-tooltip
|
||||
>Get from the card you'll use, using an NFC app</q-tooltip
|
||||
>
|
||||
</q-input>
|
||||
</div>
|
||||
<div class="col-2 q-pl-sm">
|
||||
<q-btn
|
||||
outline
|
||||
disable
|
||||
color="grey"
|
||||
icon="nfc"
|
||||
:disable="nfcTagReading"
|
||||
@click="readNfcTag()"
|
||||
>
|
||||
<q-tooltip>Tap card to scan UID</q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<q-toggle
|
||||
v-model="toggleAdvanced"
|
||||
label="Show advanced options"
|
||||
></q-toggle>
|
||||
<div v-show="toggleAdvanced" class="q-gutter-y-md">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="cardDialog.data.k0"
|
||||
type="text"
|
||||
label="Card Auth key (K0)"
|
||||
hint="Used to authentificate with the card (16 bytes in HEX). "
|
||||
@randomkey
|
||||
>
|
||||
</q-input>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="cardDialog.data.k1"
|
||||
type="text"
|
||||
label="Card Meta key (K1)"
|
||||
hint="Used for encypting of the message (16 bytes in HEX)."
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="cardDialog.data.k2"
|
||||
type="text"
|
||||
label="Card File key (K2)"
|
||||
hint="Used for CMAC of the message (16 bytes in HEX)."
|
||||
>
|
||||
</q-input>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.number="cardDialog.data.counter"
|
||||
type="number"
|
||||
label="Initial counter"
|
||||
>
|
||||
<q-tooltip class="bg-grey-8" anchor="bottom left" self="top left"
|
||||
>Zero if you don't know.</q-tooltip
|
||||
>
|
||||
</q-input>
|
||||
<q-btn
|
||||
unelevated
|
||||
color="primary"
|
||||
class="q-ml-auto"
|
||||
v-on:click="generateKeys"
|
||||
v-on:click.right="debugKeys"
|
||||
>Generate keys</q-btn
|
||||
>
|
||||
</div>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
v-if="cardDialog.data.id"
|
||||
unelevated
|
||||
color="primary"
|
||||
type="submit"
|
||||
>Update Card</q-btn
|
||||
>
|
||||
<q-btn
|
||||
v-else
|
||||
unelevated
|
||||
color="primary"
|
||||
:disable="cardDialog.data.uid == null"
|
||||
type="submit"
|
||||
>Create Card
|
||||
</q-btn>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
|
||||
>Cancel</q-btn
|
||||
>
|
||||
</div>
|
||||
</q-form>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<q-dialog v-model="qrCodeDialog.show" position="top">
|
||||
<q-card v-if="qrCodeDialog.data" class="q-pa-lg lnbits__dialog-card">
|
||||
{% raw %}
|
||||
<div class="col q-mt-lg text-center">
|
||||
<q-responsive
|
||||
:ratio="1"
|
||||
class="q-mx-xl q-mb-md"
|
||||
v-show="!qrCodeDialog.wipe"
|
||||
>
|
||||
<qrcode
|
||||
:value="qrCodeDialog.data.link"
|
||||
:options="{width: 800}"
|
||||
class="rounded-borders"
|
||||
></qrcode>
|
||||
</q-responsive>
|
||||
<p class="text-center" v-show="!qrCodeDialog.wipe">
|
||||
(QR for <strong>create</strong> the card in
|
||||
<a
|
||||
class="text-secondary"
|
||||
href="https://play.google.com/store/apps/details?id=com.lightningnfcapp"
|
||||
target="_blank"
|
||||
style="color: inherit"
|
||||
>Boltcard NFC Card Creator</a
|
||||
>)
|
||||
</p>
|
||||
<q-responsive
|
||||
:ratio="1"
|
||||
class="q-mx-xl q-mb-md"
|
||||
v-show="qrCodeDialog.wipe"
|
||||
>
|
||||
<qrcode
|
||||
:value="qrCodeDialog.data_wipe"
|
||||
:options="{width: 800}"
|
||||
class="rounded-borders"
|
||||
></qrcode>
|
||||
</q-responsive>
|
||||
<p class="text-center" v-show="qrCodeDialog.wipe">
|
||||
(QR for <strong>wipe</strong> the card in
|
||||
<a
|
||||
class="text-secondary"
|
||||
href="https://play.google.com/store/apps/details?id=com.lightningnfcapp"
|
||||
target="_blank"
|
||||
style="color: inherit"
|
||||
>Boltcard NFC Card Creator</a
|
||||
>)
|
||||
</p>
|
||||
</div>
|
||||
<div class="col q-mt-md q-mb-md text-center">
|
||||
<q-btn-toggle
|
||||
v-model="qrCodeDialog.wipe"
|
||||
rounded
|
||||
unelevated
|
||||
toggle-color="primary"
|
||||
color="white"
|
||||
text-color="primary"
|
||||
:options="[
|
||||
{label: 'Create', value: false},
|
||||
{label: 'Wipe', value: true}
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
<p style="word-break: break-all">
|
||||
<strong>Name:</strong> {{ qrCodeDialog.data.name }}<br />
|
||||
<strong>UID:</strong> {{ qrCodeDialog.data.uid }}<br />
|
||||
<strong>External ID:</strong> {{ qrCodeDialog.data.external_id }}<br />
|
||||
<strong>Lock key (K0):</strong> {{ qrCodeDialog.data.k0 }}<br />
|
||||
<strong>Meta key (K1 & K3):</strong> {{ qrCodeDialog.data.k1 }}<br />
|
||||
<strong>File key (K2 & K4):</strong> {{ qrCodeDialog.data.k2 }}<br />
|
||||
</p>
|
||||
<p>
|
||||
Always backup all keys that you're trying to write on the card. Without
|
||||
them you may not be able to change them in the future!
|
||||
</p>
|
||||
<q-btn
|
||||
unelevated
|
||||
outline
|
||||
color="grey"
|
||||
@click="copyText(qrCodeDialog.data.link)"
|
||||
label="Create link"
|
||||
v-show="!qrCodeDialog.wipe"
|
||||
>
|
||||
<q-tooltip>Click to copy, then paste to NFC Card Creator</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
unelevated
|
||||
outline
|
||||
color="grey"
|
||||
@click="copyText(qrCodeDialog.data_wipe)"
|
||||
label="Wipe data"
|
||||
v-show="qrCodeDialog.wipe"
|
||||
>
|
||||
<q-tooltip>Click to copy, then paste to NFC Card Creator</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
unelevated
|
||||
outline
|
||||
color="red"
|
||||
@click="deleteCard(qrCodeDialog.data.id)"
|
||||
label="Delete card"
|
||||
v-show="qrCodeDialog.wipe"
|
||||
v-close-popup
|
||||
>
|
||||
<q-tooltip>Backup the keys, or wipe the card first!</q-tooltip>
|
||||
</q-btn>
|
||||
{% endraw %}
|
||||
<div class="row q-mt-lg q-gutter-sm">
|
||||
<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 src="/boltcards/static/js/index.js"></script>
|
||||
{% endblock %}
|
||||
@@ -1,17 +0,0 @@
|
||||
from fastapi import Depends, Request
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from starlette.responses import HTMLResponse
|
||||
|
||||
from lnbits.core.models import User
|
||||
from lnbits.decorators import check_user_exists
|
||||
|
||||
from . import boltcards_ext, boltcards_renderer
|
||||
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
|
||||
@boltcards_ext.get("/", response_class=HTMLResponse)
|
||||
async def index(request: Request, user: User = Depends(check_user_exists)):
|
||||
return boltcards_renderer().TemplateResponse(
|
||||
"boltcards/index.html", {"request": request, "user": user.dict()}
|
||||
)
|
||||
@@ -1,165 +0,0 @@
|
||||
from http import HTTPStatus
|
||||
|
||||
from fastapi import Depends, HTTPException, Query
|
||||
|
||||
from lnbits.core.crud import get_user
|
||||
from lnbits.decorators import WalletTypeInfo, get_key_type, require_admin_key
|
||||
|
||||
from . import boltcards_ext
|
||||
from .crud import (
|
||||
create_card,
|
||||
delete_card,
|
||||
enable_disable_card,
|
||||
get_card,
|
||||
get_card_by_uid,
|
||||
get_cards,
|
||||
get_hits,
|
||||
get_refunds,
|
||||
update_card,
|
||||
)
|
||||
from .models import CreateCardData
|
||||
|
||||
|
||||
@boltcards_ext.get("/api/v1/cards")
|
||||
async def api_cards(
|
||||
g: WalletTypeInfo = Depends(get_key_type), all_wallets: bool = Query(False)
|
||||
):
|
||||
wallet_ids = [g.wallet.id]
|
||||
|
||||
if all_wallets:
|
||||
user = await get_user(g.wallet.user)
|
||||
wallet_ids = user.wallet_ids if user else []
|
||||
|
||||
return [card.dict() for card in await get_cards(wallet_ids)]
|
||||
|
||||
|
||||
@boltcards_ext.post("/api/v1/cards", status_code=HTTPStatus.CREATED)
|
||||
@boltcards_ext.put("/api/v1/cards/{card_id}", status_code=HTTPStatus.OK)
|
||||
async def api_card_create_or_update(
|
||||
data: CreateCardData,
|
||||
card_id: str = Query(None),
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||
):
|
||||
try:
|
||||
if len(bytes.fromhex(data.uid)) != 7:
|
||||
raise HTTPException(
|
||||
detail="Invalid bytes for card uid.", status_code=HTTPStatus.BAD_REQUEST
|
||||
)
|
||||
|
||||
if len(bytes.fromhex(data.k0)) != 16:
|
||||
raise HTTPException(
|
||||
detail="Invalid bytes for k0.", status_code=HTTPStatus.BAD_REQUEST
|
||||
)
|
||||
|
||||
if len(bytes.fromhex(data.k1)) != 16:
|
||||
raise HTTPException(
|
||||
detail="Invalid bytes for k1.", status_code=HTTPStatus.BAD_REQUEST
|
||||
)
|
||||
|
||||
if len(bytes.fromhex(data.k2)) != 16:
|
||||
raise HTTPException(
|
||||
detail="Invalid bytes for k2.", status_code=HTTPStatus.BAD_REQUEST
|
||||
)
|
||||
except:
|
||||
raise HTTPException(
|
||||
detail="Invalid byte data provided.", status_code=HTTPStatus.BAD_REQUEST
|
||||
)
|
||||
if card_id:
|
||||
card = await get_card(card_id)
|
||||
if not card:
|
||||
raise HTTPException(
|
||||
detail="Card does not exist.", status_code=HTTPStatus.NOT_FOUND
|
||||
)
|
||||
if card.wallet != wallet.wallet.id:
|
||||
raise HTTPException(
|
||||
detail="Not your card.", status_code=HTTPStatus.FORBIDDEN
|
||||
)
|
||||
checkUid = await get_card_by_uid(data.uid)
|
||||
if checkUid and checkUid.id != card_id:
|
||||
raise HTTPException(
|
||||
detail="UID already registered. Delete registered card and try again.",
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
)
|
||||
card = await update_card(card_id, **data.dict())
|
||||
else:
|
||||
checkUid = await get_card_by_uid(data.uid)
|
||||
if checkUid:
|
||||
raise HTTPException(
|
||||
detail="UID already registered. Delete registered card and try again.",
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
)
|
||||
card = await create_card(wallet_id=wallet.wallet.id, data=data)
|
||||
assert card
|
||||
return card.dict()
|
||||
|
||||
|
||||
@boltcards_ext.get("/api/v1/cards/enable/{card_id}/{enable}", status_code=HTTPStatus.OK)
|
||||
async def enable_card(
|
||||
card_id,
|
||||
enable,
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||
):
|
||||
card = await get_card(card_id)
|
||||
if not card:
|
||||
raise HTTPException(detail="No card found.", status_code=HTTPStatus.NOT_FOUND)
|
||||
if card.wallet != wallet.wallet.id:
|
||||
raise HTTPException(detail="Not your card.", status_code=HTTPStatus.FORBIDDEN)
|
||||
card = await enable_disable_card(enable=enable, id=card_id)
|
||||
assert card
|
||||
return card.dict()
|
||||
|
||||
|
||||
@boltcards_ext.delete("/api/v1/cards/{card_id}")
|
||||
async def api_card_delete(card_id, wallet: WalletTypeInfo = Depends(require_admin_key)):
|
||||
card = await get_card(card_id)
|
||||
|
||||
if not card:
|
||||
raise HTTPException(
|
||||
detail="Card does not exist.", status_code=HTTPStatus.NOT_FOUND
|
||||
)
|
||||
|
||||
if card.wallet != wallet.wallet.id:
|
||||
raise HTTPException(detail="Not your card.", status_code=HTTPStatus.FORBIDDEN)
|
||||
|
||||
await delete_card(card_id)
|
||||
return "", HTTPStatus.NO_CONTENT
|
||||
|
||||
|
||||
@boltcards_ext.get("/api/v1/hits")
|
||||
async def api_hits(
|
||||
g: WalletTypeInfo = Depends(get_key_type), all_wallets: bool = Query(False)
|
||||
):
|
||||
wallet_ids = [g.wallet.id]
|
||||
|
||||
if all_wallets:
|
||||
user = await get_user(g.wallet.user)
|
||||
wallet_ids = user.wallet_ids if user else []
|
||||
|
||||
cards = await get_cards(wallet_ids)
|
||||
cards_ids = []
|
||||
for card in cards:
|
||||
cards_ids.append(card.id)
|
||||
|
||||
return [hit.dict() for hit in await get_hits(cards_ids)]
|
||||
|
||||
|
||||
@boltcards_ext.get("/api/v1/refunds")
|
||||
async def api_refunds(
|
||||
g: WalletTypeInfo = Depends(get_key_type), all_wallets: bool = Query(False)
|
||||
):
|
||||
wallet_ids = [g.wallet.id]
|
||||
|
||||
if all_wallets:
|
||||
user = await get_user(g.wallet.user)
|
||||
wallet_ids = user.wallet_ids if user else []
|
||||
|
||||
cards = await get_cards(wallet_ids)
|
||||
cards_ids = []
|
||||
for card in cards:
|
||||
cards_ids.append(card.id)
|
||||
hits = await get_hits(cards_ids)
|
||||
hits_ids = []
|
||||
for hit in hits:
|
||||
hits_ids.append(hit.id)
|
||||
|
||||
return [refund.dict() for refund in await get_refunds(hits_ids)]
|
||||
@@ -1,42 +0,0 @@
|
||||
# Swap on [Boltz](https://boltz.exchange)
|
||||
providing **trustless** and **account-free** swap services since **2018.**
|
||||
move **IN** and **OUT** of the **lightning network** and remain in control of your bitcoin, at all times.
|
||||
* [Lightning Node](https://amboss.space/node/026165850492521f4ac8abd9bd8088123446d126f648ca35e60f88177dc149ceb2)
|
||||
* [Documentation](https://docs.boltz.exchange/en/latest/)
|
||||
* [Discord](https://discord.gg/d6EK85KK)
|
||||
* [Twitter](https://twitter.com/Boltzhq)
|
||||
* [FAQ](https://www.notion.so/Frequently-Asked-Questions-585328ae43944e2eba351050790d5eec) very cool!
|
||||
|
||||
# usage
|
||||
This extension lets you create swaps, reverse swaps and in the case of failure refund your onchain funds.
|
||||
|
||||
## create normal swap (Onchain -> Lightning)
|
||||
1. click on "Swap (IN)" button to open following dialog, select a wallet, choose a proper amount in the min-max range and choose a onchain address to do your refund to if the swap fails after you already commited onchain funds.
|
||||
---
|
||||

|
||||
---
|
||||
2. after you confirm your inputs, following dialog with the QR code for the onchain transaction, onchain- address and amount, will pop up.
|
||||
---
|
||||

|
||||
---
|
||||
3. after you pay this onchain address with the correct amount, boltz will see it and will pay your invoice and the sats will appear on your wallet.
|
||||
|
||||
if anything goes wrong when boltz is trying to pay your invoice, the swap will fail and you will need to refund your onchain funds after the timeout block height hit. (if boltz can pay the invoice, it wont be able to redeem your onchain funds either).
|
||||
|
||||
## create reverse swap (Lightning -> Onchain)
|
||||
1. click on "Swap (OUT)" button to open following dialog, select a wallet, choose a proper amount in the min-max range and choose a onchain address to receive your funds to. Instant settlement: means that LNbits will create the onchain claim transaction if it sees the boltz lockup transaction in the mempool, but it is not confirmed yet. it is advised to leave this checked because it is faster and the longer is takes to settle, the higher the chances are that the lightning invoice expires and the swap fails.
|
||||
---
|
||||

|
||||
---
|
||||
if this swap fails, boltz is doing the onchain refunding, because they have to commit onchain funds.
|
||||
|
||||
# refund locked onchain funds from a normal swap (Onchain -> Lightning)
|
||||
if for some reason the normal swap fails and you already paid onchain, you can easily refund your btc.
|
||||
this can happen if boltz is not able to pay your lightning invoice after you locked up your funds.
|
||||
in case that happens, there is a info icon in the Swap (In) List which opens following dialog.
|
||||
---
|
||||

|
||||
----
|
||||
if the timeout block height is exceeded you can either press refund and lnbits will do the refunding to the address you specified when creating the swap. Or download the refundfile so you can manually refund your onchain directly on the boltz.exchange website.
|
||||
if you think there is something wrong and/or you are unsure, you can ask for help either in LNbits telegram or in Boltz [Discord](https://discord.gg/d6EK85KK).
|
||||
In a recent update we made *automated check*, every 15 minutes, to check if LNbits can refund your failed swap.
|
||||
@@ -1,35 +0,0 @@
|
||||
import asyncio
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from lnbits.db import Database
|
||||
from lnbits.helpers import template_renderer
|
||||
from lnbits.tasks import catch_everything_and_restart
|
||||
|
||||
db = Database("ext_boltz")
|
||||
|
||||
boltz_ext: APIRouter = APIRouter(prefix="/boltz", tags=["boltz"])
|
||||
|
||||
|
||||
def boltz_renderer():
|
||||
return template_renderer(["lnbits/extensions/boltz/templates"])
|
||||
|
||||
|
||||
boltz_static_files = [
|
||||
{
|
||||
"path": "/boltz/static",
|
||||
"app": StaticFiles(directory="lnbits/extensions/boltz/static"),
|
||||
"name": "boltz_static",
|
||||
}
|
||||
]
|
||||
|
||||
from .tasks import check_for_pending_swaps, wait_for_paid_invoices
|
||||
from .views import * # noqa
|
||||
from .views_api import * # noqa
|
||||
|
||||
|
||||
def boltz_start():
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.create_task(check_for_pending_swaps())
|
||||
loop.create_task(catch_everything_and_restart(wait_for_paid_invoices))
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"name": "Boltz",
|
||||
"short_description": "Perform onchain/offchain swaps",
|
||||
"tile": "/boltz/static/image/boltz.png",
|
||||
"contributors": ["dni"]
|
||||
}
|
||||
@@ -1,284 +0,0 @@
|
||||
import time
|
||||
from typing import List, Optional, Union
|
||||
|
||||
from boltz_client.boltz import BoltzReverseSwapResponse, BoltzSwapResponse
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.helpers import urlsafe_short_hash
|
||||
|
||||
from . import db
|
||||
from .models import (
|
||||
AutoReverseSubmarineSwap,
|
||||
CreateAutoReverseSubmarineSwap,
|
||||
CreateReverseSubmarineSwap,
|
||||
CreateSubmarineSwap,
|
||||
ReverseSubmarineSwap,
|
||||
SubmarineSwap,
|
||||
)
|
||||
|
||||
|
||||
async def get_submarine_swaps(wallet_ids: Union[str, List[str]]) -> List[SubmarineSwap]:
|
||||
if isinstance(wallet_ids, str):
|
||||
wallet_ids = [wallet_ids]
|
||||
|
||||
q = ",".join(["?"] * len(wallet_ids))
|
||||
rows = await db.fetchall(
|
||||
f"SELECT * FROM boltz.submarineswap WHERE wallet IN ({q}) order by time DESC",
|
||||
(*wallet_ids,),
|
||||
)
|
||||
|
||||
return [SubmarineSwap(**row) for row in rows]
|
||||
|
||||
|
||||
async def get_all_pending_submarine_swaps() -> List[SubmarineSwap]:
|
||||
rows = await db.fetchall(
|
||||
f"SELECT * FROM boltz.submarineswap WHERE status='pending' order by time DESC",
|
||||
)
|
||||
return [SubmarineSwap(**row) for row in rows]
|
||||
|
||||
|
||||
async def get_submarine_swap(swap_id) -> Optional[SubmarineSwap]:
|
||||
row = await db.fetchone(
|
||||
"SELECT * FROM boltz.submarineswap WHERE id = ?", (swap_id,)
|
||||
)
|
||||
return SubmarineSwap(**row) if row else None
|
||||
|
||||
|
||||
async def create_submarine_swap(
|
||||
data: CreateSubmarineSwap,
|
||||
swap: BoltzSwapResponse,
|
||||
swap_id: str,
|
||||
refund_privkey_wif: str,
|
||||
payment_hash: str,
|
||||
) -> Optional[SubmarineSwap]:
|
||||
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO boltz.submarineswap (
|
||||
id,
|
||||
wallet,
|
||||
payment_hash,
|
||||
status,
|
||||
boltz_id,
|
||||
refund_privkey,
|
||||
refund_address,
|
||||
expected_amount,
|
||||
timeout_block_height,
|
||||
address,
|
||||
bip21,
|
||||
redeem_script,
|
||||
amount
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
swap_id,
|
||||
data.wallet,
|
||||
payment_hash,
|
||||
"pending",
|
||||
swap.id,
|
||||
refund_privkey_wif,
|
||||
data.refund_address,
|
||||
swap.expectedAmount,
|
||||
swap.timeoutBlockHeight,
|
||||
swap.address,
|
||||
swap.bip21,
|
||||
swap.redeemScript,
|
||||
data.amount,
|
||||
),
|
||||
)
|
||||
return await get_submarine_swap(swap_id)
|
||||
|
||||
|
||||
async def get_reverse_submarine_swaps(
|
||||
wallet_ids: Union[str, List[str]]
|
||||
) -> List[ReverseSubmarineSwap]:
|
||||
if isinstance(wallet_ids, str):
|
||||
wallet_ids = [wallet_ids]
|
||||
|
||||
q = ",".join(["?"] * len(wallet_ids))
|
||||
rows = await db.fetchall(
|
||||
f"SELECT * FROM boltz.reverse_submarineswap WHERE wallet IN ({q}) order by time DESC",
|
||||
(*wallet_ids,),
|
||||
)
|
||||
|
||||
return [ReverseSubmarineSwap(**row) for row in rows]
|
||||
|
||||
|
||||
async def get_all_pending_reverse_submarine_swaps() -> List[ReverseSubmarineSwap]:
|
||||
rows = await db.fetchall(
|
||||
f"SELECT * FROM boltz.reverse_submarineswap WHERE status='pending' order by time DESC"
|
||||
)
|
||||
|
||||
return [ReverseSubmarineSwap(**row) for row in rows]
|
||||
|
||||
|
||||
async def get_reverse_submarine_swap(swap_id) -> Optional[ReverseSubmarineSwap]:
|
||||
row = await db.fetchone(
|
||||
"SELECT * FROM boltz.reverse_submarineswap WHERE id = ?", (swap_id,)
|
||||
)
|
||||
return ReverseSubmarineSwap(**row) if row else None
|
||||
|
||||
|
||||
async def create_reverse_submarine_swap(
|
||||
data: CreateReverseSubmarineSwap,
|
||||
claim_privkey_wif: str,
|
||||
preimage_hex: str,
|
||||
swap: BoltzReverseSwapResponse,
|
||||
) -> ReverseSubmarineSwap:
|
||||
|
||||
swap_id = urlsafe_short_hash()
|
||||
|
||||
reverse_swap = ReverseSubmarineSwap(
|
||||
id=swap_id,
|
||||
wallet=data.wallet,
|
||||
status="pending",
|
||||
boltz_id=swap.id,
|
||||
instant_settlement=data.instant_settlement,
|
||||
preimage=preimage_hex,
|
||||
claim_privkey=claim_privkey_wif,
|
||||
lockup_address=swap.lockupAddress,
|
||||
invoice=swap.invoice,
|
||||
onchain_amount=swap.onchainAmount,
|
||||
onchain_address=data.onchain_address,
|
||||
timeout_block_height=swap.timeoutBlockHeight,
|
||||
redeem_script=swap.redeemScript,
|
||||
amount=data.amount,
|
||||
time=int(time.time()),
|
||||
)
|
||||
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO boltz.reverse_submarineswap (
|
||||
id,
|
||||
wallet,
|
||||
status,
|
||||
boltz_id,
|
||||
instant_settlement,
|
||||
preimage,
|
||||
claim_privkey,
|
||||
lockup_address,
|
||||
invoice,
|
||||
onchain_amount,
|
||||
onchain_address,
|
||||
timeout_block_height,
|
||||
redeem_script,
|
||||
amount
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
reverse_swap.id,
|
||||
reverse_swap.wallet,
|
||||
reverse_swap.status,
|
||||
reverse_swap.boltz_id,
|
||||
reverse_swap.instant_settlement,
|
||||
reverse_swap.preimage,
|
||||
reverse_swap.claim_privkey,
|
||||
reverse_swap.lockup_address,
|
||||
reverse_swap.invoice,
|
||||
reverse_swap.onchain_amount,
|
||||
reverse_swap.onchain_address,
|
||||
reverse_swap.timeout_block_height,
|
||||
reverse_swap.redeem_script,
|
||||
reverse_swap.amount,
|
||||
),
|
||||
)
|
||||
return reverse_swap
|
||||
|
||||
|
||||
async def get_auto_reverse_submarine_swaps(
|
||||
wallet_ids: List[str],
|
||||
) -> List[AutoReverseSubmarineSwap]:
|
||||
q = ",".join(["?"] * len(wallet_ids))
|
||||
rows = await db.fetchall(
|
||||
f"SELECT * FROM boltz.auto_reverse_submarineswap WHERE wallet IN ({q}) order by time DESC",
|
||||
(*wallet_ids,),
|
||||
)
|
||||
return [AutoReverseSubmarineSwap(**row) for row in rows]
|
||||
|
||||
|
||||
async def get_auto_reverse_submarine_swap(
|
||||
swap_id,
|
||||
) -> Optional[AutoReverseSubmarineSwap]:
|
||||
row = await db.fetchone(
|
||||
"SELECT * FROM boltz.auto_reverse_submarineswap WHERE id = ?", (swap_id,)
|
||||
)
|
||||
return AutoReverseSubmarineSwap(**row) if row else None
|
||||
|
||||
|
||||
async def get_auto_reverse_submarine_swap_by_wallet(
|
||||
wallet_id,
|
||||
) -> Optional[AutoReverseSubmarineSwap]:
|
||||
row = await db.fetchone(
|
||||
"SELECT * FROM boltz.auto_reverse_submarineswap WHERE wallet = ?", (wallet_id,)
|
||||
)
|
||||
return AutoReverseSubmarineSwap(**row) if row else None
|
||||
|
||||
|
||||
async def create_auto_reverse_submarine_swap(
|
||||
swap: CreateAutoReverseSubmarineSwap,
|
||||
) -> Optional[AutoReverseSubmarineSwap]:
|
||||
|
||||
swap_id = urlsafe_short_hash()
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO boltz.auto_reverse_submarineswap (
|
||||
id,
|
||||
wallet,
|
||||
onchain_address,
|
||||
instant_settlement,
|
||||
balance,
|
||||
amount
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
swap_id,
|
||||
swap.wallet,
|
||||
swap.onchain_address,
|
||||
swap.instant_settlement,
|
||||
swap.balance,
|
||||
swap.amount,
|
||||
),
|
||||
)
|
||||
return await get_auto_reverse_submarine_swap(swap_id)
|
||||
|
||||
|
||||
async def delete_auto_reverse_submarine_swap(swap_id):
|
||||
await db.execute(
|
||||
"DELETE FROM boltz.auto_reverse_submarineswap WHERE id = ?", (swap_id,)
|
||||
)
|
||||
|
||||
|
||||
async def update_swap_status(swap_id: str, status: str):
|
||||
|
||||
swap = await get_submarine_swap(swap_id)
|
||||
if swap:
|
||||
await db.execute(
|
||||
"UPDATE boltz.submarineswap SET status='"
|
||||
+ status
|
||||
+ "' WHERE id='"
|
||||
+ swap.id
|
||||
+ "'"
|
||||
)
|
||||
logger.info(
|
||||
f"Boltz - swap status change: {status}. boltz_id: {swap.boltz_id}, wallet: {swap.wallet}"
|
||||
)
|
||||
return swap
|
||||
|
||||
reverse_swap = await get_reverse_submarine_swap(swap_id)
|
||||
if reverse_swap:
|
||||
await db.execute(
|
||||
"UPDATE boltz.reverse_submarineswap SET status='"
|
||||
+ status
|
||||
+ "' WHERE id='"
|
||||
+ reverse_swap.id
|
||||
+ "'"
|
||||
)
|
||||
logger.info(
|
||||
f"Boltz - reverse swap status change: {status}. boltz_id: {reverse_swap.boltz_id}, wallet: {reverse_swap.wallet}"
|
||||
)
|
||||
return reverse_swap
|
||||
|
||||
return None
|
||||
@@ -1,64 +0,0 @@
|
||||
async def m001_initial(db):
|
||||
await db.execute(
|
||||
f"""
|
||||
CREATE TABLE boltz.submarineswap (
|
||||
id TEXT PRIMARY KEY,
|
||||
wallet TEXT NOT NULL,
|
||||
payment_hash TEXT NOT NULL,
|
||||
amount {db.big_int} NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
boltz_id TEXT NOT NULL,
|
||||
refund_address TEXT NOT NULL,
|
||||
refund_privkey TEXT NOT NULL,
|
||||
expected_amount {db.big_int} NOT NULL,
|
||||
timeout_block_height INT NOT NULL,
|
||||
address TEXT NOT NULL,
|
||||
bip21 TEXT NOT NULL,
|
||||
redeem_script TEXT NOT NULL,
|
||||
time TIMESTAMP NOT NULL DEFAULT """
|
||||
+ db.timestamp_now
|
||||
+ """
|
||||
);
|
||||
"""
|
||||
)
|
||||
await db.execute(
|
||||
f"""
|
||||
CREATE TABLE boltz.reverse_submarineswap (
|
||||
id TEXT PRIMARY KEY,
|
||||
wallet TEXT NOT NULL,
|
||||
onchain_address TEXT NOT NULL,
|
||||
amount {db.big_int} NOT NULL,
|
||||
instant_settlement BOOLEAN NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
boltz_id TEXT NOT NULL,
|
||||
timeout_block_height INT NOT NULL,
|
||||
redeem_script TEXT NOT NULL,
|
||||
preimage TEXT NOT NULL,
|
||||
claim_privkey TEXT NOT NULL,
|
||||
lockup_address TEXT NOT NULL,
|
||||
invoice TEXT NOT NULL,
|
||||
onchain_amount {db.big_int} NOT NULL,
|
||||
time TIMESTAMP NOT NULL DEFAULT """
|
||||
+ db.timestamp_now
|
||||
+ """
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
async def m002_auto_swaps(db):
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE boltz.auto_reverse_submarineswap (
|
||||
id TEXT PRIMARY KEY,
|
||||
wallet TEXT NOT NULL,
|
||||
onchain_address TEXT NOT NULL,
|
||||
amount INT NOT NULL,
|
||||
balance INT NOT NULL,
|
||||
instant_settlement BOOLEAN NOT NULL,
|
||||
time TIMESTAMP NOT NULL DEFAULT """
|
||||
+ db.timestamp_now
|
||||
+ """
|
||||
);
|
||||
"""
|
||||
)
|
||||
@@ -1,68 +0,0 @@
|
||||
from fastapi import Query
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class SubmarineSwap(BaseModel):
|
||||
id: str
|
||||
wallet: str
|
||||
amount: int
|
||||
payment_hash: str
|
||||
time: int
|
||||
status: str
|
||||
refund_privkey: str
|
||||
refund_address: str
|
||||
boltz_id: str
|
||||
expected_amount: int
|
||||
timeout_block_height: int
|
||||
address: str
|
||||
bip21: str
|
||||
redeem_script: str
|
||||
|
||||
|
||||
class CreateSubmarineSwap(BaseModel):
|
||||
wallet: str = Query(...)
|
||||
refund_address: str = Query(...)
|
||||
amount: int = Query(...)
|
||||
|
||||
|
||||
class ReverseSubmarineSwap(BaseModel):
|
||||
id: str
|
||||
wallet: str
|
||||
amount: int
|
||||
onchain_address: str
|
||||
instant_settlement: bool
|
||||
time: int
|
||||
status: str
|
||||
boltz_id: str
|
||||
preimage: str
|
||||
claim_privkey: str
|
||||
lockup_address: str
|
||||
invoice: str
|
||||
onchain_amount: int
|
||||
timeout_block_height: int
|
||||
redeem_script: str
|
||||
|
||||
|
||||
class CreateReverseSubmarineSwap(BaseModel):
|
||||
wallet: str = Query(...)
|
||||
amount: int = Query(...)
|
||||
instant_settlement: bool = Query(...)
|
||||
onchain_address: str = Query(...)
|
||||
|
||||
|
||||
class AutoReverseSubmarineSwap(BaseModel):
|
||||
id: str
|
||||
wallet: str
|
||||
amount: int
|
||||
balance: int
|
||||
onchain_address: str
|
||||
instant_settlement: bool
|
||||
time: int
|
||||
|
||||
|
||||
class CreateAutoReverseSubmarineSwap(BaseModel):
|
||||
wallet: str = Query(...)
|
||||
amount: int = Query(...)
|
||||
balance: int = Query(0)
|
||||
instant_settlement: bool = Query(...)
|
||||
onchain_address: str = Query(...)
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 37 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user