Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a85a5144d0 | ||
|
|
84bf855810 | ||
|
|
09c6e7239d | ||
|
|
53a1ae5e53 | ||
|
|
61ed636df0 | ||
|
|
43900dd6da | ||
|
|
06c553219a | ||
|
|
fc73d83bd9 | ||
|
|
4cb743d952 | ||
|
|
3b6e87b060 | ||
|
|
09e44f18e5 | ||
|
|
cabb58f8fe | ||
|
|
aac04efd0e | ||
|
|
fd9009f760 | ||
|
|
6ab413775a | ||
|
|
7eb9965205 | ||
|
|
4634ad5a5a | ||
|
|
648aaa17c5 | ||
|
|
7db5c986b3 | ||
|
|
26c31b626d | ||
|
|
e39cb20525 | ||
|
|
3cea9a90d1 | ||
|
|
3224e2e774 | ||
|
|
cfc9874517 | ||
|
|
62cd151fd6 | ||
|
|
a4d0aa5db8 | ||
|
|
77630781d7 | ||
|
|
59da350cc9 | ||
|
|
29e980dd67 | ||
|
|
9d9ce63c82 | ||
|
|
748f458b8b | ||
|
|
ce177a73b1 | ||
|
|
2885e71be2 | ||
|
|
4b6f43d274 | ||
|
|
86b5cf9421 | ||
|
|
8f5b7d85aa | ||
|
|
4d51e63924 | ||
|
|
0ce2501e1a | ||
|
|
f2351145f0 | ||
|
|
2eb7d67b2a | ||
|
|
a82093b7ec | ||
|
|
ee595eede1 | ||
|
|
dfdce54e57 | ||
|
|
1367480ec6 | ||
|
|
e2d83b516a | ||
|
|
83699289fc | ||
|
|
f04e88d8bf | ||
|
|
564edfc447 | ||
|
|
b98515df14 | ||
|
|
1e0fc84586 | ||
|
|
a1d94834ae | ||
|
|
5de4239f3c | ||
|
|
c404666d7f | ||
|
|
190a466c0a | ||
|
|
d01e3523d8 | ||
|
|
88672501d8 | ||
|
|
ce57d08163 | ||
|
|
52304e0730 | ||
|
|
6664eebf5a | ||
|
|
2a2af81827 | ||
|
|
9b47f6323f | ||
|
|
a61807a257 | ||
|
|
30e0522419 | ||
|
|
8f033a6047 | ||
|
|
a2c817a56b | ||
|
|
555350085e | ||
|
|
fc5061a67f | ||
|
|
c9c68bd8d7 | ||
|
|
810a13722c | ||
|
|
36d696b222 | ||
|
|
8b426efa3e | ||
|
|
9edc4786e1 | ||
|
|
93dc10fe94 | ||
|
|
6c8448d7a8 | ||
|
|
99e4f33142 | ||
|
|
f4f43ad361 | ||
|
|
f14ea6c577 | ||
|
|
07428ecf94 | ||
|
|
867e3d06f6 | ||
|
|
4b4b6d0bcd | ||
|
|
07b1521dad | ||
|
|
7a2ddd9826 | ||
|
|
0eb4b477b7 | ||
|
|
06a0ba58ce | ||
|
|
5399b36027 | ||
|
|
385fb4f9bc | ||
|
|
8db76b8864 | ||
|
|
9e3ab0ef26 | ||
|
|
04c9b67997 | ||
|
|
116f982aab | ||
|
|
63cc89e2b6 | ||
|
|
183e6e5661 | ||
|
|
6b3fd80e46 | ||
|
|
5999a773be | ||
|
|
7e0fadad3b | ||
|
|
b2d6243697 | ||
|
|
6b5a77fb3b | ||
|
|
cc8fb68b02 | ||
|
|
658da6b28e | ||
|
|
7d734ecb74 | ||
|
|
9177dd195b | ||
|
|
15faab4f38 | ||
|
|
2cce687865 | ||
|
|
313bd3f647 | ||
|
|
13a93836d9 | ||
|
|
4f76d0483e | ||
|
|
719d86aa9c | ||
|
|
bbad4a91ae | ||
|
|
fcebb7e28c | ||
|
|
ce5aa4c8a7 | ||
|
|
75bae67446 | ||
|
|
8c184356ef | ||
|
|
efc0547271 | ||
|
|
7a393b11fd | ||
|
|
fae3eca3c7 | ||
|
|
3398070dd5 | ||
|
|
7d98c6b080 | ||
|
|
5f4e889d3a | ||
|
|
fe774ae1a1 | ||
|
|
21e02cb20e | ||
|
|
a3d7b463ae | ||
|
|
e1e2112461 | ||
|
|
92fc9a130f | ||
|
|
e15f3cd207 | ||
|
|
041b358310 | ||
|
|
cbd3de88c4 | ||
|
|
156ab10ad6 | ||
|
|
b77c103020 | ||
|
|
1f34357ab7 | ||
|
|
d9eda48298 | ||
|
|
1be2d1a680 | ||
|
|
b55ae9daaf | ||
|
|
1f98aa2cdb | ||
|
|
55ec3f1812 | ||
|
|
7880044483 | ||
|
|
28b3f79d9b | ||
|
|
4c704305ac | ||
|
|
c47fd2a4d1 | ||
|
|
1255df43e6 | ||
|
|
6ccc10d327 | ||
|
|
8bcf5e4f70 | ||
|
|
04ec643c93 | ||
|
|
f93efbfb3a | ||
|
|
4429c0cdb6 | ||
|
|
2bdb89b4b9 | ||
|
|
75754598e1 | ||
|
|
b25a3c2bb4 | ||
|
|
e7c4de6506 | ||
|
|
c102f10fab | ||
|
|
7c28b58aaa | ||
|
|
2b27f13fa8 | ||
|
|
130f4fd0bc | ||
|
|
4894b70bad | ||
|
|
05769240d3 | ||
|
|
7bd127d22c | ||
|
|
6dc3df148d | ||
|
|
e286546b63 | ||
|
|
eae1be1db8 | ||
|
|
25ab676a3f | ||
|
|
2fd6648a9a | ||
|
|
8eda653e3f | ||
|
|
7e327b2c6a | ||
|
|
40c055e373 | ||
|
|
1d9808b269 | ||
|
|
c65ab2af87 | ||
|
|
15f093c136 | ||
|
|
5311b0a393 | ||
|
|
e72396fe31 | ||
|
|
d6d0ebb20e | ||
|
|
563dd5b499 | ||
|
|
9be894da74 | ||
|
|
87b66c554a | ||
|
|
314b533e39 | ||
|
|
af8f3382c1 | ||
|
|
67bb86c1ce | ||
|
|
f0f9e41f99 | ||
|
|
2fbaf57b5e | ||
|
|
87afba2abb | ||
|
|
c375bf1b15 | ||
|
|
dbcc920d0a | ||
|
|
2c61e219b8 | ||
|
|
6cb9b7da1f | ||
|
|
83334d67c8 | ||
|
|
ef9adc077b | ||
|
|
ffad5d79c4 | ||
|
|
09c35eaca0 | ||
|
|
52adc62bc4 | ||
|
|
0efa173084 | ||
|
|
724f8c9f63 | ||
|
|
c6981089e5 | ||
|
|
13480027dd | ||
|
|
93f1006d3a | ||
|
|
5490367179 | ||
|
|
a185197e34 | ||
|
|
aa1bddcdd5 | ||
|
|
6d24ba55aa | ||
|
|
5b2937672c | ||
|
|
957d9ce419 | ||
|
|
0e21b77b74 | ||
|
|
24e28911ab | ||
|
|
1e252d0485 | ||
|
|
e98550a125 | ||
|
|
bc55474859 | ||
|
|
d985bdaadb | ||
|
|
8e0d1cc100 | ||
|
|
cb3fd56647 | ||
|
|
9f383bfee6 | ||
|
|
3b2e28dd60 | ||
|
|
132192bc94 | ||
|
|
281c3df826 | ||
|
|
08591f34c2 | ||
|
|
f0e8ae0f5c | ||
|
|
168cb726b1 | ||
|
|
91ac245307 | ||
|
|
d098e2f710 | ||
|
|
a0f65f4cda | ||
|
|
606ee215b4 | ||
|
|
7716f819f6 | ||
|
|
8325d880cb | ||
|
|
b090470fc1 | ||
|
|
c7297d2e77 | ||
|
|
b6e111b21c | ||
|
|
7747d7b741 | ||
|
|
f3a5a8e002 | ||
|
|
157a6485b4 | ||
|
|
8867b27b09 | ||
|
|
68b607ecbc | ||
|
|
f411fd13dc | ||
|
|
baee90da67 | ||
|
|
661b713993 | ||
|
|
5a5f253fa5 | ||
|
|
77a5d7fe50 | ||
|
|
07dd4fc685 | ||
|
|
3761f7922c | ||
|
|
327b9d7f63 | ||
|
|
cacffc67ee | ||
|
|
b7fdf99a8d | ||
|
|
cd6cfff9cf | ||
|
|
7b635a31e5 | ||
|
|
b4c0cdbc7c | ||
|
|
fd765e2060 | ||
|
|
5f86627eae | ||
|
|
3af3838995 | ||
|
|
a762529fef | ||
|
|
245569d0b9 | ||
|
|
fcaeb0ac7a | ||
|
|
d2aedde21b | ||
|
|
5adc419c74 | ||
|
|
5d79327906 | ||
|
|
71e0b396d2 | ||
|
|
89d673448a | ||
|
|
aed3f3b569 | ||
|
|
d9a2a7bb95 | ||
|
|
15ede13104 | ||
|
|
62047ea758 | ||
|
|
e5e589fc40 | ||
|
|
46406792fe | ||
|
|
dbf71fed53 | ||
|
|
850087a8ec | ||
|
|
d9b045c526 | ||
|
|
e1ca6ef82a | ||
|
|
17c40d539e | ||
|
|
625fa6503c | ||
|
|
73634e5161 | ||
|
|
1fe35070f4 | ||
|
|
ad268516a9 | ||
|
|
ca94909aab | ||
|
|
b3efb4d378 | ||
|
|
5213508dc1 | ||
|
|
af9331eede | ||
|
|
9b8fe42102 | ||
|
|
a0e4a59454 | ||
|
|
eefaf3c50c | ||
|
|
89cabda123 | ||
|
|
6122a03f32 | ||
|
|
ad8b70a098 | ||
|
|
6449276003 | ||
|
|
4da651b74a | ||
|
|
b5f3a46feb | ||
|
|
1057b4693f | ||
|
|
5711b4b804 | ||
|
|
704e5a1b73 | ||
|
|
e6ca8a33c6 | ||
|
|
9c257aa23d | ||
|
|
ef752cbeca | ||
|
|
a509eb8fdb | ||
|
|
49cc8104fc | ||
|
|
f1f6af0e35 | ||
|
|
5f1cfc0e37 | ||
|
|
730ab59578 | ||
|
|
c8bdba8e53 | ||
|
|
c6c67c52db | ||
|
|
da31e3caaa | ||
|
|
d0bf47163b | ||
|
|
37ad515427 | ||
|
|
8c77f75cf1 | ||
|
|
21505471d5 | ||
|
|
baa9a35773 | ||
|
|
7f114ddcc0 | ||
|
|
92aad20dd7 | ||
|
|
0f4ae5da86 | ||
|
|
7a796c6510 | ||
|
|
0910687328 | ||
|
|
33e2fc2ea8 | ||
|
|
0c6e8394c8 | ||
|
|
d55e2a0e1f | ||
|
|
148ba9d275 | ||
|
|
d2ca774f6f | ||
|
|
233398b512 | ||
|
|
152c1dbb74 | ||
|
|
44985cb0d1 | ||
|
|
b1a7692ce4 | ||
|
|
31bff37b1e | ||
|
|
3f7a1798d5 | ||
|
|
98d4dbab8b | ||
|
|
7c7a04da9d | ||
|
|
3ccefb70fa | ||
|
|
5346ac47b1 | ||
|
|
9f4ed53888 | ||
|
|
faac56c14c | ||
|
|
3e0d0d9896 | ||
|
|
b9a004a5e4 | ||
|
|
da3cb548bd | ||
|
|
4144359617 | ||
|
|
07268b87d2 | ||
|
|
c356874bcb | ||
|
|
f9b5c7db7a | ||
|
|
20f9ddb57c | ||
|
|
ee57ba4c1a | ||
|
|
ff7f5c1ca5 | ||
|
|
1463d75ee2 | ||
|
|
f1fc4710ee | ||
|
|
00eaec8290 | ||
|
|
28c4235c94 | ||
|
|
b7d178c08e | ||
|
|
9d0ec97d39 | ||
|
|
b6f533be24 | ||
|
|
5e36bb4fcd | ||
|
|
f9b0604711 | ||
|
|
39c33699af | ||
|
|
c89721223f | ||
|
|
1e6e97c12d | ||
|
|
783efb19db | ||
|
|
9ffc63b5dc | ||
|
|
0ec8139e5c | ||
|
|
a8c3181852 | ||
|
|
e038ceb9be | ||
|
|
b4eccb9e5d | ||
|
|
691b7ad055 | ||
|
|
c84d1b66c6 | ||
|
|
cf7726ddfd | ||
|
|
bd50a3c546 | ||
|
|
c9270bbf7f | ||
|
|
6474aeb982 | ||
|
|
37ba437ad1 | ||
|
|
35af181e24 | ||
|
|
f8c58aef0e | ||
|
|
8755984bd8 | ||
|
|
0d5661cda7 | ||
|
|
c2a3fbc6c0 | ||
|
|
6bac34fc6b | ||
|
|
b54eedee84 | ||
|
|
bd07f7a5ef | ||
|
|
a40306f5cd | ||
|
|
2fecec2623 | ||
|
|
4ad89396cc | ||
|
|
82307c4839 | ||
|
|
8506199c2f | ||
|
|
d142d76148 | ||
|
|
98e9a61b98 | ||
|
|
877af0c21e | ||
|
|
dd27b190f3 | ||
|
|
2856803ca7 | ||
|
|
ca8264b1f5 | ||
|
|
39ca9da870 | ||
|
|
2b603bdc48 | ||
|
|
26780df065 | ||
|
|
248fcc06ab | ||
|
|
f794373d30 | ||
|
|
87f25ea715 | ||
|
|
b9de754598 | ||
|
|
785fb7af8e | ||
|
|
55c5ab3a6d | ||
|
|
bd07a319ab | ||
|
|
6b732a2a6a | ||
|
|
25c8dd18e0 | ||
|
|
2b1b5cadaa | ||
|
|
6b41820422 | ||
|
|
7116353431 | ||
|
|
de6827af58 | ||
|
|
40c065708a | ||
|
|
4cf9fae3e3 | ||
|
|
bf06def9b7 | ||
|
|
182894fd93 | ||
|
|
d0cf374cda | ||
|
|
1e0dab32c3 | ||
|
|
34c9e218bb |
@@ -23,3 +23,10 @@ mypy.ini
|
||||
package-lock.json
|
||||
package.json
|
||||
pytest.ini
|
||||
|
||||
.mypy_cache
|
||||
.github
|
||||
.pytest_cache
|
||||
.vscode
|
||||
bin
|
||||
dist
|
||||
|
||||
+75
-7
@@ -6,11 +6,21 @@
|
||||
# The following settings are ONLY set in your .env file.
|
||||
# They are NOT managed by the Admin UI and are not stored in the database.
|
||||
|
||||
# === First Install Token ===
|
||||
# If set the user is required to enter this token on the /first_install page
|
||||
# FIRST_INSTALL_TOKEN="myaccesstoken"
|
||||
|
||||
# === Security ===
|
||||
# When enabled (recommended), auth cookies require HTTPS and SSO will reject insecure HTTP.
|
||||
AUTH_HTTPS_ONLY=true
|
||||
|
||||
# === Logging and Development ===
|
||||
|
||||
DEBUG=False
|
||||
DEBUG_DATABASE=False
|
||||
BUNDLE_ASSETS=True
|
||||
# add `?profiler=true` to the url to enable the profiler for that request
|
||||
PROFILER=False
|
||||
|
||||
# logging into LNBITS_DATA_FOLDER/logs/
|
||||
ENABLE_LOG_TO_FILE=true
|
||||
@@ -18,9 +28,12 @@ ENABLE_LOG_TO_FILE=true
|
||||
# https://loguru.readthedocs.io/en/stable/api/logger.html#file
|
||||
LOG_ROTATION="100 MB"
|
||||
LOG_RETENTION="3 months"
|
||||
|
||||
# for database cleanup commands
|
||||
# CLEANUP_WALLETS_DAYS=90
|
||||
# Hard limit for total created users. Set to 0 to disable the limit.
|
||||
# LNBITS_MAX_USERS=0
|
||||
# Hard limit for total installed extensions. Set to 0 to disable the limit.
|
||||
# LNBITS_MAX_EXTENSIONS=0
|
||||
|
||||
# === Admin Settings ===
|
||||
|
||||
@@ -56,7 +69,7 @@ LNBITS_EXTENSIONS_DEFAULT_INSTALL="tpos"
|
||||
# LNBITS_EXT_GITHUB_TOKEN=github_pat_xxxxxxxxxxxxxxxxxx
|
||||
|
||||
# which fundingsources are allowed in the admin ui
|
||||
# LNBITS_ALLOWED_FUNDING_SOURCES="VoidWallet, FakeWallet, CoreLightningWallet, CoreLightningRestWallet, LndRestWallet, EclairWallet, LndWallet, LnTipsWallet, LNPayWallet, LNbitsWallet, BlinkWallet, AlbyWallet, ZBDWallet, PhoenixdWallet, OpenNodeWallet, NWCWallet, BreezSdkWallet, BoltzWallet, StrikeWallet, CLNRestWallet"
|
||||
# LNBITS_ALLOWED_FUNDING_SOURCES="VoidWallet, FakeWallet, CoreLightningWallet, CoreLightningRestWallet, LndRestWallet, EclairWallet, LndWallet, LnTipsWallet, LNPayWallet, LNbitsWallet, BlinkWallet, AlbyWallet, ZBDWallet, PhoenixdWallet, OpenNodeWallet, NWCWallet, BreezSdkWallet, BoltzWallet, StrikeWallet, CLNRestWallet, SparkWallet, SparkL2Wallet"
|
||||
|
||||
# uvicorn variable, allow https behind a proxy
|
||||
# IMPORTANT: this also needs the webserver to be configured to forward the headers
|
||||
@@ -91,7 +104,7 @@ AUTH_SECRET_KEY=""
|
||||
######################################
|
||||
|
||||
AUTH_TOKEN_EXPIRE_MINUTES=525600
|
||||
# Possible authorization methods: user-id-only, username-password, nostr-auth-nip98, google-auth, github-auth, keycloak-auth
|
||||
# Possible authorization methods: user-id-only, username-password, nostr-auth-nip98, google-auth, github-auth, keycloak-auth, oidc-auth
|
||||
AUTH_ALLOWED_METHODS="user-id-only, username-password"
|
||||
# Set this flag if HTTP is used for OAuth
|
||||
# OAUTHLIB_INSECURE_TRANSPORT="1"
|
||||
@@ -106,6 +119,8 @@ LNBITS_SITE_TAGLINE="Open Source Lightning Payments Platform"
|
||||
LNBITS_SITE_DESCRIPTION="The world's most powerful suite of bitcoin tools. Run for yourself, for others, or as part of a stack."
|
||||
# Choose from bitcoin, mint, flamingo, freedom, salvador, autumn, monochrome, classic, cyber
|
||||
LNBITS_THEME_OPTIONS="classic, bitcoin, flamingo, freedom, mint, autumn, monochrome, salvador, cyber"
|
||||
# Toggle the background styling on burger menus / drawers
|
||||
# LNBITS_DEFAULT_BURGER_MENU_BACKGROUND=true
|
||||
# LNBITS_CUSTOM_LOGO="https://lnbits.com/assets/images/logo/logo.svg"
|
||||
|
||||
######################################
|
||||
@@ -187,7 +202,15 @@ BOLTZ_CLIENT_ENDPOINT=127.0.0.1:9002
|
||||
BOLTZ_CLIENT_MACAROON="/home/bob/.boltz/macaroons/admin.macaroon"
|
||||
# HEXSTRING instead of path also possible
|
||||
BOLTZ_CLIENT_CERT="/home/bob/.boltz/tls.cert"
|
||||
BOLTZ_CLIENT_WALLET="lnbits"
|
||||
|
||||
# SparkL2Wallet (external sidecar: https://github.com/lnbits/spark_sidecar)
|
||||
SPARK_L2_NETWORK=MAINNET
|
||||
SPARK_L2_EXTERNAL_ENDPOINT=http://127.0.0.1:8765
|
||||
SPARK_L2_EXTERNAL_API_KEY=
|
||||
# optional tuning
|
||||
# SPARK_L2_PAY_WAIT_MS=4000
|
||||
# SPARK_L2_PAY_POLL_MS=500
|
||||
# SPARK_L2_STREAM_KEEPALIVE_MS=15000
|
||||
|
||||
# StrikeWallet
|
||||
STRIKE_API_ENDPOINT=https://api.strike.me/v1
|
||||
@@ -260,6 +283,50 @@ KEYCLOAK_DISCOVERY_URL=""
|
||||
KEYCLOAK_CLIENT_CUSTOM_ORG=""
|
||||
KEYCLOAK_CLIENT_CUSTOM_ICON=""
|
||||
|
||||
# OIDC OAuth Config
|
||||
# Generic OIDC provider configuration
|
||||
# Make sure that the redirect URI in your OIDC provider is set to: https://{domain}/api/v1/auth/oidc/token
|
||||
# Required scopes: openid, email, profile
|
||||
# The discovery URL must be accessible from your LNbits server
|
||||
# Always use HTTPS in production environments
|
||||
# The CUSTOM_ORG and CUSTOM_ICON settings allow you to customize the login button
|
||||
# For example: "Login via Zitadel" with the Zitadel logo
|
||||
OIDC_DISCOVERY_URL=""
|
||||
OIDC_CLIENT_ID=""
|
||||
OIDC_CLIENT_SECRET=""
|
||||
OIDC_CLIENT_CUSTOM_ORG=""
|
||||
OIDC_CLIENT_CUSTOM_ICON=""
|
||||
|
||||
# Example OIDC configurations for various providers:
|
||||
#
|
||||
# ZITADEL:
|
||||
# OIDC_DISCOVERY_URL=https://login.yourdomain.de/.well-known/openid-configuration
|
||||
# OIDC_CLIENT_ID=your-zitadel-client-id@project-id
|
||||
# OIDC_CLIENT_SECRET=your-zitadel-client-secret
|
||||
# OIDC_CLIENT_CUSTOM_ORG=Zitadel
|
||||
# OIDC_CLIENT_CUSTOM_ICON=/static/images/zitadel.png
|
||||
#
|
||||
# AUTHENTIK:
|
||||
# OIDC_DISCOVERY_URL=https://authentik.yourdomain.com/application/o/lnbits/.well-known/openid-configuration
|
||||
# OIDC_CLIENT_ID=your-authentik-client-id
|
||||
# OIDC_CLIENT_SECRET=your-authentik-client-secret
|
||||
# OIDC_CLIENT_CUSTOM_ORG=Authentik
|
||||
# OIDC_CLIENT_CUSTOM_ICON=/static/images/authentik.png
|
||||
#
|
||||
# AUTHELIA:
|
||||
# OIDC_DISCOVERY_URL=https://auth.yourdomain.com/.well-known/openid-configuration
|
||||
# OIDC_CLIENT_ID=your-authelia-client-id
|
||||
# OIDC_CLIENT_SECRET=your-authelia-client-secret
|
||||
# OIDC_CLIENT_CUSTOM_ORG=Authelia
|
||||
# OIDC_CLIENT_CUSTOM_ICON=/static/images/authelia.png
|
||||
#
|
||||
# OKTA:
|
||||
# OIDC_DISCOVERY_URL=https://your-domain.okta.com/.well-known/openid-configuration
|
||||
# OIDC_CLIENT_ID=your-okta-client-id
|
||||
# OIDC_CLIENT_SECRET=your-okta-client-secret
|
||||
# OIDC_CLIENT_CUSTOM_ORG=Okta
|
||||
# OIDC_CLIENT_CUSTOM_ICON=/static/images/okta.png
|
||||
|
||||
|
||||
######################################
|
||||
|
||||
@@ -315,10 +382,12 @@ LNBITS_SERVICE_FEE=0.0
|
||||
# disable fees for internal transactions
|
||||
# LNBITS_SERVICE_FEE_IGNORE_INTERNAL=true
|
||||
|
||||
# value in millisats
|
||||
# The minimum fee reserved per payment (millisats)
|
||||
LNBITS_RESERVE_FEE_MIN=2000
|
||||
# value in percent
|
||||
# The percentage of the payment amount to reserve for routing fees (percent)
|
||||
LNBITS_RESERVE_FEE_PERCENT=1.0
|
||||
# The default time to wait to for status response from the funding source when paying an invoice
|
||||
LNBITS_FUNDING_SOURCE_PAY_INVOICE_WAIT_SECONDS=5
|
||||
|
||||
# limit the maximum balance for each wallet
|
||||
# throw an error if the wallet attempts to create a new invoice
|
||||
@@ -333,4 +402,3 @@ LNBITS_RESERVE_FEE_PERCENT=1.0
|
||||
######################################
|
||||
###### Logging and Development #######
|
||||
######################################
|
||||
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
name: Build LNbits AppImage
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_call:
|
||||
inputs:
|
||||
tag_name:
|
||||
description: 'The tag name for the release'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag_name:
|
||||
description: 'The tag name for the release'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
build-linux-package:
|
||||
@@ -50,7 +61,10 @@ jobs:
|
||||
--onefile \
|
||||
--name lnbits \
|
||||
--hidden-import=embit \
|
||||
--hidden-import=bitstring.bitstore_bitarray \
|
||||
--collect-all embit \
|
||||
--collect-all bitstring \
|
||||
--collect-all bitarray \
|
||||
--collect-all lnbits \
|
||||
--collect-all sqlalchemy \
|
||||
--collect-all breez_sdk \
|
||||
@@ -75,7 +89,7 @@ jobs:
|
||||
# Build AppImage
|
||||
wget https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage
|
||||
chmod +x appimagetool-x86_64.AppImage
|
||||
TAG_NAME=${{ github.event.release.tag_name }}
|
||||
TAG_NAME=${{ inputs.tag_name }}
|
||||
APPIMAGE_NAME="LNbits-${TAG_NAME}.AppImage"
|
||||
./appimagetool-x86_64.AppImage \
|
||||
--updateinformation "gh-releases-zsync|lnbits|lnbits|latest|*.AppImage.zsync" \
|
||||
@@ -91,11 +105,6 @@ jobs:
|
||||
shell: bash
|
||||
|
||||
- name: Upload Linux Release Asset
|
||||
uses: actions/upload-release-asset@v1
|
||||
with:
|
||||
upload_url: ${{ github.event.release.upload_url }}
|
||||
asset_path: ${{ env.APPIMAGE_NAME }}
|
||||
asset_name: ${{ env.APPIMAGE_NAME }}
|
||||
asset_content_type: application/octet-stream
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: gh release upload "${{ inputs.tag_name }}" "${{ env.APPIMAGE_NAME }}" --clobber
|
||||
@@ -0,0 +1,33 @@
|
||||
name: bundle
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
bundle:
|
||||
permissions:
|
||||
contents: write
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.repo.full_name == github.repository && github.head_ref || github.event.pull_request.head.sha }}
|
||||
- uses: lnbits/lnbits/.github/actions/prepare@dev
|
||||
with:
|
||||
python-version: "3.10"
|
||||
node-version: "24.x"
|
||||
npm: true
|
||||
- name: Build and commit bundle (same-repo PR)
|
||||
if: github.event.pull_request.head.repo.full_name == github.repository
|
||||
run: |
|
||||
make bundle
|
||||
git config user.name "alan"
|
||||
git config user.email "alan@lnbits.com"
|
||||
git add lnbits/static
|
||||
if git diff --cached --quiet; then
|
||||
exit 0
|
||||
fi
|
||||
git commit -m "chore: make bundle [skip ci]"
|
||||
git push
|
||||
- name: Check bundle is up-to-date (fork PR)
|
||||
if: github.event.pull_request.head.repo.full_name != github.repository
|
||||
run: make checkbundle
|
||||
+18
-12
@@ -1,14 +1,9 @@
|
||||
name: LNbits CI
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
pull_request:
|
||||
|
||||
|
||||
jobs:
|
||||
|
||||
lint:
|
||||
uses: ./.github/workflows/lint.yml
|
||||
|
||||
@@ -16,7 +11,7 @@ jobs:
|
||||
needs: [ lint ]
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.10", "3.11", "3.12"]
|
||||
python-version: ["3.10", "3.12"]
|
||||
db-url: ["", "postgres://lnbits:lnbits@0.0.0.0:5432/lnbits"]
|
||||
uses: ./.github/workflows/tests.yml
|
||||
with:
|
||||
@@ -30,7 +25,7 @@ jobs:
|
||||
needs: [ lint ]
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.10", "3.11", "3.12"]
|
||||
python-version: ["3.10", "3.12"]
|
||||
db-url: ["", "postgres://lnbits:lnbits@0.0.0.0:5432/lnbits"]
|
||||
uses: ./.github/workflows/tests.yml
|
||||
with:
|
||||
@@ -44,7 +39,7 @@ jobs:
|
||||
needs: [ lint ]
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.10", "3.11", "3.12"]
|
||||
python-version: ["3.10", "3.12"]
|
||||
db-url: ["", "postgres://lnbits:lnbits@0.0.0.0:5432/lnbits"]
|
||||
uses: ./.github/workflows/tests.yml
|
||||
with:
|
||||
@@ -58,7 +53,7 @@ jobs:
|
||||
needs: [ lint ]
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.10", "3.11", "3.12"]
|
||||
python-version: ["3.10", "3.12"]
|
||||
uses: ./.github/workflows/migration.yml
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
@@ -74,8 +69,15 @@ jobs:
|
||||
uses: ./.github/workflows/regtest.yml
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.10"]
|
||||
backend-wallet-class: ["LndRestWallet", "LndWallet", "CoreLightningWallet", "CoreLightningRestWallet", "LNbitsWallet", "EclairWallet"]
|
||||
python-version: ["3.12"]
|
||||
backend-wallet-class:
|
||||
- BoltzWallet
|
||||
- LndRestWallet
|
||||
- LndWallet
|
||||
- CoreLightningWallet
|
||||
- CoreLightningRestWallet
|
||||
- LNbitsWallet
|
||||
- EclairWallet
|
||||
with:
|
||||
custom-pytest: "uv run pytest tests/regtest"
|
||||
python-version: ${{ matrix.python-version }}
|
||||
@@ -87,7 +89,11 @@ jobs:
|
||||
needs: [ lint ]
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.10"]
|
||||
python-version: ["3.12"]
|
||||
uses: ./.github/workflows/jmeter.yml
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
bundle:
|
||||
needs: [ lint, test-api, test-wallets, test-unit, migration, openapi, regtest, jmeter ]
|
||||
uses: ./.github/workflows/bundle.yml
|
||||
|
||||
@@ -55,10 +55,21 @@ jobs:
|
||||
- name: Build and push boltz
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile.boltz
|
||||
context: docker/boltzclient
|
||||
push: true
|
||||
tags: ${{ secrets.DOCKER_USERNAME }}/lnbits-boltz:${{ inputs.tag }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
cache-from: type=local,src=/tmp/.buildx-cache
|
||||
cache-to: type=local,dest=/tmp/.buildx-cache
|
||||
build-args: LNBITS_TAG=${{ inputs.tag }}
|
||||
|
||||
- name: Build and push sparkl2
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: docker/sparkl2
|
||||
push: true
|
||||
tags: ${{ secrets.DOCKER_USERNAME }}/lnbits-sparkl2:${{ inputs.tag }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
cache-from: type=local,src=/tmp/.buildx-cache
|
||||
cache-to: type=local,dest=/tmp/.buildx-cache
|
||||
build-args: LNBITS_TAG=${{ inputs.tag }}
|
||||
|
||||
@@ -22,6 +22,7 @@ jobs:
|
||||
- name: run LNbits
|
||||
env:
|
||||
LNBITS_ADMIN_UI: true
|
||||
AUTH_HTTPS_ONLY: false
|
||||
LNBITS_EXTENSIONS_DEFAULT_INSTALL: "watchonly, satspay, tipjar, tpos, lnurlp, withdraw"
|
||||
LNBITS_BACKEND_WALLET_CLASS: FakeWallet
|
||||
run: |
|
||||
|
||||
@@ -6,53 +6,30 @@ jobs:
|
||||
|
||||
black:
|
||||
uses: ./.github/workflows/make.yml
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.10", "3.11", "3.12"]
|
||||
with:
|
||||
make: checkblack
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
ruff:
|
||||
uses: ./.github/workflows/make.yml
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.10", "3.11", "3.12"]
|
||||
with:
|
||||
make: checkruff
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
mypy:
|
||||
uses: ./.github/workflows/make.yml
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.10", "3.11", "3.12"]
|
||||
with:
|
||||
make: mypy
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
pyright:
|
||||
uses: ./.github/workflows/make.yml
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.10", "3.11", "3.12"]
|
||||
with:
|
||||
make: pyright
|
||||
python-version: ${{ matrix.python-version }}
|
||||
npm: true
|
||||
|
||||
|
||||
prettier:
|
||||
uses: ./.github/workflows/make.yml
|
||||
with:
|
||||
make: checkprettier
|
||||
npm: true
|
||||
|
||||
bundle:
|
||||
uses: ./.github/workflows/make.yml
|
||||
with:
|
||||
make: checkbundle
|
||||
npm: true
|
||||
|
||||
poetry:
|
||||
uses: ./.github/workflows/poetry.yml
|
||||
|
||||
@@ -14,7 +14,7 @@ on:
|
||||
python-version:
|
||||
description: "python version"
|
||||
type: string
|
||||
default: "3.10"
|
||||
default: "3.12"
|
||||
|
||||
jobs:
|
||||
make:
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
os-version: ["ubuntu-24.04"]
|
||||
node-version: ["18.x"]
|
||||
node-version: ["24.x"]
|
||||
runs-on: ${{ matrix.os-version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -8,7 +8,7 @@ on:
|
||||
required: true
|
||||
type: string
|
||||
python-version:
|
||||
default: "3.10"
|
||||
default: "3.12"
|
||||
type: string
|
||||
os-version:
|
||||
default: "ubuntu-24.04"
|
||||
@@ -38,10 +38,9 @@ jobs:
|
||||
|
||||
- name: Setup Regtest
|
||||
run: |
|
||||
git clone https://github.com/lnbits/legend-regtest-enviroment.git docker
|
||||
cd docker
|
||||
chmod +x ./tests
|
||||
./tests
|
||||
cd docker/regtest
|
||||
chmod +x ./start-regtest
|
||||
./start-regtest
|
||||
sudo chmod -R a+rwx .
|
||||
|
||||
- name: Run pytest
|
||||
@@ -50,21 +49,24 @@ jobs:
|
||||
LNBITS_DATABASE_URL: ${{ inputs.db-url }}
|
||||
LNBITS_BACKEND_WALLET_CLASS: ${{ inputs.backend-wallet-class }}
|
||||
LND_REST_ENDPOINT: https://localhost:8081/
|
||||
LND_REST_CERT: ./docker/data/lnd-3/tls.cert
|
||||
LND_REST_MACAROON: ./docker/data/lnd-3/data/chain/bitcoin/regtest/admin.macaroon
|
||||
LND_REST_CERT: ./docker/regtest/data/lnd-3/tls.cert
|
||||
LND_REST_MACAROON: ./docker/regtest/data/lnd-3/data/chain/bitcoin/regtest/admin.macaroon
|
||||
LND_GRPC_ENDPOINT: localhost
|
||||
LND_GRPC_PORT: 10009
|
||||
LND_GRPC_CERT: docker/data/lnd-3/tls.cert
|
||||
LND_GRPC_MACAROON: docker/data/lnd-3/data/chain/bitcoin/regtest/admin.macaroon
|
||||
CORELIGHTNING_RPC: ./docker/data/clightning-1/regtest/lightning-rpc
|
||||
LND_GRPC_CERT: ./docker/regtest/data/lnd-3/tls.cert
|
||||
LND_GRPC_MACAROON: ./docker/regtest/data/lnd-3/data/chain/bitcoin/regtest/admin.macaroon
|
||||
CORELIGHTNING_RPC: ./docker/regtest/data/clightning-1/regtest/lightning-rpc
|
||||
CORELIGHTNING_REST_URL: https://localhost:3001
|
||||
CORELIGHTNING_REST_MACAROON: ./docker/data/clightning-2-rest/access.macaroon
|
||||
CORELIGHTNING_REST_CERT: ./docker/data/clightning-2-rest/certificate.pem
|
||||
CORELIGHTNING_REST_MACAROON: ./docker/regtest/data/clightning-2-rest/access.macaroon
|
||||
CORELIGHTNING_REST_CERT: ./docker/regtest/data/clightning-2-rest/certificate.pem
|
||||
LNBITS_ENDPOINT: http://localhost:5001
|
||||
LNBITS_KEY: "d08a3313322a4514af75d488bcc27eee"
|
||||
ECLAIR_URL: http://127.0.0.1:8082
|
||||
BOLTZ_CLIENT_ENDPOINT: 127.0.0.1:9002
|
||||
BOLTZ_MNEMONIC: abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about
|
||||
LNBITS_MAX_OUTGOING_PAYMENT_AMOUNT_SATS: 1000000000
|
||||
LNBITS_MAX_INCOMING_PAYMENT_AMOUNT_SATS: 1000000000
|
||||
LNBITS_FUNDING_SOURCE_PAY_INVOICE_WAIT_SECONDS: ${{ inputs.backend-wallet-class == 'CoreLightningRestWallet' && 60 || 5 }}
|
||||
ECLAIR_PASS: lnbits
|
||||
PYTHONUNBUFFERED: 1
|
||||
DEBUG: true
|
||||
|
||||
@@ -10,7 +10,20 @@ permissions:
|
||||
|
||||
jobs:
|
||||
|
||||
release:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Create github pre-release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
tag: ${{ github.ref_name }}
|
||||
run: |
|
||||
gh release create "$tag" --prerelease --generate-notes --draft
|
||||
|
||||
docker:
|
||||
if: github.repository == 'lnbits/lnbits'
|
||||
needs: [ release ]
|
||||
uses: ./.github/workflows/docker.yml
|
||||
with:
|
||||
tag: ${{ github.ref_name }}
|
||||
@@ -18,15 +31,36 @@ jobs:
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
||||
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
docker-latest-rc:
|
||||
if: github.repository == 'lnbits/lnbits'
|
||||
needs: [ release ]
|
||||
uses: ./.github/workflows/docker.yml
|
||||
with:
|
||||
tag: latest-rc
|
||||
secrets:
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
||||
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
pypi:
|
||||
if: github.repository == 'lnbits/lnbits'
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Install dependencies for building secp256k1
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y build-essential automake libtool libffi-dev libgmp-dev
|
||||
- uses: actions/checkout@v4
|
||||
- name: Build and publish to pypi
|
||||
uses: JRubics/poetry-publish@v1.15
|
||||
- name: Set up Python 3.10
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
pypi_token: ${{ secrets.PYPI_API_KEY }}
|
||||
python-version: "3.10"
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v6
|
||||
- name: Build the project
|
||||
run: uv build
|
||||
- name: Publish to pypi
|
||||
env:
|
||||
UV_PUBLISH_TOKEN: ${{ secrets.PYPI_API_KEY }}
|
||||
run: uv publish
|
||||
|
||||
appimage:
|
||||
needs: [ release ]
|
||||
uses: ./.github/workflows/appimage.yml
|
||||
with:
|
||||
tag_name: ${{ github.ref_name }}
|
||||
|
||||
@@ -23,6 +23,7 @@ jobs:
|
||||
gh release create "$tag" --generate-notes --draft
|
||||
|
||||
docker:
|
||||
if: github.repository == 'lnbits/lnbits'
|
||||
needs: [ release ]
|
||||
uses: ./.github/workflows/docker.yml
|
||||
with:
|
||||
@@ -32,6 +33,7 @@ jobs:
|
||||
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
docker-latest:
|
||||
if: github.repository == 'lnbits/lnbits'
|
||||
needs: [ release ]
|
||||
uses: ./.github/workflows/docker.yml
|
||||
with:
|
||||
@@ -40,15 +42,36 @@ jobs:
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
||||
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
docker-latest-rc:
|
||||
if: github.repository == 'lnbits/lnbits'
|
||||
needs: [ release ]
|
||||
uses: ./.github/workflows/docker.yml
|
||||
with:
|
||||
tag: latest-rc
|
||||
secrets:
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
||||
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
pypi:
|
||||
if: github.repository == 'lnbits/lnbits'
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Install dependencies for building secp256k1
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y build-essential automake libtool libffi-dev libgmp-dev
|
||||
- uses: actions/checkout@v4
|
||||
- name: Build and publish to pypi
|
||||
uses: JRubics/poetry-publish@v1.15
|
||||
- name: Set up Python 3.10
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
pypi_token: ${{ secrets.PYPI_API_KEY }}
|
||||
python-version: "3.10"
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v6
|
||||
- name: Build the project
|
||||
run: uv build
|
||||
- name: Publish to pypi
|
||||
env:
|
||||
UV_PUBLISH_TOKEN: ${{ secrets.PYPI_API_KEY }}
|
||||
run: uv publish
|
||||
|
||||
appimage:
|
||||
needs: [ release ]
|
||||
uses: ./.github/workflows/appimage.yml
|
||||
with:
|
||||
tag_name: ${{ github.ref_name }}
|
||||
|
||||
@@ -8,7 +8,7 @@ on:
|
||||
required: true
|
||||
type: string
|
||||
python-version:
|
||||
default: "3.10"
|
||||
default: "3.12"
|
||||
type: string
|
||||
os-version:
|
||||
default: "ubuntu-24.04"
|
||||
|
||||
+1
-1
@@ -6,6 +6,7 @@ __pycache__
|
||||
*$py.class
|
||||
.mypy_cache
|
||||
.vscode
|
||||
.codex
|
||||
*-lock.json
|
||||
.python-version
|
||||
|
||||
@@ -42,7 +43,6 @@ lnbits/static/bundle.min.js.old
|
||||
lnbits/static/bundle.min.css.old
|
||||
lnbits/static/bundle-components.min.js.old
|
||||
lnbits/upgrades
|
||||
docker
|
||||
|
||||
# Nix
|
||||
*result*
|
||||
|
||||
@@ -14,16 +14,16 @@ repos:
|
||||
- id: mixed-line-ending
|
||||
- id: check-case-conflict
|
||||
- repo: https://github.com/psf/black
|
||||
rev: 25.1.0
|
||||
rev: 26.3.1
|
||||
hooks:
|
||||
- id: black
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.12.10
|
||||
rev: v0.14.10
|
||||
hooks:
|
||||
- id: ruff
|
||||
args: [ --fix, --exit-non-zero-on-fix ]
|
||||
- repo: https://github.com/rbubley/mirrors-prettier
|
||||
rev: v3.6.2
|
||||
rev: v3.7.4
|
||||
hooks:
|
||||
- id: prettier
|
||||
types_or: [css, javascript, html, json]
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
# AGENTS.md - AI Coding Agent Guide for LNbits
|
||||
|
||||
This file guides AI coding agents working on LNbits. Keep changes small, verified, and aligned with existing project patterns.
|
||||
|
||||
## Core Behavior
|
||||
|
||||
- Think before coding. State material assumptions. Ask when ambiguity affects correctness, security, payments, wallets, or data migrations.
|
||||
- Prefer the simplest implementation that solves the request.
|
||||
- Make surgical changes. Every changed line should trace back to the task.
|
||||
- Do not refactor, reformat, rename, or clean adjacent code unless required.
|
||||
- Remove only dead code or imports created by your own changes.
|
||||
- Define success criteria for non-trivial work and verify them before reporting done.
|
||||
|
||||
## LNbits Architecture
|
||||
|
||||
- Keep core lean. Prefer/assess extensions for non-core features.
|
||||
- Preserve compatibility with existing extensions and wallet backends.
|
||||
- Follow existing patterns in `lnbits/core`, `lnbits/wallets`, `lnbits/extensions`, and frontend code.
|
||||
- Use existing CRUD, services, settings, and migration patterns.
|
||||
- Do not edit generated files, bundled vendor files, or unrelated extension code.
|
||||
|
||||
## Security-Sensitive Areas
|
||||
|
||||
Be extra cautious with payments, wallet balances, admin routes, keys, LNURL, Bolt11, funding sources, migrations, and authentication.
|
||||
|
||||
Do not expose raw stack traces or sensitive values. Do not add synchronous blocking work in hot async paths without justification.
|
||||
|
||||
## Commands and Verification
|
||||
|
||||
Read `Makefile` before running project commands.
|
||||
|
||||
Use Makefile targets instead of hand-written commands when available:
|
||||
|
||||
- `make check` for full checks.
|
||||
- `make test-unit` for unit tests.
|
||||
- `make test-api` for API tests.
|
||||
- `make test-wallets` for wallet tests.
|
||||
- `make checkbundle` when bundled frontend assets may be affected.
|
||||
- `make format` only when formatting is intended.
|
||||
|
||||
Do not run `make test` by default. Use the targeted tests available in the Makefile that are related to the work done, unless the user explicitly asks for broader test coverage.
|
||||
|
||||
## Dependencies
|
||||
|
||||
Do not add dependencies without approval. If approved, update the correct project files and explain why the dependency is necessary.
|
||||
|
||||
## Maintenance
|
||||
|
||||
LNbits maintainers own this file. They should update it when the development workflow, architecture, or verification commands materially change.
|
||||
|
||||
Do not edit, commit, push, or include changes to this file in a PR as part of normal feature work unless the user explicitly asks for `AGENTS.md` changes.
|
||||
|
||||
## Reporting
|
||||
|
||||
When finished, report:
|
||||
|
||||
- Summary of what changed.
|
||||
- Files touched.
|
||||
- Makefile targets or checks run.
|
||||
- Anything not verified and why.
|
||||
+1
-1
@@ -43,4 +43,4 @@ ENV LNBITS_HOST="0.0.0.0"
|
||||
|
||||
EXPOSE 5000
|
||||
|
||||
CMD ["sh", "-c", "uv run lnbits --port $LNBITS_PORT --host $LNBITS_HOST --forwarded-allow-ips='*'"]
|
||||
CMD ["sh", "-c", "uv --offline run --no-sync lnbits --port $LNBITS_PORT --host $LNBITS_HOST --forwarded-allow-ips='*'"]
|
||||
|
||||
@@ -66,6 +66,7 @@ test-regtest:
|
||||
LNBITS_DATA_FOLDER="./tests/data" \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
DEBUG=true \
|
||||
rm -rf ./tests/data \
|
||||
uv run pytest tests/regtest
|
||||
|
||||
test-migration:
|
||||
|
||||
@@ -1,44 +1,52 @@
|
||||
<picture >
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://i.imgur.com/QE6SIrs.png" style="width:300px">
|
||||
<img src="https://i.imgur.com/fyKPgVT.png" style="width:300px">
|
||||
</picture>
|
||||
<a href="https://lnbits.com" target="_blank" rel="noopener noreferrer">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="docs/logos/lnbits-full-inverse.svg">
|
||||
<img src="docs/logos/lnbits-full.svg" alt="LNbits" style="width:300px">
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
 [![license-badge]](LICENSE) [![docs-badge]][docs]  [<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits) [<img src="https://img.shields.io/badge/supported_by-%3E__OpenSats-f97316">](https://opensats.org)
|
||||

|
||||
 [![license-badge]](LICENSE) [![docs-badge]][docs]  [](https://extensions.lnbits.com/) [](https://shop.lnbits.com/) [<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits)
|
||||
<img alt="lnbits_head" src="docs/assets/header.jpg" />
|
||||
[](https://demo.lnbits.com/tipjar/DwaUiE4kBX6mUW6pj3X5Kg)
|
||||
|
||||
# The world's most powerful suite of bitcoin tools.
|
||||
# LNbits — The most powerful Bitcoin & Lightning toolkit
|
||||
|
||||
## Run for yourself, for others, or as part of a stack.
|
||||
> Run it for yourself, for your community, or as part of a larger stack.
|
||||
|
||||
LNbits is beta, for responsible disclosure of any concerns please contact an admin in the community chat.
|
||||
## What is LNbits?
|
||||
|
||||
LNbits is a Python server that sits on top of any funding source. It can be used as:
|
||||
LNbits is a lightweight Python server that sits on top of your Lightning funding source. It gives you safe, isolated wallets, a clean API, and an extension system for rapidly adding features - without locking you into a single node implementation. The Inspiration for LNBits came from ideas pioneered by **OpenNode** and **LNPay** — both today work as funding sources for LNbits.
|
||||
|
||||
- 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
|
||||
## What you can do with LNbits
|
||||
|
||||
LNbits can run on top of almost all Lightning funding sources.
|
||||
- **Harden app security:** Create per-wallet API keys so individual apps never touch your full balance.
|
||||
- **Extend functionality fast:** Install extensions to explore and ship Lightning features with minimal code.
|
||||
- **Build into your stack:** Use the LNbits HTTP API to integrate payments, wallets, and accounting.
|
||||
- **Cover LNURL flows:** Use LNbits as a reliable fallback wallet for LNURL.
|
||||
- **Demo in minutes:** Spin up instant wallets for workshops, proofs-of-concept, and user testing.
|
||||
|
||||
See [LNbits manual](https://docs.lnbits.org/guide/wallets.html) for more detailed documentation about each funding source.
|
||||
## Funding sources
|
||||
|
||||
Checkout the LNbits [YouTube](https://www.youtube.com/playlist?list=PLPj3KCksGbSYG0ciIQUWJru1dWstPHshe) video series.
|
||||
LNbits runs on top of most Lightning backends. Choose the one you already operate - or swap later without changing your app architecture.
|
||||
|
||||
LNbits is inspired by all the great work of [opennode.com](https://www.opennode.com/), and in particular [lnpay.co](https://lnpay.co/). Both work as funding sources for LNbits.
|
||||
- Read the [funding source guide](https://docs.lnbits.org/guide/wallets.html)
|
||||
|
||||
## Learn more
|
||||
|
||||
- Video series on [Youtube](https://www.youtube.com/@lnbits)
|
||||
- Introduction Video [LNBits V1](https://www.youtube.com/watch?v=PFAHKxvgI9Y&t=19s)
|
||||
|
||||
## Running LNbits
|
||||
|
||||
Test on our demo server [demo.lnbits.com](https://demo.lnbits.com), or on [lnbits.com](https://lnbits.com) software as a service, where you can spin up an LNbits instance for 21sats per hr.
|
||||
|
||||
See the [install guide](https://github.com/lnbits/lnbits/blob/main/docs/guide/installation.md) for details on installation and setup.
|
||||
|
||||
Get yourself familiar and test on our demo server [demo.lnbits.com](https://demo.lnbits.com), or on [lnbits.com](https://lnbits.com) software as a service, where you can spin up an LNbits instance for 21sats per hr.
|
||||
|
||||
## LNbits account system
|
||||
|
||||
LNbits is packaged with tools to help manage funds, such as a table of transactions, line chart of spending, export to csv. Each wallet also comes with its own API keys, to help partition the exposure of your funding source.
|
||||
|
||||
<img src="https://i.imgur.com/w8jdGpF.png" style="width:800px">
|
||||
<img alt="lnbits_wallet" src="docs/assets/wallet.jpg" />
|
||||
|
||||
## LNbits extension universe
|
||||
|
||||
@@ -46,29 +54,35 @@ Extend YOUR LNbits to meet YOUR needs.
|
||||
|
||||
All non-core features are installed as extensions, reducing your code base and making your LNbits unique to you. Extend your LNbits install in any direction, and even create and share your own extensions.
|
||||
|
||||
<img src="https://i.imgur.com/aEBpwJF.png" style="width:800px">
|
||||
<img alt="lnbits_extensions" src="docs/assets/extensions.jpg" />
|
||||
|
||||
## LNbits API
|
||||
|
||||
LNbits has a powerful API, many projects use LNbits to do the heavy lifting for their bitcoin/lightning services.
|
||||
|
||||
<img src="https://i.imgur.com/V742sb9.png" style="width:800px">
|
||||
<img alt="lnbits_api" src="docs/assets/api.jpg" />
|
||||
|
||||
## LNbits node manager
|
||||
|
||||
LNbits comes packaged with a light node management UI, to make running your node that much easier.
|
||||
|
||||
<img src="https://i.imgur.com/TYqIK60.png" style="width:800px">
|
||||
<img alt="lnbits_api" src="docs/assets/lightning_node.jpg" />
|
||||
|
||||
## LNbits across all your devices
|
||||
## LNbits merchant tools
|
||||
|
||||
As well as working great in a browser, LNbits has native IoS and Android apps as well as a chrome extension. So you can enjoy the same UI across ALL your devices.
|
||||
The LNbits stack can process both bitcoin and fiat payments, making it a turnkey, all-in-one solution for merchants. With orders and inventory shared across extensions, and built-in notifications for Nostr, Telegram, and email, LNbits keeps everything in sync, freeing merchants to focus on their business.
|
||||
|
||||
<img src="https://i.imgur.com/J96EbRf.png" style="width:800px">
|
||||
<img alt="lnbits_merchants" src="docs/assets/merchants_small.webp" />
|
||||
|
||||
## Tip us
|
||||
## Powered by LNbits
|
||||
|
||||
If you like this project [send some tip love](https://demo.lnbits.com/lnurlp/link/fH59GD)!
|
||||
LNbits empowers everyone with modular, open-source tools for building Bitcoin-based systems — fast, free, and extendable.
|
||||
|
||||
[](https://shop.lnbits.com/)
|
||||
[](https://shop.lnbits.com/)
|
||||
[](https://my.lnbits.com/login)
|
||||
[](https://news.lnbits.com/)
|
||||
[](https://extensions.lnbits.com/) [](https://demo.lnbits.com/tipjar/DwaUiE4kBX6mUW6pj3X5Kg)
|
||||
|
||||
[docs]: https://github.com/lnbits/lnbits/wiki
|
||||
[docs-badge]: https://img.shields.io/badge/docs-lnbits.org-673ab7.svg
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
FROM boltz/boltz-client:2.8.3 AS boltz
|
||||
ARG LNBITS_TAG=latest
|
||||
|
||||
FROM lnbits/lnbits:latest
|
||||
FROM boltz/boltz-client:latest AS boltz
|
||||
FROM lnbits/lnbits:${LNBITS_TAG}
|
||||
|
||||
COPY --from=boltz /bin/boltzd /bin/boltzcli /usr/local/bin/
|
||||
RUN ls -l /usr/local/bin/boltzd
|
||||
@@ -13,11 +14,13 @@ ENV PATH="/root/.local/bin:$PATH"
|
||||
# Reinstall dependencies just in case (needed for CMD usage)
|
||||
RUN uv sync --all-extras
|
||||
|
||||
# LNbits + boltzd configuration
|
||||
# LNbits
|
||||
ENV LNBITS_PORT="5000"
|
||||
ENV LNBITS_HOST="0.0.0.0"
|
||||
ENV LNBITS_BACKEND_WALLET_CLASS="BoltzWallet"
|
||||
ENV FUNDING_SOURCE_MAX_RETRIES=10
|
||||
ENV FUNDING_SOURCE_MAX_RETRIES="10"
|
||||
|
||||
# Boltzd
|
||||
ENV BOLTZ_CLIENT_ENDPOINT="127.0.0.1:9002"
|
||||
ENV BOLTZ_CLIENT_MACAROON="/root/.boltz/macaroons/admin.macaroon"
|
||||
ENV BOLTZ_CLIENT_CERT="/root/.boltz/tls.cert"
|
||||
@@ -25,8 +28,7 @@ ENV BOLTZ_CLIENT_WALLET="lnbits"
|
||||
|
||||
EXPOSE 5000
|
||||
|
||||
# Entrypoint to start boltzd and LNbits
|
||||
COPY dockerboltz.sh /dockerboltz.sh
|
||||
RUN chmod +x /dockerboltz.sh
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
CMD ["/dockerboltz.sh"]
|
||||
CMD ["/entrypoint.sh"]
|
||||
@@ -0,0 +1,18 @@
|
||||
!data
|
||||
data/*
|
||||
|
||||
!data/boltz
|
||||
data/boltz/*
|
||||
!data/boltz/boltz.conf
|
||||
|
||||
!data/boltz-client
|
||||
data/boltz-client/*
|
||||
!data/boltz-client/boltz.toml
|
||||
|
||||
!data/boltz-nginx
|
||||
data/boltz-nginx/*
|
||||
!data/boltz-nginx/default.conf
|
||||
|
||||
!data/eclair
|
||||
data/eclair/*
|
||||
!data/eclair/eclair.conf
|
||||
@@ -0,0 +1,99 @@
|
||||

|
||||
|
||||
# nodes
|
||||
|
||||
- lnd-1: for locally testing your current lnbits
|
||||
- lnd-2: used for boltz backend
|
||||
- lnd-3: used for lnbits inside docker
|
||||
- cln-1: for locally testing your current lnbits
|
||||
- cln-2: used for clightning-REST
|
||||
- eclair-1: for locally testing your current lnbits
|
||||
|
||||
# Installing regtest
|
||||
|
||||
get the regtest enviroment ready
|
||||
|
||||
```sh
|
||||
# Install docker https://docs.docker.com/engine/install/
|
||||
# Make sure your user has permission to use docker 'sudo usermod -aG docker ${USER}' then reboot
|
||||
# Stop/start docker 'sudo systemctl stop docker' 'sudo systemctl start docker'
|
||||
|
||||
sudo apt install jq
|
||||
git clone https://github.com/lnbits/lnbits.git
|
||||
cd lnbits
|
||||
docker build -t lnbits/lnbits .
|
||||
mkdir docker
|
||||
git clone https://github.com/lnbits/legend-regtest-enviroment.git docker
|
||||
cd docker
|
||||
chmod +x ./start-regtest
|
||||
./start-regtest # start the regtest and also run tests
|
||||
sudo chown -R $USER ./data # Give the data file permissions for user
|
||||
```
|
||||
|
||||
# Running LNbits on regtest
|
||||
|
||||
add this ENV variables to your `.env` file
|
||||
|
||||
```sh
|
||||
DEBUG=true
|
||||
|
||||
# LND
|
||||
LNBITS_BACKEND_WALLET_CLASS="LndRestWallet"
|
||||
LND_REST_ENDPOINT=https://127.0.0.1:8081/
|
||||
LND_REST_CERT=/home/user/repos/lnbits/docker/data/lnd-1/tls.cert
|
||||
LND_REST_MACAROON=/home/user/repos/lnbits/docker/data/lnd-1/data/chain/bitcoin/regtest/admin.macaroon
|
||||
|
||||
# CLN
|
||||
LNBITS_BACKEND_WALLET_CLASS="CoreLightningWallet"
|
||||
CORELIGHTNING_RPC=./docker/data/clightning-1/regtest/lightning-rpc
|
||||
|
||||
|
||||
# Run LNbits
|
||||
uv run lnbits
|
||||
|
||||
# Run LNbits with hot reload
|
||||
make dev
|
||||
```
|
||||
|
||||
# testing
|
||||
|
||||
```sh
|
||||
chmod +x ./start-regtest
|
||||
./start-regtest
|
||||
# short answer :)
|
||||
./start-regtest && echo "PASSED" || echo "FAILED" > /dev/null
|
||||
```
|
||||
|
||||
usage of the `bitcoin-cli-sim`, `lightning-cli-sim` and `lncli-sim` aliases
|
||||
|
||||
```sh
|
||||
cd ~/lnbits/docker
|
||||
source docker-scripts.sh
|
||||
# use bitcoin core, mine a block
|
||||
bitcoin-cli-sim -generate 1
|
||||
|
||||
# use c-lightning nodes
|
||||
lightning-cli-sim 1 newaddr | jq -r '.bech32' # use node 1
|
||||
lightning-cli-sim 2 getinfo # use node 2
|
||||
lightning-cli-sim 3 getinfo # use node 3
|
||||
|
||||
# use lnd nodes
|
||||
lncli-sim 1 newaddr p2wsh
|
||||
lncli-sim 2 listpeers
|
||||
```
|
||||
|
||||
# urls
|
||||
|
||||
- mempool: http://localhost:8080/
|
||||
- boltz api: http://localhost:9001/
|
||||
- lnd-1 rest: http://localhost:8081/
|
||||
- lnbits: http://localhost:5001/
|
||||
|
||||
# debugging docker logs
|
||||
|
||||
```sh
|
||||
docker logs lnbits-lnbits-1 -f
|
||||
docker logs lnbits-boltz-1 -f
|
||||
docker logs lnbits-clightning-1-1 -f
|
||||
docker logs lnbits-lnd-2-1 -f
|
||||
```
|
||||
@@ -0,0 +1,49 @@
|
||||
standalone = true
|
||||
network = "regtest"
|
||||
|
||||
# Path the the log file
|
||||
logfile = ""
|
||||
|
||||
electrumUrl = "electrs:19001"
|
||||
electrumLiquidUrl = "electrs-liquid:19002"
|
||||
|
||||
[BOLTZ]
|
||||
# By default the daemon automatically connects to the official Boltz instance for the network LND is on
|
||||
# This value is used to override that
|
||||
url = "http://boltz-nginx:9001"
|
||||
|
||||
[DATABASE]
|
||||
# Path to the SQLite database file
|
||||
# path = "/home/michael/test.db"
|
||||
|
||||
[RPC]
|
||||
# Host of
|
||||
host = "0.0.0.0"
|
||||
|
||||
# Port of the gRPC interface
|
||||
port = 9002
|
||||
|
||||
# Whether the REST proxy for the gRPC interface should be disabled
|
||||
restDisabled = false
|
||||
|
||||
# Host of the REST proxy
|
||||
restHost = "0.0.0.0"
|
||||
|
||||
# Port of the REST proxy
|
||||
restPort = 9003
|
||||
|
||||
# Path to the TLS cert for the gRPC and REST interface
|
||||
tlsCert = ""
|
||||
|
||||
# Path to the TLS private key for the gRPC and REST interface
|
||||
tlsKey = ""
|
||||
noTls = true
|
||||
|
||||
# Whether the macaroon authentication for the gRPC and REST interface should be disabled
|
||||
noMacaroons = true
|
||||
|
||||
# Path to the admin macaroon for the gRPC and REST interface
|
||||
adminMacaroonPath = ""
|
||||
|
||||
# Path to the read only macaroon for the gRPC and REST interface
|
||||
readOnlyMacaroonPath = ""
|
||||
@@ -0,0 +1,49 @@
|
||||
upstream boltz {
|
||||
server boltz:9001;
|
||||
}
|
||||
|
||||
upstream boltzr {
|
||||
server boltz:9005;
|
||||
}
|
||||
|
||||
upstream ws {
|
||||
server boltz:9004;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 9001;
|
||||
listen [::]:9001;
|
||||
server_name localhost;
|
||||
|
||||
add_header Access-Control-Allow-Origin "*" always;
|
||||
add_header Access-Control-Allow-Methods 'GET, PATCH, DELETE, POST, OPTIONS' always;
|
||||
add_header Access-Control-Allow-Headers "*" always;
|
||||
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
if ($request_method = OPTIONS) {
|
||||
return 204;
|
||||
}
|
||||
|
||||
location /v2/ws {
|
||||
proxy_pass http://ws/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "Upgrade";
|
||||
}
|
||||
|
||||
location ~ ^/v2/(lightning|swap/rescue|swap/restore) {
|
||||
proxy_pass http://boltzr;
|
||||
}
|
||||
|
||||
location /streamswapstatus {
|
||||
proxy_pass http://boltzr;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://boltz;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
[api]
|
||||
host = "0.0.0.0"
|
||||
port = 9_001
|
||||
|
||||
[grpc]
|
||||
host = "0.0.0.0"
|
||||
port = 9_000
|
||||
|
||||
[postgres]
|
||||
host = "boltz-postgres"
|
||||
port = 5432
|
||||
database = "boltz"
|
||||
username = "boltz"
|
||||
password = "boltz"
|
||||
|
||||
[sidecar]
|
||||
[sidecar.grpc]
|
||||
host = "127.0.0.1"
|
||||
port = 9003
|
||||
|
||||
[sidecar.ws]
|
||||
host = "0.0.0.0"
|
||||
port = 9004
|
||||
|
||||
[sidecar.api]
|
||||
host = "0.0.0.0"
|
||||
port = 9005
|
||||
|
||||
[swap]
|
||||
deferredClaimSymbols = ["BTC", "L-BTC"]
|
||||
|
||||
[[pairs]]
|
||||
base = "BTC"
|
||||
quote = "BTC"
|
||||
rate = 1
|
||||
fee = 0.5
|
||||
swapInFee = 0.1
|
||||
maxSwapAmount = 40_294_967
|
||||
minSwapAmount = 50_000
|
||||
|
||||
[pairs.timeoutDelta]
|
||||
chain = 1440
|
||||
reverse = 1440
|
||||
swapMinimal = 1440
|
||||
swapMaximal = 2880
|
||||
swapTaproot = 10080
|
||||
|
||||
[[pairs]]
|
||||
base = "L-BTC"
|
||||
quote = "BTC"
|
||||
fee = 0.25
|
||||
swapInFee = 0.1
|
||||
rate = 1
|
||||
maxSwapAmount = 40_294_967
|
||||
minSwapAmount = 100
|
||||
|
||||
[pairs.submarineSwap]
|
||||
minSwapAmount = 1_000
|
||||
minBatchedAmount = 21
|
||||
|
||||
[pairs.chainSwap]
|
||||
minSwapAmount = 25_000
|
||||
|
||||
[pairs.timeoutDelta]
|
||||
chain = 1440
|
||||
reverse = 1440
|
||||
swapMinimal = 1440
|
||||
swapMaximal = 2880
|
||||
swapTaproot = 10080
|
||||
|
||||
[[currencies]]
|
||||
symbol = "BTC"
|
||||
network = "bitcoinRegtest"
|
||||
minWalletBalance = 10_000_000
|
||||
minChannelBalance = 10_000_000
|
||||
maxSwapAmount = 40_294_967
|
||||
minSwapAmount = 10_000
|
||||
maxZeroConfAmount = 0
|
||||
|
||||
[currencies.chain]
|
||||
# mempoolSpace = "http://mempool-web:8090/api"
|
||||
host = "bitcoind"
|
||||
zmqpubrawtx = "tcp://bitcoind:29000"
|
||||
zmqpubrawblock = "tcp://bitcoind:29001"
|
||||
port = 18_443
|
||||
cookie = "/root/.bitcoin/regtest/.cookie"
|
||||
feeFloor = 0.2
|
||||
|
||||
wallet = "lnbits"
|
||||
|
||||
[currencies.lnd]
|
||||
host = "lnd-2"
|
||||
port = 10_009
|
||||
certpath = "/data/lnd/tls.cert"
|
||||
macaroonpath = "/data/lnd/data/chain/bitcoin/regtest/admin.macaroon"
|
||||
|
||||
|
||||
[liquid]
|
||||
symbol = "L-BTC"
|
||||
network = "liquidRegtest"
|
||||
|
||||
maxSwapAmount = 40_294_967
|
||||
minSwapAmount = 10_000
|
||||
maxZeroConfAmount = 40_294_967
|
||||
|
||||
|
||||
[liquid.chain]
|
||||
host = "elementsd"
|
||||
port = 18884
|
||||
cookie = "/root/.elements/liquidregtest/.cookie"
|
||||
zmqpubrawtx = "tcp://elementsd:31000"
|
||||
zmqpubhashblock = "tcp://elementsd:31002"
|
||||
|
||||
wallet = "lnbits"
|
||||
@@ -0,0 +1,28 @@
|
||||
eclair {
|
||||
chain = "regtest"
|
||||
|
||||
api {
|
||||
binding-ip = "0.0.0.0"
|
||||
enabled = true
|
||||
port = 8080
|
||||
password = "lnbits"
|
||||
}
|
||||
|
||||
bitcoind {
|
||||
host = "bitcoind"
|
||||
rpcport = 18443
|
||||
auth = "safecookie"
|
||||
cookie = "/root/.bitcoin/regtest/.cookie"
|
||||
|
||||
zmqblock = "tcp://bitcoind:29002"
|
||||
zmqtx = "tcp://bitcoind:29000"
|
||||
}
|
||||
|
||||
channel {
|
||||
max-funding-satoshis = 10000000000
|
||||
}
|
||||
|
||||
features {
|
||||
option_support_large_channel = mandatory
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
services:
|
||||
|
||||
lnbits:
|
||||
hostname: lnbits
|
||||
depends_on:
|
||||
- lnd-3
|
||||
image: lnbits/lnbits
|
||||
restart: on-failure
|
||||
user: "0:0"
|
||||
environment:
|
||||
LNBITS_PORT: 5001
|
||||
DEBUG: true
|
||||
LNBITS_ADMIN_UI: false
|
||||
LNBITS_BACKEND_WALLET_CLASS: "LndRestWallet"
|
||||
LNBITS_DATA_FOLDER: "./data"
|
||||
LND_REST_ENDPOINT: "https://lnd-3:8081/"
|
||||
LND_REST_CERT: "./lnd/tls.cert"
|
||||
LND_REST_MACAROON: "./lnd/data/chain/bitcoin/regtest/admin.macaroon"
|
||||
ports:
|
||||
- 5001:5001
|
||||
volumes:
|
||||
- lnbits-data:/app/data
|
||||
- ./data/lnd-3:/app/lnd:uid=1000,gid=1000
|
||||
|
||||
boltz:
|
||||
hostname: boltz
|
||||
depends_on:
|
||||
- lnd-2
|
||||
- boltz-postgres
|
||||
restart: always
|
||||
image: boltz/boltz:v3.12.1
|
||||
ports:
|
||||
- 9000:9000
|
||||
entrypoint: "sh -c 'sleep 30; /boltz-backend/bin/boltzd'"
|
||||
volumes:
|
||||
- ./data/lnd-2:/data/lnd/
|
||||
- ./data/boltz/:/root/.boltz/
|
||||
- elements-data:/root/.elements
|
||||
- bitcoin-data:/root/.bitcoin
|
||||
|
||||
boltz-client:
|
||||
hostname: boltz-client
|
||||
depends_on:
|
||||
- boltz
|
||||
restart: always
|
||||
image: boltz/boltz-client:latest
|
||||
ports:
|
||||
- 9002:9002
|
||||
- 9003:9003
|
||||
expose:
|
||||
- 9002
|
||||
healthcheck:
|
||||
test: ['CMD', 'boltzcli', '--host', 'boltz-client', 'getinfo']
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
start_period: 0s
|
||||
volumes:
|
||||
- elements-data:/root/.elements
|
||||
- ./data/boltz-client:/root/.boltz
|
||||
|
||||
boltz-backend-nginx:
|
||||
hostname: boltz-nginx
|
||||
restart: always
|
||||
image: nginx:latest
|
||||
ports:
|
||||
- 9001:9001
|
||||
volumes:
|
||||
- nginx-data:/etc/nginx/conf.d
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'curl http://localhost:9001/version']
|
||||
timeout: 1s
|
||||
retries: 10
|
||||
interval: 1s
|
||||
start_period: 0s
|
||||
|
||||
boltz-postgres:
|
||||
hostname: boltz-postgres
|
||||
restart: always
|
||||
image: postgres:14-alpine
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready --dbname boltz --username boltz"]
|
||||
interval: 5s
|
||||
timeout: 30s
|
||||
retries: 10
|
||||
start_period: 5s
|
||||
environment:
|
||||
- POSTGRES_DB=boltz
|
||||
- POSTGRES_USER=boltz
|
||||
- POSTGRES_PASSWORD=boltz
|
||||
expose:
|
||||
- 5432
|
||||
|
||||
bitcoind:
|
||||
hostname: bitcoind
|
||||
image: boltz/bitcoin-core:25.0
|
||||
command:
|
||||
- -regtest
|
||||
- -fallbackfee=0.00000253
|
||||
- -zmqpubrawtx=tcp://0.0.0.0:29000
|
||||
- -zmqpubrawblock=tcp://0.0.0.0:29001
|
||||
- -zmqpubhashblock=tcp://0.0.0.0:29002
|
||||
- -txindex
|
||||
- -rpcallowip=0.0.0.0/0
|
||||
- -rpcbind=0.0.0.0
|
||||
- -addresstype=bech32
|
||||
- -changetype=bech32
|
||||
- -dbcache=2048
|
||||
- -rpcworkqueue=256
|
||||
volumes:
|
||||
- bitcoin-data:/root/.bitcoin
|
||||
expose:
|
||||
- 29000
|
||||
- 29001
|
||||
- 29002
|
||||
- 18443
|
||||
- 18444
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"bitcoin-cli",
|
||||
"--rpccookiefile=/root/.bitcoin/regtest/.cookie",
|
||||
"-regtest",
|
||||
"getblockchaininfo",
|
||||
]
|
||||
timeout: 1s
|
||||
retries: 1
|
||||
interval: 1s
|
||||
start_period: 0s
|
||||
|
||||
clightning-1:
|
||||
hostname: clightning-1
|
||||
depends_on:
|
||||
- bitcoind
|
||||
image: boltz/c-lightning:24.11
|
||||
command:
|
||||
- --large-channels
|
||||
- --network=regtest
|
||||
- --grpc-port=9736
|
||||
- --bind-addr=0.0.0.0:9735
|
||||
- --bitcoin-rpcconnect=bitcoind
|
||||
- --bitcoin-rpcport=18443
|
||||
- --clnrest-host=0.0.0.0
|
||||
- --clnrest-port=3010
|
||||
expose:
|
||||
- 9735
|
||||
ports:
|
||||
- 9736:9736
|
||||
- 3010:3010
|
||||
volumes:
|
||||
- ./data/clightning-1:/root/.lightning/
|
||||
- bitcoin-data:/root/.bitcoin
|
||||
|
||||
clightning-2:
|
||||
hostname: clightning-2
|
||||
depends_on:
|
||||
- bitcoind
|
||||
image: boltz/c-lightning:22.11.1
|
||||
command:
|
||||
- --large-channels
|
||||
- --network=regtest
|
||||
- --grpc-port=9737
|
||||
- --bind-addr=0.0.0.0:9735
|
||||
- --bitcoin-rpcconnect=bitcoind
|
||||
- --bitcoin-rpcport=18443
|
||||
expose:
|
||||
- 9735
|
||||
ports:
|
||||
- 9737:9737
|
||||
volumes:
|
||||
- ./data/clightning-2:/root/.lightning/
|
||||
- bitcoin-data:/root/.bitcoin
|
||||
|
||||
clightning-2-rest:
|
||||
hostname: clightning-2-rest
|
||||
depends_on:
|
||||
- clightning-2
|
||||
image: saubyk/c-lightning-rest:0.10.7
|
||||
entrypoint: "sh -c 'sleep 35 && /sbin/tini -g -- ./docker-entrypoint.sh'"
|
||||
ports:
|
||||
- 3001:3001
|
||||
expose:
|
||||
- 3001
|
||||
volumes:
|
||||
- ./data/clightning-2:/root/.lightning/:uid=1000,gid=1000
|
||||
- ./data/clightning-2-rest:/usr/src/app/certs/
|
||||
- bitcoin-data:/root/.bitcoin
|
||||
|
||||
clightning-3:
|
||||
hostname: clightning-3
|
||||
depends_on:
|
||||
- bitcoind
|
||||
image: boltz/c-lightning:24.11
|
||||
command:
|
||||
- --large-channels
|
||||
- --network=regtest
|
||||
- --grpc-port=9738
|
||||
- --bind-addr=0.0.0.0:9735
|
||||
- --bitcoin-rpcconnect=bitcoind
|
||||
- --bitcoin-rpcport=18443
|
||||
expose:
|
||||
- 9735
|
||||
ports:
|
||||
- 9738:9738
|
||||
volumes:
|
||||
- ./data/clightning-3:/root/.lightning/
|
||||
- bitcoin-data:/root/.bitcoin
|
||||
|
||||
lnd-1:
|
||||
hostname: lnd-1
|
||||
depends_on:
|
||||
- bitcoind
|
||||
image: boltz/lnd:0.18.4-beta
|
||||
restart: on-failure
|
||||
command:
|
||||
- --listen=lnd-1:9735
|
||||
- --rpclisten=lnd-1:10009
|
||||
- --restlisten=lnd-1:8081
|
||||
- --bitcoin.active
|
||||
- --bitcoin.regtest
|
||||
- --bitcoin.node=bitcoind
|
||||
- --bitcoind.rpchost=bitcoind
|
||||
- --bitcoind.rpccookie=/root/.bitcoin/regtest/.cookie
|
||||
- --bitcoind.zmqpubrawtx=bitcoind:29000
|
||||
- --bitcoind.zmqpubrawblock=bitcoind:29001
|
||||
- --noseedbackup
|
||||
- --protocol.wumbo-channels
|
||||
expose:
|
||||
- 8081
|
||||
- 9735
|
||||
- 10009
|
||||
volumes:
|
||||
- ./data/lnd-1:/root/.lnd/
|
||||
- bitcoin-data:/root/.bitcoin
|
||||
|
||||
lnd-2:
|
||||
hostname: lnd-2
|
||||
depends_on:
|
||||
- bitcoind
|
||||
image: boltz/lnd:0.19.3-beta
|
||||
restart: on-failure
|
||||
command:
|
||||
- --listen=lnd-2:9735
|
||||
- --rpclisten=lnd-2:10009
|
||||
- --restlisten=lnd-2:8081
|
||||
- --bitcoin.active
|
||||
- --bitcoin.regtest
|
||||
- --bitcoin.node=bitcoind
|
||||
- --bitcoind.rpchost=bitcoind
|
||||
- --bitcoind.rpccookie=/root/.bitcoin/regtest/.cookie
|
||||
- --bitcoind.zmqpubrawtx=bitcoind:29000
|
||||
- --bitcoind.zmqpubrawblock=bitcoind:29001
|
||||
- --noseedbackup
|
||||
- --protocol.wumbo-channels
|
||||
expose:
|
||||
- 8081
|
||||
- 9735
|
||||
- 10009
|
||||
volumes:
|
||||
- ./data/lnd-2:/root/.lnd/
|
||||
- bitcoin-data:/root/.bitcoin
|
||||
|
||||
lnd-3:
|
||||
hostname: lnd-3
|
||||
depends_on:
|
||||
- bitcoind
|
||||
image: boltz/lnd:0.18.4-beta
|
||||
restart: on-failure
|
||||
command:
|
||||
- --listen=lnd-3:9735
|
||||
- --rpclisten=lnd-3:10009
|
||||
- --restlisten=lnd-3:8081
|
||||
- --bitcoin.active
|
||||
- --bitcoin.regtest
|
||||
- --bitcoin.node=bitcoind
|
||||
- --bitcoind.rpchost=bitcoind
|
||||
- --bitcoind.rpccookie=/root/.bitcoin/regtest/.cookie
|
||||
- --bitcoind.zmqpubrawtx=bitcoind:29000
|
||||
- --bitcoind.zmqpubrawblock=bitcoind:29001
|
||||
- --noseedbackup
|
||||
- --protocol.wumbo-channels
|
||||
ports:
|
||||
- 8081:8081
|
||||
- 10009:10009
|
||||
expose:
|
||||
- 8081
|
||||
- 9735
|
||||
- 10009
|
||||
volumes:
|
||||
- ./data/lnd-3:/root/.lnd/
|
||||
- bitcoin-data:/root/.bitcoin
|
||||
|
||||
lnd-4:
|
||||
hostname: lnd-4
|
||||
depends_on:
|
||||
- bitcoind
|
||||
image: boltz/lnd:0.18.4-beta
|
||||
restart: on-failure
|
||||
command:
|
||||
- --listen=lnd-4:9735
|
||||
- --rpclisten=lnd-4:10009
|
||||
- --restlisten=lnd-4:8081
|
||||
- --bitcoin.active
|
||||
- --bitcoin.regtest
|
||||
- --bitcoin.node=bitcoind
|
||||
- --bitcoind.rpchost=bitcoind
|
||||
- --bitcoind.rpccookie=/root/.bitcoin/regtest/.cookie
|
||||
- --bitcoind.zmqpubrawtx=bitcoind:29000
|
||||
- --bitcoind.zmqpubrawblock=bitcoind:29001
|
||||
- --noseedbackup
|
||||
- --protocol.wumbo-channels
|
||||
expose:
|
||||
- 8081
|
||||
- 9735
|
||||
- 10009
|
||||
volumes:
|
||||
- ./data/lnd-4:/root/.lnd/
|
||||
- bitcoin-data:/root/.bitcoin
|
||||
|
||||
eclair:
|
||||
hostname: eclair
|
||||
depends_on:
|
||||
- bitcoind
|
||||
image: boltz/eclair:0.8.0
|
||||
restart: on-failure
|
||||
entrypoint: "sh -c 'JAVA_OPTS=-Xmx512m /eclair-node/bin/eclair-node.sh -Declair.datadir=/root/eclair -Declair.printToConsole'"
|
||||
ports:
|
||||
- 8082:8080
|
||||
expose:
|
||||
- 9735
|
||||
- 8080
|
||||
volumes:
|
||||
- ./data/eclair:/root/eclair
|
||||
- bitcoin-data:/root/.bitcoin
|
||||
|
||||
electrs:
|
||||
hostname: electrs
|
||||
restart: always
|
||||
image: boltz/electrs:latest
|
||||
entrypoint: ["electrs-bitcoin"]
|
||||
command:
|
||||
- --electrum-rpc-addr
|
||||
- electrs:19001
|
||||
- --http-addr
|
||||
- electrs:3002
|
||||
- --daemon-rpc-addr
|
||||
- bitcoind:18443
|
||||
- --network
|
||||
- regtest
|
||||
- --jsonrpc-import
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -s $(hostname):3002/blocks/tip/height"]
|
||||
timeout: 1s
|
||||
retries: 20
|
||||
interval: 2s
|
||||
start_period: 5s
|
||||
ports:
|
||||
- 19001:19001
|
||||
- 3002:3002
|
||||
volumes:
|
||||
- bitcoin-data:/root/.bitcoin
|
||||
|
||||
elementsd:
|
||||
hostname: elementsd
|
||||
restart: always
|
||||
image: boltz/elements:latest
|
||||
expose:
|
||||
- 31001
|
||||
ports:
|
||||
- 31000:31000
|
||||
- 31002:31002
|
||||
- 18884:18884
|
||||
command:
|
||||
- -chain=liquidregtest
|
||||
- -txindex=1
|
||||
- -rest=1
|
||||
- -server=1
|
||||
- -rpcallowip=0.0.0.0/0
|
||||
- -validatepegin=0
|
||||
- -initialfreecoins=2100000000000000
|
||||
- -fallbackfee=0.000001
|
||||
- -rpcbind=0.0.0.0
|
||||
- -rpcport=18884
|
||||
- -zmqpubrawtx=tcp://0.0.0.0:31000
|
||||
- -zmqpubrawblock=tcp://0.0.0.0:31001
|
||||
- -zmqpubhashblock=tcp://0.0.0.0:31002
|
||||
- -acceptdiscountct=1
|
||||
- -creatediscountct=1
|
||||
volumes:
|
||||
- elements-data:/root/.elements
|
||||
|
||||
electrs-liquid:
|
||||
hostname: electrs-liquid
|
||||
restart: always
|
||||
image: boltz/electrs:latest
|
||||
entrypoint: ["electrs-liquid"]
|
||||
command:
|
||||
- --electrum-rpc-addr
|
||||
- electrs-liquid:19002
|
||||
- --http-addr
|
||||
- electrs-liquid:3003
|
||||
- --daemon-rpc-addr
|
||||
- elementsd:18884
|
||||
- --daemon-dir
|
||||
- /root/.elements
|
||||
- --network
|
||||
- liquidregtest
|
||||
- --parent-network
|
||||
- regtest
|
||||
- --jsonrpc-import
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -s $(hostname):3003/blocks/tip/height"]
|
||||
timeout: 15s
|
||||
retries: 20
|
||||
interval: 2s
|
||||
start_period: 5s
|
||||
ports:
|
||||
- 19002:19002
|
||||
- 3003:3003
|
||||
volumes:
|
||||
- elements-data:/root/.elements
|
||||
|
||||
volumes:
|
||||
lnbits-data:
|
||||
bitcoin-data:
|
||||
elements-data:
|
||||
nginx-data:
|
||||
name: nginx-data
|
||||
driver: local
|
||||
driver_opts:
|
||||
type: none
|
||||
o: bind
|
||||
device: ./data/boltz-nginx/
|
||||
@@ -0,0 +1,322 @@
|
||||
#!/bin/sh
|
||||
export COMPOSE_PROJECT_NAME=lnbits
|
||||
|
||||
boltzcli-sim() {
|
||||
docker exec -it lnbits-boltz-client-1 boltzcli "$@"
|
||||
}
|
||||
|
||||
bitcoin-cli-sim() {
|
||||
docker exec lnbits-bitcoind-1 bitcoin-cli -regtest "$@"
|
||||
}
|
||||
|
||||
elements-cli-sim() {
|
||||
docker exec lnbits-elementsd-1 elements-cli -rpcport=18884 -chain=liquidregtest "$@"
|
||||
}
|
||||
|
||||
# args(i, cmd)
|
||||
lightning-cli-sim() {
|
||||
i=$1
|
||||
shift # shift first argument so we can use $@
|
||||
docker exec lnbits-clightning-$i-1 lightning-cli --network regtest "$@"
|
||||
}
|
||||
|
||||
# args(i, cmd)
|
||||
lncli-sim() {
|
||||
i=$1
|
||||
shift # shift first argument so we can use $@
|
||||
docker exec lnbits-lnd-$i-1 lncli --network regtest --rpcserver=lnd-$i:10009 "$@"
|
||||
}
|
||||
|
||||
get-eclair-pubkey() {
|
||||
while true; do
|
||||
pubkey=$(docker exec lnbits-eclair-1 curl http://localhost:8080/getinfo -X POST -s -u :lnbits | jq -r .nodeId 2> /dev/null)
|
||||
pubkeyPrefix=$(echo $pubkey | cut -c1,2)
|
||||
if [[ "$pubkeyPrefix" == "02" || "$pubkeyPrefix" == "03" ]]; then
|
||||
echo $pubkey
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
}
|
||||
|
||||
wait-for-eclair-channel() {
|
||||
while true; do
|
||||
state=$(docker exec lnbits-eclair-1 curl http://localhost:8080/channels -X POST -s -u :lnbits | jq -r ".[0].state")
|
||||
pending=$(docker exec lnbits-eclair-1 curl -s http://localhost:8080/channels -X POST -u :lnbits| jq '. | length')
|
||||
echo "eclair-1 pendingchannels: $pending, current state: $state"
|
||||
if [[ "$state" == "NORMAL" ]]; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
}
|
||||
|
||||
# args(i)
|
||||
fund_boltz_client() {
|
||||
# first address of seed: abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about
|
||||
address="el1qq2xvpcvfup5j8zscjq05u2wxxjcyewk7979f3mmz5l7uw5pqmx6xf5xy50hsn6vhkm5euwt72x878eq6zxx2z0z676mna6kdq"
|
||||
echo "funding: $address on boltz-client"
|
||||
elements-cli-sim -named sendtoaddress address=$address amount=30 fee_rate=100 > /dev/null
|
||||
}
|
||||
|
||||
|
||||
# args(i)
|
||||
fund_clightning_node() {
|
||||
address=$(lightning-cli-sim $1 newaddr | jq -r .bech32)
|
||||
echo "funding: $address on clightning-node: $1"
|
||||
bitcoin-cli-sim -named sendtoaddress address=$address amount=30 fee_rate=100 > /dev/null
|
||||
}
|
||||
|
||||
# args(i)
|
||||
fund_lnd_node() {
|
||||
address=$(lncli-sim $1 newaddress p2wkh | jq -r .address)
|
||||
echo "funding: $address on lnd-node: $1"
|
||||
bitcoin-cli-sim -named sendtoaddress address=$address amount=30 fee_rate=100 > /dev/null
|
||||
}
|
||||
|
||||
# args(i, j)
|
||||
connect_clightning_node() {
|
||||
pubkey=$(lightning-cli-sim $2 getinfo | jq -r '.id')
|
||||
lightning-cli-sim $1 connect $pubkey@lnbits-clightning-$2-1:9735 | jq -r '.id'
|
||||
}
|
||||
|
||||
lnbits-regtest-start(){
|
||||
if ! command -v jq &> /dev/null
|
||||
then
|
||||
echo "jq is not installed"
|
||||
exit
|
||||
fi
|
||||
if ! command -v docker &> /dev/null
|
||||
then
|
||||
echo "docker is not installed"
|
||||
exit
|
||||
fi
|
||||
if ! command -v docker version &> /dev/null
|
||||
then
|
||||
echo "dockerd is not running"
|
||||
exit
|
||||
fi
|
||||
lnbits-regtest-stop
|
||||
docker compose up -d --remove-orphans
|
||||
lnbits-regtest-init
|
||||
}
|
||||
|
||||
lnbits-regtest-start-log(){
|
||||
lnbits-regtest-stop
|
||||
docker compose up --remove-orphans
|
||||
lnbits-regtest-init
|
||||
}
|
||||
|
||||
lnbits-regtest-stop(){
|
||||
docker compose down --volumes
|
||||
# clean up lightning node data
|
||||
sudo rm -rf ./data/clightning-1 ./data/clightning-2 ./data/clightning-3 ./data/lnd-1 ./data/lnd-2 ./data/lnd-3 ./data/lnd-4 ./data/boltz/boltz.db ./data/eclair/regtest ./data/boltz-client/liquid-wallet ./data/boltz-client/bitcoin-wallet ./data/boltz-client/wallet ./data/boltz-client/boltz.db
|
||||
# recreate lightning node data folders preventing permission errors
|
||||
mkdir ./data/clightning-1 ./data/clightning-2 ./data/clightning-3 ./data/lnd-1 ./data/lnd-2 ./data/lnd-3 ./data/lnd-4
|
||||
}
|
||||
|
||||
lnbits-regtest-restart(){
|
||||
lnbits-regtest-stop
|
||||
lnbits-regtest-start
|
||||
}
|
||||
|
||||
boltz-client-init(){
|
||||
for i in 0 1 2; do
|
||||
fund_boltz_client
|
||||
done
|
||||
elements-cli-sim -generate 3 > /dev/null
|
||||
}
|
||||
|
||||
lnbits-bitcoin-init(){
|
||||
echo "init_bitcoin_wallet..."
|
||||
bitcoin-cli-sim createwallet lnbits || bitcoin-cli-sim loadwallet lnbits
|
||||
echo "mining 150 blocks..."
|
||||
bitcoin-cli-sim -generate 150 > /dev/null
|
||||
}
|
||||
|
||||
lnbits-elements-init(){
|
||||
echo "init_elements_wallet..."
|
||||
elements-cli-sim createwallet lnbits || elements-cli-sim loadwallet lnbits
|
||||
echo "mining 150 blocks..."
|
||||
elements-cli-sim -generate 150 > /dev/null
|
||||
elements-cli-sim rescanblockchain
|
||||
}
|
||||
|
||||
lnbits-init(){
|
||||
echo "init_lnbits..."
|
||||
docker exec lnbits-lnbits-1 uv run python tools/create_fake_admin.py
|
||||
}
|
||||
|
||||
lnbits-regtest-init(){
|
||||
lnbits-bitcoin-init
|
||||
lnbits-elements-init
|
||||
lnbits-lightning-sync
|
||||
lnbits-lightning-init
|
||||
boltz-client-init
|
||||
lnbits-init
|
||||
}
|
||||
|
||||
lnbits-lightning-sync(){
|
||||
wait-for-clightning-sync 1
|
||||
wait-for-clightning-sync 2
|
||||
wait-for-clightning-sync 3
|
||||
wait-for-lnd-sync 1
|
||||
wait-for-lnd-sync 2
|
||||
wait-for-lnd-sync 3
|
||||
wait-for-lnd-sync 4
|
||||
}
|
||||
|
||||
lnbits-lightning-init(){
|
||||
|
||||
# create 10 UTXOs for each node
|
||||
for i in 0 1 2; do
|
||||
fund_clightning_node 1
|
||||
fund_clightning_node 2
|
||||
fund_clightning_node 3
|
||||
fund_lnd_node 1
|
||||
fund_lnd_node 2
|
||||
fund_lnd_node 3
|
||||
fund_lnd_node 4
|
||||
done
|
||||
|
||||
echo "mining 3 blocks..."
|
||||
bitcoin-cli-sim -generate 3 > /dev/null
|
||||
|
||||
lnbits-lightning-sync
|
||||
|
||||
channel_confirms=6
|
||||
channel_size=24000000 # 0.024 btc
|
||||
balance_size=12000000 # 0.12 btc
|
||||
balance_size_msat=12000000000 # 0.12 btc
|
||||
|
||||
# lnd-1 -> lnd-2
|
||||
lncli-sim 1 connect $(lncli-sim 2 getinfo | jq -r '.identity_pubkey')@lnbits-lnd-2-1 > /dev/null
|
||||
echo "open channel from lnd-1 to lnd-2"
|
||||
lncli-sim 1 openchannel $(lncli-sim 2 getinfo | jq -r '.identity_pubkey') $channel_size $balance_size > /dev/null
|
||||
bitcoin-cli-sim -generate $channel_confirms > /dev/null
|
||||
wait-for-lnd-channel 1
|
||||
|
||||
# lnd-1 -> lnd-3
|
||||
lncli-sim 1 connect $(lncli-sim 3 getinfo | jq -r '.identity_pubkey')@lnbits-lnd-3-1 > /dev/null
|
||||
echo "open channel from lnd-1 to lnd-3"
|
||||
lncli-sim 1 openchannel $(lncli-sim 3 getinfo | jq -r '.identity_pubkey') $channel_size $balance_size > /dev/null
|
||||
bitcoin-cli-sim -generate $channel_confirms > /dev/null
|
||||
wait-for-lnd-channel 1
|
||||
|
||||
# lnd-1 -> cln-1
|
||||
lncli-sim 1 connect $(lightning-cli-sim 1 getinfo | jq -r '.id')@lnbits-clightning-1-1 > /dev/null
|
||||
echo "open channel from lnd-1 to cln-1"
|
||||
lncli-sim 1 openchannel $(lightning-cli-sim 1 getinfo | jq -r '.id') $channel_size $balance_size > /dev/null
|
||||
bitcoin-cli-sim -generate $channel_confirms > /dev/null
|
||||
wait-for-lnd-channel 1
|
||||
|
||||
# lnd-1 -> cln-2
|
||||
lncli-sim 1 connect $(lightning-cli-sim 2 getinfo | jq -r '.id')@lnbits-clightning-2-1 > /dev/null
|
||||
echo "open channel from lnd-1 to cln-2"
|
||||
lncli-sim 1 openchannel $(lightning-cli-sim 2 getinfo | jq -r '.id') $channel_size $balance_size > /dev/null
|
||||
bitcoin-cli-sim -generate $channel_confirms > /dev/null
|
||||
wait-for-lnd-channel 1
|
||||
|
||||
# lnd-1 -> cln-3
|
||||
lncli-sim 1 connect $(lightning-cli-sim 3 getinfo | jq -r '.id')@lnbits-clightning-3-1 > /dev/null
|
||||
echo "open channel from lnd-1 to cln-3"
|
||||
lncli-sim 1 openchannel $(lightning-cli-sim 3 getinfo | jq -r '.id') $channel_size $balance_size > /dev/null
|
||||
bitcoin-cli-sim -generate $channel_confirms > /dev/null
|
||||
wait-for-lnd-channel 1
|
||||
|
||||
# lnd-2 -> cln-2
|
||||
lncli-sim 2 connect $(lightning-cli-sim 2 getinfo | jq -r '.id')@lnbits-clightning-2-1 > /dev/null
|
||||
echo "open channel from lnd-2 to cln-2"
|
||||
lncli-sim 2 openchannel $(lightning-cli-sim 2 getinfo | jq -r '.id') $channel_size $balance_size > /dev/null
|
||||
bitcoin-cli-sim -generate $channel_confirms > /dev/null
|
||||
wait-for-lnd-channel 2
|
||||
|
||||
# lnd-3 -> cln-3
|
||||
lncli-sim 3 connect $(lightning-cli-sim 3 getinfo | jq -r '.id')@lnbits-clightning-3-1 > /dev/null
|
||||
echo "open channel from lnd-3 to cln-1"
|
||||
lncli-sim 3 openchannel $(lightning-cli-sim 3 getinfo | jq -r '.id') $channel_size $balance_size > /dev/null
|
||||
bitcoin-cli-sim -generate $channel_confirms > /dev/null
|
||||
wait-for-lnd-channel 3
|
||||
|
||||
# lnd-3 -> cln-1
|
||||
lncli-sim 3 connect $(lightning-cli-sim 1 getinfo | jq -r '.id')@lnbits-clightning-1-1 > /dev/null
|
||||
echo "open channel from lnd-3 to cln-1"
|
||||
lncli-sim 3 openchannel $(lightning-cli-sim 1 getinfo | jq -r '.id') $channel_size $balance_size > /dev/null
|
||||
bitcoin-cli-sim -generate $channel_confirms > /dev/null
|
||||
wait-for-lnd-channel 3
|
||||
|
||||
# lnd-1 -> eclair-1
|
||||
lncli-sim 1 connect $(get-eclair-pubkey)@lnbits-eclair-1 > /dev/null
|
||||
echo "open channel from lnd-2 to eclair-1"
|
||||
lncli-sim 1 openchannel $(get-eclair-pubkey) $channel_size $balance_size > /dev/null
|
||||
bitcoin-cli-sim -generate $channel_confirms > /dev/null
|
||||
wait-for-lnd-channel 1
|
||||
|
||||
# lnd-2 -> eclair-1
|
||||
lncli-sim 2 connect $(get-eclair-pubkey)@lnbits-eclair-1 > /dev/null
|
||||
echo "open channel from lnd-2 to eclair-1"
|
||||
lncli-sim 2 openchannel $(get-eclair-pubkey) $channel_size $balance_size > /dev/null
|
||||
bitcoin-cli-sim -generate $channel_confirms > /dev/null
|
||||
wait-for-lnd-channel 2
|
||||
|
||||
wait-for-clightning-channel 1
|
||||
wait-for-clightning-channel 2
|
||||
wait-for-clightning-channel 3
|
||||
|
||||
wait-for-eclair-channel
|
||||
|
||||
lnbits-lightning-sync
|
||||
|
||||
}
|
||||
|
||||
wait-for-lnd-channel(){
|
||||
while true; do
|
||||
pending=$(lncli-sim $1 pendingchannels | jq -r '.pending_open_channels | length')
|
||||
echo "lnd-$1 pendingchannels: $pending"
|
||||
if [[ "$pending" == "0" ]]; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
}
|
||||
|
||||
wait-for-lnd-sync(){
|
||||
while true; do
|
||||
if [[ "$(lncli-sim $1 getinfo 2>&1 | jq -r '.synced_to_chain' 2> /dev/null)" == "true" ]]; then
|
||||
echo "lnd-$1 is synced!"
|
||||
break
|
||||
fi
|
||||
echo "waiting for lnd-$1 to sync..."
|
||||
sleep 1
|
||||
done
|
||||
}
|
||||
|
||||
wait-for-clightning-channel(){
|
||||
while true; do
|
||||
pending=$(lightning-cli-sim $1 getinfo | jq -r '.num_pending_channels | length')
|
||||
echo "cln-$1 pendingchannels: $pending"
|
||||
if [[ "$pending" == "0" ]]; then
|
||||
if [[ "$(lightning-cli-sim $1 getinfo 2>&1 | jq -r '.warning_bitcoind_sync' 2> /dev/null)" == "null" ]]; then
|
||||
if [[ "$(lightning-cli-sim $1 getinfo 2>&1 | jq -r '.warning_lightningd_sync' 2> /dev/null)" == "null" ]]; then
|
||||
break
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
}
|
||||
|
||||
wait-for-clightning-sync(){
|
||||
while true; do
|
||||
if [[ ! "$(lightning-cli-sim $1 getinfo 2>&1 | jq -r '.id' 2> /dev/null)" == "null" ]]; then
|
||||
if [[ "$(lightning-cli-sim $1 getinfo 2>&1 | jq -r '.warning_bitcoind_sync' 2> /dev/null)" == "null" ]]; then
|
||||
if [[ "$(lightning-cli-sim $1 getinfo 2>&1 | jq -r '.warning_lightningd_sync' 2> /dev/null)" == "null" ]]; then
|
||||
echo "cln-$1 is synced!"
|
||||
break
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
echo "waiting for cln-$1 to sync..."
|
||||
sleep 1
|
||||
done
|
||||
}
|
||||
Executable
+74
@@ -0,0 +1,74 @@
|
||||
#!/bin/bash
|
||||
print_success() {
|
||||
printf "\033[;1;32mPASSED\033[;0m $1\n"
|
||||
}
|
||||
|
||||
print_error() {
|
||||
printf "\033[;1;31mFAILED\033[;0m $1\n"
|
||||
}
|
||||
|
||||
run(){
|
||||
label=$1
|
||||
value=$2
|
||||
cmd=$3
|
||||
if [[ "$cmd" == "$value" ]]; then
|
||||
print_success "$label is $cmd"
|
||||
else
|
||||
print_error "$label is $cmd, should be $value"
|
||||
failed="true"
|
||||
fi
|
||||
}
|
||||
|
||||
failed="false"
|
||||
blockheight=213
|
||||
utxos=3
|
||||
channel_size=24000000 # 0.024 btc
|
||||
balance_size=12000000 # 0.012 btc
|
||||
|
||||
source $(pwd)/docker-scripts.sh
|
||||
lnbits-regtest-start
|
||||
echo "=================================="
|
||||
printf "\033[;1;36mregtest started! starting tests...\033[;0m\n"
|
||||
echo "=================================="
|
||||
echo ""
|
||||
|
||||
for i in 1 2 3; do
|
||||
run "lnd-$i .synced_to_chain" "true" $(lncli-sim $i getinfo | jq -r ".synced_to_chain")
|
||||
run "lnd-$i utxo count" $utxos $(lncli-sim $i listunspent | jq -r ".utxos | length")
|
||||
run "lnd-$i .block_height" $blockheight $(lncli-sim $i getinfo | jq -r ".block_height")
|
||||
if [[ "$i" == "1" ]]; then
|
||||
channel_count=6
|
||||
else
|
||||
channel_count=3
|
||||
fi
|
||||
run "lnd-$i openchannels" $channel_count $(lncli-sim $i listchannels | jq -r ".channels | length")
|
||||
run "lnd-$i .channels[0].capacity" $channel_size $(lncli-sim $i listchannels | jq -r ".channels[0].capacity")
|
||||
run "lnd-$i .channels[0].push_amount_sat" $balance_size $(lncli-sim $i listchannels | jq -r ".channels[0].push_amount_sat")
|
||||
done
|
||||
for i in 1 2 3; do
|
||||
# run "cln-$i blockheight" $blockheight $(lightning-cli-sim $i getinfo | jq -r ".blockheight")
|
||||
run "cln-$i utxo count" $utxos $(lightning-cli-sim $i listfunds | jq -r ".outputs | length")
|
||||
run "cln-$i openchannels" 2 $(lightning-cli-sim $i getinfo | jq -r ".num_active_channels")
|
||||
run "cln-$i channel[0].state" "CHANNELD_NORMAL" $(lightning-cli-sim $i listfunds | jq -r ".channels[0].state")
|
||||
run "cln-$i channel[0].amount_msat" $(($channel_size * 1000)) $(lightning-cli-sim $i listfunds | jq -r ".channels[0].amount_msat" | sed 's/msat//g')
|
||||
run "cln-$i channel[0].our_amount_msat" $(($balance_size * 1000)) $(lightning-cli-sim $i listfunds | jq -r ".channels[0].our_amount_msat" | sed 's/msat//g')
|
||||
done
|
||||
|
||||
run "eclair-1 openchannels" 2 $(docker exec lnbits-eclair-1 curl -s http://localhost:8080/channels -X POST -u :lnbits| jq '. | length')
|
||||
run "eclair-1 blockHeight" $blockheight $(docker exec lnbits-eclair-1 curl -s http://localhost:8080/getinfo -X POST -u :lnbits| jq '.blockHeight')
|
||||
run "lnbits service status" "200" $(curl -s -o /dev/null -w "%{http_code}" "http://localhost:5001/")
|
||||
run "boltz service status" "200" $(curl -s -o /dev/null --head -w "%{http_code}" "http://localhost:9001/version")
|
||||
|
||||
# return non-zero exit code if a test fails
|
||||
if [[ "$failed" == "true" ]]; then
|
||||
echo ""
|
||||
echo "=================================="
|
||||
print_error "one more more tests failed"
|
||||
echo "=================================="
|
||||
exit 1
|
||||
else
|
||||
echo ""
|
||||
echo "=================================="
|
||||
print_success "all tests passed! yay!"
|
||||
echo "=================================="
|
||||
fi
|
||||
@@ -0,0 +1,41 @@
|
||||
ARG LNBITS_TAG=latest
|
||||
|
||||
FROM lnbits/lnbits:${LNBITS_TAG}
|
||||
|
||||
RUN curl -fsSL https://deb.nodesource.com/setup_lts.x | bash -
|
||||
|
||||
RUN apt-get update && apt-get -y upgrade
|
||||
RUN apt-get install -y ca-certificates curl gnupg netcat-openbsd git nodejs
|
||||
|
||||
RUN curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
ENV PATH="/root/.local/bin:$PATH"
|
||||
|
||||
# install sparksidecar
|
||||
RUN git clone https://github.com/lnbits/spark_sidecar
|
||||
RUN cd spark_sidecar && npm ci
|
||||
|
||||
# Reinstall dependencies for lnbits just in case (needed for CMD usage)
|
||||
RUN uv sync --all-extras
|
||||
|
||||
# LNBITS
|
||||
ENV LNBITS_PORT="5000"
|
||||
ENV LNBITS_HOST="0.0.0.0"
|
||||
ENV LNBITS_BACKEND_WALLET_CLASS="SparkL2Wallet"
|
||||
ENV LNBITS_RESERVE_FEE_MIN="20000"
|
||||
ENV LNBITS_RESERVE_FEE_PERCENT="1"
|
||||
ENV LNBITS_FUNDING_SOURCE_PAY_INVOICE_WAIT_SECONDS="20"
|
||||
ENV FUNDING_SOURCE_MAX_RETRIES="10"
|
||||
|
||||
# spark sidecar
|
||||
ENV SPARK_NETWORK="MAINNET"
|
||||
ENV SPARK_SIDECAR_PORT="8765"
|
||||
ENV SPARK_PAY_WAIT_MS="20000"
|
||||
ENV SPARK_MULTIPLICITY="3"
|
||||
|
||||
EXPOSE 5000
|
||||
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
# Entrypoint to start sparksidebar and LNbits
|
||||
CMD ["/entrypoint.sh"]
|
||||
@@ -0,0 +1,25 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
node spark_sidecar/server.mjs &
|
||||
|
||||
SIDECAR_PID=$!
|
||||
|
||||
# Wait for startup
|
||||
for i in {1..10}; do
|
||||
if nc -z localhost $SPARK_SIDECAR_PORT; then
|
||||
echo "sparksidebar is up!"
|
||||
break
|
||||
fi
|
||||
echo "Waiting for sparksidebar to start..."
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# Optional: check if still not up
|
||||
if ! nc -z localhost $SPARK_SIDECAR_PORT; then
|
||||
echo "sparksidebar did not start successfully."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Starting LNbits on $LNBITS_HOST:$LNBITS_PORT..."
|
||||
exec uv run lnbits --port "$LNBITS_PORT" --host "$LNBITS_HOST" --forwarded-allow-ips='*'
|
||||
@@ -4,6 +4,7 @@ color_scheme: dark
|
||||
logo: "/logos/lnbits-full-inverse.png"
|
||||
search_enabled: true
|
||||
url: https://docs.lnbits.org
|
||||
markdown: gfm
|
||||
aux_links:
|
||||
"LNbits on GitHub":
|
||||
- "//github.com/lnbits/lnbits"
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 180 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 317 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 139 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 157 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 305 KiB |
@@ -1,3 +1,9 @@
|
||||
---
|
||||
layout: default
|
||||
title: FastAPI extension upgrade
|
||||
nav_order: 1
|
||||
---
|
||||
|
||||
## Defining a route with path parameters
|
||||
|
||||
**old:**
|
||||
+104
-49
@@ -1,78 +1,133 @@
|
||||
---
|
||||
layout: default
|
||||
title: Admin UI
|
||||
nav_order: 4
|
||||
nav_order: 1
|
||||
---
|
||||
|
||||
# Admin UI
|
||||
<a href="https://lnbits.com" target="_blank" rel="noopener noreferrer">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://i.imgur.com/QE6SIrs.png">
|
||||
<img src="https://i.imgur.com/fyKPgVT.png" alt="LNbits" style="width:300px">
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
The LNbits Admin UI lets you change LNbits settings via the LNbits frontend.
|
||||
It is disabled by default and the first time you set the environment variable `LNBITS_ADMIN_UI=true`
|
||||
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.
|
||||

|
||||

|
||||
[<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits)
|
||||
|
||||
# Super User
|
||||
# LNBits Admin UI
|
||||
|
||||
With the Admin UI we introduced the super user, it is created with the initialisation of the Admin UI and will be shown with a success message in the server logs.
|
||||
The super user has access to the server and can change settings that may crash the server and make it unresponsive via the frontend and api, like changing funding sources.
|
||||
[What you can do](#what-you-can-do-with-the-admin-ui) · [First Run](#first-run-and-super-user-id) · [Enable/Disable](#enabling-or-disabling-the-admin-ui) · [Reset](#reset-to-defaults) · [Allowed Users](#allowed-users) · [Guides](#additional-guides)
|
||||
|
||||
Also only the super user can brrrr satoshis to different wallets.
|
||||
**We introduced the Admin UI as the new default to make setup simpler and more straightforward**. Instead of hand editing the `.env` file, you configure key server settings directly in the frontend with clear labels and guardrails.
|
||||
|
||||
The super user is only stored inside the settings table of the database and after the settings are "reset to defaults" and a restart happened,
|
||||
a new super user is created.
|
||||
<ins>On a fresh install the Admin UI is enabled by default</ins>, and at first launch you are prompted to create **Super User** credentials so that sensitive operations, such as switching funding sources, remain in trusted hands. When the Admin UI is enabled, configuration is written to and read from the database; for all settings managed by the UI, the parameters in `.env` are largely no longer used. If you disable the Admin UI, the `.env` file becomes the single source of truth again.
|
||||
|
||||
The super user is never sent over the api and the frontend only receives a bool if you are super user or not.
|
||||
For privileged actions and role details see **[Super User](./super_user.md)** & [User Roles](./user_roles.md)
|
||||
For a complete reference of legacy variables consult **[.env.example](../../.env.example)**.
|
||||
|
||||
We also added a decorator for the API routes to check for super user.
|
||||
<img width="900" height="640" alt="grafik" src="https://github.com/user-attachments/assets/d8852b4b-21be-446f-a1e7-d3eb794d3505" />
|
||||
|
||||
There is also the possibility of posting the super user via webhook to another service when it is created. you can look it up here https://github.com/lnbits/lnbits/blob/main/lnbits/settings.py `class SaaSSettings`
|
||||
> [!WARNING]
|
||||
> Some settings remain `.env` only. Use **[.env.example](../../.env.example#L3-L87)** as the authoritative reference for those variables.
|
||||
|
||||
# Admin Users
|
||||
## What you can do with the Admin UI
|
||||
|
||||
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 inaccessible. Also they have access to all the extension defined in `LNBITS_ADMIN_EXTENSIONS`.
|
||||
- Switch funding sources and other server level settings
|
||||
- Manage who can access LNbits (**[Allowed Users](#allowed-users)**)
|
||||
- Promote or demote Admin Users
|
||||
- Gate extensions to Admins only or disable them globally
|
||||
- Adjust balances with credit or debit
|
||||
- Adjust site customization
|
||||
|
||||
# Allowed Users
|
||||
> [!NOTE]
|
||||
> See **[Super User](./super_user.md)** for the role and permission differences compared to Admin Users.
|
||||
|
||||
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.
|
||||
## First run and Super User ID
|
||||
|
||||
Setting this environment variable also disables account creation.
|
||||
Account creation can be also disabled by setting `LNBITS_ALLOW_NEW_ACCOUNTS=false`
|
||||
On first start with the Admin UI enabled you will be prompted to generate a Super User.
|
||||
|
||||
# How to activate
|
||||
<img width="1573" height="976" alt="Admin_UI_first_install" src="https://github.com/user-attachments/assets/05aa634f-06ec-4a4d-a5c6-d90927c90991" />
|
||||
|
||||
```
|
||||
$ sudo systemctl stop lnbits.service
|
||||
$ cd ~/lnbits
|
||||
$ sudo nano .env
|
||||
```
|
||||
If you need to read it from disk later:
|
||||
|
||||
-> set: `LNBITS_ADMIN_UI=true`
|
||||
|
||||
Now start LNbits once in the terminal window
|
||||
|
||||
```
|
||||
$ uv run lnbits
|
||||
```
|
||||
|
||||
You can now `cat` the Super User ID:
|
||||
|
||||
```
|
||||
$ cat data/.super_user
|
||||
```bash
|
||||
cat /lnbits/data/.super_user
|
||||
# example
|
||||
123de4bfdddddbbeb48c8bc8382fe123
|
||||
```
|
||||
|
||||
You can access your super user account at `/wallet?usr=super_user_id`. You just have to append it to your normal LNbits web domain.
|
||||
> [!WARNING]
|
||||
> For security reasons, Super Users and Admin users must authenticate with credentials (username and password).
|
||||
|
||||
After that you will find the **`Admin` / `Manage Server`** between `Wallets` and `Extensions`
|
||||
After login you will see **Settings** and **Users** in the sidebar between **Wallets** and **Extensions**, plus a role badge in the top left.
|
||||
|
||||
Here you can design the interface, it has credit/debit to change wallets balances and you can restrict access rights to extensions only for admins or generally deactivated for everyone. You can make users admins or set up Allowed Users if you want to restrict access. And of course the classic settings of the .env file, e.g. to change the funding source wallet or set a charge fee.
|
||||
<img width="1353" height="914" alt="grafik" src="https://github.com/user-attachments/assets/06bb4f36-a23a-4058-87ec-60440d322c25" />
|
||||
|
||||
Do not forget
|
||||
## Enabling or disabling the Admin UI
|
||||
|
||||
```
|
||||
sudo systemctl start lnbits.service
|
||||
```
|
||||
The Admin UI is enabled by default on new installs. To change the state:
|
||||
|
||||
A little hint, if you set `RESET TO DEFAULTS`, then a new Super User Account will also be created. The old one is then no longer valid.
|
||||
1. Stop LNbits
|
||||
|
||||
```bash
|
||||
sudo systemctl stop lnbits.service
|
||||
```
|
||||
|
||||
2. Edit your `.env`
|
||||
|
||||
```
|
||||
cd ~/lnbits
|
||||
sudo nano .env
|
||||
```
|
||||
|
||||
3. Set one of
|
||||
|
||||
```
|
||||
# Enable Admin UI
|
||||
LNBITS_ADMIN_UI=true
|
||||
|
||||
# Disable Admin UI
|
||||
LNBITS_ADMIN_UI=false
|
||||
```
|
||||
|
||||
4. Start LNbits
|
||||
|
||||
```
|
||||
sudo systemctl start lnbits.service
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> With the Admin UI enabled, config is DB-backed and UI-managed settings ignore .env. Disable it to revert to [.env](../../.env.example) as the single source of truth.
|
||||
|
||||
## Reset to defaults
|
||||
|
||||
Using `Reset to defaults` in the Admin UI wipes stored settings. After a restart, a new `Super User` is created and the old one is no longer valid.
|
||||
|
||||
## Allowed Users
|
||||
|
||||
When set **at least one**, LNbits becomes private: only the listed users and Admins can access the frontend. Account creation is disabled automatically. You can also disable account creation explicitly.
|
||||
|
||||
<img width="1889" height="870" alt="grafik" src="https://github.com/user-attachments/assets/89011b75-a267-44ea-971a-1517968b7af5" />
|
||||
|
||||
> [!WARNING]
|
||||
> Assign your own account first when enabling **Allowed Users** to avoid locking yourself out. If you do get locked out, use your Super User to recover access.
|
||||
|
||||
## Additional Guides
|
||||
|
||||
- **[Backend Wallets](./wallets.md)** — Explore options to fund your LNbits instance.
|
||||
- **[User Roles](./user_roles.md)** — Overview of existing roles in LNbits.
|
||||
- **[Funding sources](./funding-sources-table.md)** — What is available and how to configure each.
|
||||
- **[Install LNBits](./installation.md)** — Choose your prefared way to install LNBits.
|
||||
|
||||
## Powered by LNbits
|
||||
|
||||
LNbits empowers everyone with modular, open source tools for building Bitcoin based systems — fast, free, and extendable.
|
||||
|
||||
If you like this project, [send some tip love](https://demo.lnbits.com/tipjar/DwaUiE4kBX6mUW6pj3X5Kg) or visit our [Shop](https://shop.lnbits.de)
|
||||
|
||||
[](https://shop.lnbits.com/)
|
||||
[](https://shop.lnbits.com/)
|
||||
[](https://my.lnbits.com/login)
|
||||
[](https://news.lnbits.com/)
|
||||
[](https://extensions.lnbits.com/)
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
---
|
||||
layout: default
|
||||
title: Extension Install
|
||||
nav_order: 1
|
||||
---
|
||||
|
||||
# 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.
|
||||
|
||||
@@ -1,30 +1,87 @@
|
||||
# LNbits Funding Sources Comparison Table
|
||||
---
|
||||
layout: default
|
||||
title: Wallet comparison
|
||||
nav_order: 1
|
||||
---
|
||||
|
||||
LNbits can use a number of different Lightning Network funding source.
|
||||
<a href="https://lnbits.com" target="_blank" rel="noopener noreferrer">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://i.imgur.com/QE6SIrs.png">
|
||||
<img src="https://i.imgur.com/fyKPgVT.png" alt="LNbits" style="width:300px">
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
There may be trade-offs between the funding sources used, for example funding LNbits using Strike requires the user to KYC themselves and has some
|
||||
privacy compromises versus running your own LND node. However the technical barrier to entry of using Strike is lower than using LND.
|
||||

|
||||

|
||||
[<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits)
|
||||
|
||||
The table below offers a comparison of the different Lightning Network funding sources that can be used with LNbits.
|
||||
# Backend Wallet Comparison Table
|
||||
|
||||
LNbits lets you choose **how your wallets are funded** — from fully self-custodial nodes to simple hosted services. You can switch the funding source **without touching your apps, users, or extensions**. That means you can start fast, learn, and later upgrade to more control and privacy when you are ready.
|
||||
|
||||
**Why this matters**
|
||||
|
||||
- **Flexibility:** Pick the backend that fits your skills and constraints today, change it later with minimal friction.
|
||||
- **Speed to ship:** Use a hosted option to get live quickly; move to a node when you need more control.
|
||||
- **Scalability:** Match cost and maintenance to your stage — from hobby to production.
|
||||
- **Privacy and compliance:** Choose between self-custody and provider-managed options depending on your requirements.
|
||||
|
||||
Below is a side-by-side comparison of Lightning funding sources you can use with LNbits.
|
||||
|
||||
> [!NOTE]
|
||||
> “Backend Wallet” and “Funding Source” mean the same thing — the wallet or service that funds your LNbits.
|
||||
|
||||
## LNbits Lightning Network Funding Sources Comparison Table
|
||||
|
||||
| **Funding Source** | **Custodial Type** | **KYC Required** | **Technical Knowledge Needed** | **Node Hosting Required** | **Privacy Level** | **Liquidity Management** | **Ease of Setup** | **Maintenance Effort** | **Cost Implications** | **Scalability** | **Notes** |
|
||||
| -------------------------- | ------------------ | ------------------- | ------------------------------ | ------------------------- | ----------------- | ------------------------ | ----------------- | ---------------------- | -------------------------------------------- | --------------- | ---------------------------------------------------------------- |
|
||||
| LND (gRPC) | Self-custodial | ❌ | Higher | ✅ | High | Manual | Moderate | High | Infrastructure cost and channel opening fees | High | gRPC interface for LND; suitable for advanced integrations. |
|
||||
| CoreLightning (CLN) | Self-custodial | ❌ | Higher | ✅ | High | Manual | Moderate | High | Infrastructure cost and channel opening fees | High | Requires setting up and managing your own CLN node. |
|
||||
| Phoenixd | Self-custodial | ❌ | Medium | ❌ | Medium | Automatic | Moderate | Low | Minimal fees | Medium | Mobile wallet backend; suitable for mobile integrations. |
|
||||
| Nostr Wallet Connect (NWC) | Custodial | Depends on provider | Low | ❌ | Variable | Provider-managed | Easy | Low | May incur fees | Medium | Connects via Nostr protocol; depends on provider's policies. |
|
||||
| Boltz | Self-custodial | ❌ | Medium | ❌ | Medium | Provider-managed | Moderate | Moderate | Minimal fees | Medium | Uses submarine swaps; connects to Boltz client. |
|
||||
| LND (REST) | Self-custodial | ❌ | Higher | ✅ | High | Manual | Moderate | High | Infrastructure cost and channel opening fees | High | REST interface for LND; suitable for web integrations. |
|
||||
| CoreLightning REST | Self-custodial | ❌ | Higher | ✅ | High | Manual | Moderate | High | Infrastructure cost and channel opening fees | High | REST interface for CLN; suitable for web integrations. |
|
||||
| LNbits (another instance) | Custodial | Depends on host | Low | ❌ | Variable | Provider-managed | Easy | Low | May incur hosting fees | Medium | Connects to another LNbits instance; depends on host's policies. |
|
||||
| Alby | Custodial | ✅ | Low | ❌ | Low | Provider-managed | Easy | Low | Transaction fees apply | Medium | Browser extension wallet; suitable for web users. |
|
||||
| Breez SDK | Self-custodial | ❌ | Medium | ❌ | High | Automatic | Moderate | Low | Minimal fees | Medium | SDK for integrating Breez wallet functionalities. |
|
||||
| OpenNode | Custodial | ✅ | Low | ❌ | Low | Provider-managed | Easy | Low | Transaction fees apply | Medium | Third-party service; suitable for merchants. |
|
||||
| Blink | Custodial | ✅ | Low | ❌ | Low | Provider-managed | Easy | Low | Transaction fees apply | Medium | Third-party service; focuses on mobile integrations. |
|
||||
| ZBD | Custodial | ✅ | Low | ❌ | Low | Provider-managed | Easy | Low | Transaction fees apply | Medium | Gaming-focused payment platform. |
|
||||
| Spark (CLN) | Self-custodial | ❌ | Higher | ✅ | High | Manual | Moderate | High | Infrastructure cost and channel opening fees | High | Web interface for CLN; requires Spark server setup. |
|
||||
| Cliche Wallet | Self-custodial | ❌ | Medium | ❌ | Medium | Manual | Moderate | Moderate | Minimal fees | Medium | Lightweight wallet; suitable for embedded systems. |
|
||||
| Strike | Custodial | ✅ | Low | ❌ | Low | Provider-managed | Easy | Low | Transaction fees apply | Medium | Third-party service; suitable for quick setups. |
|
||||
| LNPay | Custodial | ✅ | Low | ❌ | Low | Provider-managed | Easy | Low | Transaction fees apply | Medium | Third-party service; suitable for quick setups. |
|
||||
| **Funding Source** | **Custodial Type** | **KYC Required** | **Technical Knowledge Needed** | **Node Hosting Required** | **Privacy Level** | **Liquidity Management** | **Ease of Setup** | **Maintenance Effort** | **Cost Implications** | **Scalability** | **Notes** |
|
||||
| ------------------------------ | ------------------------ | ------------------- | ------------------------------ | ------------------------- | ----------------- | ------------------------ | ----------------- | ---------------------- | -------------------------------------------- | --------------- | ------------------------------------------------------------------------------------------ |
|
||||
| **LND (gRPC)** | Self-custodial | ❌ | Higher | ✅ | High | Manual | Moderate | High | Infrastructure cost and channel opening fees | High | gRPC interface for LND; suitable for advanced integrations. |
|
||||
| **CoreLightning (CLN)** | Self-custodial | ❌ | Higher | ✅ | High | Manual | Moderate | High | Infrastructure cost and channel opening fees | High | Requires setting up and managing your own CLN node. |
|
||||
| **Phoenixd** | Self-custodial | ❌ | Medium | ❌ | Medium | Automatic | Moderate | Low | Minimal fees | Medium | Mobile wallet backend; suitable for mobile integrations. |
|
||||
| **Nostr Wallet Connect (NWC)** | Custodial | Depends on provider | Low | ❌ | Variable | Provider-managed | Easy | Low | May incur fees | Medium | Connects via Nostr protocol; depends on provider's policies. |
|
||||
| **Boltz** | Self-custodial | ❌ | Medium | ❌ | Medium | Provider-managed | Moderate | Moderate | Minimal fees | Medium | Uses submarine swaps; connects to Boltz client. |
|
||||
| **LND (REST)** | Self-custodial | ❌ | Higher | ✅ | High | Manual | Moderate | High | Infrastructure cost and channel opening fees | High | REST interface for LND; suitable for web integrations. |
|
||||
| **CoreLightning REST** | Self-custodial | ❌ | Higher | ✅ | High | Manual | Moderate | High | Infrastructure cost and channel opening fees | High | REST interface for CLN; suitable for web integrations. |
|
||||
| **LNbits (another instance)** | Custodial | Depends on host | Low | ❌ | Variable | Provider-managed | Easy | Low | May incur hosting fees | Medium | Connects to another LNbits instance; depends on host's policies. |
|
||||
| **Alby** | Custodial | ✅ | Low | ❌ | Low | Provider-managed | Easy | Low | Transaction fees apply | Medium | Browser extension wallet; suitable for web users. |
|
||||
| **Breez SDK** | Self-custodial | ❌ | Medium | ❌ | High | Automatic | Moderate | Low | Minimal fees | Medium | SDK for integrating Breez wallet functionalities. |
|
||||
| **OpenNode** | Custodial | ✅ | Low | ❌ | Low | Provider-managed | Easy | Low | Transaction fees apply | Medium | Third-party service; suitable for merchants. |
|
||||
| **Blink** | Custodial | ✅ | Low | ❌ | Low | Provider-managed | Easy | Low | Transaction fees apply | Medium | Third-party service; focuses on mobile integrations. |
|
||||
| **ZBD** | Custodial | ✅ | Low | ❌ | Low | Provider-managed | Easy | Low | Transaction fees apply | Medium | Gaming-focused payment platform. |
|
||||
| **Spark (CLN)** | Self-custodial | ❌ | Higher | ✅ | High | Manual | Moderate | High | Infrastructure cost and channel opening fees | High | Web interface for CLN; requires Spark server setup. |
|
||||
| **Spark (L2)** | Self-custodial | ❌ | Medium | ❌ | High | Automatic | Easy | Low | Minimal fees | Medium | Runs via Spark sidecar; seed-based self-custody. |
|
||||
| **Cliche Wallet** | Self-custodial | ❌ | Medium | ❌ | Medium | Manual | Moderate | Moderate | Minimal fees | Medium | Lightweight wallet; suitable for embedded systems. |
|
||||
| **Strike** | Custodial | ✅ | Low | ❌ | Low | Provider-managed | Easy | Low | Transaction fees apply | Medium | Third-party service; suitable for quick setups. |
|
||||
| **LNPay** | Custodial | ✅ | Low | ❌ | Low | Provider-managed | Easy | Low | Transaction fees apply | Medium | Third-party service; suitable for quick setups. |
|
||||
| **Eclair (ACINQ)** | Self-custodial | ❌ | Higher | ✅ | High | Manual | Moderate | High | Infrastructure cost and channel opening fees | High | Connects via API; you run and manage your Eclair node. |
|
||||
| **LN.tips** | Custodial/Self-Custodial | Depends on provider | Medium | ❌ | Low | Provider-managed | Moderate | Low | Transaction fees may apply | Medium | Simple hosted service; use LN.tips API as your backend. |
|
||||
| **Fake Wallet** | Testing (simulated) | ❌ | Low | ❌ | N/A | N/A | Easy | Low | None (test only) | N/A | For testing only; mints accounting units in LNbits (no real sats, unit name configurable). |
|
||||
|
||||
## Spark (L2)
|
||||
|
||||
Spark L2 uses a local Node.js sidecar to expose an HTTP API that LNbits can use as a funding source. It is self-custodial and secured by a standard mnemonic seed. Sidecar repo `https://github.com/lnbits/spark_sidecar`.
|
||||
|
||||
### Notes for readers
|
||||
|
||||
- These are typical characteristics; your exact experience may vary by configuration and provider policy.
|
||||
- Pick based on your constraints: compliance (KYC), privacy, ops effort, and time-to-ship.
|
||||
|
||||
---
|
||||
|
||||
## Additional Guides
|
||||
|
||||
- **[Admin UI](./admin_ui.md)** — Manage server settings via a clean UI (avoid editing `.env` by hand).
|
||||
- **[User Roles](./User_Roles.md)** — Quick Overview of existing Roles in LNBits.
|
||||
- **[Funding sources](./funding-sources_table.md)** — What’s available and how to enable/configure each.
|
||||
|
||||
## Powered by LNbits
|
||||
|
||||
LNbits empowers everyone with modular, open-source tools for building Bitcoin-based systems — fast, free, and extendable.
|
||||
|
||||
If you like this project, [send some tip love](https://demo.lnbits.com/tipjar/DwaUiE4kBX6mUW6pj3X5Kg) or visit our [Shop](https://shop.lnbits.de)
|
||||
|
||||
[](https://shop.lnbits.com/)
|
||||
[](https://shop.lnbits.com/)
|
||||
[](https://my.lnbits.com/login)
|
||||
[](https://news.lnbits.com/)
|
||||
[](https://extensions.lnbits.com/)
|
||||
|
||||
+443
-193
@@ -1,39 +1,72 @@
|
||||
---
|
||||
layout: default
|
||||
title: Basic installation
|
||||
nav_order: 2
|
||||
title: Installation
|
||||
nav_order: 1
|
||||
---
|
||||
|
||||
<a href="https://lnbits.com" target="_blank" rel="noopener noreferrer">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://i.imgur.com/QE6SIrs.png">
|
||||
<img src="https://i.imgur.com/fyKPgVT.png" alt="LNbits" style="width:300px">
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
   [](https://extensions.lnbits.com/) [<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits)
|
||||
|
||||
# Basic installation
|
||||
|
||||
Note that by default LNbits uses SQLite as its database, which is simple and effective but you can configure it to use PostgreSQL instead which is also described in a section below.
|
||||
> [!NOTE]
|
||||
> **Default DB:** LNbits uses SQLite by default (simple & effective). You can switch to PostgreSQL — see the section below.
|
||||
|
||||
## Option 1: AppImage (LInux)
|
||||
## Table of contents
|
||||
|
||||
### AppImage (Linux)
|
||||
- [Option 1: AppImage (Linux)](#option-1-appimage-linux)
|
||||
- [Option 2: UV (recommended for developers)](#option-2-uv-recommended-for-developers)
|
||||
- [Option 2a (Legacy): Poetry — Replaced by UV](#option-2a-legacy-poetry--replaced-by-uv)
|
||||
- [Option 3: Install script (Debian/Ubuntu)](#option-3-install-script-debianubuntu)
|
||||
- [Option 4: Nix](#option-4-nix)
|
||||
- [Option 5: Docker](#option-5-docker)
|
||||
- [Option 6: Fly.io](#option-6-flyio)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
- [Optional: PostgreSQL database](#optional-postgresql-database)
|
||||
- [Using LNbits](#using-lnbits)
|
||||
- [Additional guides](#additional-guides)
|
||||
- [Update LNbits (all methods)](#update-lnbits-all-methods)
|
||||
- [SQLite → PostgreSQL migration](#sqlite--postgresql-migration)
|
||||
- [LNbits as a systemd service](#lnbits-as-a-systemd-service)
|
||||
- [Reverse proxy with automatic HTTPS (Caddy)](#reverse-proxy-with-automatic-https-caddy)
|
||||
- [Apache2 reverse proxy over HTTPS](#apache2-reverse-proxy-over-https)
|
||||
- [Nginx reverse proxy over HTTPS](#nginx-reverse-proxy-over-https)
|
||||
- [HTTPS without a reverse proxy (self-signed)](#https-without-a-reverse-proxy-self-signed)
|
||||
- [LNbits on Umbrel behind Tor](#lnbits-on-umbrel-behind-tor)
|
||||
- [FreeBSD notes](#freebsd-notes)
|
||||
|
||||
Go to [releases](https://github.com/lnbits/lnbits/releases) and pull latest AppImage, or:
|
||||
## Option 1: AppImage (Linux)
|
||||
|
||||
**Quickstart**
|
||||
|
||||
1. Download latest AppImage from [releases](https://github.com/lnbits/lnbits/releases) **or** run:
|
||||
|
||||
```sh
|
||||
sudo apt-get install jq libfuse2
|
||||
wget $(curl -s https://api.github.com/repos/lnbits/lnbits/releases/latest | jq -r '.assets[] | select(.name | endswith(".AppImage")) | .browser_download_url') -O LNbits-latest.AppImage
|
||||
chmod +x LNbits-latest.AppImage
|
||||
LNBITS_ADMIN_UI=true HOST=0.0.0.0 PORT=5000 ./LNbits-latest.AppImage # most system settings are now in the admin UI, but pass additional .env variables here
|
||||
LNBITS_ADMIN_UI=true HOST=0.0.0.0 PORT=5000 AUTH_HTTPS_ONLY=false ./LNbits-latest.AppImage # most system settings are now in the admin UI, but pass additional .env variables here
|
||||
```
|
||||
|
||||
LNbits will create a folder for db and extension files in the folder the AppImage runs from.
|
||||
- LNbits will create a folder for DB and extension files **in the same directory** as the AppImage.
|
||||
|
||||
> [!NOTE]
|
||||
> **Next steps**
|
||||
> Install complete → **[Running LNbits](#run-the-server)**
|
||||
> Update LNBits → **[Update LNbits (all methods)](#update-lnbits-all-methods)**
|
||||
|
||||
## Option 2: UV (recommended for developers)
|
||||
|
||||
It is recommended to use the latest version of UV. Make sure you have Python version `3.12` installed.
|
||||
> [!IMPORTANT]
|
||||
> **It is recommended to use the latest version of UV & Make sure you have Python version 3.12 installed.**
|
||||
|
||||
### Install Python 3.12
|
||||
|
||||
## Option 2 (recommended): UV
|
||||
|
||||
It is recommended to use the latest version of UV. Make sure you have Python version 3.10 or higher installed.
|
||||
|
||||
### Verify Python version
|
||||
### Verify Python
|
||||
|
||||
```sh
|
||||
python3 --version
|
||||
@@ -46,15 +79,7 @@ curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
```
|
||||
|
||||
### (old) Install Poetry
|
||||
|
||||
```sh
|
||||
# If path 'export PATH="$HOME/.local/bin:$PATH"' fails, use the path echoed by the install
|
||||
curl -sSL https://install.python-poetry.org | python3 -
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
```
|
||||
|
||||
### install LNbits
|
||||
### Install LNbits
|
||||
|
||||
```sh
|
||||
git clone https://github.com/lnbits/lnbits.git
|
||||
@@ -62,10 +87,78 @@ cd lnbits
|
||||
git checkout main
|
||||
uv sync --all-extras
|
||||
|
||||
# or poetry
|
||||
# poetry env use 3.12
|
||||
# poetry install --only main
|
||||
cp .env.example .env
|
||||
# Optional: set funding source and other options in .env (e.g., `nano .env`)
|
||||
```
|
||||
|
||||
### Run the server
|
||||
|
||||
```sh
|
||||
uv run lnbits
|
||||
# To change port/host: uv run lnbits --port 9000 --host 0.0.0.0
|
||||
# Add --debug to the command above and set DEBUG=true in .env for verbose output
|
||||
```
|
||||
|
||||
### LNbits CLI
|
||||
|
||||
```sh
|
||||
# Useful for superuser ID, updating extensions, etc.
|
||||
uv run lnbits-cli --help
|
||||
```
|
||||
|
||||
### Update LNbits
|
||||
|
||||
```sh
|
||||
cd lnbits
|
||||
# Stop LNbits with Ctrl + X or your service manager
|
||||
# sudo systemctl stop lnbits
|
||||
|
||||
# Update code
|
||||
git pull --rebase
|
||||
|
||||
uv sync --all-extras
|
||||
uv run lnbits
|
||||
```
|
||||
|
||||
#### Use Admin UI → Extensions → "Update All" to bring extensions up to the proper level
|
||||
|
||||
> [!NOTE]
|
||||
> **Next steps**
|
||||
> Install complete → **[Running LNbits](#run-the-server)**
|
||||
> Update LNBits → **[Update LNbits (all methods)](#update-lnbits-all-methods)**
|
||||
|
||||
## Option 2a (Legacy): Poetry — _Replaced by UV_
|
||||
|
||||
<details>
|
||||
<summary><strong>Poetry install and update (legacy workflow)</summary>
|
||||
|
||||
This legacy section is preserved for older environments.
|
||||
**UV is the recommended (and faster) tool** for new installs. Use Poetry only if you have personal preferences or must support an older workflow.
|
||||
|
||||
> 
|
||||
> **It is recommended to use the latest version of Poetry & Make sure you have Python version 3.12 installed.**
|
||||
|
||||
### Verify Python version
|
||||
|
||||
```sh
|
||||
python3 --version
|
||||
```
|
||||
|
||||
### Install Poetry
|
||||
|
||||
```sh
|
||||
# If path 'export PATH="$HOME/.local/bin:$PATH"' fails, use the path echoed by the install
|
||||
curl -sSL https://install.python-poetry.org | python3 - && export PATH="$HOME/.local/bin:$PATH"
|
||||
```
|
||||
|
||||
### Install LNbits
|
||||
|
||||
```sh
|
||||
git clone https://github.com/lnbits/lnbits.git
|
||||
cd lnbits
|
||||
poetry env use 3.12
|
||||
git checkout main
|
||||
poetry install --only main
|
||||
cp .env.example .env
|
||||
# Optional: to set funding source amongst other options via the env `nano .env`
|
||||
```
|
||||
@@ -73,50 +166,53 @@ cp .env.example .env
|
||||
#### Running the server
|
||||
|
||||
```sh
|
||||
uv run lnbits
|
||||
# To change port/host pass 'uv run lnbits --port 9000 --host 0.0.0.0'
|
||||
|
||||
# or poetry
|
||||
# poetry run lnbits
|
||||
# adding --debug in the start-up command above to help your troubleshooting and generate a more verbose output
|
||||
# Note that you have to add the line DEBUG=true in your .env file, too.
|
||||
poetry run lnbits
|
||||
# To change port/host: poetry run lnbits --port 9000 --host 0.0.0.0
|
||||
# Add --debug to help troubleshooting (also set DEBUG=true in .env)
|
||||
```
|
||||
|
||||
#### LNbits-cli
|
||||
#### LNbits CLI
|
||||
|
||||
```sh
|
||||
# A very useful terminal client for getting the supersuer ID, updating extensions, etc
|
||||
uv run lnbits-cli --help
|
||||
# A very useful terminal client for getting the superuser ID, updating extensions, etc.
|
||||
poetry run lnbits-cli --help
|
||||
```
|
||||
|
||||
#### Updating the server
|
||||
|
||||
```sh
|
||||
cd lnbits
|
||||
# Stop LNbits with `ctrl + x` or with service manager
|
||||
# Stop LNbits with Ctrl + X or with your service manager
|
||||
# sudo systemctl stop lnbits
|
||||
|
||||
# Update LNbits
|
||||
git pull --rebase
|
||||
|
||||
# Check your poetry version with
|
||||
# poetry env list
|
||||
# If version is less 3.12, update it by running
|
||||
# poetry env use python3.12
|
||||
# poetry env remove python3.9
|
||||
# poetry env list
|
||||
# Check your Poetry Python version
|
||||
poetry env list
|
||||
# If version is less than 3.12, update it:
|
||||
poetry env use python3.12
|
||||
poetry env remove python3.X
|
||||
poetry env list
|
||||
|
||||
# Run install and start LNbits with
|
||||
# poetry install --only main
|
||||
# poetry run lnbits
|
||||
|
||||
uv sync --all-extras
|
||||
uv run lnbits
|
||||
|
||||
# use LNbits admin UI Extensions page function "Update All" do get extensions onto proper level
|
||||
# Reinstall and start
|
||||
poetry install --only main
|
||||
poetry run lnbits
|
||||
```
|
||||
|
||||
## Option 2: Install script (on Debian/Ubuntu)
|
||||
#### Use Admin UI → Extensions → "Update All" to bring extensions up to the proper level
|
||||
|
||||
> 
|
||||
> **Next steps**
|
||||
> Install complete → **[Running LNbits](#run-the-server)**
|
||||
> Update LNBits → **[Update LNbits (all methods)](#update-lnbits-all-methods)**
|
||||
|
||||
</details>
|
||||
|
||||
## Option 3: Install script (Debian/Ubuntu)
|
||||
|
||||
<details>
|
||||
<summary><strong>Show install script</strong> (one-line setup)</summary>
|
||||
|
||||
```sh
|
||||
wget https://raw.githubusercontent.com/lnbits/lnbits/main/lnbits.sh &&
|
||||
@@ -124,11 +220,19 @@ chmod +x lnbits.sh &&
|
||||
./lnbits.sh
|
||||
```
|
||||
|
||||
Now visit `0.0.0.0:5000` to make a super-user account.
|
||||
- You can use `./lnbits.sh` to run, but for more control: `cd lnbits` and use `uv run lnbits` (see Option 2).
|
||||
|
||||
`./lnbits.sh` can be used to run, but for more control `cd lnbits` and use `uv run lnbits` (see previous option).
|
||||
> 
|
||||
> **Next steps**
|
||||
> Install complete → **[Running LNbits](#run-the-server)**
|
||||
> Update LNBits → **[Update LNbits (all methods)](#update-lnbits-all-methods)**
|
||||
|
||||
## Option 3: Nix
|
||||
</details>
|
||||
|
||||
## Option 4: Nix
|
||||
|
||||
<details>
|
||||
<summary><strong>Show Nix instructions</strong> (flakes, cachix, run)</summary>
|
||||
|
||||
```sh
|
||||
# Install nix. If you have installed via another manager, remove and use this install (from https://nixos.org/download)
|
||||
@@ -181,32 +285,39 @@ but you can also set the env variables or pass command line arguments:
|
||||
|
||||
```sh
|
||||
# .env variables are currently passed when running, but LNbits can be managed with the admin UI.
|
||||
LNBITS_ADMIN_UI=true ./result/bin/lnbits --port 9000 --host 0.0.0.0
|
||||
|
||||
# Once you have created a user, you can set as the super_user
|
||||
SUPER_USER=be54db7f245346c8833eaa430e1e0405 LNBITS_ADMIN_UI=true ./result/bin/lnbits --port 9000
|
||||
LNBITS_ADMIN_UI=true AUTH_HTTPS_ONLY=false ./result/bin/lnbits --port 9000 --host 0.0.0.0
|
||||
```
|
||||
|
||||
## Option 4: Docker
|
||||
> 
|
||||
> **Next steps**
|
||||
> Update LNBits → **[Update LNbits (all methods)](#update-lnbits-all-methods)**
|
||||
|
||||
Use latest version from Docker Hub.
|
||||
</details>
|
||||
|
||||
## Option 5: Docker
|
||||
|
||||
<details>
|
||||
<summary><strong>Show Docker instructions</strong> (official image, volumes, extensions)</summary>
|
||||
|
||||
**Use latest image**
|
||||
|
||||
```sh
|
||||
docker pull lnbits/lnbits
|
||||
wget https://raw.githubusercontent.com/lnbits/lnbits/main/.env.example -O .env
|
||||
mkdir data
|
||||
docker run --detach --publish 5000:5000 --name lnbits --volume ${PWD}/.env:/app/.env --volume ${PWD}/data/:/app/data lnbits/lnbits
|
||||
docker run --detach --publish 5000:5000 --name lnbits \
|
||||
--volume ${PWD}/.env:/app/.env \
|
||||
--volume ${PWD}/data/:/app/data \
|
||||
lnbits/lnbits
|
||||
```
|
||||
|
||||
The LNbits Docker image comes with no extensions installed. User-installed extensions will be stored by default in a container directory.
|
||||
It is recommended to point the `LNBITS_EXTENSIONS_PATH` environment variable to a directory that is mapped to a Docker volume. This way, the extensions will not be reinstalled when the container is destroyed.
|
||||
Example:
|
||||
- The LNbits Docker image ships **without any extensions**; by default, any extensions you install are stored **inside the container** and will be **lost** when the container is removed, so you should set `LNBITS_EXTENSIONS_PATH` to a directory that’s **mapped to a persistent host volume** so extensions **survive rebuilds/recreates**—for example:
|
||||
|
||||
```sh
|
||||
docker run ... -e "LNBITS_EXTENSIONS_PATH='/app/data/extensions'" --volume ${PWD}/data/:/app/data ...
|
||||
```
|
||||
|
||||
Build the image yourself.
|
||||
**Build image yourself**
|
||||
|
||||
```sh
|
||||
git clone https://github.com/lnbits/lnbits.git
|
||||
@@ -214,24 +325,39 @@ cd lnbits
|
||||
docker build -t lnbits/lnbits .
|
||||
cp .env.example .env
|
||||
mkdir data
|
||||
docker run --detach --publish 5000:5000 --name lnbits --volume ${PWD}/.env:/app/.env --volume ${PWD}/data/:/app/data lnbits/lnbits
|
||||
docker run --detach --publish 5000:5000 --name lnbits \
|
||||
--volume ${PWD}/.env:/app/.env \
|
||||
--volume ${PWD}/data/:/app/data \
|
||||
lnbits/lnbits
|
||||
```
|
||||
|
||||
You can optionally override the arguments that are passed to `poetry install` during the build process by setting the Docker build argument named `POETRY_INSTALL_ARGS`. For example, to enable the Breez funding source, build the Docker image with the command:
|
||||
You can optionally override the install extras for both **Poetry** and **UV** to include optional features during build or setup:
|
||||
|
||||
- with Poetry, pass extras via the `POETRY_INSTALL_ARGS` Docker build-arg (e.g., to enable the **Breez** funding source: `docker build --build-arg POETRY_INSTALL_ARGS="-E breez" -t lnbits/lnbits .`);
|
||||
- with UV, enable extras during environment sync (e.g., locally run `uv sync --extra breez` or `uv sync --all-extras`), and—**if your Dockerfile supports it**—you can mirror the same at build time via a build-arg such as `UV_SYNC_ARGS` (example pattern: `docker build --build-arg UV_SYNC_ARGS="--extra breez" -t lnbits/lnbits .`).
|
||||
|
||||
**Enable Breez funding source at build**
|
||||
|
||||
```sh
|
||||
docker build --build-arg POETRY_INSTALL_ARGS="-E breez" -t lnbits/lnbits .
|
||||
```
|
||||
|
||||
## Option 5: Fly.io
|
||||
> 
|
||||
> **Next steps**
|
||||
> Install complete → **[Running LNbits](#run-the-server)**
|
||||
> Update LNBits → **[Update LNbits (all methods)](#update-lnbits-all-methods)**
|
||||
|
||||
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.
|
||||
</details>
|
||||
|
||||
First, sign up for an account at [Fly.io](https://fly.io) (no credit card required).
|
||||
## Option 6: Fly.io
|
||||
|
||||
Then, install the Fly.io CLI onto your device [here](https://fly.io/docs/getting-started/installing-flyctl/).
|
||||
<details>
|
||||
<summary><strong>Deploy LNbits on Fly.io (free tier friendly)</summary>
|
||||
|
||||
After install is complete, the command will output a command you should copy/paste/run to get `fly` into your `$PATH`. Something like:
|
||||
**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.**
|
||||
|
||||
1. Create an account at [Fly.io](https://fly.io).
|
||||
2. Install the Fly.io CLI ([guide](https://fly.io/docs/getting-started/installing-flyctl/)).
|
||||
|
||||
```
|
||||
flyctl was installed successfully to /home/ubuntu/.fly/bin/flyctl
|
||||
@@ -240,9 +366,9 @@ Manually add the directory to your $HOME/.bash_profile (or similar)
|
||||
export PATH="$FLYCTL_INSTALL/bin:$PATH"
|
||||
```
|
||||
|
||||
You can either run those commands, then `source ~/.bash_profile` or, if you don't, you'll have to call Fly from `~/.fly/bin/flyctl`.
|
||||
3. You can either run those commands, then `source ~/.bash_profile` or, if you don't, you'll have to call Fly from `~/.fly/bin/flyctl`.
|
||||
|
||||
Once installed, run the following commands.
|
||||
- Once installed, run the following commands.
|
||||
|
||||
```
|
||||
git clone https://github.com/lnbits/lnbits.git
|
||||
@@ -256,9 +382,16 @@ You'll be prompted to enter an app name, region, postgres (choose no), deploy no
|
||||
|
||||
You'll now find a file in the directory called `fly.toml`. Open that file and modify/add the following settings.
|
||||
|
||||
Note: Be sure to replace `${PUT_YOUR_LNBITS_ENV_VARS_HERE}` with all relevant environment variables in `.env` or `.env.example`. Environment variable strings should be quoted here, so if in `.env` you have `LNBITS_ENDPOINT=https://demo.lnbits.com` in `fly.toml` you should have `LNBITS_ENDPOINT="https://demo.lnbits.com"`.
|
||||
> 
|
||||
> Be sure to replace `${PUT_YOUR_LNBITS_ENV_VARS_HERE}` with all relevant environment variables in `.env` or `.env.example`.
|
||||
> Environment variable strings should be quoted here. For example, if `.env` has
|
||||
> `LNBITS_ENDPOINT=https://demo.lnbits.com`, then in `fly.toml` use
|
||||
> `LNBITS_ENDPOINT="https://demo.lnbits.com"`.
|
||||
|
||||
Note: Don't enter secret environment variables here. Fly.io offers secrets (via the `fly secrets` command) that are exposed as environment variables in your runtime. So, for example, if using the LND_REST funding source, you can run `fly secrets set LND_REST_MACAROON=<hex_macaroon_data>`.
|
||||
> 
|
||||
> Don't enter secret environment variables here. Fly.io offers **secrets** (via `fly secrets`) that are exposed as env vars at runtime.
|
||||
> Example (LND REST funding source):
|
||||
> `fly secrets set LND_REST_MACAROON=<hex_macaroon_data>`
|
||||
|
||||
```
|
||||
...
|
||||
@@ -313,26 +446,49 @@ sudo apt install python3.10-dev gcc build-essential
|
||||
poetry add setuptools wheel
|
||||
```
|
||||
|
||||
### Optional: PostgreSQL database
|
||||
> 
|
||||
>
|
||||
> **Next steps**
|
||||
> Install complete → **[Running LNbits](#run-the-server)**
|
||||
> Update LNbits → **[Update LNbits (all methods)](#update-lnbits-all-methods)**
|
||||
|
||||
If you want to use LNbits at scale, we recommend using PostgreSQL as the backend database. Install Postgres and setup a database for LNbits:
|
||||
## Troubleshooting
|
||||
|
||||
```sh
|
||||
# on debian/ubuntu 'sudo apt-get -y install postgresql'
|
||||
# or follow instructions at https://www.postgresql.org/download/linux/
|
||||
sudo apt install pkg-config libffi-dev libpq-dev
|
||||
|
||||
# Postgres doesn't have a default password, so we'll create one.
|
||||
# build essentials (Debian/Ubuntu)
|
||||
sudo apt install python3.10-dev gcc build-essential
|
||||
|
||||
# if secp256k1 build fails and you used poetry
|
||||
poetry add setuptools wheel
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
</details>
|
||||
|
||||
## Optional: PostgreSQL database
|
||||
|
||||
> [!TIP]
|
||||
> If you want to use LNbits at scale, we recommend using PostgreSQL as the backend database. Install Postgres and set up a database for LNbits.
|
||||
|
||||
```sh
|
||||
# Debian/Ubuntu: sudo apt-get -y install postgresql
|
||||
# or see https://www.postgresql.org/download/linux/
|
||||
|
||||
# Create a password for the postgres user
|
||||
sudo -i -u postgres
|
||||
psql
|
||||
# on psql
|
||||
ALTER USER postgres PASSWORD 'myPassword'; # choose whatever password you want
|
||||
# in psql
|
||||
ALTER USER postgres PASSWORD 'myPassword';
|
||||
\q
|
||||
# on postgres user
|
||||
# back as postgres user
|
||||
createdb lnbits
|
||||
exit
|
||||
```
|
||||
|
||||
You need to edit the `.env` file.
|
||||
**Configure LNbits**
|
||||
|
||||
```sh
|
||||
# add the database connection string to .env 'nano .env' LNBITS_DATABASE_URL=
|
||||
@@ -343,44 +499,189 @@ LNBITS_DATABASE_URL="postgres://postgres:postgres@localhost:5432/lnbits"
|
||||
|
||||
# Using LNbits
|
||||
|
||||
Now you can visit your LNbits at http://localhost:5000/.
|
||||
Visit **[http://localhost:5000/](http://localhost:5000/)** (or `0.0.0.0:5000`).
|
||||
|
||||
Now modify the `.env` file with any settings you prefer and add a proper [funding source](./wallets.md) by modifying the value of `LNBITS_BACKEND_WALLET_CLASS` and providing the extra information and credentials related to the chosen funding source.
|
||||
### Option A — First-run setup in the Browser (UI)
|
||||
|
||||
Then you can restart it and it will be using the new settings.
|
||||
1. On the **first start**, LNbits will **prompt you to Setup a SuperUser**.
|
||||
2. After creating it, you’ll be **redirected to the Admin UI as SuperUser**.
|
||||
3. In the Admin UI, **set your funding source** (backend wallet) and other preferences.
|
||||
4. **Restart LNbits** if prompted or after changing critical settings.
|
||||
|
||||
You might also need to install additional packages or perform additional setup steps, depending on the chosen backend. See [the short guide](./wallets.md) on each different funding source.
|
||||
> [!IMPORTANT]
|
||||
> Use the **SuperUser only** for initial setup and instance settings (funding source, configuration, Topup).
|
||||
> For maintenance, create a separate **Admin** account. For everyday usage (payments, wallets, etc.), **do not use the SuperUser** — use admin or regular user accounts instead. Its a bad behaviour.
|
||||
> Read more about [SuperUser](./super_user.md) and [Admin UI](./admin_ui.md)
|
||||
|
||||
Take a look at [Polar](https://lightningpolar.com/) for an excellent way of spinning up a Lightning Network dev environment.
|
||||
### Option B — Configure via `.env`
|
||||
|
||||
1. Edit your `.env` with preferred settings (funding, base URL, etc.).
|
||||
2. Set a funding source by configuring:
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`
|
||||
- plus the required credentials for your chosen backend (see **[wallets.md](./wallets.md)**).
|
||||
|
||||
3. **Restart LNbits** to apply changes.
|
||||
|
||||
---
|
||||
|
||||
> [!NOTE]
|
||||
> **Paths overview**
|
||||
>
|
||||
> - **SuperUser file:** `<lnbits_root>/data/.super_user`
|
||||
> Example: `~/lnbits/data/.super_user` • View: `cat ~/lnbits/data/.super_user`
|
||||
> - **Environment file:** `<lnbits_root>/.env` (for bare-metal installs)
|
||||
> - **Docker:** bind a host directory to `/app/data`.
|
||||
> On the host the SuperUser file is at `<host_data_dir>/.super_user`.
|
||||
> The container reads `/app/.env` (usually bind-mounted from your project root).
|
||||
|
||||
> [!TIP]
|
||||
> **Local Lightning test network**
|
||||
> Use **Polar** to spin up a safe local Lightning environment and test LNbits without touching your live setup.
|
||||
> https://lightningpolar.com/
|
||||
|
||||
> [!TIP]
|
||||
> **API comparison before updates**
|
||||
> Use **TableTown** to diff your LNbits instance against another (dev vs prod) or the upstream dev branch. Spot endpoint changes before updating.
|
||||
> Crafted by [Arbadacarbayk](https://github.com/arbadacarbaYK) - a standout contribution that makes pre-release reviews fast and reliable.
|
||||
> https://arbadacarbayk.github.io/LNbits_TableTown/
|
||||
|
||||
# Additional guides
|
||||
|
||||
## SQLite to PostgreSQL migration
|
||||
## Update LNbits (all methods)
|
||||
|
||||
If you already have LNbits installed and running, on an SQLite database, we **highly** recommend you migrate to postgres if you are planning to run LNbits on scale.
|
||||
> After updating, open **Admin UI → Extensions → “Update All”** to make sure extensions match the core version.
|
||||
|
||||
There's a script included that can do the migration easy. You should have Postgres already installed and there should be a password for the user (see Postgres install guide above). Additionally, your LNbits instance should run once on postgres to implement the database schema before the migration works:
|
||||
<details>
|
||||
<summary><strong>UV (recommended)</strong></summary>
|
||||
|
||||
```sh
|
||||
cd lnbits
|
||||
git pull --rebase
|
||||
uv sync --all-extras
|
||||
# restart (dev)
|
||||
uv run lnbits
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Poetry (legacy)</strong></summary>
|
||||
|
||||
```sh
|
||||
cd lnbits
|
||||
git pull --rebase
|
||||
# Optional: ensure Python 3.12
|
||||
poetry env list
|
||||
poetry env use python3.12
|
||||
poetry install --only main
|
||||
# restart (dev)
|
||||
poetry run lnbits
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>AppImage</strong></summary>
|
||||
|
||||
Download the latest AppImage from Releases and replace your old file **in the same directory** to keep the `./data` folder (DB, extensions).
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Install script (Debian/Ubuntu)</strong></summary>
|
||||
|
||||
```sh
|
||||
# If you installed via lnbits.sh:
|
||||
cd lnbits
|
||||
git pull --rebase
|
||||
# then use your chosen runner (UV recommended)
|
||||
uv sync --all-extras
|
||||
uv run lnbits
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Nix</strong></summary>
|
||||
|
||||
```sh
|
||||
cd lnbits
|
||||
git pull --rebase
|
||||
nix build
|
||||
# restart
|
||||
nix run
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Docker (official image)</strong></summary>
|
||||
|
||||
```sh
|
||||
docker pull lnbits/lnbits
|
||||
docker stop lnbits && docker rm lnbits
|
||||
docker run --detach --publish 5000:5000 --name lnbits \
|
||||
--volume ${PWD}/.env:/app/.env \
|
||||
--volume ${PWD}/data/:/app/data \
|
||||
lnbits/lnbits
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Docker (build yourself)</strong></summary>
|
||||
|
||||
```sh
|
||||
cd lnbits
|
||||
git pull --rebase
|
||||
docker build -t lnbits/lnbits .
|
||||
docker stop lnbits && docker rm lnbits
|
||||
docker run --detach --publish 5000:5000 --name lnbits \
|
||||
--volume ${PWD}/.env:/app/.env \
|
||||
--volume ${PWD}/data/:/app/data \
|
||||
lnbits/lnbits
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Fly.io</strong></summary>
|
||||
|
||||
```sh
|
||||
# If using Dockerfile in repo (recommended)
|
||||
cd lnbits
|
||||
git pull --rebase
|
||||
fly deploy
|
||||
# Logs & shell if needed
|
||||
fly logs
|
||||
fly ssh console
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## SQLite → PostgreSQL migration
|
||||
|
||||
> [!TIP]
|
||||
> If you run on SQLite and plan to scale, migrate to Postgres.
|
||||
|
||||
```sh
|
||||
# STOP LNbits
|
||||
|
||||
# add the database connection string to .env 'nano .env' LNBITS_DATABASE_URL=
|
||||
# postgres://<user>:<password>@<host>/<database> - alter line bellow with your user, password and db name
|
||||
# Edit .env with Postgres URL
|
||||
LNBITS_DATABASE_URL="postgres://postgres:postgres@localhost/lnbits"
|
||||
# save and exit
|
||||
|
||||
# START LNbits
|
||||
# STOP LNbits
|
||||
# START then STOP LNbits once to apply schema
|
||||
uv run python tools/conv.py
|
||||
# or
|
||||
make migration
|
||||
```
|
||||
|
||||
Hopefully, everything works and get migrated... Launch LNbits again and check if everything is working properly.
|
||||
- Launch LNbits again and verify.
|
||||
|
||||
## LNbits as a systemd service
|
||||
|
||||
Systemd is great for taking care of your LNbits instance. It will start it on boot and restart it in case it crashes. If you want to run LNbits as a systemd service on your Debian/Ubuntu/Raspbian server, create a file at `/etc/systemd/system/lnbits.service` with the following content:
|
||||
Create `/etc/systemd/system/lnbits.service`:
|
||||
|
||||
```
|
||||
# Systemd unit for lnbits
|
||||
@@ -388,17 +689,14 @@ Systemd is great for taking care of your LNbits instance. It will start it on bo
|
||||
|
||||
[Unit]
|
||||
Description=LNbits
|
||||
# you can uncomment these lines if you know what you're doing
|
||||
# it will make sure that lnbits starts after lnd (replace with your own backend service)
|
||||
# Optional: start after your backend
|
||||
#Wants=lnd.service
|
||||
#After=lnd.service
|
||||
|
||||
[Service]
|
||||
# replace with the absolute path of your lnbits installation
|
||||
WorkingDirectory=/home/lnbits/lnbits
|
||||
# same here. run `which uv` if you can't find the poetry binary
|
||||
# Find uv path via `which uv`
|
||||
ExecStart=/home/lnbits/.local/bin/uv run lnbits
|
||||
# replace with the user that you're running lnbits on
|
||||
User=lnbits
|
||||
Restart=always
|
||||
TimeoutSec=120
|
||||
@@ -409,33 +707,23 @@ Environment=PYTHONUNBUFFERED=1
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
Save the file and run the following commands:
|
||||
Enable & start:
|
||||
|
||||
```sh
|
||||
sudo systemctl enable lnbits.service
|
||||
sudo systemctl start lnbits.service
|
||||
```
|
||||
|
||||
## Reverse proxy with automatic HTTPS using Caddy
|
||||
## Reverse proxy with automatic HTTPS (Caddy)
|
||||
|
||||
Use Caddy to make your LNbits install accessible over clearnet with a domain and https cert.
|
||||
Point your domain A-record to your server IP. Install Caddy: [Caddy install guide](https://caddyserver.com/docs/install#debian-ubuntu-raspbian)
|
||||
|
||||
Point your domain at the IP of the server you're running LNbits on, by making an `A` record.
|
||||
|
||||
Install Caddy on the server
|
||||
https://caddyserver.com/docs/install#debian-ubuntu-raspbian
|
||||
|
||||
```
|
||||
```sh
|
||||
sudo caddy stop
|
||||
```
|
||||
|
||||
Create a Caddyfile
|
||||
|
||||
```
|
||||
sudo nano Caddyfile
|
||||
```
|
||||
|
||||
Assuming your LNbits is running on port `5000` add:
|
||||
Add:
|
||||
|
||||
```
|
||||
yourdomain.com {
|
||||
@@ -445,28 +733,21 @@ yourdomain.com {
|
||||
}
|
||||
```
|
||||
|
||||
Save and exit `CTRL + x`
|
||||
Save (Ctrl+X) and start:
|
||||
|
||||
```
|
||||
```sh
|
||||
sudo caddy start
|
||||
```
|
||||
|
||||
## Running behind an Apache2 reverse proxy over HTTPS
|
||||
|
||||
Install Apache2 and enable Apache2 mods:
|
||||
## Apache2 reverse proxy over HTTPS
|
||||
|
||||
```sh
|
||||
apt-get install apache2 certbot
|
||||
a2enmod headers ssl proxy proxy_http
|
||||
```
|
||||
|
||||
Create a SSL certificate with LetsEncrypt:
|
||||
|
||||
```sh
|
||||
certbot certonly --webroot --agree-tos --non-interactive --webroot-path /var/www/html -d lnbits.org
|
||||
```
|
||||
|
||||
Create an Apache2 vhost at: `/etc/apache2/sites-enabled/lnbits.conf`:
|
||||
Create `/etc/apache2/sites-enabled/lnbits.conf`:
|
||||
|
||||
```sh
|
||||
cat <<EOF > /etc/apache2/sites-enabled/lnbits.conf
|
||||
@@ -493,27 +774,20 @@ cat <<EOF > /etc/apache2/sites-enabled/lnbits.conf
|
||||
EOF
|
||||
```
|
||||
|
||||
Restart Apache2:
|
||||
Restart:
|
||||
|
||||
```sh
|
||||
service apache2 restart
|
||||
```
|
||||
|
||||
## Running behind an Nginx reverse proxy over HTTPS
|
||||
|
||||
Install nginx:
|
||||
## Nginx reverse proxy over HTTPS
|
||||
|
||||
```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`:
|
||||
Create `/etc/nginx/sites-enabled/lnbits.org`:
|
||||
|
||||
```sh
|
||||
cat <<EOF > /etc/nginx/sites-enabled/lnbits.org
|
||||
@@ -547,23 +821,22 @@ server {
|
||||
EOF
|
||||
```
|
||||
|
||||
Restart nginx:
|
||||
Restart:
|
||||
|
||||
```sh
|
||||
service nginx restart
|
||||
```
|
||||
|
||||
## 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.
|
||||
## HTTPS without a reverse proxy (self-signed)
|
||||
|
||||
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.
|
||||
Create a self-signed cert (useful for local/dev). Browsers won’t trust it by default.
|
||||
|
||||
#### Install mkcert
|
||||
### Install mkcert
|
||||
|
||||
You can find the install instructions for `mkcert` [here](https://github.com/FiloSottile/mkcert).
|
||||
|
||||
Install mkcert on Ubuntu:
|
||||
- Install instructions: [mkcert README](https://github.com/FiloSottile/mkcert)
|
||||
- Ubuntu example:
|
||||
|
||||
```sh
|
||||
sudo apt install libnss3-tools
|
||||
@@ -572,70 +845,47 @@ chmod +x mkcert-v*-linux-amd64
|
||||
sudo cp mkcert-v*-linux-amd64 /usr/local/bin/mkcert
|
||||
```
|
||||
|
||||
#### Create certificate
|
||||
### Create certificate
|
||||
|
||||
To create a certificate, first `cd` into your LNbits folder and execute the following command on Linux:
|
||||
**OpenSSL**
|
||||
|
||||
```sh
|
||||
openssl req -new -newkey rsa:4096 -x509 -sha256 -days 3650 -nodes -out cert.pem -keyout key.pem
|
||||
```
|
||||
|
||||
This will create two new files (`key.pem` and `cert.pem `).
|
||||
|
||||
Alternatively, you can use mkcert ([more info](https://kifarunix.com/how-to-create-self-signed-ssl-certificate-with-mkcert-on-ubuntu-18-04/)):
|
||||
**mkcert** (alternative)
|
||||
|
||||
```sh
|
||||
# add your local IP (192.x.x.x) as well if you want to use it in your local network
|
||||
# include your local IP (e.g., 192.x.x.x) if needed
|
||||
mkcert localhost 127.0.0.1 ::1
|
||||
```
|
||||
|
||||
You can then pass the certificate files to uvicorn when you start LNbits:
|
||||
**Run with certs**
|
||||
|
||||
```sh
|
||||
poetry run uvicorn lnbits.__main__:app --host 0.0.0.0 --port 5000 --ssl-keyfile ./key.pem --ssl-certfile ./cert.pem
|
||||
```
|
||||
|
||||
## LNbits running on Umbrel behind Tor
|
||||
## LNbits on Umbrel behind Tor
|
||||
|
||||
If you want to run LNbits on your Umbrel but want it to be reached through clearnet, _Uxellodunum_ made an extensive [guide](https://community.getumbrel.com/t/guide-lnbits-without-tor/604) on how to do it.
|
||||
See this community [guide](https://community.getumbrel.com/t/guide-lnbits-without-tor/604).
|
||||
|
||||
## Docker installation
|
||||
## FreeBSD notes
|
||||
|
||||
To install using docker you first need to build the docker image as:
|
||||
Issue with secp256k1 0.14.0 on FreeBSD (thanks @GitKalle):
|
||||
|
||||
```
|
||||
git clone https://github.com/lnbits/lnbits.git
|
||||
cd lnbits
|
||||
docker build -t lnbits/lnbits .
|
||||
```
|
||||
1. Install `py311-secp256k1` with `pkg install py311-secp256k1`.
|
||||
2. Change version in `pyproject.toml` from `0.14.0` to `0.13.2`.
|
||||
3. Rewrite `poetry.lock` with `poetry lock`.
|
||||
4. Follow install instructions with Poetry.
|
||||
|
||||
You can launch the docker in a different directory, but make sure to copy `.env.example` from lnbits there
|
||||
---
|
||||
|
||||
```
|
||||
cp <lnbits_repo>/.env.example .env
|
||||
```
|
||||
## Powered by LNbits
|
||||
|
||||
and change the configuration in `.env` as required.
|
||||
LNbits empowers everyone with modular, open-source tools for building Bitcoin-based systems — fast, free, and extendable.
|
||||
|
||||
Then create the data directory
|
||||
If you like this project [send some tip love](https://demo.lnbits.com/tipjar/DwaUiE4kBX6mUW6pj3X5Kg) or visiting our [Shop](https://shop.lnbits.com)
|
||||
|
||||
```
|
||||
mkdir data
|
||||
```
|
||||
|
||||
Then the image can be run as:
|
||||
|
||||
```
|
||||
docker run --detach --publish 5000:5000 --name lnbits -e "LNBITS_BACKEND_WALLET_CLASS='FakeWallet'" --volume ${PWD}/.env:/app/.env --volume ${PWD}/data/:/app/data lnbits
|
||||
```
|
||||
|
||||
Finally you can access your lnbits on your machine at port 5000.
|
||||
|
||||
### FreeBSD notes
|
||||
|
||||
Currently there is an issue with secp256k1 0.14.0 on FreeBSD. Thanks to @GitKalle
|
||||
|
||||
1. Install package `py311-secp256k1` with `pkg install py311-secp256k1`
|
||||
2. Change version in `pyproject.toml` from 0.14.0 to 0.13.2
|
||||
3. Rewrite `poetry.lock` file with command `poetry lock`
|
||||
4. Follow install instruction with Poetry
|
||||
[](https://shop.lnbits.com/)
|
||||
[](https://shop.lnbits.com/) [](https://my.lnbits.com/login) [](https://news.lnbits.com/) [](https://extensions.lnbits.com/)
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
# Generic OIDC Authentication Configuration
|
||||
|
||||
This document explains how to configure generic OIDC authentication for LNbits, which allows integration with various OIDC-compliant authentication providers such as Zitadel, Authentik, and others.
|
||||
|
||||
## Overview
|
||||
|
||||
The generic OIDC provider (`oidc`) complements the existing Keycloak provider and allows you to integrate any OIDC-compliant authentication service. You can customize the login button with your own organization name and icon.
|
||||
|
||||
## Configuration
|
||||
|
||||
Add the following environment variables to your `.env` file or system environment:
|
||||
|
||||
### Required Settings
|
||||
|
||||
```bash
|
||||
# Enable OIDC authentication
|
||||
LNBITS_AUTH_ALLOWED_METHODS=oidc-auth
|
||||
|
||||
# OIDC Discovery URL (well-known endpoint)
|
||||
LNBITS_OIDC_DISCOVERY_URL=https://your-oidc-provider-domain/.well-known/openid-configuration
|
||||
|
||||
# Client credentials from your OIDC provider
|
||||
LNBITS_OIDC_CLIENT_ID=your-client-id
|
||||
LNBITS_OIDC_CLIENT_SECRET=your-client-secret
|
||||
```
|
||||
|
||||
### Optional Settings - Customize the Login Button
|
||||
|
||||
You can customize how the OIDC login button appears to your users:
|
||||
|
||||
```bash
|
||||
# Custom organization name (displayed on the login button)
|
||||
# Example: "Login via Zitadel" or "Login via Authentik"
|
||||
LNBITS_OIDC_CLIENT_CUSTOM_ORG="Zitadel"
|
||||
|
||||
# Custom icon URL (displayed on the login button)
|
||||
# Can be a full URL or a path to a local image
|
||||
LNBITS_OIDC_CLIENT_CUSTOM_ICON=https://zitadel.com/favicon.svg
|
||||
```
|
||||
|
||||
If not set, the button will display "Login via OIDC" with a generic lock icon.
|
||||
|
||||
## Zitadel Configuration Example
|
||||
|
||||
For Zitadel, configure as follows:
|
||||
|
||||
1. Create a new application in Zitadel
|
||||
2. Choose "Web" application type
|
||||
3. Configure the redirect URI: `https://your-lnbits-domain/api/v1/auth/oidc/token`
|
||||
4. Save the Client ID and Client Secret
|
||||
5. Use these environment variables:
|
||||
|
||||
```bash
|
||||
LNBITS_AUTH_ALLOWED_METHODS=oidc-auth
|
||||
LNBITS_OIDC_DISCOVERY_URL=https://your-oidc-provider-domain/.well-known/openid-configuration
|
||||
LNBITS_OIDC_CLIENT_ID=your-zitadel-client-id
|
||||
LNBITS_OIDC_CLIENT_SECRET=your-zitadel-client-secret
|
||||
# Customize the button to show "Login via Zitadel" with Zitadel's logo
|
||||
LNBITS_OIDC_CLIENT_CUSTOM_ORG="Zitadel"
|
||||
LNBITS_OIDC_CLIENT_CUSTOM_ICON="https://zitadel.com/favicon.svg"
|
||||
```
|
||||
|
||||
**Result**: The login page will display a button with the text "Login via Zitadel" and the Zitadel logo.
|
||||
|
||||
## Authentik Configuration Example
|
||||
|
||||
For Authentik:
|
||||
|
||||
1. Create a new OAuth2/OpenID Provider
|
||||
2. Set the redirect URI: `https://your-lnbits-domain/api/v1/auth/oidc/token`
|
||||
3. Configure scopes: `openid`, `email`, `profile`
|
||||
4. Get the Client ID and Client Secret
|
||||
|
||||
```bash
|
||||
LNBITS_AUTH_ALLOWED_METHODS=oidc-auth
|
||||
LNBITS_OIDC_DISCOVERY_URL=https://authentik.yourdomain.com/application/o/your-app/.well-known/openid-configuration
|
||||
LNBITS_OIDC_CLIENT_ID=your-authentik-client-id
|
||||
LNBITS_OIDC_CLIENT_SECRET=your-authentik-client-secret
|
||||
LNBITS_OIDC_CLIENT_CUSTOM_ORG="Authentik"
|
||||
```
|
||||
|
||||
## Multiple Auth Methods
|
||||
|
||||
You can enable multiple authentication methods simultaneously:
|
||||
|
||||
```bash
|
||||
LNBITS_AUTH_ALLOWED_METHODS=username-password,oidc-auth,keycloak-auth
|
||||
```
|
||||
|
||||
## Discovery Endpoint Requirements
|
||||
|
||||
Your OIDC provider must expose a standard discovery endpoint (`.well-known/openid-configuration`) that includes:
|
||||
|
||||
- `authorization_endpoint`
|
||||
- `token_endpoint`
|
||||
- `userinfo_endpoint`
|
||||
- `jwks_uri` (JSON Web Key Set)
|
||||
|
||||
The OIDC implementation will automatically fetch these endpoints from the discovery URL.
|
||||
|
||||
## User Mapping
|
||||
|
||||
The OIDC provider maps user information from the OIDC userinfo endpoint:
|
||||
|
||||
- `sub` → User ID
|
||||
- `email` → Email address
|
||||
- `given_name` → First name
|
||||
- `family_name` → Last name
|
||||
- `name` or `preferred_username` → Display name
|
||||
- `picture` → Profile picture URL
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Authentication fails
|
||||
|
||||
1. Verify the discovery URL is accessible
|
||||
2. Check that Client ID and Client Secret are correct
|
||||
3. Ensure redirect URI in your OIDC provider matches: `https://your-lnbits-domain/api/v1/auth/oidc/token`
|
||||
4. Check LNbits logs for detailed error messages
|
||||
|
||||
### User info not populated
|
||||
|
||||
Some OIDC providers may use different claim names. If user information is not correctly populated, check your provider's userinfo endpoint response format and adjust the provider class if needed.
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- Always use HTTPS in production
|
||||
- Keep client secrets secure and never commit them to version control
|
||||
- Use environment variables or secure configuration management
|
||||
- Regularly rotate client secrets
|
||||
- Review OIDC provider's security best practices
|
||||
|
||||
## Implementation Details
|
||||
|
||||
The OIDC provider is implemented in `lnbits/core/models/sso/oidc.py` and extends the `fastapi_sso` library's `SSOBase` class. It uses the standard OpenID Connect flow with:
|
||||
|
||||
- Scopes: `openid`, `email`, `profile`
|
||||
- Response type: `code` (authorization code flow)
|
||||
- Discovery document for automatic endpoint resolution
|
||||
@@ -0,0 +1,132 @@
|
||||
---
|
||||
layout: default
|
||||
title: Super user
|
||||
nav_order: 1
|
||||
---
|
||||
|
||||
<a href="https://lnbits.com" target="_blank" rel="noopener noreferrer">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://i.imgur.com/QE6SIrs.png">
|
||||
<img src="https://i.imgur.com/fyKPgVT.png" alt="LNbits" style="width:300px">
|
||||
</picture>
|
||||
</a>
|
||||
|
||||

|
||||

|
||||
[<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits)
|
||||
|
||||
# LNbits Super User (SU)
|
||||
|
||||
**Table of Contents**
|
||||
|
||||
- [What is the Super User?](#what-is-the-super-user)
|
||||
- [When is the Super User created?](#when-is-the-super-user-created)
|
||||
- [Disabeling the Admin UI](#disabeling-the-admin-ui)
|
||||
- [Super User identity and storage](#super-user-identity-and-storage)
|
||||
- [Security model since v1](#security-model-since-v1)
|
||||
- [Admin vs Super User](#admin-vs-super-user)
|
||||
- [Operational guidance](#operational-guidance)
|
||||
- [Additional guides](#additional-guides)
|
||||
|
||||
<details>
|
||||
<summary><strong>TLDR</strong></summary>
|
||||
|
||||
- **No Admin UI → No Super User.** The Super User (SU) exists only when `LNBITS_ADMIN_UI=true`.
|
||||
- **Why SU exists:** SU can do a few high impact actions regular admins cannot, like **changing the funding source**, **restarting the server from the UI**, and **crediting or debiting accounts**.
|
||||
- **Login changes since v1:** Logging in by **user ID** for SU and admins is **disabled**. On first visit after enabling the Admin UI you will be prompted to set a **username and password** for the SU.
|
||||
- **Trust model:** Admins and the SU share about **99 percent of the same powers**, but the SU is the one trusted with funding source control and cannot be demoted by regular admins.
|
||||
|
||||
</details>
|
||||
|
||||
## What is the Super User?
|
||||
|
||||
The **Super User** is the owner-operator account of an LNbits instance. Think of it as your “break glass” operator with a few capabilities that are intentionally reserved for the person ultimately responsible for the server and the funding rails.
|
||||
|
||||
The SU is created alongside the [Admin UI](./admin_ui.md) and is meant to keep enviroment operations pleasant in the UI while keeping the most sensitive knobs in trusted hands.
|
||||
|
||||
**Key SU capabilities**
|
||||
|
||||
- **Change the funding source** for the instance
|
||||
- **Restart the LNbits server** from the web UI
|
||||
- **Credit or debit accounts** for operational corrections
|
||||
|
||||
> Note
|
||||
> These are separated from regular admin tasks on purpose. It helps maintain least privilege and reduces the chance of accidental or malicious changes.
|
||||
|
||||
## Admin vs Super User
|
||||
|
||||
| Capability | Admin | Super User |
|
||||
| ------------------------ | ---------- | ---------- |
|
||||
| View Admin UI | If enabled | If enabled |
|
||||
| Change funding source | — | ✓ |
|
||||
| Credit or debit accounts | — | ✓ |
|
||||
| Restart server from UI | — | ✓ |
|
||||
| Manage users and wallets | ✓ | ✓ |
|
||||
| Instance-level settings | ✓ | ✓ |
|
||||
| Manage notifications | ✓ | ✓ |
|
||||
| Exchange rates | ✓ | ✓ |
|
||||
| View all Payments | ✓ | ✓ |
|
||||
|
||||
**Why both roles?**
|
||||
In many teams the person running the server prefers to **delegate day-to-day admin work** while keeping funding and final authority safe. Admins can do almost everything; the SU retains the last few high risk powers.
|
||||
|
||||
## When is the Super User created?
|
||||
|
||||
- The SU is created **only** when you enable the Admin UI: `LNBITS_ADMIN_UI=true`.
|
||||
- If the Admin UI is **disabled**, there is **no SU** and all SU-only UI is hidden.
|
||||
|
||||
## Disabeling the Admin UI
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Read the [Admin UI guide](./admin_ui.md) before Disabeling. You are turning on a management surface; do it deliberately.
|
||||
|
||||
Set the environment variable in your deployment:
|
||||
|
||||
```bash
|
||||
# .env
|
||||
LNBITS_ADMIN_UI=false
|
||||
```
|
||||
|
||||
## Super User identity and storage
|
||||
|
||||
LNbits stores the **Super User ID** at:
|
||||
|
||||
```
|
||||
/lnbits/data/.super_user
|
||||
```
|
||||
|
||||
- Back this up along with the rest of `/lnbits/data` as part of your secure backup routine.
|
||||
- **Changing who is the SU** can only be done by someone with **CLI access to the host OS** where LNbits runs. **Regular admins cannot revoke or replace the SU in the Admin UI.**
|
||||
|
||||
## Security model since v1
|
||||
|
||||
- **User-ID logins are disabled** for SU and admin roles.
|
||||
- **Credentialed login is required:** set a **username and password** for the SU at first run of the Admin UI.
|
||||
- **SU secrecy:** Regular users and admins **cannot discover the SU user ID** through normal UI flows.
|
||||
|
||||
## Operational guidance
|
||||
|
||||
These are practical tips for running a safe and friendly instance.
|
||||
|
||||
- It is normal to **delegate admin** duties to trusted people. Admins have about **99 percent** of SU powers for day-to-day work.
|
||||
- Keep the **SU** reserved for the person legally or operationally responsible for the **funding source**.
|
||||
- Use admin roles for regular day-to-day management and keep the SU for reserved SU tasks only.
|
||||
|
||||
## Additional guides
|
||||
|
||||
- **[Admin UI](./admin_ui.md)** — Manage server settings in the browser instead of editing `.env` or using the CLI for routine tasks.
|
||||
- **[User Roles](./user_roles.md)** — Overview of roles and what they can do.
|
||||
- **[Funding sources](./funding-sources-table.md)** — Available options and how to enable and configure them.
|
||||
- **[Install LNBits](./installation.md)** — Choose your prefared way to install LNBits.
|
||||
|
||||
## Powered by LNbits
|
||||
|
||||
LNbits empowers everyone with modular, open source tools for building Bitcoin-based systems — fast, free, and extendable.
|
||||
|
||||
If you like this project, [send some tip love](https://demo.lnbits.com/tipjar/DwaUiE4kBX6mUW6pj3X5Kg) or visit our [Shop](https://shop.lnbits.de)
|
||||
|
||||
[](https://shop.lnbits.com/)
|
||||
[](https://shop.lnbits.com/)
|
||||
[](https://my.lnbits.com/login)
|
||||
[](https://news.lnbits.com/)
|
||||
[](https://extensions.lnbits.com/)
|
||||
@@ -0,0 +1,101 @@
|
||||
---
|
||||
layout: default
|
||||
title: User Roles
|
||||
nav_order: 1
|
||||
---
|
||||
|
||||
<a href="https://lnbits.com" target="_blank" rel="noopener noreferrer">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://i.imgur.com/QE6SIrs.png">
|
||||
<img src="https://i.imgur.com/fyKPgVT.png" alt="LNbits" style="width:300px">
|
||||
</picture>
|
||||
</a>
|
||||
|
||||

|
||||

|
||||
[<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits)
|
||||
|
||||
# LNbits Roles: A Quick Overview
|
||||
|
||||
### Understand **who can do what** in seconds: `Super User`, `Admin`, and `Regular User`.
|
||||
|
||||
**Jump to:**
|
||||
[Roles at a Glance](#roles-at-a-glance) •
|
||||
[Super User](#super-user--master-control) •
|
||||
[Admin](#admin--day-to-day-manager) •
|
||||
[Regular User](#regular-user--everyday-use) •
|
||||
[Best Practices](#best-practices) •
|
||||
[Additional Guides](#additional-guides)
|
||||
|
||||
---
|
||||
|
||||
## Roles at a Glance
|
||||
|
||||
| Capability | **Super User** (owner) | **Admin** (manager) | **Regular User** (end user) |
|
||||
| -------------------------------- | :--------------------: | :-----------------: | :-------------------------: |
|
||||
| Change **funding source** | ✅ | ❌ | ❌ |
|
||||
| Credit/Debit any wallet | ✅ | ❌ | ❌ |
|
||||
| Manage Admins & Users | ✅ | ✅ | ❌ |
|
||||
| Enable/disable extensions | ✅ | ✅ | ❌ |
|
||||
| Use wallets & allowed extensions | ✅ | ✅ | ✅ |
|
||||
|
||||
> **Plain talk:** **Super User** = Owner • **Admin** = Trusted manager • **Regular User** = End user
|
||||
|
||||
## Role Snapshots
|
||||
|
||||
### Super User — Master Control
|
||||
|
||||
For initial setup and rare, high-impact changes.
|
||||
|
||||
- Configure **server-level settings** (e.g., funding source).
|
||||
- **Credit/Debit** any wallet.
|
||||
- Create and manage initial **Admin(s)**.
|
||||
|
||||
> **Sign-in:** username + password (v1+). The old query-string login is retired.
|
||||
|
||||
### Admin — Day-to-Day Manager
|
||||
|
||||
For running the service without touching the most sensitive knobs.
|
||||
|
||||
- Manage **Users**, **Admins**, and **Extensions** in the Admin UI.
|
||||
- Adjust security-related settings (e.g., `rate_limiter`, `ip_blocker`).
|
||||
- Handle operations settings (e.g., `service_fee`, `invoice_expiry`).
|
||||
- Build brand design in **Site Customization**.
|
||||
- Update user accounts.
|
||||
|
||||
**Typical tasks:** onboarding users, enabling extensions, tidying wallets, reviewing activity.
|
||||
|
||||
> **Sign-in:** username + password (v1+). The old query-string login is retired.
|
||||
|
||||
### Regular User — Everyday Use
|
||||
|
||||
For using LNbits, not administering it.
|
||||
|
||||
- Access **personal wallets** and **allowed extensions**.
|
||||
- No server/admin privileges.
|
||||
|
||||
**Typical tasks:** receive and send payments, use enabled extensions.
|
||||
|
||||
## Best Practices
|
||||
|
||||
- **Minimize risk:** Reserve **Super User** for rare, sensitive actions (funding source, debit/credit). Use **Admin** for daily operations.
|
||||
- **Keep access tidy:** Review your Admin list occasionally; remove unused accounts.
|
||||
- **Change management:** Test risky changes (like funding) in a staging setup first.
|
||||
|
||||
## Additional Guides
|
||||
|
||||
- **[Admin UI](./admin_ui.md)** — Manage server settings via a clean UI (avoid editing `.env` by hand).
|
||||
- **[Super User](./super_user.md)** — Deep dive on responsibilities and safe usage patterns.
|
||||
- **[Funding sources](./funding-sources-table.md)** — What’s available and how to enable/configure each.
|
||||
|
||||
## Powered by LNbits
|
||||
|
||||
LNbits empowers everyone with modular, open-source tools for building Bitcoin-based systems—fast, free, and extendable.
|
||||
|
||||
If you like this project, [send some tip love](https://demo.lnbits.com/tipjar/DwaUiE4kBX6mUW6pj3X5Kg) or visit our [Shop](https://shop.lnbits.de)
|
||||
|
||||
[](https://shop.lnbits.com/)
|
||||
[](https://shop.lnbits.com/)
|
||||
[](https://my.lnbits.com/login)
|
||||
[](https://news.lnbits.com/)
|
||||
[](https://extensions.lnbits.com/)
|
||||
+327
-110
@@ -4,177 +4,394 @@ title: Backend wallets
|
||||
nav_order: 3
|
||||
---
|
||||
|
||||
<a href="https://lnbits.com" target="_blank" rel="noopener noreferrer">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://i.imgur.com/QE6SIrs.png">
|
||||
<img src="https://i.imgur.com/fyKPgVT.png" alt="LNbits" style="width:300px">
|
||||
</picture>
|
||||
</a>
|
||||
|
||||

|
||||

|
||||
[<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits)
|
||||
|
||||
# Backend wallets
|
||||
|
||||
LNbits can run on top of many Lightning Network funding sources with more being added regularly.
|
||||
**LNbits is modular**: You can switch the funding source (backend wallet) **without changing anything else** in your setup. Keep your extensions, apps, users, and config as-is — just point LNbits to a different backend via environment variables.
|
||||
|
||||
A backend wallet can be configured using the following LNbits environment variables:
|
||||
**What stays the same when you switch backends**
|
||||
|
||||
You can [compare the LNbits compatible Lightning Network funding sources here](wallets.md).
|
||||
- Your LNbits setup and extensions
|
||||
- Your API keys and endpoints
|
||||
- Your server and deployment setup
|
||||
|
||||
A backend wallet is selected and configured entirely through LNbits environment variables. See the options and variables below, and compare them here: [Funding-Source-Table.md](funding-sources-table.md)
|
||||
|
||||
> [!NOTE]
|
||||
> **Terminology:** “Backend Wallet” and “Funding Source” mean the same thing — the wallet or service that funds your LNbits.
|
||||
|
||||
## Funding Sources
|
||||
|
||||
## Funding Sources
|
||||
|
||||
| | | |
|
||||
| ----------------------------------------------- | ------------------------------------- | ------------------------------------------------- |
|
||||
| [CLNRest (runes)](#clnrest-runes) | [LND (REST)](#lnd-rest) | [OpenNode](#opennode) |
|
||||
| [CoreLightning](#corelightning) | [LND (gRPC)](#lnd-grpc) | [Blink](#blink) |
|
||||
| [CoreLightning REST](#corelightning-rest) | [LNbits](#lnbits) | [Alby](#alby) |
|
||||
| [Spark (Core Lightning)](#spark-core-lightning) | [LNPay](#lnpay) | [Boltz](#boltz) |
|
||||
| [Spark L2](#spark-l2) | [ZBD](#zbd) | [Phoenixd](#phoenixd) |
|
||||
| [Cliche Wallet](#cliche-wallet) | | |
|
||||
| [Breez SDK](#breez-sdk) | [Breez Liquid SDK](#breez-liquid-sdk) | [Nostr Wallet Connect](#nostr-wallet-connect-nwc) |
|
||||
| [Strike](#strike) | [Eclair (ACINQ)](#eclair-acinq) | [LN.tips](#lntips) |
|
||||
| [Fake Wallet](#fake-wallet) | | |
|
||||
|
||||
---
|
||||
|
||||
<a id="clnrest-runes"></a>
|
||||
|
||||
### CLNRest (using [runes](https://docs.corelightning.org/reference/lightning-createrune))
|
||||
|
||||
[Core lightning Rest API docs](https://docs.corelightning.org/docs/rest)
|
||||
|
||||
[Core Lightning REST API docs](https://docs.corelightning.org/docs/rest)
|
||||
Should also work with the [Rust version of CLNRest](https://github.com/daywalker90/clnrest-rs)
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: **CLNRestWallet**
|
||||
**Environment variables**
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: `CLNRestWallet`
|
||||
- `CLNREST_URL`: `https://127.0.0.1:3010`
|
||||
- `CLNREST_CA`: `/home/lightningd/.lightning/bitcoin/ca.pem` (or the content of the `ca.pem` file)
|
||||
- `CLNREST_CERT`: `/home/lightningd/.lightning/bitcoin/server.pem` (or the content of the `server.pem` file)
|
||||
- `CLNREST_READONLY_RUNE`: `lightning-cli createrune restrictions='[["method=listfunds", "method=listpays", "method=listinvoices", "method=getinfo", "method=summary", "method=waitanyinvoice"]]' | jq -r .rune`
|
||||
- `CLNREST_INVOICE_RUNE`: `lightning-cli createrune restrictions='[["method=invoice"], ["pnameamount_msat<1000001"], ["pnamelabel^LNbits"], ["rate=60"]]' | jq -r .rune`
|
||||
- `CLNREST_PAY_RUNE`: `lightning-cli createrune restrictions='[["method=pay"], ["pinvbolt11_amount<1001"], ["pnamelabel^LNbits"], ["rate=1"]]' | jq -r .rune`
|
||||
- `CLNREST_RENEPAY_RUNE`: `lightning-cli createrune restrictions='[["method=renepay"], ["pinvinvstring_amount<1001"], ["pnamelabel^LNbits"], ["rate=1"]]' | jq -r .rune`
|
||||
- `CLNREST_LAST_PAY_INDEX`: `lightning-cli listinvoices | jq -r '.invoices | map(.created_index) | max' `
|
||||
- `CLNREST_CA`: `/home/lightningd/.lightning/bitcoin/ca.pem` (or the content of the file)
|
||||
- `CLNREST_CERT`: `/home/lightningd/.lightning/bitcoin/server.pem` (or the content of the file)
|
||||
- `CLNREST_LAST_PAY_INDEX`: `lightning-cli listinvoices | jq -r '.invoices | map(.created_index) | max'`
|
||||
- `CLNREST_NODEID`: `lightning-cli getinfo | jq -r .id` (only required for v23.08)
|
||||
|
||||
### CoreLightning
|
||||
**Create runes (copy/paste)**
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: **CoreLightningWallet**
|
||||
- `CORELIGHTNING_RPC`: /file/path/lightning-rpc
|
||||
```bash
|
||||
# Read-only: funds, pays, invoices, info, summary, and invoice listener
|
||||
lightning-cli createrune \
|
||||
restrictions='[["method=listfunds","method=listpays","method=listinvoices","method=getinfo","method=summary","method=waitanyinvoice"]]' \
|
||||
| jq -r .rune
|
||||
```
|
||||
|
||||
### CoreLightning REST
|
||||
```bash
|
||||
# Invoice: max 1,000,001 msat, label must start with "LNbits", 60 req/min
|
||||
lightning-cli createrune \
|
||||
restrictions='[["method=invoice"], ["pnameamount_msat<1000001"], ["pnamelabel^LNbits"], ["rate=60"]]' \
|
||||
| jq -r .rune
|
||||
```
|
||||
|
||||
This is the old REST interface that uses [Ride The Lightning/c-lightning-REST](https://github.com/Ride-The-Lightning/c-lightning-REST)
|
||||
```bash
|
||||
# Pay: bolt11 amount < 1001 (msat), label must start with "LNbits", 1 req/min
|
||||
lightning-cli createrune \
|
||||
restrictions='[["method=pay"], ["pinvbolt11_amount<1001"], ["pnamelabel^LNbits"], ["rate=1"]]' \
|
||||
| jq -r .rune
|
||||
```
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: **CoreLightningRestWallet**
|
||||
- `CORELIGHTNING_REST_URL`: http://127.0.0.1:8185/
|
||||
- `CORELIGHTNING_REST_MACAROON`: /file/path/admin.macaroon or Base64/Hex
|
||||
- `CORELIGHTNING_REST_CERT`: /home/lightning/clnrest/tls.cert
|
||||
```bash
|
||||
# Renepay: invstring amount < 1001 (msat), label must start with "LNbits", 1 req/min
|
||||
lightning-cli createrune \
|
||||
restrictions='[["method=renepay"], ["pinvinvstring_amount<1001"], ["pnamelabel^LNbits"], ["rate=1"]]' \
|
||||
| jq -r .rune
|
||||
```
|
||||
|
||||
### Spark (Core Lightning)
|
||||
Set the resulting values into:
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: **SparkWallet**
|
||||
- `SPARK_URL`: http://10.147.17.230:9737/rpc
|
||||
- `SPARK_TOKEN`: secret_access_key
|
||||
- `CLNREST_READONLY_RUNE`
|
||||
- `CLNREST_INVOICE_RUNE`
|
||||
- `CLNREST_PAY_RUNE`
|
||||
- `CLNREST_RENEPAY_RUNE`
|
||||
|
||||
### LND (REST)
|
||||
## CoreLightning
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: **LndRestWallet**
|
||||
- `LND_REST_ENDPOINT`: http://10.147.17.230:8080/
|
||||
- `LND_REST_CERT`: /file/path/tls.cert
|
||||
- `LND_REST_MACAROON`: /file/path/admin.macaroon or Base64/Hex
|
||||
**Required env vars**
|
||||
|
||||
or
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: `CoreLightningWallet`
|
||||
- `CORELIGHTNING_RPC`: `/file/path/lightning-rpc`
|
||||
|
||||
- `LND_REST_MACAROON_ENCRYPTED`: eNcRyPtEdMaCaRoOn
|
||||
## CoreLightning REST
|
||||
|
||||
### LND (gRPC)
|
||||
Old REST interface using [RTL c-lightning-REST](https://github.com/Ride-The-Lightning/c-lightning-REST)
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: **LndWallet**
|
||||
- `LND_GRPC_ENDPOINT`: ip_address
|
||||
- `LND_GRPC_PORT`: port
|
||||
- `LND_GRPC_CERT`: /file/path/tls.cert
|
||||
- `LND_GRPC_MACAROON`: /file/path/admin.macaroon or Base64/Hex
|
||||
**Required env vars**
|
||||
|
||||
You can also use an AES-encrypted macaroon (more info) instead by using
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: `CoreLightningRestWallet`
|
||||
- `CORELIGHTNING_REST_URL`: `http://127.0.0.1:8185/`
|
||||
- `CORELIGHTNING_REST_MACAROON`: `/file/path/admin.macaroon` or Base64/Hex
|
||||
- `CORELIGHTNING_REST_CERT`: `/home/lightning/clnrest/tls.cert`
|
||||
|
||||
- `LND_GRPC_MACAROON_ENCRYPTED`: eNcRyPtEdMaCaRoOn
|
||||
## Spark (Core Lightning)
|
||||
|
||||
To encrypt your macaroon, run `uv run lnbits-cli encrypt macaroon`.
|
||||
**Required env vars**
|
||||
|
||||
### LNbits
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: `SparkWallet`
|
||||
- `SPARK_URL`: `http://10.147.17.230:9737/rpc`
|
||||
- `SPARK_TOKEN`: `secret_access_key`
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: **LNbitsWallet**
|
||||
- `LNBITS_ENDPOINT`: e.g. https://lnbits.com
|
||||
- `LNBITS_KEY`: lnbitsAdminKey
|
||||
## Spark L2
|
||||
|
||||
### LNPay
|
||||
Self-custodial funding source using the [Spark L2](https://docs.spark.money/start/overview) network. Requires a Node.js [sidecar](https://github.com/lnbits/spark_sidecar) that bridges lnbits talking to Spark. Works in addition with any Spark-compatible seed (Wallet of Satoshi, BuhoGO, BlitzWallet).
|
||||
If the sidecar is started with a `mnemonic` then that mnemonic will be used. Otherwhise if a mnemonic is set for the `Spark L2` LNbits funding source then that mnemonic will be used.
|
||||
|
||||
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.
|
||||
### Optional tuning
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: **LNPayWallet**
|
||||
- `LNPAY_API_ENDPOINT`: https://api.lnpay.co/v1/
|
||||
- `LNPAY_API_KEY`: sak_apiKey
|
||||
- `LNPAY_WALLET_KEY`: waka_apiKey
|
||||
- `SPARK_L2_PAY_WAIT_MS`: `4000` _(payment timeout in ms)_
|
||||
- `SPARK_L2_PAY_POLL_MS`: `500` _(polling interval in ms)_
|
||||
- `SPARK_L2_STREAM_KEEPALIVE_MS`: `15000` _(SSE keepalive in ms)_
|
||||
|
||||
### OpenNode
|
||||
### Example: run the sidecar
|
||||
|
||||
For the invoice to work you must have a publicly accessible URL in your LNbits. No manual webhook setting is necessary.
|
||||
```bash
|
||||
git clone https://github.com/lnbits/spark_sidecar.git
|
||||
cd spark_sidecar
|
||||
npm install
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: **OpenNodeWallet**
|
||||
- `OPENNODE_API_ENDPOINT`: https://api.opennode.com/
|
||||
- `OPENNODE_KEY`: opennodeAdminApiKey
|
||||
SPARK_MNEMONIC="bottom bottom bottom bottom bottom bottom bottom bottom bottom bottom bottom bottom" \
|
||||
SPARK_NETWORK=MAINNET \
|
||||
SPARK_SIDECAR_PORT=8765 \
|
||||
SPARK_PAY_WAIT_MS=20000 \
|
||||
node server.mjs
|
||||
```
|
||||
|
||||
### Blink
|
||||
For testing, you can generate a 12-word mnemonic at https://iancoleman.io/bip39/. Store it securely — it controls your funds. Then select Spark (L2) as the funding source in LNbits.
|
||||
|
||||
For the invoice to work you must have a publicly accessible URL in your LNbits. No manual webhook setting is necessary. You can generate a Blink API key after logging in or creating a new Blink account at: https://dashboard.blink.sv. For more info visit: https://dev.blink.sv/api/auth#create-an-api-key```
|
||||
## LND (REST)
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: **BlinkWallet**
|
||||
- `BLINK_API_ENDPOINT`: https://api.blink.sv/graphql
|
||||
- `BLINK_WS_ENDPOINT`: wss://ws.blink.sv/graphql
|
||||
- `BLINK_TOKEN`: BlinkToken
|
||||
**Required env vars**
|
||||
|
||||
### Alby
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: `LndRestWallet`
|
||||
- `LND_REST_ENDPOINT`: `http://10.147.17.230:8080/`
|
||||
- `LND_REST_CERT`: `/file/path/tls.cert`
|
||||
- `LND_REST_MACAROON`: `/file/path/admin.macaroon` or Base64/Hex
|
||||
|
||||
For the invoice to work you must have a publicly accessible URL in your LNbits. No manual webhook setting is necessary. You can generate an alby access token here: https://getalby.com/developer/access_tokens/new
|
||||
or:
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: **AlbyWallet**
|
||||
- `ALBY_API_ENDPOINT`: https://api.getalby.com/
|
||||
- `ALBY_ACCESS_TOKEN`: AlbyAccessToken
|
||||
- `LND_REST_MACAROON_ENCRYPTED`: `eNcRyPtEdMaCaRoOn`
|
||||
|
||||
### Boltz
|
||||
## LND (gRPC)
|
||||
|
||||
This funding source connects to a running [boltz-client](https://docs.boltz.exchange/v/boltz-client) and handles all lightning payments through submarine swaps on the liquid network.
|
||||
You can configure the daemon to run in standalone mode by `standalone = True` in the config file or using the cli flag (`boltzd --standalone`).
|
||||
Once running, you can create a liquid wallet using `boltzcli wallet create lnbits lbtc`.
|
||||
**Required env vars**
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: **BoltzWallet**
|
||||
- `BOLTZ_CLIENT_ENDPOINT`: 127.0.0.1:9002
|
||||
- `BOLTZ_CLIENT_MACAROON`: /home/bob/.boltz/macaroons/admin.macaroon or Base64/Hex
|
||||
- `BOLTZ_CLIENT_CERT`: /home/bob/.boltz/tls.cert or Base64/Hex
|
||||
- `BOLTZ_CLIENT_WALLET`: lnbits
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: `LndWallet`
|
||||
- `LND_GRPC_ENDPOINT`: `ip_address`
|
||||
- `LND_GRPC_PORT`: `port`
|
||||
- `LND_GRPC_CERT`: `/file/path/tls.cert`
|
||||
- `LND_GRPC_MACAROON`: `/file/path/admin.macaroon` or Base64/Hex
|
||||
|
||||
### ZBD
|
||||
You can also use an AES-encrypted macaroon instead:
|
||||
|
||||
For the invoice to work you must have a publicly accessible URL in your LNbits. No manual webhook setting is necessary. You can generate an ZBD API Key here: https://zbd.dev/docs/dashboard/projects/api
|
||||
- `LND_GRPC_MACAROON_ENCRYPTED`: `eNcRyPtEdMaCaRoOn`
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: **ZBDWallet**
|
||||
- `ZBD_API_ENDPOINT`: https://api.zebedee.io/v0/
|
||||
- `ZBD_API_KEY`: ZBDApiKey
|
||||
To encrypt your macaroon:
|
||||
|
||||
### Phoenixd
|
||||
```bash
|
||||
uv run lnbits-cli encrypt macaroon
|
||||
```
|
||||
|
||||
For the invoice to work you must have a publicly accessible URL in your LNbits. You can get a phoenixd API key from the install
|
||||
~/.phoenix/phoenix.conf, also see the documentation for phoenixd.
|
||||
## LNbits
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: **PhoenixdWallet**
|
||||
- `PHOENIXD_API_ENDPOINT`: http://localhost:9740/
|
||||
- `PHOENIXD_API_PASSWORD`: PhoenixdApiPassword
|
||||
**Required env vars**
|
||||
|
||||
### Breez SDK
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: `LNbitsWallet`
|
||||
- `LNBITS_ENDPOINT`: for example `https://lnbits.com`
|
||||
- `LNBITS_KEY`: `lnbitsAdminKey`
|
||||
|
||||
A Greenlight invite code or Greenlight partner certificate/key can be used to register a new node with Greenlight. If the Greenlight node already exists, neither are required.
|
||||
## LNPay
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: **BreezSdkWallet**
|
||||
- `BREEZ_API_KEY`: ...
|
||||
- `BREEZ_GREENLIGHT_SEED`: ...
|
||||
- `BREEZ_GREENLIGHT_INVITE_CODE`: ...
|
||||
- `BREEZ_GREENLIGHT_DEVICE_KEY`: /path/to/breezsdk/device.pem or Base64/Hex
|
||||
- `BREEZ_GREENLIGHT_DEVICE_CERT`: /path/to/breezsdk/device.crt or Base64/Hex
|
||||
For the invoice listener to work you must have a publicly accessible URL in your LNbits and set up [LNPay webhooks](https://dashboard.lnpay.co/webhook/) pointing to `<your LNbits host>/wallet/webhook` with the event **Wallet Receive** and no secret. Example: [https://mylnbits/wallet/webhook](`https://mylnbits/wallet/webhook).
|
||||
|
||||
### Breez Liquid SDK
|
||||
**Required env vars**
|
||||
|
||||
This funding source leverages the [Breez SDK - Liquid](https://sdk-doc-liquid.breez.technology/) to manage all Lightning payments via submarine swaps on the Liquid network. To get started, simply provide a mnemonic seed phrase. The easiest way to generate one is by using a liquid wallet, such as [Blockstream Green](https://blockstream.com/green/). Once generated, you can copy the seed to your environment variable or enter it in the admin UI.
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: `LNPayWallet`
|
||||
- `LNPAY_API_ENDPOINT`: `https://api.lnpay.co/v1/`
|
||||
- `LNPAY_API_KEY`: `sak_apiKey`
|
||||
- `LNPAY_WALLET_KEY`: `waka_apiKey`
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: **BreezLiquidSdkWallet**
|
||||
- `BREEZ_LIQUID_SEED`: ...
|
||||
## OpenNode
|
||||
|
||||
Each submarine swap incurs service and on-chain fees. To account for these, you may need to increase the reserve fee in the admin UI by navigating to **Settings -> Funding**, or by setting the following environment variables:
|
||||
For the invoice to work you must have a publicly accessible URL in your LNbits. No manual webhook configuration required.
|
||||
|
||||
- `LNBITS_RESERVE_FEE_MIN`: ...
|
||||
- `LNBITS_RESERVE_FEE_PERCENT`: ...
|
||||
**Required env vars**
|
||||
|
||||
### Cliche Wallet
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: `OpenNodeWallet`
|
||||
- `OPENNODE_API_ENDPOINT`: `https://api.opennode.com/`
|
||||
- `OPENNODE_KEY`: `opennodeAdminApiKey`
|
||||
|
||||
- `CLICHE_ENDPOINT`: ws://127.0.0.1:12000
|
||||
## Blink
|
||||
|
||||
### Nostr Wallet Connect (NWC)
|
||||
For the invoice to work you must have a publicly accessible URL in your LNbits. No manual webhook configuration required.
|
||||
|
||||
To use NWC as funding source in LNbits you'll need a pairing URL (also known as pairing secret) from a NWC service provider. You can find a list of providers [here](https://github.com/getAlby/awesome-nwc?tab=readme-ov-file#nwc-wallets).
|
||||
You can generate a Blink API key at [https://dashboard.blink.sv](https://dashboard.blink.sv). More info: [https://dev.blink.sv/api/auth#create-an-api-key](https://dev.blink.sv/api/auth#create-an-api-key)
|
||||
|
||||
You can configure Nostr Wallet Connect in the admin ui or using the following environment variables:
|
||||
**Required env vars**
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: **NWCWallet**
|
||||
- `NWC_PAIRING_URL`: **nostr+walletconnect://...your...pairing...secret...**
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: `BlinkWallet`
|
||||
- `BLINK_API_ENDPOINT`: `https://api.blink.sv/graphql`
|
||||
- `BLINK_WS_ENDPOINT`: `wss://ws.blink.sv/graphql`
|
||||
- `BLINK_TOKEN`: `BlinkToken`
|
||||
|
||||
## Alby
|
||||
|
||||
For the invoice to work you must have a publicly accessible URL in your LNbits. No manual webhook configuration required.
|
||||
|
||||
Generate an Alby access token here: [https://getalby.com/developer/access_tokens/new](https://getalby.com/developer/access_tokens/new)
|
||||
|
||||
**Required env vars**
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: `AlbyWallet`
|
||||
- `ALBY_API_ENDPOINT`: `https://api.getalby.com/`
|
||||
- `ALBY_ACCESS_TOKEN`: `AlbyAccessToken`
|
||||
|
||||
## Boltz
|
||||
|
||||
This connects to a running [boltz-client](https://docs.boltz.exchange/v/boltz-client) and handles Lightning payments through submarine swaps on the Liquid network.
|
||||
|
||||
You can run the daemon in standalone mode via `standalone = True` in the config or `boltzd --standalone`. Create a Liquid wallet with:
|
||||
|
||||
```bash
|
||||
boltzcli wallet create lnbits lbtc
|
||||
```
|
||||
|
||||
**Required env vars**
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: `BoltzWallet`
|
||||
- `BOLTZ_CLIENT_ENDPOINT`: `127.0.0.1:9002`
|
||||
- `BOLTZ_CLIENT_MACAROON`: `/home/bob/.boltz/macaroons/admin.macaroon` or Base64/Hex
|
||||
- `BOLTZ_CLIENT_CERT`: `/home/bob/.boltz/tls.cert` or Base64/Hex
|
||||
- `BOLTZ_CLIENT_WALLET`: `lnbits`
|
||||
|
||||
## ZBD
|
||||
|
||||
For the invoice to work you must have a publicly accessible URL in your LNbits. No manual webhook configuration required.
|
||||
|
||||
Generate a ZBD API key here: [https://zbd.dev/docs/dashboard/projects/api](https://zbd.dev/docs/dashboard/projects/api)
|
||||
|
||||
**Required env vars**
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: `ZBDWallet`
|
||||
- `ZBD_API_ENDPOINT`: `https://api.zebedee.io/v0/`
|
||||
- `ZBD_API_KEY`: `ZBDApiKey`
|
||||
|
||||
## Phoenixd
|
||||
|
||||
For the invoice to work you must have a publicly accessible URL in your LNbits.
|
||||
|
||||
You can get a phoenixd API key from `~/.phoenix/phoenix.conf`. See the phoenixd documentation for details.
|
||||
|
||||
**Required env vars**
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: `PhoenixdWallet`
|
||||
- `PHOENIXD_API_ENDPOINT`: `http://localhost:9740/`
|
||||
- `PHOENIXD_API_PASSWORD`: `PhoenixdApiPassword`
|
||||
|
||||
## Eclair (ACINQ)
|
||||
|
||||
<a id="eclair-acinq"></a>
|
||||
|
||||
Connect to an existing Eclair node so your backend handles invoices and payments.
|
||||
|
||||
**Required env vars**
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: `EclairWallet`
|
||||
- `ECLAIR_URL`: `http://127.0.0.1:8283`
|
||||
- `ECLAIR_PASSWORD`: `eclairpw`
|
||||
|
||||
## Fake Wallet
|
||||
|
||||
<a id="fake-wallet"></a>
|
||||
|
||||
A testing-only backend that mints accounting units inside LNbits accounting (no real sats).
|
||||
|
||||
**Required env vars**
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: `FakeWallet`
|
||||
- `FAKE_SECRET`: `ToTheMoon1`
|
||||
- `FAKE_UNIT`: `sats`
|
||||
|
||||
## LN.tips
|
||||
|
||||
<a id="lntips"></a>
|
||||
|
||||
As the initinal LN.tips bot is no longer active the code still exists and is widly used. Connect one of custodial services as your backend to create and pay Lightning invoices through their API or selfhost this service and run it as funding source.
|
||||
Resources: https://github.com/massmux/SatsMobiBot
|
||||
|
||||
**Required env vars**
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: `LNTipsWallet`
|
||||
- `LNTIPS_API_ENDPOINT`: `https://ln.tips`
|
||||
- `LNTIPS_API_KEY`: `LNTIPS_ADMIN_KEY`
|
||||
|
||||
## Breez SDK
|
||||
|
||||
A Greenlight invite code or Greenlight partner certificate/key can register a new node with Greenlight. If the Greenlight node already exists, neither is required.
|
||||
|
||||
**Required env vars**
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: `BreezSdkWallet`
|
||||
- `BREEZ_API_KEY`: `...`
|
||||
- `BREEZ_GREENLIGHT_SEED`: `...`
|
||||
- `BREEZ_GREENLIGHT_INVITE_CODE`: `...`
|
||||
- `BREEZ_GREENLIGHT_DEVICE_KEY`: `/path/to/breezsdk/device.pem` or Base64/Hex
|
||||
- `BREEZ_GREENLIGHT_DEVICE_CERT`: `/path/to/breezsdk/device.crt` or Base64/Hex
|
||||
|
||||
## Breez Liquid SDK
|
||||
|
||||
This uses the [Breez SDK - Liquid](https://sdk-doc-liquid.breez.technology/) to manage Lightning payments via submarine swaps on the Liquid network. Provide a mnemonic seed phrase (for example, generate one with a Liquid wallet like [Blockstream Green](https://blockstream.com/green/)) and set it in the environment or admin UI.
|
||||
|
||||
**Required env vars**
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: `BreezLiquidSdkWallet`
|
||||
- `BREEZ_LIQUID_SEED`: `...`
|
||||
|
||||
Fees apply for each submarine swap. You may need to increase the reserve fee under **Settings → Funding** or via:
|
||||
|
||||
- `LNBITS_RESERVE_FEE_MIN`: `...`
|
||||
- `LNBITS_RESERVE_FEE_PERCENT`: `...`
|
||||
|
||||
## Cliche Wallet
|
||||
|
||||
**Required env vars**
|
||||
|
||||
- `CLICHE_ENDPOINT`: `ws://127.0.0.1:12000`
|
||||
|
||||
## Nostr Wallet Connect (NWC)
|
||||
|
||||
To use NWC as a funding source you need a pairing URL (pairing secret) from an NWC provider. See providers here:
|
||||
[https://github.com/getAlby/awesome-nwc?tab=readme-ov-file#nwc-wallets](https://github.com/getAlby/awesome-nwc?tab=readme-ov-file#nwc-wallets)
|
||||
|
||||
Configure in the admin UI or via env vars:
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: `NWCWallet`
|
||||
- `NWC_PAIRING_URL`: `nostr+walletconnect://...your...pairing...secret...`
|
||||
|
||||
<a id="strike"></a>
|
||||
|
||||
## Strike (alpha)
|
||||
|
||||
Custodial provider integrated via **Strike OAuth Connect** (OAuth 2.0 / OIDC). Authenticate a Strike user in your app, then call Strike APIs on the user’s behalf once scopes are granted. Requires a Strike business account, registered OAuth client, minimal scopes, and login/logout redirect URLs.
|
||||
|
||||
Get more info here [https://docs.strike.me/strike-oauth-connect/](https://docs.strike.me/strike-oauth-connect/)
|
||||
|
||||
**Integration endpoints**
|
||||
|
||||
- `STRIKE_API_ENDPOINT`: `https://api.strike.me/v1`
|
||||
- `STRIKE_API_KEY`: `YOUR_STRIKE_API_KEY`
|
||||
|
||||
---
|
||||
|
||||
## Additional Guides
|
||||
|
||||
- **[Admin UI](./admin_ui.md)** — Manage server settings via a clean UI (avoid editing `.env` by hand).
|
||||
- **[User Roles](./user_roles.md)** — Quick Overview of existing Roles in LNBits.
|
||||
- **[Funding sources](./funding-sources-table.md)** — What’s available and how to enable/configure each.
|
||||
|
||||
## Powered by LNbits
|
||||
|
||||
LNbits empowers everyone with modular, open-source tools for building Bitcoin-based systems — fast, free, and extendable.
|
||||
|
||||
If you like this project, [send some tip love](https://demo.lnbits.com/tipjar/DwaUiE4kBX6mUW6pj3X5Kg) or visit our [Shop](https://shop.lnbits.de)
|
||||
|
||||
[](https://shop.lnbits.com/)
|
||||
[](https://shop.lnbits.com/)
|
||||
[](https://my.lnbits.com/login)
|
||||
[](https://news.lnbits.com/)
|
||||
[](https://extensions.lnbits.com/)
|
||||
|
||||
@@ -145,6 +145,7 @@
|
||||
nixpkgs.overlays = [ self.overlays.${system}.default ];
|
||||
};
|
||||
|
||||
checks = { };
|
||||
checks =
|
||||
import ./nix/tests { inherit pkgs; flake = self; };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from .core.services import create_invoice, pay_invoice
|
||||
from .decorators import (
|
||||
check_account_exists,
|
||||
check_admin,
|
||||
check_super_user,
|
||||
check_user_exists,
|
||||
@@ -11,6 +12,7 @@ from .exceptions import InvoiceError, PaymentError
|
||||
__all__ = [
|
||||
"InvoiceError",
|
||||
"PaymentError",
|
||||
"check_account_exists",
|
||||
"check_admin",
|
||||
"check_super_user",
|
||||
"check_user_exists",
|
||||
|
||||
+81
-28
@@ -23,29 +23,40 @@ from lnbits.core.crud import (
|
||||
get_installed_extensions,
|
||||
update_installed_extension_state,
|
||||
)
|
||||
from lnbits.core.crud.audit import delete_expired_audit_entries
|
||||
from lnbits.core.crud.extensions import create_installed_extension
|
||||
from lnbits.core.helpers import migrate_extension_database
|
||||
from lnbits.core.models.notifications import NotificationType
|
||||
from lnbits.core.services.extensions import deactivate_extension, get_valid_extensions
|
||||
from lnbits.core.services.notifications import enqueue_admin_notification
|
||||
from lnbits.core.services.payments import check_pending_payments
|
||||
from lnbits.core.services.funding_source import (
|
||||
check_balance_delta_changed,
|
||||
check_server_balance_against_node,
|
||||
)
|
||||
from lnbits.core.services.notifications import (
|
||||
dispatch_payment_notification,
|
||||
enqueue_admin_notification,
|
||||
process_next_notification,
|
||||
)
|
||||
from lnbits.core.services.payments import (
|
||||
check_pending_payments,
|
||||
fundingsource_invoice_producer,
|
||||
)
|
||||
from lnbits.core.tasks import (
|
||||
audit_queue,
|
||||
collect_exchange_rates_data,
|
||||
purge_audit_data,
|
||||
run_by_the_minute_tasks,
|
||||
wait_for_audit_data,
|
||||
wait_for_paid_invoices,
|
||||
wait_notification_messages,
|
||||
notify_server_status,
|
||||
process_next_audit_entry,
|
||||
refresh_extension_cache,
|
||||
)
|
||||
from lnbits.core.wasm_ext.routes.register import register_wasm_extension
|
||||
from lnbits.core.wasm_ext.wasm.events import dispatch_wasm_invoice_paid
|
||||
from lnbits.core.wasm_ext.wasm.loader import (
|
||||
is_wasm_extension_id,
|
||||
)
|
||||
from lnbits.exceptions import register_exception_handlers
|
||||
from lnbits.helpers import version_parse
|
||||
from lnbits.llms_txt import create_llms_txt_route
|
||||
from lnbits.settings import settings
|
||||
from lnbits.tasks import (
|
||||
cancel_all_tasks,
|
||||
create_permanent_task,
|
||||
register_invoice_listener,
|
||||
)
|
||||
from lnbits.utils.cache import cache
|
||||
from lnbits.utils.logger import (
|
||||
configure_logger,
|
||||
@@ -65,9 +76,10 @@ from .middleware import (
|
||||
InstalledExtensionMiddleware,
|
||||
add_first_install_middleware,
|
||||
add_ip_block_middleware,
|
||||
add_profiler_middleware,
|
||||
add_ratelimit_middleware,
|
||||
)
|
||||
from .tasks import internal_invoice_listener, invoice_listener, run_interval
|
||||
from .task_manager import task_manager
|
||||
|
||||
|
||||
async def startup(app: FastAPI):
|
||||
@@ -101,6 +113,9 @@ async def startup(app: FastAPI):
|
||||
# register core routes
|
||||
init_core_routers(app)
|
||||
|
||||
# register llms.txt endpoint for AI agents
|
||||
create_llms_txt_route(app)
|
||||
|
||||
# initialize tasks
|
||||
register_async_tasks()
|
||||
|
||||
@@ -128,7 +143,7 @@ async def shutdown():
|
||||
settings.lnbits_running = False
|
||||
|
||||
# shutdown event
|
||||
cancel_all_tasks()
|
||||
task_manager.cancel_all_tasks()
|
||||
|
||||
# wait a bit to allow them to finish, so that cleanup can run without problems
|
||||
await asyncio.sleep(0.1)
|
||||
@@ -161,6 +176,7 @@ def create_app() -> FastAPI:
|
||||
|
||||
# Allow registering new extensions routes without direct access to the `app` object
|
||||
core_app_extra.register_new_ext_routes = register_new_ext_routes(app)
|
||||
core_app_extra.register_new_wasm_ext_routes = register_new_wasm_ext_routes(app)
|
||||
core_app_extra.register_new_ratelimiter = register_new_ratelimiter(app)
|
||||
|
||||
# register static files
|
||||
@@ -196,6 +212,9 @@ def create_app() -> FastAPI:
|
||||
|
||||
register_exception_handlers(app)
|
||||
|
||||
if settings.profiler:
|
||||
add_profiler_middleware(app)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
@@ -404,6 +423,13 @@ def register_new_ext_routes(app: FastAPI) -> Callable:
|
||||
return register_new_ext_routes_fn
|
||||
|
||||
|
||||
def register_new_wasm_ext_routes(app: FastAPI) -> Callable:
|
||||
def register_new_wasm_ext_routes_fn(ext_id: str):
|
||||
register_wasm_extension(app, ext_id)
|
||||
|
||||
return register_new_wasm_ext_routes_fn
|
||||
|
||||
|
||||
def register_new_ratelimiter(app: FastAPI) -> Callable:
|
||||
def register_new_ratelimiter_fn():
|
||||
limiter = Limiter(
|
||||
@@ -457,32 +483,59 @@ async def check_and_register_extensions(app: FastAPI) -> None:
|
||||
await check_installed_extensions(app)
|
||||
for ext in await get_valid_extensions(False):
|
||||
try:
|
||||
if is_wasm_extension_id(ext.code):
|
||||
register_wasm_extension(app, ext.code)
|
||||
continue
|
||||
register_ext_routes(app, ext)
|
||||
register_ext_tasks(ext)
|
||||
except Exception as exc:
|
||||
logger.error(f"Could not load extension `{ext.code}`: {exc!s}")
|
||||
await update_installed_extension_state(ext_id=ext.code, active=False)
|
||||
|
||||
|
||||
def register_async_tasks() -> None:
|
||||
|
||||
create_permanent_task(wait_for_audit_data)
|
||||
create_permanent_task(wait_notification_messages)
|
||||
task_manager.init()
|
||||
|
||||
create_permanent_task(run_interval(30 * 60, check_pending_payments))
|
||||
create_permanent_task(invoice_listener)
|
||||
create_permanent_task(internal_invoice_listener)
|
||||
create_permanent_task(cache.invalidate_forever)
|
||||
# listen to all incoming payments and dispatch payment notifications
|
||||
# note: should be the first in task list for a bit quicker notifications
|
||||
task_manager.register_invoice_listener(dispatch_payment_notification, "core")
|
||||
|
||||
# core invoice listener
|
||||
invoice_queue: asyncio.Queue = asyncio.Queue()
|
||||
register_invoice_listener(invoice_queue, "core")
|
||||
create_permanent_task(lambda: wait_for_paid_invoices(invoice_queue))
|
||||
# periodic tasks
|
||||
task_manager.create_permanent_task(cache.invalidate_cache, interval=10)
|
||||
task_manager.create_permanent_task(delete_expired_audit_entries, interval=60 * 60)
|
||||
task_manager.create_permanent_task(
|
||||
check_pending_payments,
|
||||
interval=settings.lnbits_funding_source_pending_interval_seconds,
|
||||
)
|
||||
task_manager.create_permanent_task(
|
||||
collect_exchange_rates_data,
|
||||
interval=max(60, settings.lnbits_exchange_history_refresh_interval_seconds),
|
||||
)
|
||||
task_manager.create_permanent_task(check_balance_delta_changed, interval=60)
|
||||
task_manager.create_permanent_task(
|
||||
check_server_balance_against_node,
|
||||
interval=60 * settings.lnbits_watchdog_interval_minutes,
|
||||
)
|
||||
task_manager.create_permanent_task(
|
||||
notify_server_status,
|
||||
interval=60 * 60 * settings.lnbits_notification_server_status_hours,
|
||||
)
|
||||
task_manager.create_permanent_task(refresh_extension_cache, interval=60)
|
||||
|
||||
create_permanent_task(run_by_the_minute_tasks)
|
||||
create_permanent_task(purge_audit_data)
|
||||
create_permanent_task(collect_exchange_rates_data)
|
||||
# permanent tasks run in a loop, will be restarted if they fail
|
||||
task_manager.create_permanent_task(fundingsource_invoice_producer)
|
||||
task_manager.create_permanent_task(process_next_notification)
|
||||
task_manager.create_permanent_task(process_next_audit_entry)
|
||||
|
||||
async def dispatch_extension_invoice_paid(payment) -> None:
|
||||
await dispatch_wasm_invoice_paid(payment)
|
||||
|
||||
task_manager.register_invoice_listener(dispatch_extension_invoice_paid, "core_wasm")
|
||||
|
||||
# server logs for websocket
|
||||
if settings.lnbits_admin_ui:
|
||||
server_log_task = initialize_server_websocket_logger()
|
||||
create_permanent_task(server_log_task)
|
||||
task_manager.create_permanent_task(
|
||||
server_log_task, name="server_websocket_logger"
|
||||
)
|
||||
|
||||
@@ -3,6 +3,7 @@ from fastapi import APIRouter, FastAPI
|
||||
from .db import core_app_extra, db
|
||||
from .views.admin_api import admin_router
|
||||
from .views.api import api_router
|
||||
from .views.asset_api import asset_router
|
||||
from .views.audit_api import audit_router
|
||||
from .views.auth_api import auth_router
|
||||
from .views.callback_api import callback_router
|
||||
@@ -44,6 +45,7 @@ def init_core_routers(app: FastAPI):
|
||||
app.include_router(webpush_router)
|
||||
app.include_router(users_router)
|
||||
app.include_router(audit_router)
|
||||
app.include_router(asset_router)
|
||||
app.include_router(fiat_router)
|
||||
app.include_router(lnurl_router)
|
||||
|
||||
|
||||
@@ -12,18 +12,19 @@ from .extensions import (
|
||||
drop_extension_db,
|
||||
get_installed_extension,
|
||||
get_installed_extensions,
|
||||
get_installed_extensions_count,
|
||||
get_user_active_extensions_ids,
|
||||
get_user_extension,
|
||||
get_user_extensions,
|
||||
update_installed_extension,
|
||||
update_installed_extension_state,
|
||||
update_installed_extension_wasm_runtime_limits,
|
||||
update_user_extension,
|
||||
)
|
||||
from .payments import (
|
||||
DateTrunc,
|
||||
check_internal,
|
||||
create_payment,
|
||||
delete_expired_invoices,
|
||||
delete_wallet_payment,
|
||||
get_latest_payments_by_extension,
|
||||
get_payment,
|
||||
@@ -58,6 +59,7 @@ from .users import (
|
||||
get_account_by_username,
|
||||
get_account_by_username_or_email,
|
||||
get_accounts,
|
||||
get_accounts_count,
|
||||
get_user,
|
||||
get_user_access_control_lists,
|
||||
get_user_from_account,
|
||||
@@ -100,7 +102,6 @@ __all__ = [
|
||||
"delete_accounts_no_wallets",
|
||||
"delete_admin_settings",
|
||||
"delete_dbversion",
|
||||
"delete_expired_invoices",
|
||||
"delete_installed_extension",
|
||||
"delete_tinyurl",
|
||||
"delete_unused_wallets",
|
||||
@@ -117,11 +118,13 @@ __all__ = [
|
||||
"get_account_by_username",
|
||||
"get_account_by_username_or_email",
|
||||
"get_accounts",
|
||||
"get_accounts_count",
|
||||
"get_admin_settings",
|
||||
"get_db_version",
|
||||
"get_db_versions",
|
||||
"get_installed_extension",
|
||||
"get_installed_extensions",
|
||||
"get_installed_extensions_count",
|
||||
"get_latest_payments_by_extension",
|
||||
"get_payment",
|
||||
"get_payments",
|
||||
@@ -152,6 +155,7 @@ __all__ = [
|
||||
"update_admin_settings",
|
||||
"update_installed_extension",
|
||||
"update_installed_extension_state",
|
||||
"update_installed_extension_wasm_runtime_limits",
|
||||
"update_migration_version",
|
||||
"update_payment",
|
||||
"update_payment_checking_id",
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
from lnbits.core.db import db
|
||||
from lnbits.core.models.assets import Asset, AssetFilters, AssetInfo
|
||||
from lnbits.db import Connection, Filters, Page
|
||||
|
||||
|
||||
async def create_asset(
|
||||
entry: Asset,
|
||||
conn: Connection | None = None,
|
||||
) -> None:
|
||||
await (conn or db).insert("assets", entry)
|
||||
|
||||
|
||||
async def get_user_asset_info(
|
||||
user_id: str,
|
||||
asset_id: str,
|
||||
conn: Connection | None = None,
|
||||
) -> AssetInfo | None:
|
||||
return await (conn or db).fetchone(
|
||||
query="SELECT * from assets WHERE id = :asset_id AND user_id = :user_id",
|
||||
values={"asset_id": asset_id, "user_id": user_id},
|
||||
model=AssetInfo,
|
||||
)
|
||||
|
||||
|
||||
async def get_asset_info(
|
||||
asset_id: str, conn: Connection | None = None
|
||||
) -> AssetInfo | None:
|
||||
return await (conn or db).fetchone(
|
||||
query="SELECT * from assets WHERE id = :asset_id",
|
||||
values={"asset_id": asset_id},
|
||||
model=AssetInfo,
|
||||
)
|
||||
|
||||
|
||||
async def get_user_asset(
|
||||
user_id: str,
|
||||
asset_id: str,
|
||||
conn: Connection | None = None,
|
||||
) -> Asset | None:
|
||||
return await (conn or db).fetchone(
|
||||
query="SELECT * from assets WHERE id = :asset_id AND user_id = :user_id",
|
||||
values={"asset_id": asset_id, "user_id": user_id},
|
||||
model=Asset,
|
||||
)
|
||||
|
||||
|
||||
async def get_public_asset(
|
||||
asset_id: str,
|
||||
conn: Connection | None = None,
|
||||
) -> Asset | None:
|
||||
return await (conn or db).fetchone(
|
||||
query="SELECT * from assets WHERE id = :asset_id AND is_public = true",
|
||||
values={"asset_id": asset_id},
|
||||
model=Asset,
|
||||
)
|
||||
|
||||
|
||||
async def get_public_asset_info(
|
||||
asset_id: str,
|
||||
conn: Connection | None = None,
|
||||
) -> AssetInfo | None:
|
||||
return await (conn or db).fetchone(
|
||||
query="SELECT * from assets WHERE id = :asset_id AND is_public = true",
|
||||
values={"asset_id": asset_id},
|
||||
model=AssetInfo,
|
||||
)
|
||||
|
||||
|
||||
async def update_user_asset_info(
|
||||
asset: AssetInfo,
|
||||
) -> AssetInfo:
|
||||
await db.update("assets", asset)
|
||||
return asset
|
||||
|
||||
|
||||
async def delete_user_asset(
|
||||
user_id: str, asset_id: str, conn: Connection | None = None
|
||||
) -> None:
|
||||
await (conn or db).execute(
|
||||
query="DELETE FROM assets WHERE id = :asset_id AND user_id = :user_id",
|
||||
values={"asset_id": asset_id, "user_id": user_id},
|
||||
)
|
||||
|
||||
|
||||
async def get_user_assets(
|
||||
user_id: str,
|
||||
filters: Filters[AssetFilters] | None = None,
|
||||
conn: Connection | None = None,
|
||||
) -> Page[AssetInfo]:
|
||||
filters = filters or Filters()
|
||||
filters.sortby = filters.sortby or "created_at"
|
||||
return await (conn or db).fetch_page(
|
||||
query="SELECT * FROM assets",
|
||||
where=["user_id = :user_id"],
|
||||
values={"user_id": user_id},
|
||||
filters=filters,
|
||||
model=AssetInfo,
|
||||
table_name="assets",
|
||||
)
|
||||
|
||||
|
||||
async def get_user_assets_count(user_id: str) -> int:
|
||||
result = await db.execute(
|
||||
query="SELECT COUNT(*) as count FROM assets WHERE user_id = :user_id",
|
||||
values={"user_id": user_id},
|
||||
)
|
||||
row = result.mappings().first()
|
||||
return row.get("count", 0)
|
||||
@@ -16,11 +16,12 @@ async def get_audit_entries(
|
||||
conn: Connection | None = None,
|
||||
) -> Page[AuditEntry]:
|
||||
return await (conn or db).fetch_page(
|
||||
"SELECT * from audit",
|
||||
"SELECT * FROM audit",
|
||||
[],
|
||||
{},
|
||||
filters=filters,
|
||||
model=AuditEntry,
|
||||
table_name="audit",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import json
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from lnbits.core.db import db
|
||||
from lnbits.core.models.extensions import (
|
||||
InstallableExtension,
|
||||
UserExtension,
|
||||
WasmInvocation,
|
||||
WasmInvocationStats,
|
||||
)
|
||||
from lnbits.db import Connection, Database
|
||||
|
||||
@@ -11,6 +16,11 @@ async def create_installed_extension(
|
||||
conn: Connection | None = None,
|
||||
) -> None:
|
||||
await (conn or db).insert("installed_extensions", ext)
|
||||
await update_installed_extension_wasm_runtime_limits(
|
||||
ext_id=ext.id,
|
||||
limits=ext.wasm_runtime_limits,
|
||||
conn=conn,
|
||||
)
|
||||
|
||||
|
||||
async def update_installed_extension(
|
||||
@@ -18,6 +28,11 @@ async def update_installed_extension(
|
||||
conn: Connection | None = None,
|
||||
) -> None:
|
||||
await (conn or db).update("installed_extensions", ext)
|
||||
await update_installed_extension_wasm_runtime_limits(
|
||||
ext_id=ext.id,
|
||||
limits=ext.wasm_runtime_limits,
|
||||
conn=conn,
|
||||
)
|
||||
|
||||
|
||||
async def update_installed_extension_state(
|
||||
@@ -31,6 +46,33 @@ async def update_installed_extension_state(
|
||||
)
|
||||
|
||||
|
||||
async def update_installed_extension_wasm_runtime_limits(
|
||||
*, ext_id: str, limits: dict, conn: Connection | None = None
|
||||
) -> None:
|
||||
if not await _has_installed_extension_wasm_runtime_limits_column(conn=conn):
|
||||
return
|
||||
|
||||
await (conn or db).execute(
|
||||
"""
|
||||
UPDATE installed_extensions
|
||||
SET wasm_runtime_limits = :limits
|
||||
WHERE id = :id
|
||||
""",
|
||||
{"id": ext_id, "limits": json.dumps(limits)},
|
||||
)
|
||||
|
||||
|
||||
async def _has_installed_extension_wasm_runtime_limits_column(
|
||||
conn: Connection | None = None,
|
||||
) -> bool:
|
||||
row: dict | None = await (conn or db).fetchone(
|
||||
"SELECT version FROM dbversions WHERE db = 'core'"
|
||||
)
|
||||
if not row:
|
||||
return False
|
||||
return int(row["version"] or 0) >= 48
|
||||
|
||||
|
||||
async def delete_installed_extension(
|
||||
*, ext_id: str, conn: Connection | None = None
|
||||
) -> None:
|
||||
@@ -90,6 +132,13 @@ async def get_installed_extensions(
|
||||
return all_extensions
|
||||
|
||||
|
||||
async def get_installed_extensions_count(conn: Connection | None = None) -> int:
|
||||
row: dict | None = await (conn or db).fetchone(
|
||||
"SELECT COUNT(*) as count FROM installed_extensions"
|
||||
)
|
||||
return int(row["count"]) if row else 0
|
||||
|
||||
|
||||
async def get_user_extension(
|
||||
user_id: str, extension: str, conn: Connection | None = None
|
||||
) -> UserExtension | None:
|
||||
@@ -137,3 +186,158 @@ async def get_user_active_extensions_ids(
|
||||
UserExtension,
|
||||
)
|
||||
return [ext.extension for ext in exts]
|
||||
|
||||
|
||||
async def create_wasm_invocation(
|
||||
invocation: WasmInvocation,
|
||||
conn: Connection | None = None,
|
||||
) -> None:
|
||||
await (conn or db).insert("wasm_invocations", invocation)
|
||||
|
||||
|
||||
async def update_wasm_invocation(
|
||||
invocation: WasmInvocation,
|
||||
conn: Connection | None = None,
|
||||
) -> None:
|
||||
await (conn or db).update("wasm_invocations", invocation)
|
||||
|
||||
|
||||
async def get_wasm_invocation(
|
||||
invocation_id: str,
|
||||
conn: Connection | None = None,
|
||||
) -> WasmInvocation | None:
|
||||
return await (conn or db).fetchone(
|
||||
"SELECT * FROM wasm_invocations WHERE id = :id",
|
||||
{"id": invocation_id},
|
||||
model=WasmInvocation,
|
||||
)
|
||||
|
||||
|
||||
async def get_wasm_invocations(
|
||||
*,
|
||||
extension_id: str | None = None,
|
||||
status: str | None = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
conn: Connection | None = None,
|
||||
) -> list[WasmInvocation]:
|
||||
where: list[str] = []
|
||||
values: dict = {
|
||||
"limit": max(1, min(limit, 500)),
|
||||
"offset": max(offset, 0),
|
||||
}
|
||||
if extension_id:
|
||||
where.append("extension_id = :extension_id")
|
||||
values["extension_id"] = extension_id
|
||||
if status:
|
||||
where.append("status = :status")
|
||||
values["status"] = status
|
||||
|
||||
query = "SELECT * FROM wasm_invocations"
|
||||
if where:
|
||||
query += f" WHERE {' AND '.join(where)}"
|
||||
query += " ORDER BY started_at DESC LIMIT :limit OFFSET :offset"
|
||||
|
||||
return await (conn or db).fetchall(query, values, model=WasmInvocation)
|
||||
|
||||
|
||||
async def get_running_wasm_invocations(
|
||||
conn: Connection | None = None,
|
||||
) -> list[WasmInvocation]:
|
||||
return await get_wasm_invocations(status="running", conn=conn)
|
||||
|
||||
|
||||
async def get_wasm_invocation_stats(
|
||||
*,
|
||||
extension_id: str | None = None,
|
||||
since: datetime | None = None,
|
||||
conn: Connection | None = None,
|
||||
) -> WasmInvocationStats:
|
||||
database = conn or db
|
||||
where: list[str] = []
|
||||
values: dict = {}
|
||||
if extension_id:
|
||||
where.append("extension_id = :extension_id")
|
||||
values["extension_id"] = extension_id
|
||||
if since:
|
||||
where.append(f"started_at >= {database.timestamp_placeholder('since')}")
|
||||
values["since"] = since
|
||||
|
||||
query = """
|
||||
SELECT
|
||||
COUNT(*) AS total,
|
||||
COALESCE(SUM(CASE WHEN status = 'running' THEN 1 ELSE 0 END), 0)
|
||||
AS running,
|
||||
COALESCE(SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END), 0)
|
||||
AS completed,
|
||||
COALESCE(SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END), 0)
|
||||
AS failed,
|
||||
COALESCE(SUM(CASE WHEN status = 'stopped' THEN 1 ELSE 0 END), 0)
|
||||
AS stopped,
|
||||
COALESCE(SUM(CASE WHEN status = 'timeout' THEN 1 ELSE 0 END), 0)
|
||||
AS timeout,
|
||||
COALESCE(AVG(duration_ms), 0) AS avg_duration_ms,
|
||||
COALESCE(MAX(duration_ms), 0) AS max_duration_ms,
|
||||
COALESCE(SUM(host_call_count), 0) AS host_call_count,
|
||||
COALESCE(SUM(http_call_count), 0) AS http_call_count,
|
||||
COALESCE(SUM(storage_call_count), 0) AS storage_call_count,
|
||||
COALESCE(SUM(wallet_call_count), 0) AS wallet_call_count
|
||||
FROM wasm_invocations
|
||||
"""
|
||||
if where:
|
||||
query += f" WHERE {' AND '.join(where)}"
|
||||
|
||||
row: dict | None = await (conn or db).fetchone(query, values)
|
||||
if not row:
|
||||
return WasmInvocationStats()
|
||||
|
||||
return WasmInvocationStats(
|
||||
total=int(row["total"] or 0),
|
||||
running=int(row["running"] or 0),
|
||||
completed=int(row["completed"] or 0),
|
||||
failed=int(row["failed"] or 0),
|
||||
stopped=int(row["stopped"] or 0),
|
||||
timeout=int(row["timeout"] or 0),
|
||||
avg_duration_ms=float(row["avg_duration_ms"] or 0),
|
||||
max_duration_ms=int(row["max_duration_ms"] or 0),
|
||||
host_call_count=int(row["host_call_count"] or 0),
|
||||
http_call_count=int(row["http_call_count"] or 0),
|
||||
storage_call_count=int(row["storage_call_count"] or 0),
|
||||
wallet_call_count=int(row["wallet_call_count"] or 0),
|
||||
)
|
||||
|
||||
|
||||
async def delete_old_wasm_invocations(
|
||||
retention_days: int,
|
||||
conn: Connection | None = None,
|
||||
) -> int:
|
||||
if retention_days <= 0:
|
||||
return 0
|
||||
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(days=retention_days)
|
||||
database = conn or db
|
||||
result = await database.execute(
|
||||
f"""
|
||||
DELETE FROM wasm_invocations
|
||||
WHERE status != 'running'
|
||||
AND started_at < {database.timestamp_placeholder("cutoff")}
|
||||
""", # noqa: S608
|
||||
{"cutoff": cutoff},
|
||||
)
|
||||
return int(result.rowcount or 0)
|
||||
|
||||
|
||||
async def mark_stale_wasm_invocations(
|
||||
conn: Connection | None = None,
|
||||
) -> None:
|
||||
database = conn or db
|
||||
await database.execute(
|
||||
f"""
|
||||
UPDATE wasm_invocations
|
||||
SET status = 'abandoned',
|
||||
finished_at = {database.timestamp_placeholder("finished_at")},
|
||||
stop_reason = 'Server restarted before invocation finished.'
|
||||
WHERE status = 'running'
|
||||
""", # noqa: S608
|
||||
{"finished_at": datetime.now(timezone.utc)},
|
||||
)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from datetime import datetime, timezone
|
||||
from time import time
|
||||
from typing import Any
|
||||
|
||||
@@ -33,13 +34,12 @@ async def get_payment(checking_id: str, conn: Connection | None = None) -> Payme
|
||||
|
||||
async def get_standalone_payment(
|
||||
checking_id_or_hash: str,
|
||||
conn: Connection | None = None,
|
||||
incoming: bool | None = False,
|
||||
wallet_id: str | None = None,
|
||||
conn: Connection | None = None,
|
||||
) -> Payment | None:
|
||||
clause: str = "checking_id = :checking_id OR payment_hash = :hash"
|
||||
values = {
|
||||
"wallet_id": wallet_id,
|
||||
"checking_id": checking_id_or_hash,
|
||||
"hash": checking_id_or_hash,
|
||||
}
|
||||
@@ -47,6 +47,10 @@ async def get_standalone_payment(
|
||||
clause = f"({clause}) AND amount > 0"
|
||||
|
||||
if wallet_id:
|
||||
wallet = await get_wallet(wallet_id, conn=conn)
|
||||
if not wallet or not wallet.can_view_payments:
|
||||
return None
|
||||
values["wallet_id"] = wallet.source_wallet_id
|
||||
clause = f"({clause}) AND wallet_id = :wallet_id"
|
||||
|
||||
row = await (conn or db).fetchone(
|
||||
@@ -66,13 +70,16 @@ async def get_standalone_payment(
|
||||
async def get_wallet_payment(
|
||||
wallet_id: str, payment_hash: str, conn: Connection | None = None
|
||||
) -> Payment | None:
|
||||
wallet = await get_wallet(wallet_id, conn=conn)
|
||||
if not wallet or not wallet.can_view_payments:
|
||||
return None
|
||||
payment = await (conn or db).fetchone(
|
||||
"""
|
||||
SELECT *
|
||||
FROM apipayments
|
||||
WHERE wallet_id = :wallet AND payment_hash = :hash
|
||||
""",
|
||||
{"wallet": wallet_id, "hash": payment_hash},
|
||||
{"wallet": wallet.source_wallet_id, "hash": payment_hash},
|
||||
Payment,
|
||||
)
|
||||
return payment
|
||||
@@ -118,7 +125,6 @@ async def get_payments_paginated( # noqa: C901
|
||||
Filters payments to be returned by:
|
||||
- complete | pending | failed | outgoing | incoming.
|
||||
"""
|
||||
|
||||
values: dict[str, Any] = {
|
||||
"time": since,
|
||||
}
|
||||
@@ -128,7 +134,11 @@ async def get_payments_paginated( # noqa: C901
|
||||
clause.append(f"time > {db.timestamp_placeholder('time')}")
|
||||
|
||||
if wallet_id:
|
||||
values["wallet_id"] = wallet_id
|
||||
wallet = await get_wallet(wallet_id, conn=conn)
|
||||
if not wallet or not wallet.can_view_payments:
|
||||
return Page(data=[], total=0)
|
||||
|
||||
values["wallet_id"] = wallet.source_wallet_id
|
||||
clause.append("wallet_id = :wallet_id")
|
||||
elif user_id:
|
||||
only_user_wallets = await _only_user_wallets_statement(user_id, conn=conn)
|
||||
@@ -139,14 +149,12 @@ async def get_payments_paginated( # noqa: C901
|
||||
f"(status = '{PaymentState.SUCCESS}' OR status = '{PaymentState.PENDING}')"
|
||||
)
|
||||
elif complete:
|
||||
clause.append(
|
||||
f"""
|
||||
clause.append(f"""
|
||||
(
|
||||
status = '{PaymentState.SUCCESS}'
|
||||
OR (amount < 0 AND status = '{PaymentState.PENDING}')
|
||||
)
|
||||
"""
|
||||
)
|
||||
""")
|
||||
elif pending:
|
||||
clause.append(f"status = '{PaymentState.PENDING}'")
|
||||
elif failed:
|
||||
@@ -171,6 +179,7 @@ async def get_payments_paginated( # noqa: C901
|
||||
values,
|
||||
filters=filters,
|
||||
model=Payment,
|
||||
table_name="apipayments",
|
||||
)
|
||||
|
||||
|
||||
@@ -229,32 +238,6 @@ async def get_payments_status_count() -> PaymentsStatusCount:
|
||||
)
|
||||
|
||||
|
||||
async def delete_expired_invoices(
|
||||
conn: Connection | None = None,
|
||||
) -> None:
|
||||
# first we delete all invoices older than one month
|
||||
|
||||
await (conn or db).execute(
|
||||
# Timestamp placeholder is safe from SQL injection (not user input)
|
||||
f"""
|
||||
DELETE FROM apipayments
|
||||
WHERE status = :status AND amount > 0
|
||||
AND time < {db.timestamp_placeholder("delta")}
|
||||
""", # noqa: S608
|
||||
{"status": f"{PaymentState.PENDING}", "delta": int(time() - 2592000)},
|
||||
)
|
||||
# then we delete all invoices whose expiry date is in the past
|
||||
await (conn or db).execute(
|
||||
# Timestamp placeholder is safe from SQL injection (not user input)
|
||||
f"""
|
||||
DELETE FROM apipayments
|
||||
WHERE status = :status AND amount > 0
|
||||
AND expiry < {db.timestamp_placeholder("now")}
|
||||
""", # noqa: S608
|
||||
{"status": f"{PaymentState.PENDING}", "now": int(time())},
|
||||
)
|
||||
|
||||
|
||||
async def create_payment(
|
||||
checking_id: str,
|
||||
data: CreatePayment,
|
||||
@@ -281,7 +264,10 @@ async def create_payment(
|
||||
webhook=data.webhook,
|
||||
fee=-abs(data.fee),
|
||||
tag=extra.get("tag", None),
|
||||
extension=data.extension,
|
||||
extra=extra,
|
||||
labels=data.labels or [],
|
||||
external_id=data.external_id,
|
||||
)
|
||||
|
||||
await (conn or db).insert("apipayments", payment)
|
||||
@@ -293,8 +279,16 @@ async def update_payment_checking_id(
|
||||
checking_id: str, new_checking_id: str, conn: Connection | None = None
|
||||
) -> None:
|
||||
await (conn or db).execute(
|
||||
"UPDATE apipayments SET checking_id = :new_id WHERE checking_id = :old_id",
|
||||
{"new_id": new_checking_id, "old_id": checking_id},
|
||||
f"""
|
||||
UPDATE apipayments
|
||||
SET checking_id = :new_id, updated_at = {db.timestamp_placeholder("now")}
|
||||
WHERE checking_id = :old_id
|
||||
""", # noqa: S608
|
||||
{
|
||||
"new_id": new_checking_id,
|
||||
"old_id": checking_id,
|
||||
"now": int(time()),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -302,36 +296,38 @@ async def update_payment(
|
||||
payment: Payment,
|
||||
new_checking_id: str | None = None,
|
||||
conn: Connection | None = None,
|
||||
) -> None:
|
||||
) -> Payment:
|
||||
payment.updated_at = datetime.now(timezone.utc)
|
||||
await (conn or db).update(
|
||||
"apipayments", payment, "WHERE checking_id = :checking_id"
|
||||
)
|
||||
if new_checking_id and new_checking_id != payment.checking_id:
|
||||
await update_payment_checking_id(payment.checking_id, new_checking_id, conn)
|
||||
payment.checking_id = new_checking_id
|
||||
return payment
|
||||
|
||||
|
||||
async def get_payments_history(
|
||||
wallet_id: str | None = None,
|
||||
group: DateTrunc = "day",
|
||||
filters: Filters | None = None,
|
||||
conn: Connection | None = None,
|
||||
) -> list[PaymentHistoryPoint]:
|
||||
if not filters:
|
||||
filters = Filters()
|
||||
|
||||
date_trunc = db.datetime_grouping(group)
|
||||
|
||||
values = {
|
||||
values: dict[str, Any] = {
|
||||
"wallet_id": wallet_id,
|
||||
}
|
||||
# count outgoing payments if they are still pending
|
||||
where = [
|
||||
f"""
|
||||
where = [f"""
|
||||
wallet_id = :wallet_id AND (
|
||||
status = '{PaymentState.SUCCESS}'
|
||||
OR (amount < 0 AND status = '{PaymentState.PENDING}')
|
||||
)
|
||||
"""
|
||||
]
|
||||
"""]
|
||||
clause = filters.where(where)
|
||||
transactions: list[dict] = await db.fetchall(
|
||||
# This query is safe from SQL injection:
|
||||
@@ -349,13 +345,13 @@ async def get_payments_history(
|
||||
filters.values(values),
|
||||
)
|
||||
if wallet_id:
|
||||
wallet = await get_wallet(wallet_id)
|
||||
if wallet:
|
||||
balance = wallet.balance_msat
|
||||
else:
|
||||
raise ValueError("Unknown wallet")
|
||||
wallet = await get_wallet(wallet_id, conn=conn)
|
||||
if not wallet or not wallet.can_view_payments:
|
||||
return []
|
||||
balance = wallet.balance_msat
|
||||
values["wallet_id"] = wallet.source_wallet_id
|
||||
else:
|
||||
balance = await get_total_balance()
|
||||
balance = await get_total_balance(conn=conn)
|
||||
|
||||
# since we dont know the balance at the starting point,
|
||||
# we take the current balance and walk backwards
|
||||
@@ -380,7 +376,6 @@ async def get_payment_count_stats(
|
||||
user_id: str | None = None,
|
||||
conn: Connection | None = None,
|
||||
) -> list[PaymentCountStat]:
|
||||
|
||||
if not filters:
|
||||
filters = Filters()
|
||||
extra_stmts = []
|
||||
@@ -413,7 +408,6 @@ async def get_daily_stats(
|
||||
user_id: str | None = None,
|
||||
conn: Connection | None = None,
|
||||
) -> tuple[list[PaymentDailyStats], list[PaymentDailyStats]]:
|
||||
|
||||
if not filters:
|
||||
filters = Filters()
|
||||
|
||||
@@ -463,7 +457,6 @@ async def get_wallets_stats(
|
||||
user_id: str | None = None,
|
||||
conn: Connection | None = None,
|
||||
) -> list[PaymentWalletStats]:
|
||||
|
||||
if not filters:
|
||||
filters = Filters()
|
||||
|
||||
|
||||
@@ -8,11 +8,18 @@ from lnbits.db import dict_to_model
|
||||
from lnbits.settings import (
|
||||
AdminSettings,
|
||||
EditableSettings,
|
||||
FundingSourcesSettings,
|
||||
SettingsField,
|
||||
SuperSettings,
|
||||
settings,
|
||||
)
|
||||
|
||||
RESET_PRESERVED_SETTINGS = (
|
||||
"lnbits_webpush_pubkey",
|
||||
"lnbits_webpush_privkey",
|
||||
*FundingSourcesSettings.__fields__,
|
||||
)
|
||||
|
||||
|
||||
async def get_super_settings() -> SuperSettings | None:
|
||||
data = await get_settings_by_tag("core")
|
||||
@@ -69,16 +76,14 @@ async def delete_admin_settings(tag: str | None = "core") -> None:
|
||||
|
||||
|
||||
async def reset_core_settings() -> None:
|
||||
await db.execute(
|
||||
"""
|
||||
DELETE FROM system_settings WHERE tag = 'core'
|
||||
AND id NOT IN (
|
||||
'super_user',
|
||||
'lnbits_webpush_pubkey',
|
||||
'lnbits_webpush_privkey'
|
||||
)
|
||||
""",
|
||||
)
|
||||
core_settings = await get_settings_by_tag("core") or {}
|
||||
super_user = await get_settings_field("super_user")
|
||||
await delete_admin_settings()
|
||||
if super_user:
|
||||
await set_settings_field("super_user", super_user.value)
|
||||
for field in RESET_PRESERVED_SETTINGS:
|
||||
if field in core_settings:
|
||||
await set_settings_field(field, core_settings[field])
|
||||
|
||||
|
||||
async def create_admin_settings(super_user: str, new_settings: dict) -> SuperSettings:
|
||||
@@ -105,7 +110,8 @@ async def get_settings_field(
|
||||
)
|
||||
if not row:
|
||||
return None
|
||||
return SettingsField(id=row["id"], value=json.loads(row["value"]), tag=row["tag"])
|
||||
value = json.loads(row["value"]) if row["value"] else None
|
||||
return SettingsField(id=row["id"], value=value, tag=row["tag"])
|
||||
|
||||
|
||||
async def set_settings_field(id_: str, value: Any | None, tag: str | None = "core"):
|
||||
|
||||
+122
-40
@@ -4,10 +4,17 @@ from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from lnbits.core.crud.extensions import get_user_active_extensions_ids
|
||||
from lnbits.core.crud.wallets import get_wallets
|
||||
from lnbits.core.crud.wallets import (
|
||||
clear_wallet_cache,
|
||||
create_wallet,
|
||||
get_standalone_wallet,
|
||||
get_wallets,
|
||||
)
|
||||
from lnbits.core.db import db
|
||||
from lnbits.core.models import UserAcls
|
||||
from lnbits.db import Connection, Filters, Page
|
||||
from lnbits.helpers import sha256s
|
||||
from lnbits.utils.cache import cache
|
||||
|
||||
from ..models import (
|
||||
Account,
|
||||
@@ -30,9 +37,16 @@ async def create_account(
|
||||
return account
|
||||
|
||||
|
||||
async def update_account(account: Account) -> Account:
|
||||
async def get_accounts_count(conn: Connection | None = None) -> int:
|
||||
row: dict | None = await (conn or db).fetchone(
|
||||
"SELECT COUNT(*) as count FROM accounts"
|
||||
)
|
||||
return int(row["count"]) if row else 0
|
||||
|
||||
|
||||
async def update_account(account: Account, conn: Connection | None = None) -> Account:
|
||||
account.updated_at = datetime.now(timezone.utc)
|
||||
await db.update("accounts", account)
|
||||
await (conn or db).update("accounts", account)
|
||||
return account
|
||||
|
||||
|
||||
@@ -41,6 +55,7 @@ async def delete_account(user_id: str, conn: Connection | None = None) -> None:
|
||||
"DELETE from accounts WHERE id = :user",
|
||||
{"user": user_id},
|
||||
)
|
||||
await clear_user_id_cache(user_id)
|
||||
|
||||
|
||||
async def get_accounts(
|
||||
@@ -49,17 +64,22 @@ async def get_accounts(
|
||||
) -> Page[AccountOverview]:
|
||||
where_clauses = []
|
||||
values: dict[str, Any] = {}
|
||||
filters = filters or Filters()
|
||||
|
||||
# Make wallet filter explicit
|
||||
wallet_filter = (
|
||||
next((f for f in filters.filters if f.field == "wallet_id"), None)
|
||||
if filters
|
||||
else None
|
||||
)
|
||||
if filters and wallet_filter and wallet_filter.values:
|
||||
where_clauses.append("wallets.id = :wallet_id")
|
||||
values = {**values, "wallet_id": next(iter(wallet_filter.values.values()))}
|
||||
filters.filters = [f for f in filters.filters if f.field != "wallet_id"]
|
||||
wallet_filter = filters.get_filter_by_field("wallet_id")
|
||||
|
||||
if wallet_filter and wallet_filter.values:
|
||||
wallet_id_value = next(iter(wallet_filter.values.values()), None)
|
||||
wallet = (
|
||||
await get_standalone_wallet(wallet_id_value, deleted=None, conn=conn)
|
||||
if wallet_id_value
|
||||
else None
|
||||
)
|
||||
if not wallet:
|
||||
return Page(data=[], total=0)
|
||||
where_clauses.append("accounts.id = :account_id")
|
||||
values = {**values, "account_id": wallet.user}
|
||||
filters.remove_filter_by_field("wallet_id")
|
||||
|
||||
return await (conn or db).fetch_page(
|
||||
"""
|
||||
@@ -69,6 +89,7 @@ async def get_accounts(
|
||||
accounts.email,
|
||||
accounts.pubkey,
|
||||
accounts.external_id,
|
||||
accounts.activated,
|
||||
SUM(COALESCE((
|
||||
SELECT balance FROM balances WHERE wallet_id = wallets.id
|
||||
), 0)) as balance_msat,
|
||||
@@ -89,15 +110,22 @@ async def get_accounts(
|
||||
filters=filters,
|
||||
model=AccountOverview,
|
||||
group_by=["accounts.id"],
|
||||
table_name="accounts",
|
||||
)
|
||||
|
||||
|
||||
async def get_account(user_id: str, conn: Connection | None = None) -> Account | None:
|
||||
async def get_account(
|
||||
user_id: str, active_only: bool = True, conn: Connection | None = None
|
||||
) -> Account | None:
|
||||
if len(user_id) == 0:
|
||||
return None
|
||||
|
||||
return await (conn or db).fetchone(
|
||||
"SELECT * FROM accounts WHERE id = :id",
|
||||
{"id": user_id},
|
||||
"""
|
||||
SELECT * FROM accounts
|
||||
WHERE id = :id AND (activated = true OR activated = :activated)
|
||||
""",
|
||||
{"id": user_id, "activated": active_only},
|
||||
Account,
|
||||
)
|
||||
|
||||
@@ -123,66 +151,98 @@ async def delete_accounts_no_wallets(
|
||||
|
||||
|
||||
async def get_account_by_username(
|
||||
username: str, conn: Connection | None = None
|
||||
username: str, active_only: bool = True, conn: Connection | None = None
|
||||
) -> Account | None:
|
||||
if len(username) == 0:
|
||||
return None
|
||||
|
||||
return await (conn or db).fetchone(
|
||||
"SELECT * FROM accounts WHERE LOWER(username) = :username",
|
||||
{"username": username.lower()},
|
||||
"""
|
||||
SELECT * FROM accounts
|
||||
WHERE
|
||||
LOWER(username) = :username
|
||||
AND (activated = true OR activated = :activated)
|
||||
""",
|
||||
{"username": username.lower(), "activated": active_only},
|
||||
Account,
|
||||
)
|
||||
|
||||
|
||||
async def get_account_by_pubkey(
|
||||
pubkey: str, conn: Connection | None = None
|
||||
pubkey: str, active_only: bool = True, conn: Connection | None = None
|
||||
) -> Account | None:
|
||||
return await (conn or db).fetchone(
|
||||
"SELECT * FROM accounts WHERE LOWER(pubkey) = :pubkey",
|
||||
{"pubkey": pubkey.lower()},
|
||||
"""
|
||||
SELECT * FROM accounts
|
||||
WHERE
|
||||
LOWER(pubkey) = :pubkey
|
||||
AND (activated = true OR activated = :activated)
|
||||
""",
|
||||
{"pubkey": pubkey.lower(), "activated": active_only},
|
||||
Account,
|
||||
)
|
||||
|
||||
|
||||
async def get_account_by_email(
|
||||
email: str, conn: Connection | None = None
|
||||
email: str, active_only: bool = True, conn: Connection | None = None
|
||||
) -> Account | None:
|
||||
if len(email) == 0:
|
||||
return None
|
||||
|
||||
return await (conn or db).fetchone(
|
||||
"SELECT * FROM accounts WHERE LOWER(email) = :email",
|
||||
{"email": email.lower()},
|
||||
"""
|
||||
SELECT * FROM accounts
|
||||
WHERE
|
||||
LOWER(email) = :email
|
||||
AND (activated = true OR activated = :activated)
|
||||
""",
|
||||
{"email": email.lower(), "activated": active_only},
|
||||
Account,
|
||||
)
|
||||
|
||||
|
||||
async def get_account_by_username_or_email(
|
||||
username_or_email: str, conn: Connection | None = None
|
||||
username_or_email: str,
|
||||
active_only: bool = True,
|
||||
conn: Connection | None = None,
|
||||
) -> Account | None:
|
||||
|
||||
return await (conn or db).fetchone(
|
||||
"""
|
||||
SELECT * FROM accounts
|
||||
WHERE LOWER(email) = :value or LOWER(username) = :value
|
||||
WHERE
|
||||
(LOWER(email) = :value or LOWER(username) = :value)
|
||||
AND (activated = true OR activated = :activated)
|
||||
""",
|
||||
{"value": username_or_email.lower()},
|
||||
{"value": username_or_email.lower(), "activated": active_only},
|
||||
Account,
|
||||
)
|
||||
|
||||
|
||||
async def get_user(user_id: str, conn: Connection | None = None) -> User | None:
|
||||
account = await get_account(user_id, conn)
|
||||
if not account:
|
||||
return None
|
||||
return await get_user_from_account(account, conn)
|
||||
async def get_user(
|
||||
user_id: str, active_only: bool = True, conn: Connection | None = None
|
||||
) -> User | None:
|
||||
async with db.reuse_conn(conn) if conn else db.connect() as conn:
|
||||
account = await get_account(user_id, active_only, conn=conn)
|
||||
if not account:
|
||||
return None
|
||||
return await get_user_from_account(account, conn=conn)
|
||||
|
||||
|
||||
async def get_user_from_account(
|
||||
account: Account, conn: Connection | None = None
|
||||
) -> User | None:
|
||||
extensions = await get_user_active_extensions_ids(account.id, conn)
|
||||
wallets = await get_wallets(account.id, False, conn=conn)
|
||||
async with db.reuse_conn(conn) if conn else db.connect() as conn:
|
||||
extensions = await get_user_active_extensions_ids(account.id, conn=conn)
|
||||
wallets = await get_wallets(account.id, deleted=False, conn=conn)
|
||||
|
||||
if len(wallets) == 0:
|
||||
wallet = await create_wallet(user_id=account.id, conn=conn)
|
||||
wallets.append(wallet)
|
||||
|
||||
return User(
|
||||
id=account.id,
|
||||
activated=account.activated,
|
||||
email=account.email,
|
||||
username=account.username,
|
||||
pubkey=account.pubkey,
|
||||
@@ -196,21 +256,43 @@ async def get_user_from_account(
|
||||
super_user=account.is_super_user,
|
||||
fiat_providers=account.fiat_providers,
|
||||
has_password=account.password_hash is not None,
|
||||
ui_customization=account.ui_customization or {},
|
||||
)
|
||||
|
||||
|
||||
async def update_user_access_control_list(user_acls: UserAcls):
|
||||
async def update_user_access_control_list(
|
||||
user_acls: UserAcls, conn: Connection | None = None
|
||||
):
|
||||
user_acls.updated_at = datetime.now(timezone.utc)
|
||||
await db.update("accounts", user_acls)
|
||||
await (conn or db).update("accounts", user_acls)
|
||||
|
||||
|
||||
async def get_user_access_control_lists(
|
||||
user_id: str, conn: Connection | None = None
|
||||
user_id: str, active_only: bool = True, conn: Connection | None = None
|
||||
) -> UserAcls:
|
||||
user_acls = await (conn or db).fetchone(
|
||||
"SELECT id, access_control_list FROM accounts WHERE id = :id",
|
||||
{"id": user_id},
|
||||
"""
|
||||
SELECT id, access_control_list FROM accounts
|
||||
WHERE id = :user_id AND (activated = true OR activated = :activated)
|
||||
""",
|
||||
{"user_id": user_id, "activated": active_only},
|
||||
UserAcls,
|
||||
)
|
||||
|
||||
return user_acls or UserAcls(id=user_id)
|
||||
|
||||
|
||||
async def clear_user_id_cache(user_id: str):
|
||||
user = await get_user(user_id, active_only=True)
|
||||
if user:
|
||||
clear_user_cache(user)
|
||||
|
||||
|
||||
def clear_user_cache(user: User):
|
||||
user_cache_key: str | None = cache.pop(
|
||||
f"auth:user:cache_key:{sha256s(user.id)}", None
|
||||
)
|
||||
if user_cache_key:
|
||||
cache.pop(user_cache_key)
|
||||
for wallet in user.wallets:
|
||||
clear_wallet_cache(wallet)
|
||||
|
||||
+125
-16
@@ -3,9 +3,10 @@ from time import time
|
||||
from uuid import uuid4
|
||||
|
||||
from lnbits.core.db import db
|
||||
from lnbits.core.models.wallets import WalletsFilters
|
||||
from lnbits.core.models.wallets import BaseWallet, WalletsFilters, WalletType
|
||||
from lnbits.db import Connection, Filters, Page
|
||||
from lnbits.settings import settings
|
||||
from lnbits.utils.cache import cache
|
||||
|
||||
from ..models import Wallet
|
||||
|
||||
@@ -14,17 +15,22 @@ async def create_wallet(
|
||||
*,
|
||||
user_id: str,
|
||||
wallet_name: str | None = None,
|
||||
wallet_type: WalletType = WalletType.LIGHTNING,
|
||||
shared_wallet_id: str | None = None,
|
||||
conn: Connection | None = None,
|
||||
) -> Wallet:
|
||||
wallet_id = uuid4().hex
|
||||
wallet = Wallet(
|
||||
id=wallet_id,
|
||||
name=wallet_name or settings.lnbits_default_wallet_name,
|
||||
wallet_type=wallet_type.value,
|
||||
shared_wallet_id=shared_wallet_id,
|
||||
user=user_id,
|
||||
adminkey=uuid4().hex,
|
||||
inkey=uuid4().hex,
|
||||
currency=settings.lnbits_default_accounting_currency or "USD",
|
||||
)
|
||||
|
||||
await (conn or db).insert("wallets", wallet)
|
||||
return wallet
|
||||
|
||||
@@ -39,13 +45,14 @@ async def update_wallet(
|
||||
|
||||
|
||||
async def delete_wallet(
|
||||
*,
|
||||
user_id: str,
|
||||
wallet_id: str,
|
||||
deleted: bool = True,
|
||||
conn: Connection | None = None,
|
||||
) -> None:
|
||||
clear_wallet_id_cache(wallet_id)
|
||||
now = int(time())
|
||||
|
||||
await (conn or db).execute(
|
||||
# Timestamp placeholder is safe from SQL injection (not user input)
|
||||
f"""
|
||||
@@ -58,6 +65,7 @@ async def delete_wallet(
|
||||
|
||||
|
||||
async def force_delete_wallet(wallet_id: str, conn: Connection | None = None) -> None:
|
||||
clear_wallet_id_cache(wallet_id)
|
||||
await (conn or db).execute(
|
||||
"DELETE FROM wallets WHERE id = :wallet",
|
||||
{"wallet": wallet_id},
|
||||
@@ -67,6 +75,7 @@ async def force_delete_wallet(wallet_id: str, conn: Connection | None = None) ->
|
||||
async def delete_wallet_by_id(
|
||||
wallet_id: str, conn: Connection | None = None
|
||||
) -> int | None:
|
||||
clear_wallet_id_cache(wallet_id)
|
||||
now = int(time())
|
||||
result = await (conn or db).execute(
|
||||
# Timestamp placeholder is safe from SQL injection (not user input)
|
||||
@@ -103,8 +112,8 @@ async def delete_unused_wallets(
|
||||
)
|
||||
|
||||
|
||||
async def get_wallet(
|
||||
wallet_id: str, deleted: bool | None = None, conn: Connection | None = None
|
||||
async def get_standalone_wallet(
|
||||
wallet_id: str, deleted: bool | None = False, conn: Connection | None = None
|
||||
) -> Wallet | None:
|
||||
query = """
|
||||
SELECT *, COALESCE((
|
||||
@@ -121,8 +130,23 @@ async def get_wallet(
|
||||
)
|
||||
|
||||
|
||||
async def get_wallet(
|
||||
wallet_id: str, deleted: bool | None = False, conn: Connection | None = None
|
||||
) -> Wallet | None:
|
||||
wallet = await get_standalone_wallet(wallet_id, deleted, conn)
|
||||
if not wallet:
|
||||
return None
|
||||
if wallet.is_lightning_shared_wallet:
|
||||
return await get_source_wallet(wallet, conn)
|
||||
|
||||
return wallet
|
||||
|
||||
|
||||
async def get_wallets(
|
||||
user_id: str, deleted: bool | None = None, conn: Connection | None = None
|
||||
user_id: str,
|
||||
deleted: bool | None = False,
|
||||
wallet_type: WalletType | None = None,
|
||||
conn: Connection | None = None,
|
||||
) -> list[Wallet]:
|
||||
query = """
|
||||
SELECT *, COALESCE((
|
||||
@@ -132,12 +156,20 @@ async def get_wallets(
|
||||
"""
|
||||
if deleted is not None:
|
||||
query += " AND deleted = :deleted "
|
||||
return await (conn or db).fetchall(
|
||||
if wallet_type is not None:
|
||||
query += " AND wallet_type = :wallet_type "
|
||||
wallets = await (conn or db).fetchall(
|
||||
query,
|
||||
{"user": user_id, "deleted": deleted},
|
||||
{
|
||||
"user": user_id,
|
||||
"deleted": deleted,
|
||||
"wallet_type": wallet_type.value if wallet_type else None,
|
||||
},
|
||||
Wallet,
|
||||
)
|
||||
|
||||
return await get_source_wallets(wallets, conn)
|
||||
|
||||
|
||||
async def get_wallets_paginated(
|
||||
user_id: str,
|
||||
@@ -149,7 +181,7 @@ async def get_wallets_paginated(
|
||||
deleted = False
|
||||
|
||||
where: list[str] = [""" "user" = :user AND deleted = :deleted """]
|
||||
return await (conn or db).fetch_page(
|
||||
wallets = await (conn or db).fetch_page(
|
||||
"""
|
||||
SELECT *, COALESCE((
|
||||
SELECT balance FROM balances WHERE wallet_id = wallets.id
|
||||
@@ -159,20 +191,27 @@ async def get_wallets_paginated(
|
||||
values={"user": user_id, "deleted": deleted},
|
||||
filters=filters,
|
||||
model=Wallet,
|
||||
table_name="wallets",
|
||||
)
|
||||
|
||||
wallets.data = await get_source_wallets(wallets.data, conn)
|
||||
return wallets
|
||||
|
||||
|
||||
async def get_wallets_ids(
|
||||
user_id: str, deleted: bool | None = None, conn: Connection | None = None
|
||||
user_id: str, deleted: bool | None = False, conn: Connection | None = None
|
||||
) -> list[str]:
|
||||
query = """SELECT id FROM wallets WHERE "user" = :user"""
|
||||
query = """SELECT * FROM wallets WHERE "user" = :user"""
|
||||
if deleted is not None:
|
||||
query += "AND deleted = :deleted"
|
||||
result: list[dict] = await (conn or db).fetchall(
|
||||
query += " AND deleted = :deleted "
|
||||
wallets = await (conn or db).fetchall(
|
||||
query,
|
||||
{"user": user_id, "deleted": deleted},
|
||||
Wallet,
|
||||
)
|
||||
return [row["id"] for row in result]
|
||||
|
||||
wallets = await get_source_wallets(wallets, conn)
|
||||
return [w.source_wallet_id for w in wallets if w.can_view_payments]
|
||||
|
||||
|
||||
async def get_wallets_count():
|
||||
@@ -185,20 +224,90 @@ async def get_wallet_for_key(
|
||||
key: str,
|
||||
conn: Connection | None = None,
|
||||
) -> Wallet | None:
|
||||
return await (conn or db).fetchone(
|
||||
wallet = await (conn or db).fetchone(
|
||||
"""
|
||||
SELECT *, COALESCE((
|
||||
SELECT wallets.*, COALESCE((
|
||||
SELECT balance FROM balances WHERE wallet_id = wallets.id
|
||||
), 0)
|
||||
AS balance_msat FROM wallets
|
||||
WHERE (adminkey = :key OR inkey = :key) AND deleted = false
|
||||
INNER JOIN accounts ON wallets.user = accounts.id
|
||||
WHERE (adminkey = :key OR inkey = :key)
|
||||
AND deleted = false
|
||||
AND accounts.activated = true
|
||||
""",
|
||||
{"key": key},
|
||||
Wallet,
|
||||
)
|
||||
if not wallet:
|
||||
return None
|
||||
|
||||
if wallet.is_lightning_shared_wallet:
|
||||
mw = await get_source_wallet(wallet, conn)
|
||||
return mw
|
||||
return wallet
|
||||
|
||||
|
||||
async def get_base_wallet_for_key(
|
||||
key: str,
|
||||
conn: Connection | None = None,
|
||||
) -> BaseWallet | None:
|
||||
wallet = await (conn or db).fetchone(
|
||||
"""
|
||||
SELECT wallets.id, "user", wallet_type, adminkey, inkey FROM wallets
|
||||
INNER JOIN accounts ON wallets.user = accounts.id
|
||||
WHERE (adminkey = :key OR inkey = :key)
|
||||
AND deleted = false
|
||||
AND accounts.activated = true
|
||||
""",
|
||||
{"key": key},
|
||||
BaseWallet,
|
||||
)
|
||||
if not wallet:
|
||||
return None
|
||||
|
||||
return wallet
|
||||
|
||||
|
||||
async def get_source_wallet(
|
||||
wallet: Wallet, conn: Connection | None = None
|
||||
) -> Wallet | None:
|
||||
if not wallet.is_lightning_shared_wallet:
|
||||
return wallet
|
||||
if not wallet.shared_wallet_id:
|
||||
return None
|
||||
|
||||
shared_wallet = await get_standalone_wallet(wallet.shared_wallet_id, False, conn)
|
||||
if not shared_wallet:
|
||||
return None
|
||||
wallet.mirror_shared_wallet(shared_wallet)
|
||||
return wallet
|
||||
|
||||
|
||||
async def get_source_wallets(
|
||||
wallet: list[Wallet], conn: Connection | None = None
|
||||
) -> list[Wallet]:
|
||||
source_wallets = []
|
||||
for w in wallet:
|
||||
source_wallet = await get_source_wallet(w, conn)
|
||||
if source_wallet:
|
||||
source_wallets.append(source_wallet)
|
||||
return source_wallets
|
||||
|
||||
|
||||
async def get_total_balance(conn: Connection | None = None):
|
||||
result = await (conn or db).execute("SELECT SUM(balance) as balance FROM balances")
|
||||
row = result.mappings().first()
|
||||
return row.get("balance", 0) or 0
|
||||
|
||||
|
||||
def clear_wallet_id_cache(wallet_id: str):
|
||||
cached_wallet: BaseWallet | None = cache.pop(f"auth:wallet:{wallet_id}")
|
||||
if cached_wallet:
|
||||
cache.pop(f"auth:x-api-key:{cached_wallet.adminkey}")
|
||||
cache.pop(f"auth:x-api-key:{cached_wallet.inkey}")
|
||||
|
||||
|
||||
def clear_wallet_cache(wallet: Wallet):
|
||||
cache.pop(f"auth:wallet:{wallet.id}")
|
||||
cache.pop(f"auth:x-api-key:{wallet.adminkey}")
|
||||
cache.pop(f"auth:x-api-key:{wallet.inkey}")
|
||||
|
||||
@@ -15,6 +15,8 @@ from lnbits.core.crud import (
|
||||
from lnbits.core.db import db as core_db
|
||||
from lnbits.core.models import DbVersion
|
||||
from lnbits.core.models.extensions import InstallableExtension
|
||||
from lnbits.core.wasm_ext.storage.crud import migrate_wasm_extension_database
|
||||
from lnbits.core.wasm_ext.wasm.loader import is_wasm_extension_id
|
||||
from lnbits.db import COCKROACH, POSTGRES, SQLITE, Connection
|
||||
from lnbits.settings import settings
|
||||
|
||||
@@ -22,7 +24,16 @@ from lnbits.settings import settings
|
||||
async def migrate_extension_database(
|
||||
ext: InstallableExtension, current_version: DbVersion | None = None
|
||||
):
|
||||
if is_wasm_extension_id(ext.id):
|
||||
await migrate_wasm_extension_database(ext, current_version)
|
||||
return
|
||||
else:
|
||||
await migrate_py_extension_database(ext, current_version)
|
||||
|
||||
|
||||
async def migrate_py_extension_database(
|
||||
ext: InstallableExtension, current_version: DbVersion | None = None
|
||||
):
|
||||
try:
|
||||
ext_migrations = importlib.import_module(f"{ext.module_name}.migrations")
|
||||
ext_db = importlib.import_module(ext.module_name).db
|
||||
|
||||
+242
-95
@@ -10,31 +10,26 @@ from lnbits.db import Connection
|
||||
|
||||
|
||||
async def m000_create_migrations_table(db: Connection):
|
||||
await db.execute(
|
||||
"""
|
||||
await db.execute("""
|
||||
CREATE TABLE IF NOT EXISTS dbversions (
|
||||
db TEXT PRIMARY KEY,
|
||||
version INT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
async def m001_initial(db: Connection):
|
||||
"""
|
||||
Initial LNbits tables.
|
||||
"""
|
||||
await db.execute(
|
||||
"""
|
||||
await db.execute("""
|
||||
CREATE TABLE IF NOT EXISTS accounts (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT,
|
||||
pass TEXT
|
||||
);
|
||||
"""
|
||||
)
|
||||
await db.execute(
|
||||
"""
|
||||
""")
|
||||
await db.execute("""
|
||||
CREATE TABLE IF NOT EXISTS extensions (
|
||||
"user" TEXT NOT NULL,
|
||||
extension TEXT NOT NULL,
|
||||
@@ -42,10 +37,8 @@ async def m001_initial(db: Connection):
|
||||
|
||||
UNIQUE ("user", extension)
|
||||
);
|
||||
"""
|
||||
)
|
||||
await db.execute(
|
||||
"""
|
||||
""")
|
||||
await db.execute("""
|
||||
CREATE TABLE IF NOT EXISTS wallets (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
@@ -53,10 +46,8 @@ async def m001_initial(db: Connection):
|
||||
adminkey TEXT NOT NULL,
|
||||
inkey TEXT
|
||||
);
|
||||
"""
|
||||
)
|
||||
await db.execute(
|
||||
f"""
|
||||
""")
|
||||
await db.execute(f"""
|
||||
CREATE TABLE IF NOT EXISTS apipayments (
|
||||
payhash TEXT NOT NULL,
|
||||
amount {db.big_int} NOT NULL,
|
||||
@@ -67,11 +58,9 @@ async def m001_initial(db: Connection):
|
||||
time TIMESTAMP NOT NULL DEFAULT {db.timestamp_now},
|
||||
UNIQUE (wallet, payhash)
|
||||
);
|
||||
"""
|
||||
)
|
||||
""")
|
||||
|
||||
await db.execute(
|
||||
"""
|
||||
await db.execute("""
|
||||
CREATE VIEW balances AS
|
||||
SELECT wallet, COALESCE(SUM(s), 0) AS balance FROM (
|
||||
SELECT wallet, SUM(amount) AS s -- incoming
|
||||
@@ -85,8 +74,7 @@ async def m001_initial(db: Connection):
|
||||
GROUP BY wallet
|
||||
)x
|
||||
GROUP BY wallet;
|
||||
"""
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
async def m002_add_fields_to_apipayments(db: Connection):
|
||||
@@ -149,8 +137,7 @@ async def m004_ensure_fees_are_always_negative(db: Connection):
|
||||
"""
|
||||
|
||||
await db.execute("DROP VIEW balances")
|
||||
await db.execute(
|
||||
"""
|
||||
await db.execute("""
|
||||
CREATE VIEW balances AS
|
||||
SELECT wallet, COALESCE(SUM(s), 0) AS balance FROM (
|
||||
SELECT wallet, SUM(amount) AS s -- incoming
|
||||
@@ -164,8 +151,7 @@ async def m004_ensure_fees_are_always_negative(db: Connection):
|
||||
GROUP BY wallet
|
||||
)x
|
||||
GROUP BY wallet;
|
||||
"""
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
async def m005_balance_check_balance_notify(db: Connection):
|
||||
@@ -174,8 +160,7 @@ async def m005_balance_check_balance_notify(db: Connection):
|
||||
LNbits wallet and of balanceNotify URLs supplied by users to empty their wallets.
|
||||
"""
|
||||
|
||||
await db.execute(
|
||||
"""
|
||||
await db.execute("""
|
||||
CREATE TABLE IF NOT EXISTS balance_check (
|
||||
wallet TEXT NOT NULL REFERENCES wallets (id),
|
||||
service TEXT NOT NULL,
|
||||
@@ -183,19 +168,16 @@ async def m005_balance_check_balance_notify(db: Connection):
|
||||
|
||||
UNIQUE(wallet, service)
|
||||
);
|
||||
"""
|
||||
)
|
||||
""")
|
||||
|
||||
await db.execute(
|
||||
"""
|
||||
await db.execute("""
|
||||
CREATE TABLE IF NOT EXISTS balance_notify (
|
||||
wallet TEXT NOT NULL REFERENCES wallets (id),
|
||||
url TEXT NOT NULL,
|
||||
|
||||
UNIQUE(wallet, url)
|
||||
);
|
||||
"""
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
async def m006_add_invoice_expiry_to_apipayments(db: Connection):
|
||||
@@ -262,19 +244,16 @@ async def m007_set_invoice_expiries(db: Connection):
|
||||
|
||||
|
||||
async def m008_create_admin_settings_table(db: Connection):
|
||||
await db.execute(
|
||||
"""
|
||||
await db.execute("""
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
super_user TEXT,
|
||||
editable_settings TEXT NOT NULL DEFAULT '{}'
|
||||
);
|
||||
"""
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
async def m009_create_tinyurl_table(db: Connection):
|
||||
await db.execute(
|
||||
f"""
|
||||
await db.execute(f"""
|
||||
CREATE TABLE IF NOT EXISTS tiny_url (
|
||||
id TEXT PRIMARY KEY,
|
||||
url TEXT,
|
||||
@@ -282,13 +261,11 @@ async def m009_create_tinyurl_table(db: Connection):
|
||||
wallet TEXT,
|
||||
time TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
|
||||
);
|
||||
"""
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
async def m010_create_installed_extensions_table(db: Connection):
|
||||
await db.execute(
|
||||
"""
|
||||
await db.execute("""
|
||||
CREATE TABLE IF NOT EXISTS installed_extensions (
|
||||
id TEXT PRIMARY KEY,
|
||||
version TEXT NOT NULL,
|
||||
@@ -299,8 +276,7 @@ async def m010_create_installed_extensions_table(db: Connection):
|
||||
active BOOLEAN DEFAULT false,
|
||||
meta TEXT NOT NULL DEFAULT '{}'
|
||||
);
|
||||
"""
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
async def m011_optimize_balances_view(db: Connection):
|
||||
@@ -309,23 +285,19 @@ async def m011_optimize_balances_view(db: Connection):
|
||||
over the payments table instead of 2.
|
||||
"""
|
||||
await db.execute("DROP VIEW balances")
|
||||
await db.execute(
|
||||
"""
|
||||
await db.execute("""
|
||||
CREATE VIEW balances AS
|
||||
SELECT wallet, SUM(amount - abs(fee)) AS balance
|
||||
FROM apipayments
|
||||
WHERE (pending = false AND amount > 0) OR amount < 0
|
||||
GROUP BY wallet
|
||||
"""
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
async def m012_add_currency_to_wallet(db: Connection):
|
||||
await db.execute(
|
||||
"""
|
||||
await db.execute("""
|
||||
ALTER TABLE wallets ADD COLUMN currency TEXT
|
||||
"""
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
async def m013_add_deleted_to_wallets(db: Connection):
|
||||
@@ -345,15 +317,13 @@ async def m014_set_deleted_wallets(db: Connection):
|
||||
Sets deleted column to wallets.
|
||||
"""
|
||||
try:
|
||||
result = await db.execute(
|
||||
"""
|
||||
result = await db.execute("""
|
||||
SELECT *
|
||||
FROM wallets
|
||||
WHERE user LIKE 'del:%'
|
||||
AND adminkey LIKE 'del:%'
|
||||
AND inkey LIKE 'del:%'
|
||||
"""
|
||||
)
|
||||
""")
|
||||
rows = result.mappings().all()
|
||||
|
||||
for row in rows:
|
||||
@@ -386,8 +356,7 @@ async def m014_set_deleted_wallets(db: Connection):
|
||||
|
||||
|
||||
async def m015_create_push_notification_subscriptions_table(db: Connection):
|
||||
await db.execute(
|
||||
f"""
|
||||
await db.execute(f"""
|
||||
CREATE TABLE IF NOT EXISTS webpush_subscriptions (
|
||||
endpoint TEXT NOT NULL,
|
||||
"user" TEXT NOT NULL,
|
||||
@@ -396,8 +365,7 @@ async def m015_create_push_notification_subscriptions_table(db: Connection):
|
||||
timestamp TIMESTAMP NOT NULL DEFAULT {db.timestamp_now},
|
||||
PRIMARY KEY (endpoint, "user")
|
||||
);
|
||||
"""
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
async def m016_add_username_column_to_accounts(db: Connection):
|
||||
@@ -484,8 +452,7 @@ async def m018_balances_view_exclude_deleted(db: Connection):
|
||||
Make deleted wallets not show up in the balances view.
|
||||
"""
|
||||
await db.execute("DROP VIEW balances")
|
||||
await db.execute(
|
||||
"""
|
||||
await db.execute("""
|
||||
CREATE VIEW balances AS
|
||||
SELECT apipayments.wallet,
|
||||
SUM(apipayments.amount - ABS(apipayments.fee)) AS balance
|
||||
@@ -495,8 +462,7 @@ async def m018_balances_view_exclude_deleted(db: Connection):
|
||||
AND ((apipayments.pending = false AND apipayments.amount > 0)
|
||||
OR apipayments.amount < 0)
|
||||
GROUP BY wallet
|
||||
"""
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
async def m019_balances_view_based_on_wallets(db: Connection):
|
||||
@@ -505,8 +471,7 @@ async def m019_balances_view_based_on_wallets(db: Connection):
|
||||
Important for querying whole lnbits balances.
|
||||
"""
|
||||
await db.execute("DROP VIEW balances")
|
||||
await db.execute(
|
||||
"""
|
||||
await db.execute("""
|
||||
CREATE VIEW balances AS
|
||||
SELECT apipayments.wallet,
|
||||
SUM(apipayments.amount - ABS(apipayments.fee)) AS balance
|
||||
@@ -516,8 +481,7 @@ async def m019_balances_view_based_on_wallets(db: Connection):
|
||||
AND ((apipayments.pending = false AND apipayments.amount > 0)
|
||||
OR apipayments.amount < 0)
|
||||
GROUP BY apipayments.wallet
|
||||
"""
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
async def m020_add_column_column_to_user_extensions(db: Connection):
|
||||
@@ -536,8 +500,7 @@ async def m021_add_success_failed_to_apipayments(db: Connection):
|
||||
await db.execute("UPDATE apipayments SET status = 'success' WHERE NOT pending")
|
||||
|
||||
await db.execute("DROP VIEW balances")
|
||||
await db.execute(
|
||||
"""
|
||||
await db.execute("""
|
||||
CREATE VIEW balances AS
|
||||
SELECT apipayments.wallet,
|
||||
SUM(apipayments.amount - ABS(apipayments.fee)) AS balance
|
||||
@@ -549,8 +512,7 @@ async def m021_add_success_failed_to_apipayments(db: Connection):
|
||||
OR (apipayments.status IN ('success', 'pending') AND apipayments.amount < 0)
|
||||
)
|
||||
GROUP BY apipayments.wallet
|
||||
"""
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
async def m022_add_pubkey_to_accounts(db: Connection):
|
||||
@@ -581,8 +543,7 @@ async def m024_drop_pending(db: Connection):
|
||||
|
||||
async def m025_refresh_view(db: Connection):
|
||||
await db.execute("DROP VIEW balances")
|
||||
await db.execute(
|
||||
"""
|
||||
await db.execute("""
|
||||
CREATE VIEW balances AS
|
||||
SELECT apipayments.wallet_id,
|
||||
SUM(apipayments.amount - ABS(apipayments.fee)) AS balance
|
||||
@@ -594,8 +555,7 @@ async def m025_refresh_view(db: Connection):
|
||||
OR (apipayments.status IN ('success', 'pending') AND apipayments.amount < 0)
|
||||
)
|
||||
GROUP BY apipayments.wallet_id
|
||||
"""
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
async def m026_update_payment_table(db: Connection):
|
||||
@@ -658,8 +618,7 @@ async def m027_update_apipayments_data(db: Connection):
|
||||
|
||||
async def m028_update_settings(db: Connection):
|
||||
|
||||
await db.execute(
|
||||
"""
|
||||
await db.execute("""
|
||||
CREATE TABLE IF NOT EXISTS system_settings (
|
||||
id TEXT PRIMARY KEY,
|
||||
value TEXT,
|
||||
@@ -667,8 +626,7 @@ async def m028_update_settings(db: Connection):
|
||||
|
||||
UNIQUE (id, tag)
|
||||
);
|
||||
"""
|
||||
)
|
||||
""")
|
||||
|
||||
async def _insert_key_value(id_: str, value: Any):
|
||||
await db.execute(
|
||||
@@ -691,8 +649,7 @@ async def m028_update_settings(db: Connection):
|
||||
|
||||
|
||||
async def m029_create_audit_table(db: Connection):
|
||||
await db.execute(
|
||||
f"""
|
||||
await db.execute(f"""
|
||||
CREATE TABLE IF NOT EXISTS audit (
|
||||
component TEXT,
|
||||
ip_address TEXT,
|
||||
@@ -706,16 +663,13 @@ async def m029_create_audit_table(db: Connection):
|
||||
delete_at TIMESTAMP,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
|
||||
);
|
||||
"""
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
async def m030_add_user_api_tokens_column(db: Connection):
|
||||
await db.execute(
|
||||
"""
|
||||
await db.execute("""
|
||||
ALTER TABLE accounts ADD COLUMN access_control_list TEXT
|
||||
"""
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
async def m031_add_color_and_icon_to_wallets(db: Connection):
|
||||
@@ -738,8 +692,201 @@ async def m033_update_payment_table(db: Connection):
|
||||
|
||||
|
||||
async def m034_add_stored_paylinks_to_wallet(db: Connection):
|
||||
await db.execute(
|
||||
"""
|
||||
await db.execute("""
|
||||
ALTER TABLE wallets ADD COLUMN stored_paylinks TEXT
|
||||
"""
|
||||
""")
|
||||
|
||||
|
||||
async def m035_add_wallet_type_column(db: Connection):
|
||||
await db.execute("""
|
||||
ALTER TABLE wallets ADD COLUMN wallet_type TEXT DEFAULT 'lightning'
|
||||
""")
|
||||
|
||||
|
||||
async def m036_add_shared_wallet_column(db: Connection):
|
||||
await db.execute("""
|
||||
ALTER TABLE wallets ADD COLUMN shared_wallet_id TEXT
|
||||
""")
|
||||
|
||||
|
||||
async def m037_create_assets_table(db: Connection):
|
||||
await db.execute(f"""
|
||||
CREATE TABLE IF NOT EXISTS assets (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
mime_type TEXT NOT NULL,
|
||||
is_public BOOLEAN NOT NULL DEFAULT false,
|
||||
name TEXT NOT NULL,
|
||||
size_bytes INT NOT NULL,
|
||||
thumbnail_base64 TEXT,
|
||||
thumbnail {db.blob},
|
||||
data {db.blob} NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
|
||||
);
|
||||
""")
|
||||
|
||||
|
||||
async def m038_add_labels_for_payments(db: Connection):
|
||||
await db.execute("""
|
||||
ALTER TABLE apipayments ADD COLUMN labels TEXT
|
||||
""")
|
||||
|
||||
|
||||
async def m039_index_payments(db: Connection):
|
||||
indexes = [
|
||||
"wallet_id",
|
||||
"checking_id",
|
||||
"payment_hash",
|
||||
"amount",
|
||||
"fee",
|
||||
"labels",
|
||||
"time",
|
||||
"status",
|
||||
"memo",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
for index in indexes:
|
||||
logger.debug(f"Creating index idx_payments_{index}...")
|
||||
await db.execute(f"""
|
||||
CREATE INDEX IF NOT EXISTS idx_payments_{index} ON apipayments ({index});
|
||||
""")
|
||||
|
||||
|
||||
async def m040_index_wallets(db: Connection):
|
||||
indexes = [
|
||||
"id",
|
||||
"user",
|
||||
"deleted",
|
||||
"adminkey",
|
||||
"inkey",
|
||||
"wallet_type",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
for index in indexes:
|
||||
logger.debug(f"Creating index idx_wallets_{index}...")
|
||||
await db.execute(f"""
|
||||
CREATE INDEX IF NOT EXISTS idx_wallets_{index} ON wallets ("{index}");
|
||||
""")
|
||||
|
||||
|
||||
async def m042_index_accounts(db: Connection):
|
||||
indexes = [
|
||||
"id",
|
||||
"email",
|
||||
"username",
|
||||
"pubkey",
|
||||
"external_id",
|
||||
]
|
||||
|
||||
for index in indexes:
|
||||
logger.debug(f"Creating index idx_wallets_{index}...")
|
||||
await db.execute(f"""
|
||||
CREATE INDEX IF NOT EXISTS idx_accounts_{index} ON accounts ("{index}");
|
||||
""")
|
||||
|
||||
|
||||
async def m043_add_ui_customization_to_accounts(db: Connection):
|
||||
"""
|
||||
Adds ui_customization column to accounts.
|
||||
Used for server side persistence of UI customization settings.
|
||||
"""
|
||||
await db.execute("ALTER TABLE accounts ADD COLUMN ui_customization TEXT")
|
||||
|
||||
|
||||
async def m044_add_activated_to_accounts(db: Connection):
|
||||
"""
|
||||
Adds activated column to accounts.
|
||||
Used for account activation status.
|
||||
"""
|
||||
await db.execute("ALTER TABLE accounts ADD COLUMN activated BOOLEAN DEFAULT true")
|
||||
|
||||
|
||||
async def m045_add_external_id_to_payments(db: Connection):
|
||||
"""
|
||||
Adds external_id column to apipayments.
|
||||
Used for external payment references.
|
||||
"""
|
||||
await db.execute("ALTER TABLE apipayments ADD COLUMN external_id TEXT")
|
||||
logger.debug("Creating index idx_payments_external_id...")
|
||||
await db.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_payments_external_id
|
||||
ON apipayments (external_id);
|
||||
""")
|
||||
|
||||
|
||||
async def m046_add_permissions_to_installed_extensions(db: Connection):
|
||||
"""
|
||||
Adds granted permissions to installed extensions.
|
||||
"""
|
||||
await db.execute(
|
||||
"ALTER TABLE installed_extensions ADD COLUMN permissions TEXT DEFAULT '[]'"
|
||||
)
|
||||
|
||||
|
||||
async def m047_create_wasm_invocations_table(db: Connection):
|
||||
"""
|
||||
Tracks WASM extension invocations for runtime monitoring and controls.
|
||||
"""
|
||||
await db.execute(f"""
|
||||
CREATE TABLE IF NOT EXISTS wasm_invocations (
|
||||
id TEXT PRIMARY KEY,
|
||||
extension_id TEXT NOT NULL,
|
||||
export_name TEXT NOT NULL,
|
||||
trigger_type TEXT NOT NULL DEFAULT 'unknown',
|
||||
status TEXT NOT NULL DEFAULT 'running',
|
||||
started_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now},
|
||||
finished_at TIMESTAMP,
|
||||
duration_ms INT,
|
||||
user_id TEXT,
|
||||
wallet_id TEXT,
|
||||
request_id TEXT,
|
||||
method TEXT,
|
||||
path TEXT,
|
||||
event_type TEXT,
|
||||
payment_hash TEXT,
|
||||
checking_id TEXT,
|
||||
memory_peak_bytes INT,
|
||||
request_bytes INT,
|
||||
response_bytes INT,
|
||||
host_call_count INT NOT NULL DEFAULT 0,
|
||||
http_call_count INT NOT NULL DEFAULT 0,
|
||||
storage_call_count INT NOT NULL DEFAULT 0,
|
||||
wallet_call_count INT NOT NULL DEFAULT 0,
|
||||
error_type TEXT,
|
||||
error_message TEXT,
|
||||
stop_reason TEXT,
|
||||
"context" TEXT NOT NULL DEFAULT '{{}}'
|
||||
);
|
||||
""")
|
||||
await db.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_wasm_invocations_extension_started
|
||||
ON wasm_invocations (extension_id, started_at);
|
||||
""")
|
||||
await db.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_wasm_invocations_status
|
||||
ON wasm_invocations (status);
|
||||
""")
|
||||
await db.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_wasm_invocations_started
|
||||
ON wasm_invocations (started_at);
|
||||
""")
|
||||
|
||||
|
||||
async def m048_add_wasm_runtime_limits_to_installed_extensions(db: Connection):
|
||||
"""
|
||||
Adds per-extension WASM runtime limit overrides.
|
||||
"""
|
||||
await db.execute(
|
||||
"ALTER TABLE installed_extensions "
|
||||
"ADD COLUMN wasm_runtime_limits TEXT DEFAULT '{}'"
|
||||
)
|
||||
|
||||
|
||||
async def m049_add_permissions_to_user_extensions(db: Connection):
|
||||
"""
|
||||
Adds user-level extension permission grants.
|
||||
"""
|
||||
await db.execute("ALTER TABLE extensions ADD COLUMN permissions TEXT DEFAULT '{}'")
|
||||
|
||||
@@ -25,6 +25,7 @@ from .payments import (
|
||||
PaymentState,
|
||||
PaymentWalletStats,
|
||||
SettleInvoice,
|
||||
UpdatePaymentExtra,
|
||||
)
|
||||
from .tinyurl import TinyURL
|
||||
from .users import (
|
||||
@@ -46,7 +47,7 @@ from .users import (
|
||||
UserAcls,
|
||||
UserExtra,
|
||||
)
|
||||
from .wallets import BaseWallet, CreateWallet, KeyType, Wallet, WalletTypeInfo
|
||||
from .wallets import CreateWallet, KeyType, Wallet, WalletInfo, WalletTypeInfo
|
||||
from .webpush import CreateWebPushSubscription, WebPushSubscription
|
||||
|
||||
__all__ = [
|
||||
@@ -57,7 +58,6 @@ __all__ = [
|
||||
"AuditEntry",
|
||||
"AuditFilters",
|
||||
"BalanceDelta",
|
||||
"BaseWallet",
|
||||
"Callback",
|
||||
"CancelInvoice",
|
||||
"ConversionData",
|
||||
@@ -91,6 +91,7 @@ __all__ = [
|
||||
"SimpleStatus",
|
||||
"TinyURL",
|
||||
"UpdateBalance",
|
||||
"UpdatePaymentExtra",
|
||||
"UpdateSuperuserPassword",
|
||||
"UpdateUser",
|
||||
"UpdateUserPassword",
|
||||
@@ -99,6 +100,7 @@ __all__ = [
|
||||
"UserAcls",
|
||||
"UserExtra",
|
||||
"Wallet",
|
||||
"WalletInfo",
|
||||
"WalletTypeInfo",
|
||||
"WebPushSubscription",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from lnbits.db import FilterModel
|
||||
|
||||
|
||||
class AssetInfo(BaseModel):
|
||||
id: str
|
||||
mime_type: str
|
||||
name: str
|
||||
is_public: bool = False
|
||||
size_bytes: int
|
||||
thumbnail_base64: str | None = None
|
||||
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class Asset(AssetInfo):
|
||||
user_id: str
|
||||
data: bytes
|
||||
|
||||
|
||||
class AssetUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
is_public: bool | None = None
|
||||
|
||||
|
||||
class AssetFilters(FilterModel):
|
||||
__search_fields__ = ["name"]
|
||||
__sort_fields__ = [
|
||||
"created_at",
|
||||
"name",
|
||||
]
|
||||
|
||||
name: str | None = None
|
||||
@@ -6,12 +6,16 @@ import json
|
||||
import os
|
||||
import shutil
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from collections.abc import Mapping
|
||||
from datetime import datetime, timezone
|
||||
from enum import Enum
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
import httpx
|
||||
from loguru import logger
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field, StrictStr
|
||||
|
||||
from lnbits.helpers import (
|
||||
download_url,
|
||||
@@ -20,6 +24,8 @@ from lnbits.helpers import (
|
||||
version_parse,
|
||||
)
|
||||
from lnbits.settings import settings
|
||||
from lnbits.task_manager import task_manager
|
||||
from lnbits.utils.cache import cache
|
||||
|
||||
|
||||
class ExplicitRelease(BaseModel):
|
||||
@@ -53,9 +59,10 @@ class GitHubRelease(BaseModel):
|
||||
|
||||
|
||||
class Manifest(BaseModel):
|
||||
featured: list[str] = []
|
||||
extensions: list[ExplicitRelease] = []
|
||||
repos: list[GitHubRelease] = []
|
||||
featured: list[str] = []
|
||||
categories: dict[str, list[str]] = {}
|
||||
|
||||
|
||||
class GitHubRepoRelease(BaseModel):
|
||||
@@ -74,6 +81,23 @@ class GitHubRepo(BaseModel):
|
||||
default_branch: str
|
||||
|
||||
|
||||
class ExtensionPermission(BaseModel):
|
||||
id: StrictStr
|
||||
description: StrictStr | None = None
|
||||
policies: list[Any] | None = None
|
||||
|
||||
class Config:
|
||||
extra = "ignore"
|
||||
|
||||
@staticmethod
|
||||
def list_from_config(config_json: Mapping[str, Any]) -> list[ExtensionPermission]:
|
||||
return [
|
||||
ExtensionPermission.parse_obj(permission)
|
||||
for permission in config_json.get("permissions") or []
|
||||
if isinstance(permission, dict) and permission.get("id")
|
||||
]
|
||||
|
||||
|
||||
class ExtensionConfig(BaseModel):
|
||||
name: str
|
||||
short_description: str
|
||||
@@ -81,6 +105,8 @@ class ExtensionConfig(BaseModel):
|
||||
warning: str | None = ""
|
||||
min_lnbits_version: str | None
|
||||
max_lnbits_version: str | None
|
||||
extension_type: str | None = None
|
||||
permissions: list[ExtensionPermission] = []
|
||||
|
||||
def is_version_compatible(self) -> bool:
|
||||
return is_lnbits_version_ok(self.min_lnbits_version, self.max_lnbits_version)
|
||||
@@ -115,11 +141,83 @@ class UserExtensionInfo(BaseModel):
|
||||
payment_hash_to_enable: str | None = None
|
||||
|
||||
|
||||
class ExtensionBackgroundPaymentDestinationPolicy(str, Enum):
|
||||
OWN_WALLETS_ONLY = "own_wallets_only"
|
||||
EXTERNAL_ALLOWED = "external_allowed"
|
||||
|
||||
|
||||
class ExtensionBackgroundPaymentGrant(BaseModel):
|
||||
id: StrictStr = Field(..., min_length=1, max_length=128)
|
||||
wallet_id: str = Field(..., min_length=1, max_length=128)
|
||||
enabled: bool = True
|
||||
max_amount: int = Field(..., gt=0)
|
||||
destination_policy: ExtensionBackgroundPaymentDestinationPolicy
|
||||
|
||||
|
||||
class ExtensionBackgroundPaymentGrantRequest(BaseModel):
|
||||
wallet_id: str = Field(..., min_length=1, max_length=128)
|
||||
max_amount: int = Field(..., gt=0)
|
||||
destination_policy: ExtensionBackgroundPaymentDestinationPolicy
|
||||
|
||||
def to_grant(self, grant_id: str | None = None) -> ExtensionBackgroundPaymentGrant:
|
||||
return ExtensionBackgroundPaymentGrant(
|
||||
id=grant_id or str(uuid4()),
|
||||
wallet_id=self.wallet_id,
|
||||
enabled=True,
|
||||
max_amount=self.max_amount,
|
||||
destination_policy=self.destination_policy,
|
||||
)
|
||||
|
||||
|
||||
class ExtensionWalletPaymentsWatchGrant(BaseModel):
|
||||
id: StrictStr = Field(..., min_length=1, max_length=128)
|
||||
wallet_id: str = Field(..., min_length=1, max_length=128)
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
class ExtensionWalletPaymentsWatchGrantRequest(BaseModel):
|
||||
wallet_id: str = Field(..., min_length=1, max_length=128)
|
||||
|
||||
def to_grant(
|
||||
self, grant_id: str | None = None
|
||||
) -> ExtensionWalletPaymentsWatchGrant:
|
||||
return ExtensionWalletPaymentsWatchGrant(
|
||||
id=grant_id or str(uuid4()),
|
||||
wallet_id=self.wallet_id,
|
||||
enabled=True,
|
||||
)
|
||||
|
||||
|
||||
class ExtensionPermissionCheckItem(BaseModel):
|
||||
id: StrictStr
|
||||
grant: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class ExtensionPermissionCheckRequest(BaseModel):
|
||||
permissions: list[ExtensionPermissionCheckItem] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ExtensionPermissionCheckResult(BaseModel):
|
||||
id: StrictStr
|
||||
approved: bool
|
||||
grant: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class ExtensionPermissionCheckResponse(BaseModel):
|
||||
permissions: list[ExtensionPermissionCheckResult] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ExtensionPermissionsResponse(BaseModel):
|
||||
extension_permissions: list[ExtensionPermission] = Field(default_factory=list)
|
||||
user_permissions: dict[str, list[dict[str, Any]]] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class UserExtension(BaseModel):
|
||||
user: str
|
||||
extension: str
|
||||
active: bool
|
||||
extra: UserExtensionInfo | None = None
|
||||
permissions: dict = Field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def is_paid(self) -> bool:
|
||||
@@ -141,6 +239,7 @@ class UserExtension(BaseModel):
|
||||
class Extension(BaseModel):
|
||||
code: str
|
||||
is_valid: bool
|
||||
is_wasm: bool = False
|
||||
name: str | None = None
|
||||
short_description: str | None = None
|
||||
tile: str | None = None
|
||||
@@ -157,6 +256,8 @@ class Extension(BaseModel):
|
||||
|
||||
@property
|
||||
def is_upgrade_extension(self) -> bool:
|
||||
if self.is_wasm:
|
||||
return False
|
||||
return self.upgrade_hash != ""
|
||||
|
||||
@classmethod
|
||||
@@ -164,13 +265,71 @@ class Extension(BaseModel):
|
||||
return Extension(
|
||||
code=ext_info.id,
|
||||
is_valid=True,
|
||||
is_wasm=ext_info.is_wasm,
|
||||
name=ext_info.name,
|
||||
short_description=ext_info.short_description,
|
||||
tile=ext_info.icon,
|
||||
tile=_extension_tile(ext_info),
|
||||
upgrade_hash=ext_info.hash if ext_info.ext_upgrade_dir.is_dir() else "",
|
||||
)
|
||||
|
||||
|
||||
class WasmInvocation(BaseModel):
|
||||
id: str
|
||||
extension_id: str
|
||||
export_name: str
|
||||
trigger_type: str = "unknown"
|
||||
status: str = "running"
|
||||
started_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
finished_at: datetime | None = None
|
||||
duration_ms: int | None = None
|
||||
user_id: str | None = None
|
||||
wallet_id: str | None = None
|
||||
request_id: str | None = None
|
||||
method: str | None = None
|
||||
path: str | None = None
|
||||
event_type: str | None = None
|
||||
payment_hash: str | None = None
|
||||
checking_id: str | None = None
|
||||
memory_peak_bytes: int | None = None
|
||||
request_bytes: int | None = None
|
||||
response_bytes: int | None = None
|
||||
host_call_count: int = 0
|
||||
http_call_count: int = 0
|
||||
storage_call_count: int = 0
|
||||
wallet_call_count: int = 0
|
||||
error_type: str | None = None
|
||||
error_message: str | None = None
|
||||
stop_reason: str | None = None
|
||||
context: dict = Field(default_factory=dict)
|
||||
|
||||
|
||||
class WasmInvocationStats(BaseModel):
|
||||
total: int = 0
|
||||
running: int = 0
|
||||
completed: int = 0
|
||||
failed: int = 0
|
||||
stopped: int = 0
|
||||
timeout: int = 0
|
||||
avg_duration_ms: float = 0
|
||||
max_duration_ms: int = 0
|
||||
host_call_count: int = 0
|
||||
http_call_count: int = 0
|
||||
storage_call_count: int = 0
|
||||
wallet_call_count: int = 0
|
||||
|
||||
|
||||
class WasmRuntimeLimitsUpdate(BaseModel):
|
||||
limits: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class WasmRuntimeLimitsInfo(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
active: bool | None = False
|
||||
wasm_runtime_limits: dict[str, int] = Field(default_factory=dict)
|
||||
effective_wasm_runtime_limits: dict[str, int] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class ExtensionRelease(BaseModel):
|
||||
name: str
|
||||
version: str
|
||||
@@ -267,10 +426,30 @@ class ExtensionRelease(BaseModel):
|
||||
async def get_github_releases(cls, org: str, repo: str) -> list[ExtensionRelease]:
|
||||
try:
|
||||
github_releases = await cls.fetch_github_releases(org, repo)
|
||||
return [
|
||||
extension_releases = [
|
||||
ExtensionRelease.from_github_release(f"{org}/{repo}", r)
|
||||
for r in github_releases
|
||||
]
|
||||
for release in extension_releases:
|
||||
if not release.details_link:
|
||||
continue
|
||||
try:
|
||||
config = await ExtensionConfig.fetch_github_release_config(
|
||||
org, repo, release.version
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(e)
|
||||
config = None
|
||||
if not config:
|
||||
continue
|
||||
|
||||
release.min_lnbits_version = config.min_lnbits_version
|
||||
release.max_lnbits_version = config.max_lnbits_version
|
||||
release.is_version_compatible = config.is_version_compatible()
|
||||
|
||||
release.icon = icon_to_github_url(f"{org}/{repo}", config.tile)
|
||||
|
||||
return extension_releases
|
||||
except Exception as e:
|
||||
logger.warning(e)
|
||||
return []
|
||||
@@ -286,7 +465,6 @@ class ExtensionRelease(BaseModel):
|
||||
|
||||
@classmethod
|
||||
async def fetch_release_details(cls, details_link: str) -> dict | None:
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(details_link)
|
||||
@@ -311,6 +489,7 @@ class ExtensionMeta(BaseModel):
|
||||
dependencies: list[str] = []
|
||||
archive: str | None = None
|
||||
featured: bool = False
|
||||
categories: list[str] = []
|
||||
paid_features: str | None = None
|
||||
has_paid_release: bool = False
|
||||
has_free_release: bool = False
|
||||
@@ -325,6 +504,8 @@ class InstallableExtension(BaseModel):
|
||||
icon: str | None = None
|
||||
stars: int = 0
|
||||
meta: ExtensionMeta | None = None
|
||||
permissions: list[ExtensionPermission] = []
|
||||
wasm_runtime_limits: dict = Field(default_factory=dict, no_database=True)
|
||||
|
||||
@property
|
||||
def hash(self) -> str:
|
||||
@@ -377,6 +558,18 @@ class InstallableExtension(BaseModel):
|
||||
return False
|
||||
return self.meta.pay_to_enable.required is True
|
||||
|
||||
@property
|
||||
def is_wasm(self) -> bool:
|
||||
config_path = Path(self.ext_dir, "config.json")
|
||||
if not config_path.is_file():
|
||||
return False
|
||||
try:
|
||||
with open(config_path, encoding="utf-8") as json_file:
|
||||
config_json = json.load(json_file)
|
||||
except Exception:
|
||||
return False
|
||||
return config_json.get("extension_type") == "wasm"
|
||||
|
||||
async def download_archive(self):
|
||||
logger.info(f"Downloading extension {self.name} ({self.installed_version}).")
|
||||
ext_zip_file = self.zip_path
|
||||
@@ -409,6 +602,22 @@ class InstallableExtension(BaseModel):
|
||||
os.remove(ext_zip_file)
|
||||
raise AssertionError("File hash missmatch. Will not install.")
|
||||
|
||||
def load_archive_config(self) -> dict[str, Any]:
|
||||
if not self.zip_path.is_file():
|
||||
return {}
|
||||
|
||||
try:
|
||||
with zipfile.ZipFile(self.zip_path, "r") as archive:
|
||||
config_name = _archive_config_name(archive.namelist())
|
||||
if not config_name:
|
||||
return {}
|
||||
with archive.open(config_name) as config_file:
|
||||
config = json.load(config_file)
|
||||
except Exception as exc:
|
||||
raise ValueError(f"Cannot read extension config for '{self.id}'.") from exc
|
||||
|
||||
return config if isinstance(config, dict) else {}
|
||||
|
||||
def extract_archive(self):
|
||||
logger.info(f"Extracting extension {self.name} ({self.installed_version}).")
|
||||
Path(settings.lnbits_extensions_upgrade_path).mkdir(parents=True, exist_ok=True)
|
||||
@@ -587,6 +796,7 @@ class InstallableExtension(BaseModel):
|
||||
version=version,
|
||||
short_description=config_json.get("short_description"),
|
||||
icon=config_json.get("tile"),
|
||||
permissions=ExtensionPermission.list_from_config(config_json),
|
||||
meta=ExtensionMeta(
|
||||
installed_release=ExtensionRelease(
|
||||
name=ext_id,
|
||||
@@ -606,6 +816,40 @@ class InstallableExtension(BaseModel):
|
||||
|
||||
@classmethod
|
||||
async def get_installable_extensions(
|
||||
cls, post_refresh_cache: bool = False
|
||||
) -> list[InstallableExtension]:
|
||||
extension_list: list[InstallableExtension] = []
|
||||
|
||||
cache_key = "extensions:installable"
|
||||
cache_value = cache.value(cache_key)
|
||||
if not cache_value:
|
||||
extension_list = await cls._get_installable_extensions()
|
||||
cache.set(cache_key, extension_list, expiry=3600) # one hour
|
||||
return extension_list
|
||||
|
||||
if cache_value.older_than(10 * 60) or post_refresh_cache:
|
||||
# refresh cache in background if older than 10 minutes or requested
|
||||
task_manager.create_task(
|
||||
cls._refresh_installable_extensions_cache(),
|
||||
"refresh_installable_extensions_cache",
|
||||
)
|
||||
|
||||
extension_list = cache_value.value # type: ignore
|
||||
return extension_list
|
||||
|
||||
@classmethod
|
||||
async def _refresh_installable_extensions_cache(
|
||||
cls,
|
||||
) -> None:
|
||||
cache_key = "extensions:installable"
|
||||
extension_list: list[InstallableExtension] = (
|
||||
await cls._get_installable_extensions()
|
||||
)
|
||||
|
||||
cache.set(cache_key, extension_list, expiry=3600)
|
||||
|
||||
@classmethod
|
||||
async def _get_installable_extensions(
|
||||
cls,
|
||||
) -> list[InstallableExtension]:
|
||||
extension_list: list[InstallableExtension] = []
|
||||
@@ -627,6 +871,11 @@ class InstallableExtension(BaseModel):
|
||||
|
||||
meta = ext.meta or ExtensionMeta()
|
||||
meta.featured = ext.id in manifest.featured
|
||||
meta.categories = [
|
||||
category
|
||||
for category, ext_ids in manifest.categories.items()
|
||||
if ext.id in ext_ids
|
||||
]
|
||||
ext.meta = meta
|
||||
extension_list += [ext]
|
||||
|
||||
@@ -642,6 +891,11 @@ class InstallableExtension(BaseModel):
|
||||
ext.check_release_updates(release)
|
||||
meta = ext.meta or ExtensionMeta()
|
||||
meta.featured = ext.id in manifest.featured
|
||||
meta.categories = [
|
||||
category
|
||||
for category, ext_ids in manifest.categories.items()
|
||||
if ext.id in ext_ids
|
||||
]
|
||||
ext.meta = meta
|
||||
extension_list += [ext]
|
||||
except Exception as e:
|
||||
@@ -736,6 +990,7 @@ class CreateExtension(BaseModel):
|
||||
version: str
|
||||
cost_sats: int | None = 0
|
||||
payment_hash: str | None = None
|
||||
permissions: list[ExtensionPermission] = []
|
||||
|
||||
|
||||
class ExtensionDetailsRequest(BaseModel):
|
||||
@@ -744,6 +999,32 @@ class ExtensionDetailsRequest(BaseModel):
|
||||
version: str
|
||||
|
||||
|
||||
class ExtensionReviewsStatus(BaseModel):
|
||||
tag: str
|
||||
avg_rating: float
|
||||
review_count: int
|
||||
|
||||
|
||||
class CreateExtensionReview(BaseModel):
|
||||
tag: str
|
||||
name: str | None = Field(None)
|
||||
rating: int = Field(..., ge=0, le=1000)
|
||||
comment: str | None = Field(None)
|
||||
|
||||
|
||||
class ExtensionReviewPaymentRequest(BaseModel):
|
||||
payment_hash: str
|
||||
payment_request: str
|
||||
|
||||
|
||||
class ExtensionReview(BaseModel):
|
||||
id: str
|
||||
name: str | None = Field(default=None)
|
||||
tag: str | None = Field(default=None)
|
||||
rating: int = Field(default=0, ge=0, le=1000)
|
||||
comment: str | None = Field(default=None)
|
||||
|
||||
|
||||
async def github_api_get(url: str, error_msg: str | None) -> Any:
|
||||
headers = {"User-Agent": settings.user_agent}
|
||||
if settings.lnbits_ext_github_token:
|
||||
@@ -762,3 +1043,21 @@ def icon_to_github_url(source_repo: str, path: str | None) -> str:
|
||||
_, _, *rest = path.split("/")
|
||||
tail = "/".join(rest)
|
||||
return f"https://github.com/{source_repo}/raw/main/{tail}"
|
||||
|
||||
|
||||
def wasm_extension_icon_url(ext_id: str) -> str:
|
||||
return f"/ext-assets/{ext_id}/assets/icon.png"
|
||||
|
||||
|
||||
def _extension_tile(ext_info: InstallableExtension) -> str | None:
|
||||
if ext_info.is_wasm:
|
||||
return wasm_extension_icon_url(ext_info.id)
|
||||
return ext_info.icon
|
||||
|
||||
|
||||
def _archive_config_name(names: list[str]) -> str | None:
|
||||
for name in names:
|
||||
path = PurePosixPath(name)
|
||||
if len(path.parts) == 2 and path.name == "config.json":
|
||||
return name
|
||||
return None
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import random
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Literal
|
||||
|
||||
@@ -55,6 +57,29 @@ class DataField(BaseModel):
|
||||
field_type += ' = "sat"'
|
||||
return f"{field_name}: {field_type}"
|
||||
|
||||
def field_to_random_value(self) -> str:
|
||||
field_name = camel_to_snake(self.name)
|
||||
field_value: Any = f'"{self.type}"'
|
||||
if self.type == "json":
|
||||
field_value = '"{}"'
|
||||
elif self.type == "wallet":
|
||||
field_value = f'"{uuid.uuid4()}"'
|
||||
elif self.type == "currency":
|
||||
field_value = '"sat"'
|
||||
elif self.type in ["str", "text"]:
|
||||
field_value = f'"{field_name}_{urlsafe_short_hash()}"'
|
||||
elif self.type == "int":
|
||||
field_value = random.randint(1, 100) # noqa: S311
|
||||
elif self.type == "float":
|
||||
field_value = random.uniform(1.0, 100.0) # noqa: S311
|
||||
elif self.type == "bool":
|
||||
field_value = random.choice([True, False]) # noqa: S311
|
||||
elif self.type == "datetime":
|
||||
random_days = random.randint(-30, 30) # noqa: S311
|
||||
random_date = datetime.now(timezone.utc) - timedelta(days=random_days)
|
||||
field_value = f"""datetime.fromisoformat("{random_date.isoformat()}")"""
|
||||
return f"{field_name} = {field_value},"
|
||||
|
||||
def field_to_js(self) -> str:
|
||||
field_name = camel_to_snake(self.name)
|
||||
default_value = "null"
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
@@ -11,8 +13,41 @@ def _do_nothing(*_):
|
||||
|
||||
class CoreAppExtra:
|
||||
register_new_ext_routes: Callable = _do_nothing
|
||||
register_new_wasm_ext_routes: Callable = _do_nothing
|
||||
register_new_ratelimiter: Callable
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.wasm_extension_registry = WasmExtensionRegistry()
|
||||
|
||||
|
||||
class WasmExtensionRegistry:
|
||||
def __init__(self) -> None:
|
||||
self._extensions: dict[str, Any] = {}
|
||||
|
||||
def register(self, extension: Any) -> None:
|
||||
self.require_available(extension)
|
||||
self._extensions[extension.id] = extension
|
||||
|
||||
def require_available(self, extension: Any) -> None:
|
||||
existing = self._extensions.get(extension.id)
|
||||
if existing and not _same_wasm_extension_registration(existing, extension):
|
||||
raise ValueError(
|
||||
f"WASM extension id '{extension.id}' is already registered."
|
||||
)
|
||||
|
||||
def get(self, ext_id: str) -> Any | None:
|
||||
return self._extensions.get(ext_id)
|
||||
|
||||
def list(self) -> list[Any]:
|
||||
return list(self._extensions.values())
|
||||
|
||||
|
||||
def _same_wasm_extension_registration(left: Any, right: Any) -> bool:
|
||||
try:
|
||||
return Path(left.root_path).resolve() == Path(right.root_path).resolve()
|
||||
except (AttributeError, TypeError):
|
||||
return left is right
|
||||
|
||||
|
||||
class ConversionData(BaseModel):
|
||||
from_: str = "sat"
|
||||
|
||||
@@ -6,23 +6,17 @@ from typing import Literal
|
||||
|
||||
from fastapi import Query
|
||||
from lnurl import LnurlWithdrawResponse
|
||||
from loguru import logger
|
||||
from pydantic import BaseModel, Field, validator
|
||||
|
||||
from lnbits.db import FilterModel
|
||||
from lnbits.fiat import get_fiat_provider
|
||||
from lnbits.fiat.base import (
|
||||
FiatPaymentFailedStatus,
|
||||
FiatPaymentPendingStatus,
|
||||
FiatPaymentStatus,
|
||||
FiatPaymentSuccessStatus,
|
||||
)
|
||||
from lnbits.helpers import is_valid_external_id
|
||||
from lnbits.utils.exchange_rates import allowed_currencies
|
||||
from lnbits.wallets import get_funding_source
|
||||
from lnbits.wallets.base import (
|
||||
PaymentFailedStatus,
|
||||
PaymentPendingStatus,
|
||||
PaymentStatus,
|
||||
PaymentSuccessStatus,
|
||||
)
|
||||
|
||||
|
||||
@@ -41,6 +35,11 @@ class PaymentExtra(BaseModel):
|
||||
lnurl_response: str | None = None
|
||||
|
||||
|
||||
class UpdatePaymentExtra(BaseModel):
|
||||
payment_hash: str
|
||||
extra: dict = Field(default_factory=dict)
|
||||
|
||||
|
||||
class PayInvoice(BaseModel):
|
||||
payment_request: str
|
||||
description: str | None = None
|
||||
@@ -55,10 +54,17 @@ class CreatePayment(BaseModel):
|
||||
amount_msat: int
|
||||
memo: str
|
||||
extra: dict | None = {}
|
||||
extension: str | None = None
|
||||
preimage: str | None = None
|
||||
expiry: datetime | None = None
|
||||
webhook: str | None = None
|
||||
fee: int = 0
|
||||
labels: list[str] | None = None
|
||||
external_id: str | None = None
|
||||
|
||||
@validator("external_id")
|
||||
def validate_external_id(cls, external_id):
|
||||
return _validate_external_id(external_id)
|
||||
|
||||
|
||||
class Payment(BaseModel):
|
||||
@@ -68,7 +74,7 @@ class Payment(BaseModel):
|
||||
amount: int
|
||||
fee: int
|
||||
bolt11: str
|
||||
# payment_request: str | None
|
||||
payment_request: str | None = Field(default=None, no_database=True)
|
||||
fiat_provider: str | None = None
|
||||
status: str = PaymentState.PENDING
|
||||
memo: str | None = None
|
||||
@@ -81,7 +87,20 @@ class Payment(BaseModel):
|
||||
time: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
labels: list[str] = []
|
||||
extra: dict = {}
|
||||
external_id: str | None = None
|
||||
|
||||
@validator("external_id")
|
||||
def validate_external_id(cls, external_id):
|
||||
return _validate_external_id(external_id)
|
||||
|
||||
def __init__(self, **data):
|
||||
super().__init__(**data)
|
||||
if "fiat_payment_request" in self.extra:
|
||||
self.payment_request = self.extra["fiat_payment_request"]
|
||||
else:
|
||||
self.payment_request = self.bolt11
|
||||
|
||||
@property
|
||||
def pending(self) -> bool:
|
||||
@@ -121,69 +140,48 @@ class Payment(BaseModel):
|
||||
"fiat_"
|
||||
)
|
||||
|
||||
async def check_status(
|
||||
self, skip_internal_payment_notifications: bool | None = False
|
||||
) -> PaymentStatus:
|
||||
if self.is_internal:
|
||||
if self.success:
|
||||
return PaymentSuccessStatus()
|
||||
if self.failed:
|
||||
return PaymentFailedStatus()
|
||||
if self.is_in and self.fiat_provider:
|
||||
fiat_status = await self.check_fiat_status(
|
||||
skip_internal_payment_notifications
|
||||
)
|
||||
return PaymentStatus(paid=fiat_status.paid)
|
||||
return PaymentPendingStatus()
|
||||
funding_source = get_funding_source()
|
||||
if self.is_out:
|
||||
status = await funding_source.get_payment_status(self.checking_id)
|
||||
else:
|
||||
status = await funding_source.get_invoice_status(self.checking_id)
|
||||
return status
|
||||
# DEPRECATED: in v1.5.0, use service check_payment_status instead
|
||||
async def check_status(self) -> PaymentStatus:
|
||||
logger.warning("payment.check_status() is deprecated.")
|
||||
from lnbits.core.services.payments import check_payment_status
|
||||
|
||||
async def check_fiat_status(
|
||||
self, skip_internal_payment_notifications: bool | None = False
|
||||
) -> FiatPaymentStatus:
|
||||
if not self.is_internal:
|
||||
return FiatPaymentPendingStatus()
|
||||
if self.success:
|
||||
return FiatPaymentSuccessStatus()
|
||||
if self.failed:
|
||||
return FiatPaymentFailedStatus()
|
||||
return await check_payment_status(self)
|
||||
|
||||
if not self.fiat_provider:
|
||||
return FiatPaymentPendingStatus()
|
||||
# DEPRECATED: in v1.5.0, use service check_payment_status instead
|
||||
async def check_fiat_status(self) -> FiatPaymentStatus:
|
||||
logger.warning("payment.check_fiat_status() is deprecated.")
|
||||
from lnbits.core.services.fiat_providers import check_fiat_status
|
||||
|
||||
checking_id = self.extra.get("fiat_checking_id")
|
||||
if not checking_id:
|
||||
return FiatPaymentPendingStatus()
|
||||
|
||||
fiat_provider = await get_fiat_provider(self.fiat_provider)
|
||||
if not fiat_provider:
|
||||
return FiatPaymentPendingStatus()
|
||||
fiat_status = await fiat_provider.get_invoice_status(checking_id)
|
||||
|
||||
if skip_internal_payment_notifications:
|
||||
return fiat_status
|
||||
|
||||
if fiat_status.success:
|
||||
# notify receivers asynchronously
|
||||
from lnbits.tasks import internal_invoice_queue
|
||||
|
||||
await internal_invoice_queue.put(self.checking_id)
|
||||
|
||||
return fiat_status
|
||||
return await check_fiat_status(self)
|
||||
|
||||
|
||||
class PaymentFilters(FilterModel):
|
||||
__search_fields__ = ["memo", "amount", "wallet_id", "tag", "status", "time"]
|
||||
__search_fields__ = [
|
||||
"memo",
|
||||
"amount",
|
||||
"wallet_id",
|
||||
"tag",
|
||||
"status",
|
||||
"time",
|
||||
"labels",
|
||||
"external_id",
|
||||
]
|
||||
|
||||
__sort_fields__ = ["created_at", "amount", "fee", "memo", "time", "tag"]
|
||||
__sort_fields__ = [
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"amount",
|
||||
"fee",
|
||||
"memo",
|
||||
"time",
|
||||
"tag",
|
||||
"external_id",
|
||||
]
|
||||
|
||||
status: str | None
|
||||
tag: str | None
|
||||
checking_id: str | None
|
||||
external_id: str | None
|
||||
amount: int
|
||||
fee: int
|
||||
memo: str | None
|
||||
@@ -191,6 +189,7 @@ class PaymentFilters(FilterModel):
|
||||
preimage: str | None
|
||||
payment_hash: str | None
|
||||
wallet_id: str | None
|
||||
labels: str | None
|
||||
|
||||
|
||||
class PaymentDataPoint(BaseModel):
|
||||
@@ -261,10 +260,13 @@ class CreateInvoice(BaseModel):
|
||||
)
|
||||
expiry: int | None = None
|
||||
extra: dict | None = None
|
||||
extension: str | None = None
|
||||
webhook: str | None = None
|
||||
bolt11: str | None = None
|
||||
lnurl_withdraw: LnurlWithdrawResponse | None = None
|
||||
fiat_provider: str | None = None
|
||||
labels: list[str] = []
|
||||
external_id: str | None = Query(default=None, max_length=256)
|
||||
|
||||
@validator("payment_hash")
|
||||
def check_hex(cls, v):
|
||||
@@ -279,6 +281,10 @@ class CreateInvoice(BaseModel):
|
||||
raise ValueError("The provided unit is not supported")
|
||||
return v
|
||||
|
||||
@validator("external_id")
|
||||
def validate_external_id(cls, external_id):
|
||||
return _validate_external_id(external_id)
|
||||
|
||||
|
||||
class PaymentsStatusCount(BaseModel):
|
||||
incoming: int = 0
|
||||
@@ -313,3 +319,16 @@ class CancelInvoice(BaseModel):
|
||||
def check_hex(cls, v):
|
||||
_ = bytes.fromhex(v)
|
||||
return v
|
||||
|
||||
|
||||
class UpdatePaymentLabels(BaseModel):
|
||||
labels: list[str] = []
|
||||
|
||||
|
||||
def _validate_external_id(external_id: str | None) -> str | None:
|
||||
if external_id and not is_valid_external_id(external_id):
|
||||
raise ValueError(
|
||||
"Invalid external id. Max length is 256 characters. "
|
||||
"Space and newlines are not allowed."
|
||||
)
|
||||
return external_id
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""SSO authentication providers for LNbits"""
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Generic OIDC SSO Login Helper"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
from fastapi_sso.sso.base import DiscoveryDocument, OpenID, SSOBase
|
||||
|
||||
|
||||
class OidcSSO(SSOBase):
|
||||
"""Class providing login via Generic OIDC OAuth (e.g., Zitadel, Authentik, etc.)"""
|
||||
|
||||
provider = "oidc"
|
||||
scope = ["openid", "email", "profile"]
|
||||
discovery_url = ""
|
||||
|
||||
async def openid_from_response(
|
||||
self, response: dict, session: Optional["httpx.AsyncClient"] = None
|
||||
) -> OpenID:
|
||||
"""Return OpenID from user information provided by OIDC provider"""
|
||||
return OpenID(
|
||||
email=response.get("email", ""),
|
||||
provider=self.provider,
|
||||
id=response.get("sub"),
|
||||
first_name=response.get("given_name"),
|
||||
last_name=response.get("family_name"),
|
||||
display_name=response.get("name") or response.get("preferred_username"),
|
||||
picture=response.get("picture"),
|
||||
)
|
||||
|
||||
async def get_discovery_document(self) -> DiscoveryDocument:
|
||||
"""Get document containing handy urls"""
|
||||
async with httpx.AsyncClient() as session:
|
||||
response = await session.get(self.discovery_url)
|
||||
content = response.json()
|
||||
|
||||
return content
|
||||
@@ -1,4 +1,6 @@
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class TinyURL(BaseModel):
|
||||
@@ -6,4 +8,4 @@ class TinyURL(BaseModel):
|
||||
url: str
|
||||
endless: bool
|
||||
wallet: str
|
||||
time: float
|
||||
time: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
@@ -12,6 +12,7 @@ from lnbits.db import FilterModel
|
||||
from lnbits.helpers import (
|
||||
is_valid_email_address,
|
||||
is_valid_external_id,
|
||||
is_valid_label,
|
||||
is_valid_pubkey,
|
||||
is_valid_username,
|
||||
)
|
||||
@@ -29,6 +30,21 @@ class UserNotifications(BaseModel):
|
||||
incoming_payments_sats: int = 0
|
||||
|
||||
|
||||
class WalletInviteRequest(BaseModel):
|
||||
request_id: str
|
||||
from_user_name: str | None = None
|
||||
to_wallet_id: str
|
||||
to_wallet_name: str
|
||||
|
||||
|
||||
class UserLabel(BaseModel):
|
||||
name: str = Field(regex=r"([A-Za-z0-9 ._-]{1,100}$)")
|
||||
description: str | None = Field(default=None, max_length=250)
|
||||
color: str | None = Field(
|
||||
default=None, regex=r"^#[0-9A-Fa-f]{6}$"
|
||||
) # e.g., "#RRGGBB"
|
||||
|
||||
|
||||
class UserExtra(BaseModel):
|
||||
email_verified: bool | None = False
|
||||
first_name: str | None = None
|
||||
@@ -46,6 +62,55 @@ class UserExtra(BaseModel):
|
||||
|
||||
notifications: UserNotifications = UserNotifications()
|
||||
|
||||
wallet_invite_requests: list[WalletInviteRequest] = []
|
||||
|
||||
labels: list[UserLabel] = []
|
||||
|
||||
def add_wallet_invite_request(
|
||||
self,
|
||||
request_id: str,
|
||||
to_wallet_id: str,
|
||||
to_wallet_name: str,
|
||||
from_user_name: str | None = None,
|
||||
) -> WalletInviteRequest:
|
||||
self.remove_wallet_invite_request(request_id)
|
||||
invite = WalletInviteRequest(
|
||||
request_id=request_id,
|
||||
from_user_name=from_user_name,
|
||||
to_wallet_id=to_wallet_id,
|
||||
to_wallet_name=to_wallet_name,
|
||||
)
|
||||
self.wallet_invite_requests.append(invite)
|
||||
return invite
|
||||
|
||||
def find_wallet_invite_request(self, request_id: str) -> WalletInviteRequest | None:
|
||||
for invite in self.wallet_invite_requests:
|
||||
if invite.request_id == request_id:
|
||||
return invite
|
||||
return None
|
||||
|
||||
def validate_labels(self):
|
||||
seen_labels = set()
|
||||
for label in self.labels:
|
||||
if not label.name:
|
||||
raise ValueError("Label name cannot be empty.")
|
||||
# apply the same rule for labels as for usernames
|
||||
if not is_valid_label(label.name):
|
||||
raise ValueError(f"Invalid label name: {label.name}")
|
||||
if label.name in seen_labels:
|
||||
raise ValueError(f"Duplicate label name: {label.name}")
|
||||
seen_labels.add(label.name)
|
||||
|
||||
def remove_wallet_invite_request(
|
||||
self,
|
||||
request_id: str,
|
||||
):
|
||||
self.wallet_invite_requests = [
|
||||
invite
|
||||
for invite in self.wallet_invite_requests
|
||||
if invite.request_id != request_id
|
||||
]
|
||||
|
||||
|
||||
class EndpointAccess(BaseModel):
|
||||
path: str
|
||||
@@ -107,14 +172,23 @@ class UserAcls(BaseModel):
|
||||
return None
|
||||
|
||||
|
||||
class Account(BaseModel):
|
||||
class AccountId(BaseModel):
|
||||
id: str
|
||||
|
||||
@property
|
||||
def is_admin_id(self) -> bool:
|
||||
return settings.is_admin_user(self.id)
|
||||
|
||||
|
||||
class Account(AccountId):
|
||||
activated: bool = True
|
||||
external_id: str | None = None # for external account linking
|
||||
username: str | None = None
|
||||
password_hash: str | None = None
|
||||
pubkey: str | None = None
|
||||
email: str | None = None
|
||||
extra: UserExtra = UserExtra()
|
||||
ui_customization: dict = Field(default_factory=dict)
|
||||
|
||||
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
@@ -129,6 +203,10 @@ class Account(BaseModel):
|
||||
self.is_admin = settings.is_admin_user(self.id)
|
||||
self.fiat_providers = settings.get_fiat_providers_for_user(self.id)
|
||||
|
||||
@property
|
||||
def has_password(self) -> bool:
|
||||
return self.password_hash is not None
|
||||
|
||||
def hash_password(self, password: str) -> str:
|
||||
"""sets and returns the hashed password"""
|
||||
salt = gensalt()
|
||||
@@ -160,8 +238,11 @@ class Account(BaseModel):
|
||||
if user_uuid4.hex != self.id:
|
||||
raise ValueError("User ID is not valid UUID4 hex string.")
|
||||
|
||||
self.extra.validate_labels()
|
||||
|
||||
|
||||
class AccountOverview(Account):
|
||||
activated: bool = True
|
||||
transaction_count: int | None = 0
|
||||
wallet_count: int | None = 0
|
||||
balance_msat: int | None = 0
|
||||
@@ -170,7 +251,7 @@ class AccountOverview(Account):
|
||||
|
||||
class AccountFilters(FilterModel):
|
||||
__search_fields__ = [
|
||||
"user",
|
||||
"id",
|
||||
"email",
|
||||
"username",
|
||||
"pubkey",
|
||||
@@ -178,17 +259,18 @@ class AccountFilters(FilterModel):
|
||||
"wallet_id",
|
||||
]
|
||||
__sort_fields__ = [
|
||||
"balance_msat",
|
||||
"id",
|
||||
"email",
|
||||
"username",
|
||||
"transaction_count",
|
||||
"wallet_count",
|
||||
"last_payment",
|
||||
"pubkey",
|
||||
"external_id",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
email: str | None = None
|
||||
user: str | None = None
|
||||
id: str | None = None
|
||||
username: str | None = None
|
||||
email: str | None = None
|
||||
pubkey: str | None = None
|
||||
external_id: str | None = None
|
||||
wallet_id: str | None = None
|
||||
@@ -196,6 +278,7 @@ class AccountFilters(FilterModel):
|
||||
|
||||
class User(BaseModel):
|
||||
id: str
|
||||
activated: bool = True
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
email: str | None = None
|
||||
@@ -209,6 +292,7 @@ class User(BaseModel):
|
||||
fiat_providers: list[str] = []
|
||||
has_password: bool = False
|
||||
extra: UserExtra = UserExtra()
|
||||
ui_customization: dict = Field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def wallet_ids(self) -> list[str]:
|
||||
@@ -234,6 +318,7 @@ class RegisterUser(BaseModel):
|
||||
username: str = Query(default=..., min_length=2, max_length=20)
|
||||
password: str = Query(default=..., min_length=8, max_length=50)
|
||||
password_repeat: str = Query(default=..., min_length=8, max_length=50)
|
||||
invitation_code: str | None = Query(default=None, max_length=256)
|
||||
|
||||
|
||||
class CreateUser(BaseModel):
|
||||
@@ -277,6 +362,7 @@ class UpdateSuperuserPassword(BaseModel):
|
||||
username: str = Query(default=..., min_length=2, max_length=20)
|
||||
password: str = Query(default=..., min_length=8, max_length=50)
|
||||
password_repeat: str = Query(default=..., min_length=8, max_length=50)
|
||||
first_install_token: str | None = Query(None)
|
||||
|
||||
|
||||
class LoginUsr(BaseModel):
|
||||
|
||||
+172
-11
@@ -4,16 +4,14 @@ from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from enum import Enum
|
||||
|
||||
from lnurl import encode as lnurl_encode
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from lnbits.core.models.lnurl import StoredPayLinks
|
||||
from lnbits.db import FilterModel
|
||||
from lnbits.helpers import url_for
|
||||
from lnbits.settings import settings
|
||||
|
||||
|
||||
class BaseWallet(BaseModel):
|
||||
class WalletInfo(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
adminkey: str
|
||||
@@ -21,18 +19,109 @@ class BaseWallet(BaseModel):
|
||||
balance_msat: int
|
||||
|
||||
|
||||
class WalletType(Enum):
|
||||
LIGHTNING = "lightning"
|
||||
LIGHTNING_SHARED = "lightning-shared"
|
||||
|
||||
|
||||
class WalletPermission(Enum):
|
||||
VIEW_PAYMENTS = "view-payments"
|
||||
RECEIVE_PAYMENTS = "receive-payments"
|
||||
SEND_PAYMENTS = "send-payments"
|
||||
|
||||
def __str__(self):
|
||||
return self.value
|
||||
|
||||
|
||||
class WalletShareStatus(Enum):
|
||||
INVITE_SENT = "invite_sent"
|
||||
APPROVED = "approved"
|
||||
|
||||
|
||||
class WalletSharePermission(BaseModel):
|
||||
# unique identifier for this share request
|
||||
request_id: str | None = None
|
||||
# username of the invited user
|
||||
username: str
|
||||
# ID of the wallet being shared with
|
||||
shared_with_wallet_id: str | None = None
|
||||
# permissions being granted
|
||||
permissions: list[WalletPermission] = []
|
||||
# status of the share request
|
||||
status: WalletShareStatus
|
||||
comment: str | None = None
|
||||
|
||||
def approve(
|
||||
self,
|
||||
permissions: list[WalletPermission] | None = None,
|
||||
shared_with_wallet_id: str | None = None,
|
||||
):
|
||||
self.status = WalletShareStatus.APPROVED
|
||||
if permissions is not None:
|
||||
self.permissions = permissions
|
||||
if shared_with_wallet_id is not None:
|
||||
self.shared_with_wallet_id = shared_with_wallet_id
|
||||
|
||||
@property
|
||||
def is_approved(self) -> bool:
|
||||
return self.status == WalletShareStatus.APPROVED
|
||||
|
||||
|
||||
class WalletExtra(BaseModel):
|
||||
icon: str = "flash_on"
|
||||
color: str = "primary"
|
||||
pinned: bool = False
|
||||
# What permissions this wallet grants when it's shared with other users
|
||||
shared_with: list[WalletSharePermission] = []
|
||||
|
||||
def invite_user_to_shared_wallet(
|
||||
self,
|
||||
request_id: str,
|
||||
request_type: WalletShareStatus,
|
||||
username: str,
|
||||
permissions: list[WalletPermission] | None = None,
|
||||
) -> WalletSharePermission:
|
||||
share = WalletSharePermission(
|
||||
request_id=request_id,
|
||||
username=username,
|
||||
status=request_type,
|
||||
permissions=permissions or [],
|
||||
)
|
||||
self.shared_with.append(share)
|
||||
return share
|
||||
|
||||
def find_share_by_id(self, request_id: str) -> WalletSharePermission | None:
|
||||
for share in self.shared_with:
|
||||
if share.request_id == request_id:
|
||||
return share
|
||||
return None
|
||||
|
||||
def find_share_for_wallet(
|
||||
self, shared_with_wallet_id: str
|
||||
) -> WalletSharePermission | None:
|
||||
for share in self.shared_with:
|
||||
if share.shared_with_wallet_id == shared_with_wallet_id:
|
||||
return share
|
||||
return None
|
||||
|
||||
def remove_share_by_id(self, request_id: str):
|
||||
self.shared_with = [
|
||||
share for share in self.shared_with if share.request_id != request_id
|
||||
]
|
||||
|
||||
|
||||
class Wallet(BaseModel):
|
||||
class BaseWallet(BaseModel):
|
||||
id: str
|
||||
user: str
|
||||
name: str
|
||||
wallet_type: str = WalletType.LIGHTNING.value
|
||||
adminkey: str
|
||||
inkey: str
|
||||
|
||||
|
||||
class Wallet(BaseWallet):
|
||||
name: str
|
||||
# Must be set only for shared wallets
|
||||
shared_wallet_id: str | None = None
|
||||
deleted: bool = False
|
||||
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
@@ -40,6 +129,65 @@ class Wallet(BaseModel):
|
||||
balance_msat: int = Field(default=0, no_database=True)
|
||||
extra: WalletExtra = WalletExtra()
|
||||
stored_paylinks: StoredPayLinks = StoredPayLinks()
|
||||
# What permission this wallet has when it's a shared wallet
|
||||
share_permissions: list[WalletPermission] = Field(default=[], no_database=True)
|
||||
|
||||
def __init__(self, **data):
|
||||
super().__init__(**data)
|
||||
self._validate_data()
|
||||
|
||||
def mirror_shared_wallet(
|
||||
self,
|
||||
shared_wallet: Wallet,
|
||||
):
|
||||
if not shared_wallet.is_lightning_wallet:
|
||||
return None
|
||||
|
||||
self.wallet_type = WalletType.LIGHTNING_SHARED.value
|
||||
self.shared_wallet_id = shared_wallet.id
|
||||
self.name = shared_wallet.name
|
||||
self.share_permissions = shared_wallet.get_share_permissions(self.id)
|
||||
|
||||
if len(self.share_permissions):
|
||||
self.currency = shared_wallet.currency
|
||||
self.balance_msat = shared_wallet.balance_msat
|
||||
|
||||
self.stored_paylinks = shared_wallet.stored_paylinks
|
||||
self.extra.icon = shared_wallet.extra.icon
|
||||
self.extra.color = shared_wallet.extra.color
|
||||
|
||||
def get_share_permissions(self, wallet_id: str) -> list[WalletPermission]:
|
||||
for share in self.extra.shared_with:
|
||||
if share.shared_with_wallet_id == wallet_id and share.is_approved:
|
||||
return share.permissions
|
||||
return []
|
||||
|
||||
def has_permission(self, permission: WalletPermission) -> bool:
|
||||
if self.is_lightning_wallet:
|
||||
return True
|
||||
if self.is_lightning_shared_wallet:
|
||||
return permission in self.share_permissions
|
||||
|
||||
return False
|
||||
|
||||
@property
|
||||
def source_wallet_id(self) -> str:
|
||||
"""For shared wallets return the original wallet ID, else return own ID."""
|
||||
if self.is_lightning_shared_wallet and len(self.share_permissions):
|
||||
return self.shared_wallet_id or self.id
|
||||
return self.id
|
||||
|
||||
@property
|
||||
def can_receive_payments(self) -> bool:
|
||||
return self.has_permission(WalletPermission.RECEIVE_PAYMENTS)
|
||||
|
||||
@property
|
||||
def can_send_payments(self) -> bool:
|
||||
return self.has_permission(WalletPermission.SEND_PAYMENTS)
|
||||
|
||||
@property
|
||||
def can_view_payments(self) -> bool:
|
||||
return self.has_permission(WalletPermission.VIEW_PAYMENTS)
|
||||
|
||||
@property
|
||||
def balance(self) -> int:
|
||||
@@ -50,16 +198,23 @@ class Wallet(BaseModel):
|
||||
return self.balance_msat - settings.fee_reserve(self.balance_msat)
|
||||
|
||||
@property
|
||||
def lnurlwithdraw_full(self) -> str:
|
||||
url = url_for("/withdraw", external=True, usr=self.user, wal=self.id)
|
||||
try:
|
||||
return lnurl_encode(url)
|
||||
except Exception:
|
||||
return ""
|
||||
def is_lightning_wallet(self) -> bool:
|
||||
return self.wallet_type == WalletType.LIGHTNING.value
|
||||
|
||||
@property
|
||||
def is_lightning_shared_wallet(self) -> bool:
|
||||
return self.wallet_type == WalletType.LIGHTNING_SHARED.value
|
||||
|
||||
def _validate_data(self):
|
||||
if self.is_lightning_shared_wallet:
|
||||
if not self.shared_wallet_id:
|
||||
raise ValueError("Shared wallet ID must be set for shared wallets.")
|
||||
|
||||
|
||||
class CreateWallet(BaseModel):
|
||||
name: str | None = None
|
||||
wallet_type: WalletType = WalletType.LIGHTNING
|
||||
shared_wallet_id: str | None = None
|
||||
|
||||
|
||||
class KeyType(Enum):
|
||||
@@ -78,6 +233,12 @@ class WalletTypeInfo:
|
||||
wallet: Wallet
|
||||
|
||||
|
||||
@dataclass
|
||||
class BaseWalletTypeInfo:
|
||||
key_type: KeyType
|
||||
wallet: BaseWallet
|
||||
|
||||
|
||||
class WalletsFilters(FilterModel):
|
||||
__search_fields__ = ["id", "name", "currency"]
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from .fiat_providers import check_fiat_status
|
||||
from .funding_source import (
|
||||
get_balance_delta,
|
||||
switch_to_voidwallet,
|
||||
@@ -7,6 +8,7 @@ from .notifications import enqueue_admin_notification, send_payment_notification
|
||||
from .payments import (
|
||||
calculate_fiat_amounts,
|
||||
cancel_hold_invoice,
|
||||
check_payment_status,
|
||||
check_transaction_status,
|
||||
check_wallet_limits,
|
||||
create_fiat_invoice,
|
||||
@@ -40,6 +42,8 @@ __all__ = [
|
||||
"calculate_fiat_amounts",
|
||||
"cancel_hold_invoice",
|
||||
"check_admin_settings",
|
||||
"check_fiat_status",
|
||||
"check_payment_status",
|
||||
"check_transaction_status",
|
||||
"check_wallet_limits",
|
||||
"check_webpush_settings",
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
import base64
|
||||
import io
|
||||
from urllib.parse import quote
|
||||
from uuid import uuid4
|
||||
|
||||
import filetype
|
||||
from fastapi import UploadFile
|
||||
from loguru import logger
|
||||
from PIL import Image
|
||||
|
||||
from lnbits.core.crud.assets import create_asset, get_user_assets_count
|
||||
from lnbits.core.models.assets import Asset
|
||||
from lnbits.settings import settings
|
||||
|
||||
IMAGE_MIME_TYPE_ALIASES = {
|
||||
"heic": "image/heic",
|
||||
"heics": "image/heics",
|
||||
"heif": "image/heif",
|
||||
"image/jpg": "image/jpeg",
|
||||
"jpeg": "image/jpeg",
|
||||
"jpg": "image/jpeg",
|
||||
"png": "image/png",
|
||||
}
|
||||
PIL_IMAGE_FORMAT_MIME_TYPES = {
|
||||
"JPEG": "image/jpeg",
|
||||
"PNG": "image/png",
|
||||
}
|
||||
INLINE_ASSET_MIME_TYPES = {
|
||||
"image/heic",
|
||||
"image/heics",
|
||||
"image/heif",
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
}
|
||||
ASSET_SECURITY_HEADERS = {
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"Content-Security-Policy": (
|
||||
"sandbox; default-src 'none'; script-src 'none'; "
|
||||
"object-src 'none'; base-uri 'none'"
|
||||
),
|
||||
}
|
||||
THUMBNAIL_FORMAT_MIME_TYPES = {
|
||||
"jpg": "image/jpeg",
|
||||
"jpeg": "image/jpeg",
|
||||
"png": "image/png",
|
||||
}
|
||||
|
||||
|
||||
async def create_user_asset(user_id: str, file: UploadFile, is_public: bool) -> Asset:
|
||||
if not file.content_type:
|
||||
raise ValueError("File must have a content type.")
|
||||
|
||||
content_type = normalize_asset_mime_type(file.content_type)
|
||||
filename = file.filename or "unnamed"
|
||||
|
||||
if content_type not in allowed_asset_mime_types():
|
||||
raise ValueError(f"File type '{file.content_type}' not allowed.")
|
||||
|
||||
if not settings.is_unlimited_assets_user(user_id):
|
||||
user_assets_count = await get_user_assets_count(user_id)
|
||||
if user_assets_count >= settings.lnbits_max_assets_per_user:
|
||||
raise ValueError(
|
||||
f"Max upload count of {settings.lnbits_max_assets_per_user} exceeded."
|
||||
)
|
||||
|
||||
contents = await file.read()
|
||||
if len(contents) > settings.lnbits_max_asset_size_mb * 1024 * 1024:
|
||||
raise ValueError(
|
||||
f"File limit of {settings.lnbits_max_asset_size_mb}MB exceeded."
|
||||
)
|
||||
|
||||
stored_mime_type = detect_image_mime_type(contents)
|
||||
if stored_mime_type != content_type:
|
||||
logger.warning(
|
||||
"Image MIME type mismatch: declared={}, detected={}",
|
||||
content_type,
|
||||
stored_mime_type,
|
||||
)
|
||||
raise ValueError(
|
||||
"Image file content does not match declared file type. "
|
||||
f"Declared: '{content_type}', detected: '{stored_mime_type}'."
|
||||
)
|
||||
|
||||
thumb_buffer = thumbnail_from_bytes(contents)
|
||||
|
||||
asset = Asset(
|
||||
id=uuid4().hex,
|
||||
user_id=user_id,
|
||||
mime_type=stored_mime_type,
|
||||
is_public=is_public,
|
||||
name=filename,
|
||||
size_bytes=len(contents),
|
||||
thumbnail_base64=(
|
||||
base64.b64encode(thumb_buffer.getvalue()).decode("utf-8")
|
||||
if thumb_buffer
|
||||
else None
|
||||
),
|
||||
data=contents,
|
||||
)
|
||||
|
||||
await create_asset(asset)
|
||||
return asset
|
||||
|
||||
|
||||
def normalize_asset_mime_type(content_type: str) -> str:
|
||||
content_type = content_type.split(";", 1)[0].strip().lower()
|
||||
return IMAGE_MIME_TYPE_ALIASES.get(content_type, content_type)
|
||||
|
||||
|
||||
def normalize_media_type(media_type: str) -> str:
|
||||
return media_type.split(";", 1)[0].strip().lower() or "application/octet-stream"
|
||||
|
||||
|
||||
def thumbnail_media_type() -> str:
|
||||
thumbnail_format = (settings.lnbits_asset_thumbnail_format or "png").strip().lower()
|
||||
return THUMBNAIL_FORMAT_MIME_TYPES.get(thumbnail_format, "application/octet-stream")
|
||||
|
||||
|
||||
def content_disposition(disposition: str, filename: str) -> str:
|
||||
safe_filename = filename or "unnamed"
|
||||
quoted_filename = quote(safe_filename, safe="")
|
||||
if quoted_filename == safe_filename:
|
||||
return f'{disposition}; filename="{safe_filename}"'
|
||||
return f"{disposition}; filename*=utf-8''{quoted_filename}"
|
||||
|
||||
|
||||
def allowed_asset_mime_types() -> set[str]:
|
||||
return {
|
||||
mime_type
|
||||
for mime_type in (
|
||||
normalize_asset_mime_type(mime_type)
|
||||
for mime_type in settings.lnbits_assets_allowed_mime_types
|
||||
)
|
||||
if mime_type.startswith("image/")
|
||||
}
|
||||
|
||||
|
||||
def detect_image_mime_type(contents: bytes) -> str:
|
||||
kind = filetype.guess(contents)
|
||||
mime_type = normalize_asset_mime_type(kind.mime) if kind else None
|
||||
|
||||
if mime_type and mime_type in PIL_IMAGE_FORMAT_MIME_TYPES.values():
|
||||
verify_pil_image(contents, mime_type)
|
||||
return mime_type
|
||||
|
||||
if mime_type and mime_type.startswith("image/"):
|
||||
return mime_type
|
||||
|
||||
try:
|
||||
with Image.open(io.BytesIO(contents)) as image:
|
||||
image.verify()
|
||||
mime_type = PIL_IMAGE_FORMAT_MIME_TYPES.get(image.format or "")
|
||||
except Exception as exc:
|
||||
raise ValueError(
|
||||
"Image file content does not match declared file type."
|
||||
) from exc
|
||||
|
||||
if not mime_type:
|
||||
raise ValueError("Image file content does not match declared file type.")
|
||||
|
||||
return mime_type
|
||||
|
||||
|
||||
def verify_pil_image(contents: bytes, mime_type: str) -> None:
|
||||
try:
|
||||
with Image.open(io.BytesIO(contents)) as image:
|
||||
image.verify()
|
||||
detected_mime_type = PIL_IMAGE_FORMAT_MIME_TYPES.get(image.format or "")
|
||||
except Exception as exc:
|
||||
raise ValueError(
|
||||
"Image file content does not match declared file type."
|
||||
) from exc
|
||||
|
||||
if detected_mime_type != mime_type:
|
||||
raise ValueError("Image file content does not match declared file type.")
|
||||
|
||||
|
||||
def thumbnail_from_bytes(contents: bytes) -> io.BytesIO | None:
|
||||
try:
|
||||
image = Image.open(io.BytesIO(contents))
|
||||
|
||||
thumbnail_width = min(256, settings.lnbits_asset_thumbnail_width)
|
||||
thumbnail_height = min(256, settings.lnbits_asset_thumbnail_height)
|
||||
image.thumbnail((thumbnail_width, thumbnail_height))
|
||||
|
||||
# Save thumbnail to an in-memory buffer
|
||||
thumb_buffer = io.BytesIO()
|
||||
thumbnail_format = settings.lnbits_asset_thumbnail_format or "PNG"
|
||||
image.save(thumb_buffer, format=thumbnail_format)
|
||||
thumb_buffer.seek(0)
|
||||
return thumb_buffer
|
||||
except Exception as exc:
|
||||
logger.warning(f"Failed to create thumbnail: {exc}")
|
||||
return None
|
||||
@@ -1,5 +1,12 @@
|
||||
import asyncio
|
||||
import importlib
|
||||
import re
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from threading import RLock
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from loguru import logger
|
||||
|
||||
@@ -9,20 +16,177 @@ from lnbits.core.crud import (
|
||||
delete_installed_extension,
|
||||
get_db_version,
|
||||
get_installed_extension,
|
||||
get_installed_extensions_count,
|
||||
update_installed_extension_state,
|
||||
)
|
||||
from lnbits.core.crud.extensions import (
|
||||
create_wasm_invocation,
|
||||
delete_old_wasm_invocations,
|
||||
get_installed_extensions,
|
||||
get_wasm_invocation,
|
||||
mark_stale_wasm_invocations,
|
||||
update_installed_extension,
|
||||
update_installed_extension_wasm_runtime_limits,
|
||||
update_wasm_invocation,
|
||||
)
|
||||
from lnbits.core.crud.extensions import (
|
||||
get_wasm_invocation_stats as get_wasm_invocation_stats_crud,
|
||||
)
|
||||
from lnbits.core.crud.extensions import (
|
||||
get_wasm_invocations as get_wasm_invocations_crud,
|
||||
)
|
||||
from lnbits.core.helpers import migrate_extension_database
|
||||
from lnbits.settings import settings
|
||||
from lnbits.core.wasm_ext.api.permissions import validate_wasm_extension_permissions
|
||||
from lnbits.core.wasm_ext.wasm.loader import is_wasm_extension_id
|
||||
from lnbits.db import Connection
|
||||
from lnbits.settings import WasmRuntimeLimits, settings
|
||||
|
||||
from ..models.extensions import Extension, ExtensionMeta, InstallableExtension
|
||||
from ..models.extensions import (
|
||||
Extension,
|
||||
ExtensionMeta,
|
||||
ExtensionPermission,
|
||||
InstallableExtension,
|
||||
WasmInvocation,
|
||||
WasmInvocationStats,
|
||||
)
|
||||
|
||||
_WASM_INVOCATION_CLEANUP_INTERVAL = timedelta(hours=1)
|
||||
WASM_RUNTIME_LIMIT_FIELDS = tuple(WasmRuntimeLimits.__fields__.keys())
|
||||
|
||||
|
||||
@dataclass
|
||||
class WasmInvocationHandle:
|
||||
invocation: WasmInvocation
|
||||
engine: Any | None = None
|
||||
store: Any | None = None
|
||||
runtime_limits: dict[str, int] | None = None
|
||||
stop_requested: bool = False
|
||||
stop_reason: str | None = None
|
||||
|
||||
|
||||
_wasm_invocation_lock = RLock()
|
||||
_wasm_invocation_ready_lock = asyncio.Lock()
|
||||
_wasm_invocation_handles: dict[str, WasmInvocationHandle] = {}
|
||||
_wasm_invocations_marked_stale = False
|
||||
_wasm_invocations_last_cleanup_at: datetime | None = None
|
||||
|
||||
|
||||
def wasm_runtime_limit_defaults() -> dict[str, int]:
|
||||
return {field: int(getattr(settings, field)) for field in WASM_RUNTIME_LIMIT_FIELDS}
|
||||
|
||||
|
||||
def validate_wasm_runtime_limit_overrides(
|
||||
limits: Mapping[str, Any] | None,
|
||||
*,
|
||||
strict: bool = True,
|
||||
) -> dict[str, int]:
|
||||
if not limits:
|
||||
return {}
|
||||
|
||||
validated: dict[str, int] = {}
|
||||
for field, raw_value in limits.items():
|
||||
if field not in WASM_RUNTIME_LIMIT_FIELDS:
|
||||
if strict:
|
||||
raise ValueError(f"Unknown WASM runtime limit field '{field}'.")
|
||||
continue
|
||||
|
||||
value = _validate_wasm_runtime_limit_value(field, raw_value, strict=strict)
|
||||
if value is None:
|
||||
continue
|
||||
validated[field] = value
|
||||
|
||||
return validated
|
||||
|
||||
|
||||
def _validate_wasm_runtime_limit_value(
|
||||
field: str,
|
||||
raw_value: Any,
|
||||
*,
|
||||
strict: bool,
|
||||
) -> int | None:
|
||||
if raw_value is None or raw_value == "":
|
||||
return None
|
||||
if isinstance(raw_value, bool):
|
||||
return _invalid_wasm_runtime_limit(field, strict, "must be an integer")
|
||||
if isinstance(raw_value, str):
|
||||
raw_value = raw_value.strip()
|
||||
if raw_value == "":
|
||||
return None
|
||||
if not raw_value.isdecimal():
|
||||
return _invalid_wasm_runtime_limit(field, strict, "must be an integer")
|
||||
if isinstance(raw_value, float) and not raw_value.is_integer():
|
||||
return _invalid_wasm_runtime_limit(field, strict, "must be an integer")
|
||||
|
||||
try:
|
||||
value = int(raw_value)
|
||||
except (TypeError, ValueError) as exc:
|
||||
return _invalid_wasm_runtime_limit(
|
||||
field,
|
||||
strict,
|
||||
"must be an integer",
|
||||
exc=exc,
|
||||
)
|
||||
if value < 0:
|
||||
return _invalid_wasm_runtime_limit(field, strict, "cannot be negative")
|
||||
return value
|
||||
|
||||
|
||||
def _invalid_wasm_runtime_limit(
|
||||
field: str,
|
||||
strict: bool,
|
||||
message: str,
|
||||
*,
|
||||
exc: Exception | None = None,
|
||||
) -> int | None:
|
||||
if not strict:
|
||||
return None
|
||||
error = ValueError(f"WASM runtime limit '{field}' {message}.")
|
||||
if exc:
|
||||
raise error from exc
|
||||
raise error
|
||||
|
||||
|
||||
def resolve_wasm_runtime_limits(
|
||||
installed_extension: InstallableExtension | None = None,
|
||||
) -> dict[str, int]:
|
||||
limits = wasm_runtime_limit_defaults()
|
||||
if installed_extension:
|
||||
limits.update(
|
||||
validate_wasm_runtime_limit_overrides(
|
||||
installed_extension.wasm_runtime_limits,
|
||||
strict=False,
|
||||
)
|
||||
)
|
||||
return limits
|
||||
|
||||
|
||||
async def get_wasm_runtime_limits_for_extension(ext_id: str) -> dict[str, int]:
|
||||
installed_extension = await get_installed_extension(ext_id)
|
||||
return resolve_wasm_runtime_limits(installed_extension)
|
||||
|
||||
|
||||
async def update_wasm_extension_runtime_limits(
|
||||
ext_id: str,
|
||||
limits: Mapping[str, Any] | None,
|
||||
) -> dict[str, int]:
|
||||
installed_extension = await get_installed_extension(ext_id)
|
||||
if not installed_extension:
|
||||
raise ValueError(f"Extension '{ext_id}' is not installed.")
|
||||
if not installed_extension.is_wasm:
|
||||
raise ValueError(f"Extension '{ext_id}' is not a WASM extension.")
|
||||
|
||||
validated_limits = validate_wasm_runtime_limit_overrides(limits)
|
||||
await update_installed_extension_wasm_runtime_limits(
|
||||
ext_id=ext_id,
|
||||
limits=validated_limits,
|
||||
)
|
||||
return validated_limits
|
||||
|
||||
|
||||
async def install_extension(
|
||||
ext_info: InstallableExtension, skip_download: bool | None = False
|
||||
ext_info: InstallableExtension,
|
||||
skip_download: bool | None = False,
|
||||
granted_permissions: list[ExtensionPermission] | None = None,
|
||||
) -> Extension:
|
||||
|
||||
ext_info.meta = ext_info.meta or ExtensionMeta()
|
||||
@@ -36,10 +200,19 @@ async def install_extension(
|
||||
installed_ext = await get_installed_extension(ext_info.id)
|
||||
if installed_ext and installed_ext.meta:
|
||||
ext_info.meta.payments = installed_ext.meta.payments
|
||||
if installed_ext:
|
||||
ext_info.wasm_runtime_limits = installed_ext.wasm_runtime_limits
|
||||
|
||||
await check_extensions_limit(installed_ext)
|
||||
|
||||
if not skip_download:
|
||||
await ext_info.download_archive()
|
||||
|
||||
extension_config = ext_info.load_archive_config()
|
||||
ext_info.permissions = validate_wasm_extension_permissions(
|
||||
ext_info, granted_permissions, extension_config
|
||||
)
|
||||
|
||||
ext_info.extract_archive()
|
||||
|
||||
db_version = await get_db_version(ext_info.id)
|
||||
@@ -57,11 +230,409 @@ async def install_extension(
|
||||
# call stop while the old routes are still active
|
||||
await stop_extension_background_work(ext_info.id)
|
||||
|
||||
await start_extension_background_work(ext_info.id)
|
||||
if not extension.is_wasm:
|
||||
await start_extension_background_work(ext_info.id)
|
||||
|
||||
return extension
|
||||
|
||||
|
||||
async def check_extensions_limit(installed_ext: InstallableExtension | None = None):
|
||||
if settings.lnbits_max_extensions == 0 or installed_ext:
|
||||
return
|
||||
|
||||
extensions_count = await get_installed_extensions_count()
|
||||
if extensions_count >= settings.lnbits_max_extensions:
|
||||
raise ValueError("Max amount of extensions have been installed")
|
||||
|
||||
|
||||
async def ensure_wasm_invocation_monitoring_ready() -> None:
|
||||
global _wasm_invocations_last_cleanup_at, _wasm_invocations_marked_stale
|
||||
|
||||
async with _wasm_invocation_ready_lock:
|
||||
now = _now()
|
||||
if not _wasm_invocations_marked_stale:
|
||||
await mark_stale_wasm_invocations()
|
||||
_wasm_invocations_marked_stale = True
|
||||
|
||||
if (
|
||||
_wasm_invocations_last_cleanup_at is None
|
||||
or now - _wasm_invocations_last_cleanup_at
|
||||
>= _WASM_INVOCATION_CLEANUP_INTERVAL
|
||||
):
|
||||
_wasm_invocations_last_cleanup_at = now
|
||||
await delete_old_wasm_invocations(
|
||||
settings.lnbits_wasm_invocation_retention_days
|
||||
)
|
||||
|
||||
|
||||
async def start_wasm_invocation(
|
||||
*,
|
||||
extension_id: str,
|
||||
export_name: str,
|
||||
trigger_type: str = "unknown",
|
||||
user_id: str | None = None,
|
||||
wallet_id: str | None = None,
|
||||
request_id: str | None = None,
|
||||
method: str | None = None,
|
||||
path: str | None = None,
|
||||
event_type: str | None = None,
|
||||
payment_hash: str | None = None,
|
||||
checking_id: str | None = None,
|
||||
request_bytes: int | None = None,
|
||||
context: dict | None = None,
|
||||
runtime_limits: dict[str, int] | None = None,
|
||||
) -> WasmInvocation:
|
||||
await ensure_wasm_invocation_monitoring_ready()
|
||||
_check_wasm_invocation_concurrency(
|
||||
extension_id=extension_id,
|
||||
user_id=user_id,
|
||||
limits=runtime_limits,
|
||||
)
|
||||
|
||||
invocation = WasmInvocation(
|
||||
id=uuid4().hex,
|
||||
extension_id=extension_id,
|
||||
export_name=export_name,
|
||||
trigger_type=trigger_type,
|
||||
user_id=user_id,
|
||||
wallet_id=wallet_id,
|
||||
request_id=request_id,
|
||||
method=method,
|
||||
path=path,
|
||||
event_type=event_type,
|
||||
payment_hash=payment_hash,
|
||||
checking_id=checking_id,
|
||||
request_bytes=request_bytes,
|
||||
context=_safe_wasm_invocation_context(context or {}),
|
||||
)
|
||||
await create_wasm_invocation(invocation)
|
||||
|
||||
with _wasm_invocation_lock:
|
||||
_wasm_invocation_handles[invocation.id] = WasmInvocationHandle(
|
||||
invocation,
|
||||
runtime_limits=runtime_limits,
|
||||
)
|
||||
|
||||
return invocation
|
||||
|
||||
|
||||
def attach_wasm_invocation_runtime(
|
||||
invocation_id: str,
|
||||
*,
|
||||
engine: Any,
|
||||
store: Any,
|
||||
) -> None:
|
||||
with _wasm_invocation_lock:
|
||||
handle = _wasm_invocation_handles.get(invocation_id)
|
||||
if not handle:
|
||||
return
|
||||
handle.engine = engine
|
||||
handle.store = store
|
||||
if handle.stop_requested:
|
||||
_interrupt_wasm_invocation(handle)
|
||||
|
||||
|
||||
def record_wasm_invocation_host_call(
|
||||
invocation_id: str | None,
|
||||
method_id: str,
|
||||
) -> None:
|
||||
if not invocation_id:
|
||||
return
|
||||
|
||||
with _wasm_invocation_lock:
|
||||
handle = _wasm_invocation_handles.get(invocation_id)
|
||||
if not handle:
|
||||
return
|
||||
|
||||
invocation = handle.invocation
|
||||
invocation.host_call_count += 1
|
||||
category = _wasm_host_call_category(method_id)
|
||||
if category == "http":
|
||||
invocation.http_call_count += 1
|
||||
elif category == "storage":
|
||||
invocation.storage_call_count += 1
|
||||
elif category == "wallet":
|
||||
invocation.wallet_call_count += 1
|
||||
|
||||
_check_wasm_host_call_limit(invocation, category, handle.runtime_limits)
|
||||
|
||||
|
||||
async def stop_wasm_invocation(
|
||||
invocation_id: str,
|
||||
*,
|
||||
reason: str = "Stopped by admin.",
|
||||
) -> bool:
|
||||
interrupted = False
|
||||
with _wasm_invocation_lock:
|
||||
handle = _wasm_invocation_handles.get(invocation_id)
|
||||
if handle:
|
||||
handle.stop_requested = True
|
||||
handle.stop_reason = reason
|
||||
handle.invocation.stop_reason = reason
|
||||
interrupted = _interrupt_wasm_invocation(handle)
|
||||
|
||||
invocation = await get_wasm_invocation(invocation_id)
|
||||
if invocation and invocation.status == "running":
|
||||
invocation.stop_reason = reason
|
||||
await update_wasm_invocation(invocation)
|
||||
|
||||
return interrupted
|
||||
|
||||
|
||||
async def stop_wasm_extension_invocations(
|
||||
extension_id: str,
|
||||
*,
|
||||
reason: str = "Extension deactivated.",
|
||||
) -> int:
|
||||
with _wasm_invocation_lock:
|
||||
invocation_ids = [
|
||||
invocation_id
|
||||
for invocation_id, handle in _wasm_invocation_handles.items()
|
||||
if handle.invocation.extension_id == extension_id
|
||||
]
|
||||
|
||||
for invocation_id in invocation_ids:
|
||||
await stop_wasm_invocation(invocation_id, reason=reason)
|
||||
|
||||
return len(invocation_ids)
|
||||
|
||||
|
||||
def wasm_invocation_stop_requested(invocation_id: str) -> bool:
|
||||
with _wasm_invocation_lock:
|
||||
handle = _wasm_invocation_handles.get(invocation_id)
|
||||
return bool(handle and handle.stop_requested)
|
||||
|
||||
|
||||
def get_wasm_invocation_stop_reason(invocation_id: str) -> str | None:
|
||||
with _wasm_invocation_lock:
|
||||
handle = _wasm_invocation_handles.get(invocation_id)
|
||||
return handle.stop_reason if handle else None
|
||||
|
||||
|
||||
async def finish_wasm_invocation(
|
||||
invocation_id: str,
|
||||
*,
|
||||
status: str,
|
||||
response_bytes: int | None = None,
|
||||
memory_peak_bytes: int | None = None,
|
||||
error_type: str | None = None,
|
||||
error_message: str | None = None,
|
||||
stop_reason: str | None = None,
|
||||
) -> None:
|
||||
with _wasm_invocation_lock:
|
||||
handle = _wasm_invocation_handles.pop(invocation_id, None)
|
||||
|
||||
invocation = (
|
||||
handle.invocation if handle else await get_wasm_invocation(invocation_id)
|
||||
)
|
||||
if not invocation:
|
||||
return
|
||||
|
||||
reason = stop_reason or (handle.stop_reason if handle else None)
|
||||
if handle and handle.stop_requested and status == "failed":
|
||||
status = "stopped"
|
||||
reason = reason or "Stopped by admin."
|
||||
|
||||
finished_at = _now()
|
||||
invocation.status = status
|
||||
invocation.finished_at = finished_at
|
||||
invocation.duration_ms = max(
|
||||
0, int((finished_at - invocation.started_at).total_seconds() * 1000)
|
||||
)
|
||||
invocation.response_bytes = response_bytes
|
||||
invocation.memory_peak_bytes = memory_peak_bytes
|
||||
invocation.error_type = error_type
|
||||
invocation.error_message = _safe_wasm_error_message(error_message)
|
||||
invocation.stop_reason = reason
|
||||
|
||||
await update_wasm_invocation(invocation)
|
||||
|
||||
|
||||
def get_current_wasm_invocations(
|
||||
extension_id: str | None = None,
|
||||
) -> list[WasmInvocation]:
|
||||
with _wasm_invocation_lock:
|
||||
invocations = []
|
||||
for handle in _wasm_invocation_handles.values():
|
||||
if extension_id and handle.invocation.extension_id != extension_id:
|
||||
continue
|
||||
invocation = handle.invocation.copy(deep=True)
|
||||
if handle.stop_requested and invocation.status == "running":
|
||||
invocation.status = "stopping"
|
||||
invocation.stop_reason = handle.stop_reason
|
||||
invocations.append(invocation)
|
||||
|
||||
return sorted(
|
||||
invocations, key=lambda invocation: invocation.started_at, reverse=True
|
||||
)
|
||||
|
||||
|
||||
def _check_wasm_invocation_concurrency(
|
||||
*,
|
||||
extension_id: str,
|
||||
user_id: str | None,
|
||||
limits: dict[str, int] | None,
|
||||
) -> None:
|
||||
if not limits:
|
||||
return
|
||||
|
||||
with _wasm_invocation_lock:
|
||||
handles = list(_wasm_invocation_handles.values())
|
||||
if _wasm_limit_exceeded(
|
||||
limits["wasm_runtime_max_concurrent_invocations"],
|
||||
len(handles) + 1,
|
||||
):
|
||||
raise ValueError("WASM runtime has too many active invocations.")
|
||||
|
||||
extension_invocations = sum(
|
||||
1 for handle in handles if handle.invocation.extension_id == extension_id
|
||||
)
|
||||
if _wasm_limit_exceeded(
|
||||
limits["wasm_runtime_max_concurrent_invocations_per_extension"],
|
||||
extension_invocations + 1,
|
||||
):
|
||||
raise ValueError(
|
||||
f"WASM extension '{extension_id}' has too many active invocations."
|
||||
)
|
||||
|
||||
if not user_id:
|
||||
return
|
||||
|
||||
user_invocations = sum(
|
||||
1 for handle in handles if handle.invocation.user_id == user_id
|
||||
)
|
||||
if _wasm_limit_exceeded(
|
||||
limits["wasm_runtime_max_concurrent_invocations_per_user"],
|
||||
user_invocations + 1,
|
||||
):
|
||||
raise ValueError("WASM user has too many active invocations.")
|
||||
|
||||
|
||||
def _check_wasm_host_call_limit(
|
||||
invocation: WasmInvocation,
|
||||
category: str,
|
||||
limits: dict[str, int] | None,
|
||||
) -> None:
|
||||
if not limits:
|
||||
return
|
||||
|
||||
if _wasm_limit_exceeded(
|
||||
limits["wasm_runtime_max_host_calls"],
|
||||
invocation.host_call_count,
|
||||
):
|
||||
raise ValueError("WASM host call limit exceeded.")
|
||||
|
||||
category_limits = {
|
||||
"http": (
|
||||
limits["wasm_runtime_max_http_calls"],
|
||||
invocation.http_call_count,
|
||||
),
|
||||
"storage": (
|
||||
limits["wasm_runtime_max_storage_calls"],
|
||||
invocation.storage_call_count,
|
||||
),
|
||||
"wallet": (
|
||||
limits["wasm_runtime_max_wallet_calls"],
|
||||
invocation.wallet_call_count,
|
||||
),
|
||||
}
|
||||
category_limit = category_limits.get(category)
|
||||
if category_limit and _wasm_limit_exceeded(*category_limit):
|
||||
raise ValueError(f"WASM {category} host call limit exceeded.")
|
||||
|
||||
|
||||
def _wasm_limit_exceeded(limit: int, value: int) -> bool:
|
||||
return limit > 0 and value > limit
|
||||
|
||||
|
||||
async def get_wasm_invocation_history(
|
||||
*,
|
||||
extension_id: str | None = None,
|
||||
status: str | None = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
) -> list[WasmInvocation]:
|
||||
await ensure_wasm_invocation_monitoring_ready()
|
||||
return await get_wasm_invocations_crud(
|
||||
extension_id=extension_id,
|
||||
status=status,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
|
||||
async def get_wasm_invocation_summary(
|
||||
*,
|
||||
extension_id: str | None = None,
|
||||
hours: int = 24,
|
||||
) -> WasmInvocationStats:
|
||||
await ensure_wasm_invocation_monitoring_ready()
|
||||
since = _now() - timedelta(hours=max(1, min(hours, 24 * 30)))
|
||||
return await get_wasm_invocation_stats_crud(
|
||||
extension_id=extension_id,
|
||||
since=since,
|
||||
)
|
||||
|
||||
|
||||
def _interrupt_wasm_invocation(handle: WasmInvocationHandle) -> bool:
|
||||
if not handle.store or not handle.engine:
|
||||
return False
|
||||
try:
|
||||
handle.store.set_epoch_deadline(1)
|
||||
handle.engine.increment_epoch()
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
f"Failed to interrupt WASM invocation '{handle.invocation.id}': {exc}"
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def _wasm_host_call_category(method_id: str) -> str:
|
||||
if method_id.startswith("http.") or method_id.startswith("extension.api."):
|
||||
return "http"
|
||||
if method_id.startswith("storage."):
|
||||
return "storage"
|
||||
if method_id.startswith("wallet."):
|
||||
return "wallet"
|
||||
return "host"
|
||||
|
||||
|
||||
def _safe_wasm_invocation_context(context: dict) -> dict:
|
||||
safe_context: dict = {}
|
||||
for key, value in context.items():
|
||||
if not isinstance(key, str):
|
||||
continue
|
||||
if value is None or isinstance(value, (bool, int, float)):
|
||||
safe_context[key[:64]] = value
|
||||
elif isinstance(value, str):
|
||||
safe_context[key[:64]] = value[:256]
|
||||
return safe_context
|
||||
|
||||
|
||||
def _safe_wasm_error_message(message: str | None) -> str | None:
|
||||
if not message:
|
||||
return None
|
||||
|
||||
safe_message = message[:500]
|
||||
redactions = [
|
||||
(
|
||||
r"(?i)(api[-_ ]?key|token|authorization|password|secret|preimage)"
|
||||
r"\s*[:=]\s*[^\s,;]+",
|
||||
r"\1=[redacted]",
|
||||
),
|
||||
(r"(?i)bearer\s+[A-Za-z0-9._~+/=-]+", "Bearer [redacted]"),
|
||||
(r"\b[a-fA-F0-9]{64}\b", "[redacted-hex]"),
|
||||
]
|
||||
for pattern, replacement in redactions:
|
||||
safe_message = re.sub(pattern, replacement, safe_message)
|
||||
return safe_message
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
async def uninstall_extension(ext_id: str):
|
||||
await stop_extension_background_work(ext_id)
|
||||
|
||||
@@ -74,12 +645,19 @@ async def uninstall_extension(ext_id: str):
|
||||
|
||||
|
||||
async def activate_extension(ext: Extension):
|
||||
if ext.is_wasm:
|
||||
core_app_extra.register_new_wasm_ext_routes(ext.code)
|
||||
await update_installed_extension_state(ext_id=ext.code, active=True)
|
||||
return
|
||||
|
||||
core_app_extra.register_new_ext_routes(ext)
|
||||
await update_installed_extension_state(ext_id=ext.code, active=True)
|
||||
await start_extension_background_work(ext.code)
|
||||
|
||||
|
||||
async def deactivate_extension(ext_id: str):
|
||||
if is_wasm_extension_id(ext_id):
|
||||
await stop_wasm_extension_invocations(ext_id, reason="Extension deactivated.")
|
||||
settings.deactivate_extension_paths(ext_id)
|
||||
await update_installed_extension_state(ext_id=ext_id, active=False)
|
||||
await stop_extension_background_work(ext_id)
|
||||
@@ -90,6 +668,9 @@ async def stop_extension_background_work(ext_id: str) -> bool:
|
||||
Stop background work for extension (like asyncio.Tasks, WebSockets, etc).
|
||||
Extension must expose a `myextension_stop()` function if it is starting tasks.
|
||||
"""
|
||||
if is_wasm_extension_id(ext_id):
|
||||
return True
|
||||
|
||||
upgrade_hash = settings.extension_upgrade_hash(ext_id)
|
||||
ext = Extension(code=ext_id, is_valid=True, upgrade_hash=upgrade_hash)
|
||||
|
||||
@@ -122,6 +703,9 @@ async def start_extension_background_work(ext_id: str) -> bool:
|
||||
Extension CAN expose a `myextension_start()` function if it is starting tasks.
|
||||
Extension MUST expose a `myextension_stop()` in that case.
|
||||
"""
|
||||
if is_wasm_extension_id(ext_id):
|
||||
return False
|
||||
|
||||
upgrade_hash = settings.extension_upgrade_hash(ext_id)
|
||||
ext = Extension(code=ext_id, is_valid=True, upgrade_hash=upgrade_hash)
|
||||
|
||||
@@ -149,9 +733,9 @@ async def start_extension_background_work(ext_id: str) -> bool:
|
||||
|
||||
|
||||
async def get_valid_extensions(
|
||||
include_deactivated: bool | None = True,
|
||||
include_deactivated: bool | None = True, conn: Connection | None = None
|
||||
) -> list[Extension]:
|
||||
installed_extensions = await get_installed_extensions()
|
||||
installed_extensions = await get_installed_extensions(conn=conn)
|
||||
valid_extensions = [Extension.from_installable_ext(e) for e in installed_extensions]
|
||||
|
||||
if include_deactivated:
|
||||
|
||||
@@ -20,6 +20,7 @@ from lnbits.helpers import (
|
||||
camel_to_words,
|
||||
download_url,
|
||||
lowercase_first_letter,
|
||||
snake_to_camel,
|
||||
)
|
||||
from lnbits.settings import settings
|
||||
|
||||
@@ -195,13 +196,29 @@ def _copy_ext_stub_to_build_dir(
|
||||
ext_build_dir = Path(working_dir, new_ext_id, working_dir_name, new_ext_id)
|
||||
shutil.rmtree(ext_build_dir, True)
|
||||
|
||||
shutil.copytree(ext_stub_cache_dir, ext_build_dir)
|
||||
shutil.copytree(
|
||||
ext_stub_cache_dir,
|
||||
ext_build_dir,
|
||||
ignore=shutil.ignore_patterns(
|
||||
"__pycache__",
|
||||
".git",
|
||||
".env",
|
||||
".venv",
|
||||
"*.env",
|
||||
"*.log",
|
||||
"node_modules",
|
||||
".mypy_cache",
|
||||
".pytest_cache",
|
||||
".ruff_cache",
|
||||
),
|
||||
)
|
||||
return ext_build_dir
|
||||
|
||||
|
||||
def _replace_jinja_placeholders(data: ExtensionData, ext_stub_dir: Path) -> None:
|
||||
parsed_data = _parse_extension_data(data)
|
||||
for py_file in py_files:
|
||||
test_files = [f"tests/{p.name}" for p in Path(ext_stub_dir, "tests").glob("*.py")]
|
||||
for py_file in py_files + test_files:
|
||||
template_path = Path(ext_stub_dir, py_file).as_posix()
|
||||
rederer = _render_file(template_path, parsed_data)
|
||||
with open(template_path, "w", encoding="utf-8") as f:
|
||||
@@ -209,7 +226,7 @@ def _replace_jinja_placeholders(data: ExtensionData, ext_stub_dir: Path) -> None
|
||||
|
||||
_remove_lines_with_string(template_path, remove_line_marker)
|
||||
|
||||
template_path = Path(ext_stub_dir, "static", "js", "index.js").as_posix()
|
||||
template_path = Path(ext_stub_dir, "static", "index.js").as_posix()
|
||||
rederer = _render_file(
|
||||
template_path, {"preview": data.preview_action, **parsed_data}
|
||||
)
|
||||
@@ -234,9 +251,7 @@ def _replace_jinja_placeholders(data: ExtensionData, ext_stub_dir: Path) -> None
|
||||
"settingsFormDialog.data",
|
||||
ext_stub_dir,
|
||||
)
|
||||
template_path = Path(
|
||||
ext_stub_dir, "templates", "extension_builder_stub", "index.html"
|
||||
).as_posix()
|
||||
template_path = Path(ext_stub_dir, "static", "index.vue").as_posix()
|
||||
rederer = _render_file(
|
||||
template_path,
|
||||
{
|
||||
@@ -263,12 +278,12 @@ def _replace_jinja_placeholders(data: ExtensionData, ext_stub_dir: Path) -> None
|
||||
"publicClientData",
|
||||
ext_stub_dir,
|
||||
)
|
||||
public_template_path = Path(
|
||||
ext_stub_dir, "templates", "extension_builder_stub", "public_page.html"
|
||||
)
|
||||
public_template_path = Path(ext_stub_dir, "static", "public_page.vue")
|
||||
public_component_path = Path(ext_stub_dir, "static", "public_page.js")
|
||||
template_path = public_template_path.as_posix()
|
||||
if not data.public_page.has_public_page:
|
||||
public_template_path.unlink(missing_ok=True)
|
||||
public_component_path.unlink(missing_ok=True)
|
||||
else:
|
||||
rederer = _render_file(
|
||||
template_path,
|
||||
@@ -308,8 +323,10 @@ def zip_directory(source_dir, zip_path):
|
||||
|
||||
def _rename_extension_builder_stub(data: ExtensionData, extension_dir: Path) -> None:
|
||||
extension_dir_path = extension_dir.as_posix()
|
||||
# the order of fields is important, do not chage
|
||||
rename_values = {
|
||||
"extension_builder_stub_name": data.name,
|
||||
"extension_builder_stub_camel_name": snake_to_camel(data.id, True),
|
||||
"extension_builder_stub_short_description": data.short_description or "",
|
||||
"extension_builder_stub": data.id,
|
||||
"OwnerData": data.owner_data.name,
|
||||
@@ -328,7 +345,7 @@ def _rename_extension_builder_stub(data: ExtensionData, extension_dir: Path) ->
|
||||
directory=extension_dir_path,
|
||||
old_text=old_text,
|
||||
new_text=new_text,
|
||||
file_extensions=[".py", ".js", ".html", ".md", ".json", ".toml"],
|
||||
file_extensions=[".py", ".js", ".vue", ".html", ".md", ".json", ".toml"],
|
||||
)
|
||||
|
||||
_rename_files_and_dirs_in_directory(
|
||||
@@ -406,6 +423,7 @@ def _parse_extension_data(data: ExtensionData) -> dict:
|
||||
"owner_data": {
|
||||
"name": data.owner_data.name,
|
||||
"editable": data.owner_data.editable,
|
||||
"fields": [field.name for field in data.owner_data.fields],
|
||||
"js_fields": [
|
||||
field.field_to_js()
|
||||
for field in data.owner_data.fields
|
||||
@@ -432,6 +450,18 @@ def _parse_extension_data(data: ExtensionData) -> dict:
|
||||
],
|
||||
"db_fields": [field.field_to_db() for field in data.owner_data.fields],
|
||||
"all_fields": [field.field_to_py() for field in data.owner_data.fields],
|
||||
"random_fields_values": [
|
||||
field.field_to_random_value() for field in data.owner_data.fields
|
||||
],
|
||||
"public_fields": [
|
||||
field.field_to_py()
|
||||
for field in data.owner_data.fields
|
||||
if field.name
|
||||
in [
|
||||
data.public_page.owner_data_fields.name,
|
||||
data.public_page.owner_data_fields.description,
|
||||
]
|
||||
],
|
||||
},
|
||||
"client_data": {
|
||||
"name": data.client_data.name,
|
||||
@@ -457,6 +487,9 @@ def _parse_extension_data(data: ExtensionData) -> dict:
|
||||
],
|
||||
"db_fields": [field.field_to_db() for field in data.client_data.fields],
|
||||
"all_fields": [field.field_to_py() for field in data.client_data.fields],
|
||||
"random_fields_values": [
|
||||
field.field_to_random_value() for field in data.client_data.fields
|
||||
],
|
||||
},
|
||||
"settings_data": {
|
||||
"enabled": data.settings_data.enabled,
|
||||
@@ -536,7 +569,7 @@ def _rename_files_and_dirs_in_directory(directory, old_text, new_text):
|
||||
logger.warning(f"Failed to rename directory {old_dir_path}: {e}")
|
||||
|
||||
|
||||
def _is_excluded_dir(path):
|
||||
def _is_excluded_dir(path: str) -> bool:
|
||||
for excluded_dir in excluded_dirs:
|
||||
if path.startswith(excluded_dir):
|
||||
return True
|
||||
|
||||
@@ -1,16 +1,26 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import time
|
||||
from base64 import b64encode
|
||||
|
||||
import httpx
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.core.crud import get_wallet
|
||||
from lnbits.core.crud.payments import create_payment, get_standalone_payment
|
||||
from lnbits.core.crud.payments import create_payment, update_payment
|
||||
from lnbits.core.models import CreatePayment, Payment, PaymentState
|
||||
from lnbits.core.models.misc import SimpleStatus
|
||||
from lnbits.db import Connection
|
||||
from lnbits.fiat import get_fiat_provider
|
||||
from lnbits.fiat.base import (
|
||||
FiatPaymentFailedStatus,
|
||||
FiatPaymentPendingStatus,
|
||||
FiatPaymentStatus,
|
||||
FiatPaymentSuccessStatus,
|
||||
)
|
||||
from lnbits.settings import settings
|
||||
from lnbits.task_manager import task_manager
|
||||
|
||||
|
||||
async def handle_fiat_payment_confirmation(
|
||||
@@ -27,6 +37,235 @@ async def handle_fiat_payment_confirmation(
|
||||
logger.warning(e)
|
||||
|
||||
|
||||
async def check_fiat_status(payment: Payment) -> FiatPaymentStatus:
|
||||
if not payment.is_internal:
|
||||
return FiatPaymentPendingStatus()
|
||||
if payment.success:
|
||||
return FiatPaymentSuccessStatus()
|
||||
if payment.failed:
|
||||
return FiatPaymentFailedStatus()
|
||||
|
||||
if not payment.fiat_provider:
|
||||
return FiatPaymentPendingStatus()
|
||||
|
||||
checking_id = payment.extra.get("fiat_checking_id")
|
||||
if not checking_id:
|
||||
return FiatPaymentPendingStatus()
|
||||
|
||||
fiat_provider = await get_fiat_provider(payment.fiat_provider)
|
||||
if not fiat_provider:
|
||||
return FiatPaymentPendingStatus()
|
||||
fiat_status = await fiat_provider.get_invoice_status(checking_id)
|
||||
|
||||
if fiat_status.success:
|
||||
payment.status = PaymentState.SUCCESS.value
|
||||
await update_payment(payment)
|
||||
await handle_fiat_payment_confirmation(payment)
|
||||
task_manager.internal_invoice_queue.put_nowait(payment)
|
||||
|
||||
return fiat_status
|
||||
|
||||
|
||||
def check_stripe_signature(
|
||||
payload: bytes,
|
||||
sig_header: str | None,
|
||||
secret: str | None,
|
||||
tolerance_seconds=300,
|
||||
):
|
||||
if not sig_header:
|
||||
logger.warning("Stripe-Signature header is missing.")
|
||||
raise ValueError("Stripe-Signature header is missing.")
|
||||
|
||||
if not secret:
|
||||
logger.warning("Stripe webhook signing secret is not set.")
|
||||
raise ValueError("Stripe webhook cannot be verified.")
|
||||
|
||||
# Split the Stripe-Signature header
|
||||
items = dict(i.split("=") for i in sig_header.split(","))
|
||||
timestamp = int(items["t"])
|
||||
signature = items["v1"]
|
||||
|
||||
# Check timestamp tolerance
|
||||
if abs(time.time() - timestamp) > tolerance_seconds:
|
||||
logger.warning("Timestamp outside tolerance.")
|
||||
logger.debug(
|
||||
f"Current time: {time.time()}, "
|
||||
f"Timestamp: {timestamp}, "
|
||||
f"Tolerance: {tolerance_seconds} seconds"
|
||||
)
|
||||
|
||||
raise ValueError("Timestamp outside tolerance." f"Timestamp: {timestamp}")
|
||||
|
||||
signed_payload = f"{timestamp}.{payload.decode()}"
|
||||
|
||||
# Compute HMAC SHA256 using the webhook secret
|
||||
computed_signature = hmac.new(
|
||||
key=secret.encode(), msg=signed_payload.encode(), digestmod=hashlib.sha256
|
||||
).hexdigest()
|
||||
|
||||
# Compare signatures using constant time comparison
|
||||
if hmac.compare_digest(computed_signature, signature) is not True:
|
||||
logger.warning("Stripe signature verification failed.")
|
||||
raise ValueError("Stripe signature verification failed.")
|
||||
|
||||
|
||||
async def verify_paypal_webhook(headers, payload: bytes):
|
||||
"""
|
||||
Validate PayPal webhook signatures using the PayPal verify API.
|
||||
"""
|
||||
webhook_id = settings.paypal_webhook_id
|
||||
if not webhook_id:
|
||||
logger.warning("PayPal webhook ID not set.")
|
||||
raise ValueError("PayPal webhook cannot be verified. Missing webhook ID.")
|
||||
|
||||
required_headers = {
|
||||
"PAYPAL-TRANSMISSION-ID": headers.get("PAYPAL-TRANSMISSION-ID"),
|
||||
"PAYPAL-TRANSMISSION-TIME": headers.get("PAYPAL-TRANSMISSION-TIME"),
|
||||
"PAYPAL-TRANSMISSION-SIG": headers.get("PAYPAL-TRANSMISSION-SIG"),
|
||||
"PAYPAL-CERT-URL": headers.get("PAYPAL-CERT-URL"),
|
||||
"PAYPAL-AUTH-ALGO": headers.get("PAYPAL-AUTH-ALGO"),
|
||||
}
|
||||
if not all(required_headers.values()):
|
||||
logger.warning("Missing PayPal webhook headers.")
|
||||
raise ValueError("PayPal webhook cannot be verified. Missing headers.")
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(base_url=settings.paypal_api_endpoint) as client:
|
||||
token_resp = await client.post(
|
||||
"/v1/oauth2/token",
|
||||
data={"grant_type": "client_credentials"},
|
||||
auth=(
|
||||
settings.paypal_client_id or "",
|
||||
settings.paypal_client_secret or "",
|
||||
),
|
||||
)
|
||||
token_resp.raise_for_status()
|
||||
access_token = token_resp.json().get("access_token")
|
||||
if not access_token:
|
||||
raise ValueError("PayPal token missing in verification flow.")
|
||||
|
||||
verify_resp = await client.post(
|
||||
"/v1/notifications/verify-webhook-signature",
|
||||
json={
|
||||
"auth_algo": required_headers["PAYPAL-AUTH-ALGO"],
|
||||
"cert_url": required_headers["PAYPAL-CERT-URL"],
|
||||
"transmission_id": required_headers["PAYPAL-TRANSMISSION-ID"],
|
||||
"transmission_sig": required_headers["PAYPAL-TRANSMISSION-SIG"],
|
||||
"transmission_time": required_headers["PAYPAL-TRANSMISSION-TIME"],
|
||||
"webhook_id": webhook_id,
|
||||
"webhook_event": json.loads(payload.decode()),
|
||||
},
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
)
|
||||
verify_resp.raise_for_status()
|
||||
verification_status = verify_resp.json().get("verification_status")
|
||||
if verification_status != "SUCCESS":
|
||||
raise ValueError("PayPal webhook verification failed.")
|
||||
except Exception as exc:
|
||||
logger.warning(exc)
|
||||
raise ValueError("PayPal webhook cannot be verified.") from exc
|
||||
|
||||
|
||||
def check_square_signature(
|
||||
payload: bytes,
|
||||
sig_header: str | None,
|
||||
secret: str | None,
|
||||
notification_url: str | None,
|
||||
):
|
||||
if not sig_header:
|
||||
logger.warning("Square signature header is missing.")
|
||||
raise ValueError("Square signature header is missing.")
|
||||
|
||||
if not secret:
|
||||
logger.warning("Square webhook signature key is not set.")
|
||||
raise ValueError("Square webhook cannot be verified.")
|
||||
|
||||
if not notification_url:
|
||||
logger.warning("Square webhook notification URL is not set.")
|
||||
raise ValueError("Square webhook cannot be verified.")
|
||||
|
||||
signed_payload = notification_url.encode() + payload
|
||||
computed_signature = b64encode(
|
||||
hmac.new(
|
||||
key=secret.encode(), msg=signed_payload, digestmod=hashlib.sha256
|
||||
).digest()
|
||||
).decode()
|
||||
|
||||
if hmac.compare_digest(computed_signature, sig_header) is not True:
|
||||
logger.warning("Square signature verification failed.")
|
||||
raise ValueError("Square signature verification failed.")
|
||||
|
||||
|
||||
def check_revolut_signature(
|
||||
payload: bytes,
|
||||
sig_header: str | None,
|
||||
timestamp_header: str | None,
|
||||
secret: str | None,
|
||||
tolerance_seconds=300,
|
||||
):
|
||||
if not sig_header:
|
||||
logger.warning("Revolut signature header is missing.")
|
||||
raise ValueError("Revolut signature header is missing.")
|
||||
|
||||
if not timestamp_header:
|
||||
logger.warning("Revolut timestamp header is missing.")
|
||||
raise ValueError("Revolut timestamp header is missing.")
|
||||
|
||||
if not secret:
|
||||
logger.warning("Revolut webhook signing secret is not set.")
|
||||
raise ValueError("Revolut webhook cannot be verified.")
|
||||
|
||||
try:
|
||||
timestamp = int(timestamp_header)
|
||||
except ValueError as exc:
|
||||
logger.warning("Invalid Revolut timestamp.")
|
||||
raise ValueError("Invalid Revolut timestamp.") from exc
|
||||
|
||||
timestamp_seconds = timestamp / 1000 if timestamp > 9999999999 else timestamp
|
||||
|
||||
if abs(time.time() - timestamp_seconds) > tolerance_seconds:
|
||||
logger.warning("Timestamp outside tolerance.")
|
||||
raise ValueError("Timestamp outside tolerance." f"Timestamp: {timestamp}")
|
||||
|
||||
signed_payload = b"v1." + timestamp_header.encode() + b"." + payload
|
||||
digest = hmac.new(
|
||||
key=secret.encode(), msg=signed_payload, digestmod=hashlib.sha256
|
||||
).hexdigest()
|
||||
expected_signature = f"v1={digest}"
|
||||
|
||||
provided_signatures = [sig.strip() for sig in sig_header.split(",") if sig.strip()]
|
||||
if not any(
|
||||
hmac.compare_digest(expected_signature, provided)
|
||||
for provided in provided_signatures
|
||||
):
|
||||
logger.warning("Revolut signature verification failed.")
|
||||
raise ValueError("Revolut signature verification failed.")
|
||||
|
||||
|
||||
async def test_connection(provider: str) -> SimpleStatus:
|
||||
"""
|
||||
Test the connection to Stripe by checking if the API key is valid.
|
||||
This function should be called when setting up or testing the Stripe integration.
|
||||
"""
|
||||
fiat_provider = await get_fiat_provider(provider)
|
||||
if not fiat_provider:
|
||||
return SimpleStatus(
|
||||
success=False,
|
||||
message=f"Fiat provider '{provider}' not found.",
|
||||
)
|
||||
status = await fiat_provider.status()
|
||||
if status.error_message:
|
||||
return SimpleStatus(
|
||||
success=False,
|
||||
message=f"Cconnection test failed: {status.error_message}",
|
||||
)
|
||||
|
||||
return SimpleStatus(
|
||||
success=True,
|
||||
message="Connection test successful." f" Balance: {status.balance}.",
|
||||
)
|
||||
|
||||
|
||||
async def _credit_fiat_service_fee_wallet(
|
||||
payment: Payment, conn: Connection | None = None
|
||||
):
|
||||
@@ -104,90 +343,3 @@ async def _debit_fiat_service_faucet_wallet(
|
||||
status=PaymentState.SUCCESS,
|
||||
conn=conn,
|
||||
)
|
||||
|
||||
|
||||
async def handle_stripe_event(event: dict):
|
||||
event_id = event.get("id")
|
||||
event_object = event.get("data", {}).get("object", {})
|
||||
object_type = event_object.get("object")
|
||||
payment_hash = event_object.get("metadata", {}).get("payment_hash")
|
||||
logger.debug(
|
||||
f"Handling Stripe event: '{event_id}'. Type: '{object_type}'."
|
||||
f" Payment hash: '{payment_hash}'."
|
||||
)
|
||||
if not payment_hash:
|
||||
logger.warning("Stripe event does not contain a payment hash.")
|
||||
return
|
||||
|
||||
payment = await get_standalone_payment(payment_hash)
|
||||
if not payment:
|
||||
logger.warning(f"No payment found for hash: '{payment_hash}'.")
|
||||
return
|
||||
await payment.check_fiat_status()
|
||||
|
||||
|
||||
def check_stripe_signature(
|
||||
payload: bytes,
|
||||
sig_header: str | None,
|
||||
secret: str | None,
|
||||
tolerance_seconds=300,
|
||||
):
|
||||
if not sig_header:
|
||||
logger.warning("Stripe-Signature header is missing.")
|
||||
raise ValueError("Stripe-Signature header is missing.")
|
||||
|
||||
if not secret:
|
||||
logger.warning("Stripe webhook signing secret is not set.")
|
||||
raise ValueError("Stripe webhook cannot be verified.")
|
||||
|
||||
# Split the Stripe-Signature header
|
||||
items = dict(i.split("=") for i in sig_header.split(","))
|
||||
timestamp = int(items["t"])
|
||||
signature = items["v1"]
|
||||
|
||||
# Check timestamp tolerance
|
||||
if abs(time.time() - timestamp) > tolerance_seconds:
|
||||
logger.warning("Timestamp outside tolerance.")
|
||||
logger.debug(
|
||||
f"Current time: {time.time()}, "
|
||||
f"Timestamp: {timestamp}, "
|
||||
f"Tolerance: {tolerance_seconds} seconds"
|
||||
)
|
||||
|
||||
raise ValueError("Timestamp outside tolerance." f"Timestamp: {timestamp}")
|
||||
|
||||
signed_payload = f"{timestamp}.{payload.decode()}"
|
||||
|
||||
# Compute HMAC SHA256 using the webhook secret
|
||||
computed_signature = hmac.new(
|
||||
key=secret.encode(), msg=signed_payload.encode(), digestmod=hashlib.sha256
|
||||
).hexdigest()
|
||||
|
||||
# Compare signatures using constant time comparison
|
||||
if hmac.compare_digest(computed_signature, signature) is not True:
|
||||
logger.warning("Stripe signature verification failed.")
|
||||
raise ValueError("Stripe signature verification failed.")
|
||||
|
||||
|
||||
async def test_connection(provider: str) -> SimpleStatus:
|
||||
"""
|
||||
Test the connection to Stripe by checking if the API key is valid.
|
||||
This function should be called when setting up or testing the Stripe integration.
|
||||
"""
|
||||
fiat_provider = await get_fiat_provider(provider)
|
||||
if not fiat_provider:
|
||||
return SimpleStatus(
|
||||
success=False,
|
||||
message=f"Fiat provider '{provider}' not found.",
|
||||
)
|
||||
status = await fiat_provider.status()
|
||||
if status.error_message:
|
||||
return SimpleStatus(
|
||||
success=False,
|
||||
message=f"Cconnection test failed: {status.error_message}",
|
||||
)
|
||||
|
||||
return SimpleStatus(
|
||||
success=True,
|
||||
message="Connection test successful." f" Balance: {status.balance}.",
|
||||
)
|
||||
|
||||
@@ -66,11 +66,14 @@ async def check_server_balance_against_node():
|
||||
|
||||
|
||||
async def check_balance_delta_changed():
|
||||
if settings.notification_balance_delta_threshold_sats <= 0:
|
||||
return
|
||||
status = await get_balance_delta()
|
||||
if settings.latest_balance_delta_sats is None:
|
||||
settings.latest_balance_delta_sats = status.delta_sats
|
||||
return
|
||||
if status.delta_sats != settings.latest_balance_delta_sats:
|
||||
delta_change = abs(status.delta_sats - settings.latest_balance_delta_sats)
|
||||
if delta_change >= settings.notification_balance_delta_threshold_sats:
|
||||
enqueue_admin_notification(
|
||||
NotificationType.balance_delta,
|
||||
{
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import asyncio
|
||||
import json
|
||||
import smtplib
|
||||
from asyncio.tasks import create_task
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
from http import HTTPStatus
|
||||
@@ -16,6 +17,7 @@ from lnbits.core.crud import (
|
||||
mark_webhook_sent,
|
||||
)
|
||||
from lnbits.core.crud.users import get_user
|
||||
from lnbits.core.crud.wallets import get_wallet
|
||||
from lnbits.core.models import Payment, Wallet
|
||||
from lnbits.core.models.notifications import (
|
||||
NOTIFICATION_TEMPLATES,
|
||||
@@ -72,7 +74,7 @@ async def send_admin_notification(
|
||||
message: str,
|
||||
message_type: str | None = None,
|
||||
) -> None:
|
||||
return await send_notification(
|
||||
return await send_notification_in_background(
|
||||
settings.lnbits_telegram_notifications_chat_id,
|
||||
settings.lnbits_nostr_notifications_identifiers,
|
||||
settings.lnbits_email_notifications_to_emails,
|
||||
@@ -95,7 +97,7 @@ async def send_user_notification(
|
||||
if user_notifications.nostr_identifier
|
||||
else []
|
||||
)
|
||||
return await send_notification(
|
||||
return await send_notification_in_background(
|
||||
user_notifications.telegram_chat_id,
|
||||
nostr_identifiers,
|
||||
email_address,
|
||||
@@ -220,12 +222,29 @@ async def send_email(
|
||||
msg["Subject"] = subject
|
||||
msg.attach(MIMEText(message, "plain"))
|
||||
username = username if len(username) > 0 else from_email
|
||||
with smtplib.SMTP(server, port) as smtp_server:
|
||||
smtp_server.starttls()
|
||||
smtp_server.login(username, password)
|
||||
smtp_server.sendmail(from_email, to_emails, msg.as_string())
|
||||
|
||||
def _send() -> bool:
|
||||
with smtplib.SMTP(server, port) as smtp_server:
|
||||
smtp_server.starttls()
|
||||
smtp_server.login(username, password)
|
||||
smtp_server.sendmail(from_email, to_emails, msg.as_string())
|
||||
return True
|
||||
|
||||
try:
|
||||
return await asyncio.to_thread(_send)
|
||||
except Exception as e:
|
||||
logger.warning(f"Sending Email failed. {e!s}")
|
||||
return False
|
||||
|
||||
|
||||
async def dispatch_payment_notification(payment: Payment) -> None:
|
||||
"""
|
||||
This worker dispatches the payment notifications.
|
||||
"""
|
||||
wallet = await get_wallet(payment.wallet_id)
|
||||
if wallet:
|
||||
await send_payment_notification(wallet, payment)
|
||||
|
||||
|
||||
async def dispatch_webhook(payment: Payment):
|
||||
"""
|
||||
@@ -240,6 +259,10 @@ async def dispatch_webhook(payment: Payment):
|
||||
async with httpx.AsyncClient(headers=headers) as client:
|
||||
try:
|
||||
check_callback_url(payment.webhook)
|
||||
except ValueError as exc:
|
||||
await mark_webhook_sent(payment.payment_hash, "-1")
|
||||
logger.warning(f"Invalid webhook URL {payment.webhook}: {exc!s}")
|
||||
try:
|
||||
r = await client.post(payment.webhook, json=payment.json(), timeout=40)
|
||||
r.raise_for_status()
|
||||
await mark_webhook_sent(payment.payment_hash, str(r.status_code))
|
||||
@@ -257,6 +280,12 @@ async def dispatch_webhook(payment: Payment):
|
||||
async def send_payment_notification(wallet: Wallet, payment: Payment):
|
||||
try:
|
||||
await send_ws_payment_notification(wallet, payment)
|
||||
for shared in wallet.extra.shared_with:
|
||||
if not shared.shared_with_wallet_id:
|
||||
continue
|
||||
shared_wallet = await get_wallet(shared.shared_with_wallet_id)
|
||||
if shared_wallet and shared_wallet.can_view_payments:
|
||||
await send_ws_payment_notification(shared_wallet, payment)
|
||||
except Exception as e:
|
||||
logger.error(f"Error sending websocket payment notification {e!s}")
|
||||
try:
|
||||
@@ -275,6 +304,34 @@ async def send_payment_notification(wallet: Wallet, payment: Payment):
|
||||
logger.error(f"Error dispatching webhook: {e!s}")
|
||||
|
||||
|
||||
def send_payment_notification_in_background(wallet: Wallet, payment: Payment):
|
||||
try:
|
||||
create_task(send_payment_notification(wallet, payment))
|
||||
except Exception as e:
|
||||
logger.warning(f"Error sending payment notification: {e}")
|
||||
|
||||
|
||||
async def send_notification_in_background(
|
||||
telegram_chat_id: str | None,
|
||||
nostr_identifiers: list[str] | None,
|
||||
email_addresses: list[str] | None,
|
||||
message: str,
|
||||
message_type: str | None = None,
|
||||
):
|
||||
try:
|
||||
create_task(
|
||||
send_notification(
|
||||
telegram_chat_id,
|
||||
nostr_identifiers,
|
||||
email_addresses,
|
||||
message,
|
||||
message_type,
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Error sending notification in background: {e}")
|
||||
|
||||
|
||||
async def send_ws_payment_notification(wallet: Wallet, payment: Payment):
|
||||
# TODO: websocket message should be a clean payment model
|
||||
# await websocket_manager.send(wallet.inkey, payment.json())
|
||||
@@ -381,7 +438,7 @@ def _is_message_type_enabled(message_type: NotificationType) -> bool:
|
||||
if message_type == NotificationType.watchdog_check:
|
||||
return settings.lnbits_notification_watchdog
|
||||
if message_type == NotificationType.balance_delta:
|
||||
return settings.notification_balance_delta_changed
|
||||
return settings.notification_balance_delta_threshold_sats > 0
|
||||
if message_type == NotificationType.server_start_stop:
|
||||
return settings.lnbits_notification_server_start_stop
|
||||
if message_type == NotificationType.server_status:
|
||||
|
||||
@@ -17,14 +17,15 @@ from lnbits.db import Connection, Filters
|
||||
from lnbits.decorators import check_user_extension_access
|
||||
from lnbits.exceptions import InvoiceError, PaymentError, UnsupportedError
|
||||
from lnbits.fiat import get_fiat_provider
|
||||
from lnbits.helpers import check_callback_url
|
||||
from lnbits.helpers import check_callback_url, daystart_timestamp
|
||||
from lnbits.settings import settings
|
||||
from lnbits.tasks import create_task, internal_invoice_queue_put
|
||||
from lnbits.task_manager import task_manager
|
||||
from lnbits.utils.crypto import fake_privkey, random_secret_and_hash, verify_preimage
|
||||
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis, satoshis_amount_as_fiat
|
||||
from lnbits.wallets import fake_wallet, get_funding_source
|
||||
from lnbits.wallets.base import (
|
||||
InvoiceResponse,
|
||||
PaymentFailedStatus,
|
||||
PaymentPendingStatus,
|
||||
PaymentResponse,
|
||||
PaymentStatus,
|
||||
@@ -47,7 +48,8 @@ from ..models import (
|
||||
PaymentState,
|
||||
Wallet,
|
||||
)
|
||||
from .notifications import send_payment_notification
|
||||
from .fiat_providers import check_fiat_status
|
||||
from .notifications import send_payment_notification_in_background
|
||||
|
||||
payment_lock = asyncio.Lock()
|
||||
wallets_payments_lock: dict[str, asyncio.Lock] = {}
|
||||
@@ -61,11 +63,14 @@ async def pay_invoice(
|
||||
extra: dict | None = None,
|
||||
description: str = "",
|
||||
tag: str = "",
|
||||
labels: list[str] | None = None,
|
||||
external_id: str | None = None,
|
||||
conn: Connection | None = None,
|
||||
) -> Payment:
|
||||
if settings.lnbits_only_allow_incoming_payments:
|
||||
raise PaymentError("Only incoming payments allowed.", status="failed")
|
||||
invoice = _validate_payment_request(payment_request, max_sat)
|
||||
|
||||
if not invoice.amount_msat:
|
||||
raise ValueError("Missig invoice amount.")
|
||||
|
||||
@@ -73,25 +78,35 @@ async def pay_invoice(
|
||||
amount_msat = invoice.amount_msat
|
||||
wallet = await _check_wallet_for_payment(wallet_id, tag, amount_msat, new_conn)
|
||||
|
||||
if not wallet.can_send_payments:
|
||||
raise PaymentError(
|
||||
"Wallet does not have permission to pay invoices.",
|
||||
status="failed",
|
||||
)
|
||||
|
||||
if await is_internal_status_success(invoice.payment_hash, new_conn):
|
||||
raise PaymentError("Internal invoice already paid.", status="failed")
|
||||
|
||||
_, extra = await calculate_fiat_amounts(amount_msat / 1000, wallet, extra=extra)
|
||||
|
||||
create_payment_model = CreatePayment(
|
||||
wallet_id=wallet_id,
|
||||
wallet_id=wallet.source_wallet_id,
|
||||
bolt11=payment_request,
|
||||
payment_hash=invoice.payment_hash,
|
||||
amount_msat=-amount_msat,
|
||||
expiry=invoice.expiry_date,
|
||||
memo=description or invoice.description or "",
|
||||
extra=extra,
|
||||
labels=labels,
|
||||
external_id=external_id,
|
||||
)
|
||||
|
||||
payment = await _pay_invoice(wallet.id, create_payment_model, conn)
|
||||
|
||||
async with db.reuse_conn(conn) if conn else db.connect() as new_conn:
|
||||
await _credit_service_fee_wallet(wallet, payment, new_conn)
|
||||
payment = await _pay_invoice(
|
||||
wallet.source_wallet_id, create_payment_model, conn=new_conn
|
||||
)
|
||||
|
||||
await _credit_service_fee_wallet(wallet, payment, conn=new_conn)
|
||||
|
||||
return payment
|
||||
|
||||
@@ -110,7 +125,7 @@ async def create_payment_request(
|
||||
|
||||
async def create_fiat_invoice(
|
||||
wallet_id: str, invoice_data: CreateInvoice, conn: Connection | None = None
|
||||
):
|
||||
) -> Payment:
|
||||
fiat_provider_name = invoice_data.fiat_provider
|
||||
if not fiat_provider_name:
|
||||
raise ValueError("Fiat provider is required for fiat invoices.")
|
||||
@@ -156,15 +171,15 @@ async def create_fiat_invoice(
|
||||
|
||||
internal_payment.fiat_provider = fiat_provider_name
|
||||
internal_payment.extra["fiat_checking_id"] = fiat_invoice.checking_id
|
||||
# todo: move to payent
|
||||
# TODO: move to payment
|
||||
internal_payment.extra["fiat_payment_request"] = fiat_invoice.payment_request
|
||||
new_checking_id = (
|
||||
f"fiat_{fiat_provider_name}_"
|
||||
f"{fiat_invoice.checking_id or internal_payment.checking_id}"
|
||||
)
|
||||
await update_payment(internal_payment, new_checking_id, conn=conn)
|
||||
internal_payment.checking_id = new_checking_id
|
||||
|
||||
internal_payment = await update_payment(
|
||||
internal_payment, new_checking_id, conn=conn
|
||||
)
|
||||
return internal_payment
|
||||
|
||||
|
||||
@@ -190,19 +205,24 @@ async def create_wallet_invoice(wallet_id: str, data: CreateInvoice) -> Payment:
|
||||
# do not save memo if description_hash or unhashed_description is set
|
||||
memo = ""
|
||||
|
||||
payment = await create_invoice(
|
||||
wallet_id=wallet_id,
|
||||
amount=data.amount,
|
||||
memo=memo,
|
||||
currency=data.unit,
|
||||
description_hash=description_hash,
|
||||
unhashed_description=unhashed_description,
|
||||
expiry=data.expiry,
|
||||
extra=data.extra,
|
||||
webhook=data.webhook,
|
||||
internal=data.internal,
|
||||
payment_hash=data.payment_hash,
|
||||
)
|
||||
async with db.connect() as conn:
|
||||
payment = await create_invoice(
|
||||
wallet_id=wallet_id,
|
||||
amount=data.amount,
|
||||
memo=memo,
|
||||
currency=data.unit,
|
||||
description_hash=description_hash,
|
||||
unhashed_description=unhashed_description,
|
||||
expiry=data.expiry,
|
||||
extra=data.extra,
|
||||
extension=data.extension,
|
||||
webhook=data.webhook,
|
||||
internal=data.internal,
|
||||
payment_hash=data.payment_hash,
|
||||
labels=data.labels,
|
||||
external_id=data.external_id,
|
||||
conn=conn,
|
||||
)
|
||||
|
||||
if data.lnurl_withdraw:
|
||||
try:
|
||||
@@ -241,6 +261,9 @@ async def create_invoice(
|
||||
webhook: str | None = None,
|
||||
internal: bool | None = False,
|
||||
payment_hash: str | None = None,
|
||||
extension: str | None = None,
|
||||
labels: list[str] | None = None,
|
||||
external_id: str | None = None,
|
||||
conn: Connection | None = None,
|
||||
) -> Payment:
|
||||
if not amount > 0:
|
||||
@@ -250,6 +273,12 @@ async def create_invoice(
|
||||
if not user_wallet:
|
||||
raise InvoiceError(f"Could not fetch wallet '{wallet_id}'.", status="failed")
|
||||
|
||||
if not user_wallet.can_receive_payments:
|
||||
raise InvoiceError(
|
||||
"Wallet does not have permission to create invoices.",
|
||||
status="failed",
|
||||
)
|
||||
|
||||
invoice_memo = None if description_hash else memo[:640]
|
||||
|
||||
# use the fake wallet if the invoice is for internal use only
|
||||
@@ -308,7 +337,7 @@ async def create_invoice(
|
||||
invoice = bolt11_decode(invoice_response.payment_request)
|
||||
|
||||
create_payment_model = CreatePayment(
|
||||
wallet_id=wallet_id,
|
||||
wallet_id=user_wallet.source_wallet_id,
|
||||
bolt11=invoice_response.payment_request,
|
||||
payment_hash=invoice.payment_hash,
|
||||
preimage=invoice_response.preimage,
|
||||
@@ -316,8 +345,11 @@ async def create_invoice(
|
||||
expiry=invoice.expiry_date,
|
||||
memo=memo,
|
||||
extra=extra,
|
||||
extension=extension,
|
||||
webhook=webhook,
|
||||
fee=invoice_response.fee_msat or 0,
|
||||
labels=labels,
|
||||
external_id=external_id,
|
||||
)
|
||||
|
||||
payment = await create_payment(
|
||||
@@ -339,13 +371,22 @@ async def update_pending_payments(wallet_id: str):
|
||||
await update_pending_payment(payment)
|
||||
|
||||
|
||||
async def update_pending_payment(payment: Payment) -> Payment:
|
||||
status = await payment.check_status()
|
||||
async def update_pending_payment(
|
||||
payment: Payment, conn: Connection | None = None
|
||||
) -> Payment:
|
||||
if payment.is_in and payment.is_expired:
|
||||
payment.status = PaymentState.FAILED
|
||||
payment.labels.append("expired")
|
||||
await update_payment(payment, conn=conn)
|
||||
logger.info(f"invoice {payment.checking_id} expired, marked as failed")
|
||||
return payment
|
||||
|
||||
status = await check_payment_status(payment)
|
||||
if status.failed:
|
||||
payment.status = PaymentState.FAILED
|
||||
await update_payment(payment)
|
||||
payment = await update_payment(payment, conn=conn)
|
||||
elif status.success:
|
||||
payment = await update_payment_success_status(payment, status)
|
||||
payment = await update_payment_success_status(payment, status, conn=conn)
|
||||
return payment
|
||||
|
||||
|
||||
@@ -456,7 +497,7 @@ async def update_wallet_balance(
|
||||
await create_payment(
|
||||
checking_id=f"internal_{payment_hash}",
|
||||
data=CreatePayment(
|
||||
wallet_id=wallet.id,
|
||||
wallet_id=wallet.source_wallet_id,
|
||||
bolt11=bolt11,
|
||||
payment_hash=payment_hash,
|
||||
amount_msat=amount * 1000,
|
||||
@@ -475,7 +516,7 @@ async def update_wallet_balance(
|
||||
raise ValueError("Balance change failed, amount exceeds maximum balance.")
|
||||
async with db.reuse_conn(conn) if conn else db.connect() as conn:
|
||||
payment = await create_invoice(
|
||||
wallet_id=wallet.id,
|
||||
wallet_id=wallet.source_wallet_id,
|
||||
amount=amount,
|
||||
memo="Admin credit",
|
||||
internal=True,
|
||||
@@ -483,7 +524,7 @@ async def update_wallet_balance(
|
||||
)
|
||||
payment.status = PaymentState.SUCCESS
|
||||
await update_payment(payment, conn=conn)
|
||||
await internal_invoice_queue_put(payment.checking_id)
|
||||
task_manager.internal_invoice_queue.put_nowait(payment)
|
||||
|
||||
|
||||
async def check_wallet_limits(
|
||||
@@ -523,10 +564,9 @@ async def check_wallet_daily_withdraw_limit(
|
||||
raise ValueError("It is not allowed to spend funds from this server.")
|
||||
|
||||
payments = await get_payments(
|
||||
since=int(time.time()) - 60 * 60 * 24,
|
||||
since=daystart_timestamp(),
|
||||
outgoing=True,
|
||||
wallet_id=wallet_id,
|
||||
limit=1,
|
||||
conn=conn,
|
||||
)
|
||||
if len(payments) == 0:
|
||||
@@ -594,7 +634,25 @@ async def check_transaction_status(
|
||||
if payment.status == PaymentState.SUCCESS.value:
|
||||
return PaymentSuccessStatus(fee_msat=payment.fee)
|
||||
|
||||
return await payment.check_status()
|
||||
return await check_payment_status(payment)
|
||||
|
||||
|
||||
async def check_payment_status(payment: Payment) -> PaymentStatus:
|
||||
if payment.is_internal:
|
||||
if payment.success:
|
||||
return PaymentSuccessStatus()
|
||||
if payment.failed:
|
||||
return PaymentFailedStatus()
|
||||
if payment.is_in and payment.fiat_provider:
|
||||
fiat_status = await check_fiat_status(payment)
|
||||
return PaymentStatus(paid=fiat_status.paid)
|
||||
return PaymentPendingStatus()
|
||||
funding_source = get_funding_source()
|
||||
if payment.is_out:
|
||||
status = await funding_source.get_payment_status(payment.checking_id)
|
||||
else:
|
||||
status = await funding_source.get_invoice_status(payment.checking_id)
|
||||
return status
|
||||
|
||||
|
||||
async def get_payments_daily_stats(
|
||||
@@ -682,6 +740,7 @@ async def _pay_internal_invoice(
|
||||
internal_payment = await check_internal(
|
||||
create_payment_model.payment_hash, conn=conn
|
||||
)
|
||||
|
||||
if not internal_payment:
|
||||
return None
|
||||
|
||||
@@ -690,6 +749,7 @@ async def _pay_internal_invoice(
|
||||
internal_invoice = await get_standalone_payment(
|
||||
internal_payment.checking_id, incoming=True, conn=conn
|
||||
)
|
||||
|
||||
if not internal_invoice:
|
||||
raise PaymentError("Internal payment not found.", status="failed")
|
||||
|
||||
@@ -711,6 +771,7 @@ async def _pay_internal_invoice(
|
||||
|
||||
internal_id = f"internal_{create_payment_model.payment_hash}"
|
||||
logger.debug(f"creating temporary internal payment with id {internal_id}")
|
||||
|
||||
payment = await create_payment(
|
||||
checking_id=internal_id,
|
||||
data=create_payment_model,
|
||||
@@ -725,13 +786,16 @@ async def _pay_internal_invoice(
|
||||
await update_payment(internal_payment, conn=conn)
|
||||
logger.success(f"internal payment successful {internal_payment.checking_id}")
|
||||
|
||||
await send_payment_notification(wallet, payment)
|
||||
|
||||
# notify receiver asynchronously
|
||||
from lnbits.tasks import internal_invoice_queue
|
||||
await _send_payment_notification_in_background(
|
||||
wallet.id, payment, conn=conn
|
||||
) # notify the sender
|
||||
await _send_payment_notification_in_background(
|
||||
internal_payment.wallet_id, internal_payment, conn=conn
|
||||
) # notify the receiver
|
||||
|
||||
# notify receiver asynchronously (extension listeners)
|
||||
logger.debug(f"enqueuing internal invoice {internal_payment.checking_id}")
|
||||
await internal_invoice_queue.put(internal_payment.checking_id)
|
||||
task_manager.internal_invoice_queue.put_nowait(internal_payment)
|
||||
|
||||
return payment
|
||||
|
||||
@@ -768,14 +832,15 @@ async def _pay_external_invoice(
|
||||
|
||||
fee_reserve_msat = fee_reserve(amount_msat, internal=False)
|
||||
|
||||
task = create_task(
|
||||
_fundingsource_pay_invoice(checking_id, payment.bolt11, fee_reserve_msat)
|
||||
task = task_manager.create_task(
|
||||
_fundingsource_pay_invoice(checking_id, payment.bolt11, fee_reserve_msat),
|
||||
f"fundingsource_pay_invoice_{checking_id}",
|
||||
)
|
||||
|
||||
# make sure a hold invoice or deferred payment is not blocking the server
|
||||
wait_time = max(1, settings.lnbits_funding_source_pay_invoice_wait_seconds)
|
||||
try:
|
||||
payment_response = await asyncio.wait_for(task, timeout=wait_time)
|
||||
payment_response = await asyncio.wait_for(task.task, timeout=wait_time)
|
||||
except asyncio.TimeoutError:
|
||||
# return pending payment on timeout
|
||||
logger.debug(
|
||||
@@ -798,7 +863,8 @@ async def _pay_external_invoice(
|
||||
payment = await update_payment_success_status(
|
||||
payment, payment_response, conn=conn
|
||||
)
|
||||
await send_payment_notification(wallet, payment)
|
||||
|
||||
await _send_payment_notification_in_background(wallet.id, payment, conn=conn)
|
||||
logger.success(f"payment successful {payment_response.checking_id}")
|
||||
|
||||
payment.checking_id = payment_response.checking_id
|
||||
@@ -815,7 +881,7 @@ async def update_payment_success_status(
|
||||
payment.status = PaymentState.SUCCESS
|
||||
payment.fee = -(abs(status.fee_msat or 0) + abs(service_fee_msat))
|
||||
payment.preimage = payment.preimage or status.preimage
|
||||
await update_payment(payment, conn=conn)
|
||||
payment = await update_payment(payment, conn=conn)
|
||||
return payment
|
||||
|
||||
|
||||
@@ -841,7 +907,7 @@ async def _verify_external_payment(
|
||||
raise PaymentError("Payment already paid.", status="success")
|
||||
|
||||
# payment failed
|
||||
status = await payment.check_status()
|
||||
status = await check_payment_status(payment)
|
||||
if status.failed:
|
||||
raise PaymentError(
|
||||
"Payment is failed node, retrying is not possible.", status="failed"
|
||||
@@ -910,7 +976,7 @@ async def _credit_service_fee_wallet(
|
||||
|
||||
memo = f"""
|
||||
Service fee for payment of {abs(payment.sat)} sats.
|
||||
Wallet: '{wallet.name}' ({wallet.id})."""
|
||||
Wallet: '{wallet.name}' ({wallet.source_wallet_id})."""
|
||||
|
||||
create_payment_model = CreatePayment(
|
||||
wallet_id=settings.lnbits_service_fee_wallet,
|
||||
@@ -1006,3 +1072,56 @@ async def cancel_hold_invoice(payment: Payment) -> InvoiceResponse:
|
||||
await update_payment(payment)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
async def _send_payment_notification_in_background(
|
||||
wallet_id: str, payment: Payment, conn: Connection | None = None
|
||||
):
|
||||
# fetch balance again
|
||||
wallet = await get_wallet(wallet_id, conn=conn)
|
||||
if not wallet:
|
||||
raise PaymentError(f"Could not fetch wallet '{wallet_id}'.", status="failed")
|
||||
send_payment_notification_in_background(wallet, payment)
|
||||
|
||||
|
||||
async def update_invoice_from_paid_invoices_stream(checking_id: str) -> Payment | None:
|
||||
"""
|
||||
Takes a checking_id of an incoming payment from paid_invoices_stream()
|
||||
Checks its status, updates its status and returns it.
|
||||
returns None if no incoming payment was found or the status is not successful
|
||||
"""
|
||||
payment = await get_standalone_payment(checking_id, incoming=True)
|
||||
if not payment:
|
||||
logger.warning(f"No incoming payment found for '{checking_id}'.")
|
||||
return None
|
||||
|
||||
status = await check_payment_status(payment)
|
||||
|
||||
if not status.success:
|
||||
logger.error(
|
||||
"Unexpected status response from paid_invoices_stream. Skipping update."
|
||||
)
|
||||
return None
|
||||
|
||||
payment.fee = status.fee_msat or payment.fee
|
||||
# only overwrite preimage if status.preimage provides it
|
||||
payment.preimage = status.preimage or payment.preimage
|
||||
payment.status = PaymentState.SUCCESS
|
||||
payment = await update_payment(payment)
|
||||
|
||||
return payment
|
||||
|
||||
|
||||
async def fundingsource_invoice_producer() -> None:
|
||||
"""
|
||||
will collect all invoices that come directly from the backend wallet.
|
||||
|
||||
Called registered in the app startup sequence and run by taskmanager.
|
||||
"""
|
||||
funding_source = get_funding_source()
|
||||
async for checking_id in funding_source.paid_invoices_stream():
|
||||
logger.info(f"got a payment notification {checking_id}")
|
||||
payment = await update_invoice_from_paid_invoices_stream(checking_id)
|
||||
if payment:
|
||||
logger.success(f"fundingsource invoice {checking_id} settled")
|
||||
task_manager.invoice_queue.put_nowait(payment)
|
||||
|
||||
@@ -3,7 +3,11 @@ from uuid import uuid4
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.core.crud.settings import set_settings_field
|
||||
from lnbits.core.db import db
|
||||
from lnbits.core.models.extensions import UserExtension
|
||||
from lnbits.core.models.users import RegisterUser
|
||||
from lnbits.db import Connection
|
||||
from lnbits.settings import (
|
||||
EditableSettings,
|
||||
SuperSettings,
|
||||
@@ -19,6 +23,7 @@ from ..crud import (
|
||||
get_account_by_email,
|
||||
get_account_by_pubkey,
|
||||
get_account_by_username,
|
||||
get_accounts_count,
|
||||
get_super_settings,
|
||||
get_user_extensions,
|
||||
get_user_from_account,
|
||||
@@ -48,43 +53,60 @@ async def create_user_account_no_ckeck(
|
||||
account: Account | None = None,
|
||||
wallet_name: str | None = None,
|
||||
default_exts: list[str] | None = None,
|
||||
conn: Connection | None = None,
|
||||
) -> User:
|
||||
async with db.reuse_conn(conn) if conn else db.connect() as conn:
|
||||
await check_users_limit(conn)
|
||||
|
||||
if account:
|
||||
account.validate_fields()
|
||||
if account.username and await get_account_by_username(account.username):
|
||||
raise ValueError("Username already exists.")
|
||||
if account:
|
||||
account.validate_fields()
|
||||
if account.username and await get_account_by_username(
|
||||
account.username, conn=conn
|
||||
):
|
||||
raise ValueError("Username already exists.")
|
||||
|
||||
if account.email and await get_account_by_email(account.email):
|
||||
raise ValueError("Email already exists.")
|
||||
if account.email and await get_account_by_email(account.email, conn=conn):
|
||||
raise ValueError("Email already exists.")
|
||||
|
||||
if account.pubkey and await get_account_by_pubkey(account.pubkey):
|
||||
raise ValueError("Pubkey already exists.")
|
||||
if account.pubkey and await get_account_by_pubkey(
|
||||
account.pubkey, conn=conn
|
||||
):
|
||||
raise ValueError("Pubkey already exists.")
|
||||
|
||||
if not account.id:
|
||||
account.id = uuid4().hex
|
||||
if not account.id:
|
||||
account.id = uuid4().hex
|
||||
|
||||
account = await create_account(account)
|
||||
await create_wallet(
|
||||
user_id=account.id,
|
||||
wallet_name=wallet_name or settings.lnbits_default_wallet_name,
|
||||
)
|
||||
account = await create_account(account, conn=conn)
|
||||
await create_wallet(
|
||||
user_id=account.id,
|
||||
wallet_name=wallet_name or settings.lnbits_default_wallet_name,
|
||||
conn=conn,
|
||||
)
|
||||
|
||||
user_extensions = (default_exts or []) + settings.lnbits_user_default_extensions
|
||||
for ext_id in user_extensions:
|
||||
try:
|
||||
user_ext = UserExtension(user=account.id, extension=ext_id, active=True)
|
||||
await create_user_extension(user_ext)
|
||||
except Exception as e:
|
||||
logger.error(f"Error enabeling default extension {ext_id}: {e}")
|
||||
user_extensions = (default_exts or []) + settings.lnbits_user_default_extensions
|
||||
for ext_id in user_extensions:
|
||||
try:
|
||||
user_ext = UserExtension(user=account.id, extension=ext_id, active=True)
|
||||
await create_user_extension(user_ext, conn=conn)
|
||||
except Exception as e:
|
||||
logger.error(f"Error enabeling default extension {ext_id}: {e}")
|
||||
|
||||
user = await get_user_from_account(account)
|
||||
user = await get_user_from_account(account, conn=conn)
|
||||
if not user:
|
||||
raise ValueError("Cannot find user for account.")
|
||||
|
||||
return user
|
||||
|
||||
|
||||
async def check_users_limit(conn: Connection | None = None):
|
||||
if settings.lnbits_max_users == 0:
|
||||
return
|
||||
|
||||
users_count = await get_accounts_count(conn=conn)
|
||||
if users_count >= settings.lnbits_max_users:
|
||||
raise ValueError("Max amount of users have been created")
|
||||
|
||||
|
||||
async def update_user_account(account: Account) -> Account:
|
||||
account.validate_fields()
|
||||
|
||||
@@ -163,6 +185,12 @@ async def check_admin_settings():
|
||||
if account and account.extra and account.extra.provider == "env":
|
||||
settings.first_install = True
|
||||
|
||||
if settings.has_first_install_token_changed():
|
||||
logger.warning("First install token is changed. Resetting admin settings.")
|
||||
new_settings = await init_admin_settings()
|
||||
settings.super_user = new_settings.super_user
|
||||
settings.first_install = True
|
||||
|
||||
logger.success(
|
||||
"✔️ Admin UI is enabled. run `uv run lnbits-cli superuser` "
|
||||
"to get the superuser."
|
||||
@@ -184,3 +212,25 @@ async def init_admin_settings(super_user: str | None = None) -> SuperSettings:
|
||||
|
||||
editable_settings = EditableSettings.from_dict(settings.dict())
|
||||
return await create_admin_settings(account.id, editable_settings.dict())
|
||||
|
||||
|
||||
async def check_register_activation_settings(data: RegisterUser):
|
||||
if not settings.lnbits_require_user_activation:
|
||||
return None
|
||||
if settings.lnbits_user_activation_by_invitation_code:
|
||||
code = data.invitation_code.strip() if data.invitation_code else ""
|
||||
if len(code) == 0:
|
||||
raise ValueError("Invitation code cannot be empty.")
|
||||
|
||||
if code == settings.lnbits_register_reusable_activation_code:
|
||||
return None
|
||||
if code in settings.lnbits_register_one_time_activation_codes:
|
||||
settings.lnbits_register_one_time_activation_codes.remove(code)
|
||||
await set_settings_field(
|
||||
"lnbits_register_one_time_activation_codes",
|
||||
settings.lnbits_register_one_time_activation_codes,
|
||||
)
|
||||
return None
|
||||
raise ValueError("Invalid invitation code.")
|
||||
|
||||
raise ValueError("No activation method provided.")
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
from lnbits.core.crud.users import (
|
||||
get_account,
|
||||
get_account_by_username_or_email,
|
||||
update_account,
|
||||
)
|
||||
from lnbits.core.crud.wallets import (
|
||||
create_wallet,
|
||||
force_delete_wallet,
|
||||
get_standalone_wallet,
|
||||
get_wallet,
|
||||
get_wallets,
|
||||
update_wallet,
|
||||
)
|
||||
from lnbits.core.models.misc import SimpleStatus
|
||||
from lnbits.core.models.users import Account
|
||||
from lnbits.core.models.wallets import (
|
||||
Wallet,
|
||||
WalletSharePermission,
|
||||
WalletShareStatus,
|
||||
WalletType,
|
||||
)
|
||||
from lnbits.db import Connection
|
||||
from lnbits.helpers import sha256s
|
||||
|
||||
|
||||
async def invite_to_wallet(
|
||||
source_wallet: Wallet, data: WalletSharePermission
|
||||
) -> WalletSharePermission:
|
||||
if not source_wallet.is_lightning_wallet:
|
||||
raise ValueError("Only lightning wallets can be shared.")
|
||||
if not data.username:
|
||||
raise ValueError("Username or email missing.")
|
||||
invited_user = await get_account_by_username_or_email(data.username)
|
||||
if not invited_user:
|
||||
raise ValueError("Invited user not found.")
|
||||
|
||||
request_id = sha256s(invited_user.id + source_wallet.id)
|
||||
share = source_wallet.extra.find_share_by_id(request_id)
|
||||
if share:
|
||||
raise ValueError("User already invited to this wallet.")
|
||||
|
||||
invite_request = source_wallet.extra.invite_user_to_shared_wallet(
|
||||
request_id=request_id,
|
||||
request_type=WalletShareStatus.INVITE_SENT,
|
||||
username=data.username,
|
||||
permissions=data.permissions,
|
||||
)
|
||||
await update_wallet(source_wallet)
|
||||
|
||||
wallet_owner = await get_account(source_wallet.user)
|
||||
if not wallet_owner:
|
||||
raise ValueError("Cannot find wallet owner.")
|
||||
invited_user.extra.add_wallet_invite_request(
|
||||
request_id=request_id,
|
||||
from_user_name=wallet_owner.username or wallet_owner.email,
|
||||
to_wallet_id=source_wallet.id,
|
||||
to_wallet_name=source_wallet.name,
|
||||
)
|
||||
await update_account(invited_user)
|
||||
|
||||
return invite_request
|
||||
|
||||
|
||||
async def reject_wallet_invitation(invited_user_id: str, share_request_id: str):
|
||||
invited_user = await get_account(invited_user_id)
|
||||
if not invited_user:
|
||||
raise ValueError("Invited user not found.")
|
||||
|
||||
existing_request = invited_user.extra.find_wallet_invite_request(share_request_id)
|
||||
if not existing_request:
|
||||
raise ValueError("Invitation not found.")
|
||||
|
||||
invited_user.extra.remove_wallet_invite_request(share_request_id)
|
||||
await update_account(invited_user)
|
||||
|
||||
|
||||
async def update_wallet_share_permissions(
|
||||
source_wallet: Wallet, data: WalletSharePermission
|
||||
) -> WalletSharePermission:
|
||||
if not source_wallet.is_lightning_wallet:
|
||||
raise ValueError("Only lightning wallets can be shared.")
|
||||
if not data.shared_with_wallet_id:
|
||||
raise ValueError("Wallet ID missing.")
|
||||
|
||||
share = source_wallet.extra.find_share_for_wallet(data.shared_with_wallet_id)
|
||||
if not share:
|
||||
raise ValueError("Share not found")
|
||||
|
||||
if not share.shared_with_wallet_id:
|
||||
raise ValueError("Share does not have a mirror wallet ID.")
|
||||
|
||||
mirror_wallet = await get_wallet(share.shared_with_wallet_id)
|
||||
if not mirror_wallet:
|
||||
raise ValueError("Target wallet not found")
|
||||
if not mirror_wallet.is_lightning_shared_wallet:
|
||||
raise ValueError("Target wallet is not a shared wallet.")
|
||||
if mirror_wallet.shared_wallet_id != source_wallet.id:
|
||||
raise ValueError("Not the owner of the shared wallet.")
|
||||
|
||||
share.approve(permissions=data.permissions)
|
||||
await update_wallet(source_wallet)
|
||||
return share
|
||||
|
||||
|
||||
async def delete_wallet_share(source_wallet: Wallet, request_id: str) -> SimpleStatus:
|
||||
if not source_wallet.is_lightning_wallet:
|
||||
raise ValueError("Source wallet is not a lightning wallet.")
|
||||
|
||||
share = source_wallet.extra.find_share_by_id(request_id)
|
||||
if not share:
|
||||
raise ValueError("Wallet share not found.")
|
||||
source_wallet.extra.remove_share_by_id(request_id)
|
||||
|
||||
invited_user = await get_account_by_username_or_email(share.username)
|
||||
if not invited_user:
|
||||
await update_wallet(source_wallet)
|
||||
return SimpleStatus(
|
||||
success=True, message="Permission removed. Invited user not found."
|
||||
)
|
||||
if invited_user.extra.find_wallet_invite_request(request_id):
|
||||
invited_user.extra.remove_wallet_invite_request(request_id)
|
||||
await update_account(invited_user)
|
||||
|
||||
mirror_wallets = await get_wallets(
|
||||
invited_user.id, wallet_type=WalletType.LIGHTNING_SHARED
|
||||
)
|
||||
mirror_wallet = next(
|
||||
(w for w in mirror_wallets if w.shared_wallet_id == source_wallet.id), None
|
||||
)
|
||||
|
||||
if not mirror_wallet:
|
||||
await update_wallet(source_wallet)
|
||||
return SimpleStatus(
|
||||
success=True, message="Permission removed. Target wallet not found."
|
||||
)
|
||||
|
||||
if not mirror_wallet.is_lightning_shared_wallet:
|
||||
raise ValueError("Target wallet is not a shared lightning wallet.")
|
||||
|
||||
if mirror_wallet.shared_wallet_id != source_wallet.id:
|
||||
raise ValueError("Not the owner of the shared wallet.")
|
||||
|
||||
await force_delete_wallet(mirror_wallet.id)
|
||||
|
||||
await update_wallet(source_wallet)
|
||||
return SimpleStatus(success=True, message="Permission removed.")
|
||||
|
||||
|
||||
async def create_lightning_shared_wallet(
|
||||
user_id: str,
|
||||
source_wallet_id: str,
|
||||
conn: Connection | None = None,
|
||||
) -> Wallet:
|
||||
source_wallet = await get_standalone_wallet(source_wallet_id, conn=conn)
|
||||
if not source_wallet:
|
||||
raise ValueError("Shared wallet does not exist.")
|
||||
|
||||
if not source_wallet.is_lightning_wallet:
|
||||
raise ValueError("Shared wallet is not a lightning wallet.")
|
||||
|
||||
if source_wallet.user == user_id:
|
||||
raise ValueError("Cannot mirror your own wallet.")
|
||||
|
||||
invited_user = await get_account(user_id, conn=conn)
|
||||
if not invited_user:
|
||||
raise ValueError("Cannot find invited user.")
|
||||
|
||||
return await _accept_invitation_to_shared_wallet(
|
||||
invited_user, source_wallet, conn=conn
|
||||
)
|
||||
|
||||
|
||||
async def _accept_invitation_to_shared_wallet(
|
||||
invited_user: Account,
|
||||
source_wallet: Wallet,
|
||||
conn: Connection | None = None,
|
||||
) -> Wallet:
|
||||
request_id = sha256s(invited_user.id + source_wallet.id)
|
||||
existing_request = source_wallet.extra.find_share_by_id(request_id)
|
||||
if not existing_request:
|
||||
raise ValueError("No invitation found for this invited user.")
|
||||
if existing_request.status == WalletShareStatus.APPROVED:
|
||||
raise ValueError("This wallet is already shared with you.")
|
||||
if existing_request.status != WalletShareStatus.INVITE_SENT:
|
||||
raise ValueError("Unknown request type.")
|
||||
|
||||
invited_user.extra.remove_wallet_invite_request(request_id)
|
||||
await update_account(invited_user)
|
||||
|
||||
# todo: double check if user already has a mirror wallet for this source wallet
|
||||
|
||||
mirror_wallet = await create_wallet(
|
||||
user_id=invited_user.id,
|
||||
wallet_name=source_wallet.name,
|
||||
wallet_type=WalletType.LIGHTNING_SHARED,
|
||||
shared_wallet_id=source_wallet.id,
|
||||
conn=conn,
|
||||
)
|
||||
existing_request.approve(shared_with_wallet_id=mirror_wallet.id)
|
||||
await update_wallet(source_wallet, conn=conn)
|
||||
mirror_wallet.mirror_shared_wallet(source_wallet)
|
||||
return mirror_wallet
|
||||
+47
-130
@@ -1,78 +1,44 @@
|
||||
import asyncio
|
||||
import traceback
|
||||
from collections.abc import Callable, Coroutine
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.core.crud import (
|
||||
create_audit_entry,
|
||||
get_wallet,
|
||||
)
|
||||
from lnbits.core.crud.audit import delete_expired_audit_entries
|
||||
from lnbits.core.crud import create_audit_entry
|
||||
from lnbits.core.crud.payments import get_payments_status_count
|
||||
from lnbits.core.crud.users import get_accounts
|
||||
from lnbits.core.crud.wallets import get_wallets_count
|
||||
from lnbits.core.models.audit import AuditEntry
|
||||
from lnbits.core.models.extensions import InstallableExtension
|
||||
from lnbits.core.models.notifications import NotificationType
|
||||
from lnbits.core.services.funding_source import (
|
||||
check_balance_delta_changed,
|
||||
check_server_balance_against_node,
|
||||
get_balance_delta,
|
||||
)
|
||||
from lnbits.core.services.funding_source import get_balance_delta
|
||||
from lnbits.core.services.notifications import (
|
||||
enqueue_admin_notification,
|
||||
process_next_notification,
|
||||
send_payment_notification,
|
||||
)
|
||||
from lnbits.db import Filters
|
||||
from lnbits.settings import settings
|
||||
from lnbits.tasks import create_unique_task
|
||||
from lnbits.utils.exchange_rates import btc_rates
|
||||
from lnbits.utils.cache import cache
|
||||
from lnbits.utils.exchange_rates import btc_price_from_aggregator, btc_rates
|
||||
|
||||
audit_queue: asyncio.Queue[AuditEntry] = asyncio.Queue()
|
||||
|
||||
|
||||
async def run_by_the_minute_tasks() -> None:
|
||||
minute_counter = 0
|
||||
while settings.lnbits_running:
|
||||
status_minutes = settings.lnbits_notification_server_status_hours * 60
|
||||
|
||||
if settings.notification_balance_delta_changed:
|
||||
try:
|
||||
# runs by default every minute, the delta should not change that often
|
||||
await check_balance_delta_changed()
|
||||
except Exception as ex:
|
||||
logger.error(ex)
|
||||
|
||||
if minute_counter % settings.lnbits_watchdog_interval_minutes == 0:
|
||||
try:
|
||||
await check_server_balance_against_node()
|
||||
except Exception as ex:
|
||||
logger.error(ex)
|
||||
|
||||
if minute_counter % status_minutes == 0:
|
||||
try:
|
||||
await _notify_server_status()
|
||||
except Exception as ex:
|
||||
logger.error(ex)
|
||||
|
||||
if minute_counter % 60 == 0:
|
||||
try:
|
||||
# initialize the list of all extensions
|
||||
await InstallableExtension.get_installable_extensions()
|
||||
except Exception as ex:
|
||||
logger.error(ex)
|
||||
|
||||
minute_counter += 1
|
||||
await asyncio.sleep(60)
|
||||
async def process_next_audit_entry() -> None:
|
||||
"""
|
||||
Waits for audit entries to be pushed to the queue.
|
||||
Then it inserts the entries into the DB.
|
||||
"""
|
||||
data = await audit_queue.get()
|
||||
await create_audit_entry(data)
|
||||
|
||||
|
||||
async def _notify_server_status() -> None:
|
||||
async def refresh_extension_cache() -> None:
|
||||
# only refreshes every 10 minutes
|
||||
await InstallableExtension.get_installable_extensions()
|
||||
|
||||
|
||||
async def notify_server_status() -> None:
|
||||
accounts = await get_accounts(filters=Filters(limit=0))
|
||||
wallets_count = await get_wallets_count()
|
||||
payments = await get_payments_status_count()
|
||||
|
||||
status = await get_balance_delta()
|
||||
values = {
|
||||
"up_time": settings.lnbits_server_up_time,
|
||||
@@ -89,87 +55,38 @@ async def _notify_server_status() -> None:
|
||||
enqueue_admin_notification(NotificationType.server_status, values)
|
||||
|
||||
|
||||
async def wait_for_paid_invoices(invoice_paid_queue: asyncio.Queue) -> None:
|
||||
"""
|
||||
This worker dispatches events to all extensions and dispatches webhooks.
|
||||
"""
|
||||
while settings.lnbits_running:
|
||||
payment = await invoice_paid_queue.get()
|
||||
logger.trace("received invoice paid event")
|
||||
# payment notification
|
||||
wallet = await get_wallet(payment.wallet_id)
|
||||
if wallet:
|
||||
await send_payment_notification(wallet, payment)
|
||||
|
||||
|
||||
async def wait_for_audit_data() -> None:
|
||||
"""
|
||||
Waits for audit entries to be pushed to the queue.
|
||||
Then it inserts the entries into the DB.
|
||||
"""
|
||||
while settings.lnbits_running:
|
||||
data = await audit_queue.get()
|
||||
try:
|
||||
await create_audit_entry(data)
|
||||
except Exception as ex:
|
||||
logger.warning(ex)
|
||||
await asyncio.sleep(3)
|
||||
|
||||
|
||||
async def wait_notification_messages() -> None:
|
||||
|
||||
while settings.lnbits_running:
|
||||
try:
|
||||
await process_next_notification()
|
||||
except Exception as ex:
|
||||
logger.warning("Payment notification error", ex)
|
||||
await asyncio.sleep(3)
|
||||
|
||||
|
||||
async def purge_audit_data() -> None:
|
||||
"""
|
||||
Remove audit entries which have passed their retention period.
|
||||
"""
|
||||
while settings.lnbits_running:
|
||||
try:
|
||||
await delete_expired_audit_entries()
|
||||
except Exception as ex:
|
||||
logger.warning(ex)
|
||||
|
||||
# clean every hour
|
||||
await asyncio.sleep(60 * 60)
|
||||
|
||||
|
||||
async def collect_exchange_rates_data() -> None:
|
||||
"""
|
||||
Collect exchange rates data. Used for monitoring only.
|
||||
"""
|
||||
while settings.lnbits_running:
|
||||
currency = settings.lnbits_default_accounting_currency or "USD"
|
||||
max_history_size = settings.lnbits_exchange_history_size
|
||||
sleep_time = settings.lnbits_exchange_history_refresh_interval_seconds
|
||||
|
||||
if sleep_time > 0:
|
||||
try:
|
||||
rates = await btc_rates(currency)
|
||||
if rates:
|
||||
rates_values = [r[1] for r in rates]
|
||||
lnbits_rate = sum(rates_values) / len(rates_values)
|
||||
rates.append(("LNbits", lnbits_rate))
|
||||
settings.append_exchange_rate_datapoint(dict(rates), max_history_size)
|
||||
except Exception as ex:
|
||||
logger.warning(ex)
|
||||
else:
|
||||
sleep_time = 60
|
||||
await asyncio.sleep(sleep_time)
|
||||
|
||||
|
||||
def _create_unique_task(name: str, func: Callable):
|
||||
async def _to_coro(func: Callable[[], Coroutine]) -> Coroutine:
|
||||
return await func()
|
||||
|
||||
currency = settings.lnbits_default_accounting_currency or "USD"
|
||||
max_history_size = settings.lnbits_exchange_history_size
|
||||
try:
|
||||
create_unique_task(name, _to_coro(func))
|
||||
except Exception as e:
|
||||
logger.error(f"Error in {name} task", e)
|
||||
logger.error(traceback.format_exc())
|
||||
if (
|
||||
settings.lnbits_price_aggregator_enabled
|
||||
and settings.lnbits_price_aggregator_url
|
||||
):
|
||||
price = await btc_price_from_aggregator(currency)
|
||||
if price:
|
||||
cache.set(
|
||||
f"btc-price-{currency}",
|
||||
price,
|
||||
expiry=settings.lnbits_exchange_rate_cache_seconds,
|
||||
)
|
||||
settings.append_exchange_rate_datapoint(
|
||||
{"Aggregator": price}, max_history_size
|
||||
)
|
||||
else:
|
||||
rates = await btc_rates(currency)
|
||||
if rates:
|
||||
rates_values = [r[1] for r in rates]
|
||||
lnbits_rate = sum(rates_values) / len(rates_values)
|
||||
rates.append(("LNbits", lnbits_rate))
|
||||
cache.set(
|
||||
f"btc-price-{currency}",
|
||||
lnbits_rate,
|
||||
expiry=settings.lnbits_exchange_rate_cache_seconds,
|
||||
)
|
||||
settings.append_exchange_rate_datapoint(dict(rates), max_history_size)
|
||||
except Exception as ex:
|
||||
logger.warning(ex)
|
||||
|
||||
@@ -1,197 +0,0 @@
|
||||
<q-tab-panel name="exchange_providers">
|
||||
<h6 class="q-my-none q-mb-sm">
|
||||
<span v-text="$t('exchange_providers')"></span>
|
||||
</h6>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-8 col-sm-12">
|
||||
<div class="q-pa-sm">
|
||||
<canvas
|
||||
style="
|
||||
width: 100% !important;
|
||||
height: auto !important;
|
||||
min-height: 350px;
|
||||
max-height: 50vh;
|
||||
"
|
||||
ref="exchangeRatesChart"
|
||||
></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4 col-sm-12">
|
||||
<q-input
|
||||
filled
|
||||
v-model="formData.lnbits_exchange_history_refresh_interval_seconds"
|
||||
type="number"
|
||||
label="Refresh Interval (seconds)"
|
||||
hint="How often should the exchange rates be fetched. Set to zero to disable."
|
||||
>
|
||||
</q-input>
|
||||
<q-input
|
||||
filled
|
||||
v-model="formData.lnbits_exchange_history_size"
|
||||
type="number"
|
||||
label="History Size"
|
||||
hint="How many data points should be kept in memory."
|
||||
>
|
||||
</q-input>
|
||||
<ul>
|
||||
<li>
|
||||
<code>Refresh Interval</code> and <code> History Size </code>are for
|
||||
historical purposes only.
|
||||
</li>
|
||||
<li>These two settings do not affect the live price computation.</li>
|
||||
<li>
|
||||
Chart currency:
|
||||
<strong
|
||||
><span
|
||||
v-text="formData.lnbits_default_accounting_currency || 'USD'"
|
||||
></span
|
||||
></strong>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row q-mt-md">
|
||||
<div class="col-6">
|
||||
<q-btn
|
||||
@click="addExchangeProvider()"
|
||||
label="Add Exchange Provider"
|
||||
color="primary"
|
||||
class="q-mb-md"
|
||||
>
|
||||
</q-btn>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<q-btn
|
||||
@click="getDefaultSetting('lnbits_exchange_rate_providers')"
|
||||
flat
|
||||
:label="$t('reset_defaults')"
|
||||
color="primary"
|
||||
class="float-right"
|
||||
>
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<q-table
|
||||
row-key="name"
|
||||
:rows="formData.lnbits_exchange_rate_providers"
|
||||
:columns="exchangesTable.columns"
|
||||
v-model:pagination="exchangesTable.pagination"
|
||||
>
|
||||
<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">
|
||||
<span v-text="col.label"></span>
|
||||
</q-th>
|
||||
</q-tr>
|
||||
</template>
|
||||
<template v-slot:body="props">
|
||||
<q-tr :props="props">
|
||||
<q-td>
|
||||
<q-btn
|
||||
@click="removeExchangeProvider(props.row)"
|
||||
round
|
||||
icon="delete"
|
||||
size="sm"
|
||||
color="negative"
|
||||
class="q-ml-xs"
|
||||
>
|
||||
</q-btn>
|
||||
</q-td>
|
||||
<q-td>
|
||||
<q-input
|
||||
dense
|
||||
filled
|
||||
v-model="props.row.name"
|
||||
@update:model-value="touchSettings()"
|
||||
type="text"
|
||||
>
|
||||
</q-input>
|
||||
</q-td>
|
||||
<q-td full-width>
|
||||
<q-input
|
||||
dense
|
||||
filled
|
||||
v-model="props.row.api_url"
|
||||
@update:model-value="touchSettings()"
|
||||
type="text"
|
||||
>
|
||||
</q-input
|
||||
></q-td>
|
||||
<q-td>
|
||||
<q-input
|
||||
dense
|
||||
filled
|
||||
v-model="props.row.path"
|
||||
@update:model-value="touchSettings()"
|
||||
type="text"
|
||||
>
|
||||
</q-input>
|
||||
</q-td>
|
||||
<q-td>
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
v-model="props.row.exclude_to"
|
||||
@update:model-value="touchSettings()"
|
||||
multiple
|
||||
:options="{{ currencies | safe }}"
|
||||
></q-select>
|
||||
</q-td>
|
||||
<q-td>
|
||||
<q-btn
|
||||
@click="showTickerConversionDialog(props.row)"
|
||||
round
|
||||
icon="add"
|
||||
size="sm"
|
||||
color="gray"
|
||||
class="q-ml-xs"
|
||||
>
|
||||
</q-btn>
|
||||
<q-chip
|
||||
v-for="ticker, index in props.row.ticker_conversion"
|
||||
:key="ticker"
|
||||
removable
|
||||
dense
|
||||
filled
|
||||
@remove="removeExchangeTickerConversion(props.row, ticker)"
|
||||
color="primary"
|
||||
text-color="white"
|
||||
:label="ticker"
|
||||
class="ellipsis"
|
||||
>
|
||||
</q-chip>
|
||||
</q-td>
|
||||
</q-tr>
|
||||
</template>
|
||||
</q-table>
|
||||
<ul>
|
||||
<li>
|
||||
<code>API URL</code> and <code>JSON Path</code> fields can use the
|
||||
<code>{to}</code> and <code>{TO}</code> placeholders for the code of the
|
||||
currency
|
||||
</li>
|
||||
<li>
|
||||
<code>{TO}</code> is the uppercase code and <code>{to}</code> is the
|
||||
lowercase code
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<q-separator class="q-ma-md"></q-separator>
|
||||
<div class="row">
|
||||
<div class="col-md-4 col-sm-12">
|
||||
<q-input
|
||||
filled
|
||||
v-model="formData.lnbits_exchange_rate_cache_seconds"
|
||||
type="number"
|
||||
label="Exchange rate cache (seconds)"
|
||||
hint="For how many seconds should the exchange rate be cached."
|
||||
>
|
||||
</q-input>
|
||||
</div>
|
||||
<div class="col-md-8 col-sm-12"></div>
|
||||
</div>
|
||||
</q-tab-panel>
|
||||
@@ -1,287 +0,0 @@
|
||||
<q-tab-panel name="fiat_providers">
|
||||
<h6 class="q-my-none q-mb-sm">
|
||||
<span v-text="$t('fiat_providers')"></span>
|
||||
<q-btn
|
||||
round
|
||||
flat
|
||||
@click="hideInputsToggle()"
|
||||
:icon="hideInputToggle ? 'visibility_off' : 'visibility'"
|
||||
></q-btn>
|
||||
</h6>
|
||||
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<q-list bordered class="rounded-borders">
|
||||
<q-expansion-item header-class="text-primary text-bold">
|
||||
<template v-slot:header>
|
||||
<q-item-section avatar>
|
||||
<q-avatar>
|
||||
<img
|
||||
:src="'{{ static_url_for('static', 'images/stripe_logo.ico') }}'"
|
||||
/>
|
||||
</q-avatar>
|
||||
</q-item-section>
|
||||
|
||||
<q-item-section> Stripe </q-item-section>
|
||||
|
||||
<q-item-section side>
|
||||
<div class="row items-center">
|
||||
<q-toggle
|
||||
size="md"
|
||||
:label="$t('enabled')"
|
||||
v-model="formData.stripe_enabled"
|
||||
color="green"
|
||||
unchecked-icon="clear"
|
||||
/>
|
||||
</div>
|
||||
</q-item-section>
|
||||
</template>
|
||||
|
||||
<q-card class="q-pb-xl">
|
||||
<q-expansion-item :label="$t('api')" default-opened>
|
||||
<q-card-section class="q-pa-md">
|
||||
<q-input
|
||||
filled
|
||||
type="text"
|
||||
v-model="formData.stripe_api_endpoint"
|
||||
:label="$t('endpoint')"
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
class="q-mt-md"
|
||||
:type="hideInputToggle ? 'password' : 'text'"
|
||||
v-model="formData.stripe_api_secret_key"
|
||||
:label="$t('secret_key')"
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
class="q-mt-md"
|
||||
type="text"
|
||||
v-model="formData.stripe_payment_success_url"
|
||||
:label="$t('callback_success_url')"
|
||||
:hint="$t('callback_success_url_hint')"
|
||||
></q-input>
|
||||
</q-card-section>
|
||||
<q-card-section class="q-pa-md">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
class="float-right"
|
||||
:label="$t('check_connection')"
|
||||
@click="checkFiatProvider('stripe')"
|
||||
></q-btn>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-expansion-item>
|
||||
|
||||
<q-expansion-item :label="$t('webhook')" default-opened>
|
||||
<q-card-section>
|
||||
<span v-text="$t('webhook_stripe_description')"></span>
|
||||
</q-card-section>
|
||||
<q-card-section>
|
||||
<q-input
|
||||
filled
|
||||
class="q-mt-md"
|
||||
type="text"
|
||||
disable
|
||||
v-model="formData.stripe_payment_webhook_url"
|
||||
:label="$t('webhook_url')"
|
||||
:hint="$t('webhook_url_hint')"
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
class="q-mt-md"
|
||||
:type="hideInputToggle ? 'password' : 'text'"
|
||||
v-model="formData.stripe_webhook_signing_secret"
|
||||
:label="$t('signing_secret')"
|
||||
:hint="$t('signing_secret_hint')"
|
||||
></q-input>
|
||||
</q-card-section>
|
||||
<q-card-section>
|
||||
<span v-text="$t('webhook_events_list')"></span>
|
||||
<ul>
|
||||
<li><code>checkout.session.async_payment_failed</code></li>
|
||||
<li><code>checkout.session.async_payment_succeeded</code></li>
|
||||
<li><code>checkout.session.completed</code></li>
|
||||
<li><code>checkout.session.expired</code></li>
|
||||
</ul>
|
||||
</q-card-section>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item :label="$t('service_fee')">
|
||||
<q-card-section>
|
||||
<div class="row">
|
||||
<div class="col-md-4 col-sm-12">
|
||||
<q-input
|
||||
filled
|
||||
class="q-ma-sm"
|
||||
type="number"
|
||||
min="0"
|
||||
v-model="formData.stripe_limits.service_fee_percent"
|
||||
@update:model-value="touchSettings()"
|
||||
:label="$t('service_fee_label')"
|
||||
:hint="$t('service_fee_hint')"
|
||||
></q-input>
|
||||
</div>
|
||||
<div class="col-md-4 col-sm-12">
|
||||
<q-input
|
||||
filled
|
||||
class="q-ma-sm"
|
||||
type="number"
|
||||
min="0"
|
||||
v-model="formData.stripe_limits.service_max_fee_sats"
|
||||
@update:model-value="touchSettings()"
|
||||
:label="$t('service_fee_max')"
|
||||
:hint="$t('service_fee_max_hint')"
|
||||
></q-input>
|
||||
</div>
|
||||
<div class="col-md-4 col-sm-12">
|
||||
<q-input
|
||||
filled
|
||||
class="q-ma-sm"
|
||||
type="text"
|
||||
v-model="formData.stripe_limits.service_fee_wallet_id"
|
||||
@update:model-value="touchSettings()"
|
||||
:label="$t('fee_wallet_label')"
|
||||
:hint="$t('fee_wallet_hint')"
|
||||
></q-input>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item :label="$t('amount_limits')">
|
||||
<q-card-section>
|
||||
<div class="row">
|
||||
<div class="col-md-4 col-sm-12">
|
||||
<q-input
|
||||
filled
|
||||
class="q-ma-sm"
|
||||
type="number"
|
||||
min="0"
|
||||
v-model="formData.stripe_limits.service_min_amount_sats"
|
||||
@update:model-value="touchSettings()"
|
||||
:label="$t('min_incoming_payment_amount')"
|
||||
:hint="$t('min_incoming_payment_amount_desc')"
|
||||
></q-input>
|
||||
</div>
|
||||
<div class="col-md-4 col-sm-12">
|
||||
<q-input
|
||||
filled
|
||||
class="q-ma-sm"
|
||||
type="number"
|
||||
min="0"
|
||||
v-model="formData.stripe_limits.service_max_amount_sats"
|
||||
@update:model-value="touchSettings()"
|
||||
:label="$t('max_incoming_payment_amount')"
|
||||
:hint="$t('max_incoming_payment_amount_desc')"
|
||||
></q-input>
|
||||
</div>
|
||||
<div class="col-md-4 col-sm-12">
|
||||
<q-input
|
||||
filled
|
||||
class="q-ma-sm"
|
||||
v-model="formData.stripe_limits.service_faucet_wallet_id"
|
||||
@update:model-value="touchSettings()"
|
||||
:label="$t('faucest_wallet_id')"
|
||||
:hint="$t('faucest_wallet_id_hint')"
|
||||
></q-input>
|
||||
</div>
|
||||
</div>
|
||||
<q-item>
|
||||
<q-item-section>
|
||||
<q-item-label v-text="$t('faucest_wallet')"></q-item-label>
|
||||
<q-item-label caption>
|
||||
<ul>
|
||||
<li>
|
||||
<span
|
||||
v-text="$t('faucest_wallet_desc_1', {provider: 'stripe'})"
|
||||
></span>
|
||||
</li>
|
||||
<li>
|
||||
<span
|
||||
v-text="$t('faucest_wallet_desc_2', {provider: 'stripe'})"
|
||||
></span>
|
||||
</li>
|
||||
<li>
|
||||
<span v-text="$t('faucest_wallet_desc_3')"></span>
|
||||
</li>
|
||||
<li>
|
||||
<span
|
||||
v-text="$t('faucest_wallet_desc_4', {provider: 'stripe'})"
|
||||
></span>
|
||||
</li>
|
||||
<li>
|
||||
<span v-text="$t('faucest_wallet_desc_5')"></span>
|
||||
</li>
|
||||
</ul>
|
||||
<br />
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-card-section>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item :label="$t('allowed_users')">
|
||||
<q-card-section>
|
||||
<q-input
|
||||
filled
|
||||
v-model="formAddStripeUser"
|
||||
@keydown.enter="addAllowedUser"
|
||||
type="text"
|
||||
:label="$t('allowed_users_label')"
|
||||
:hint="$t('allowed_users_hint_feature', {feature: 'Stripe'})"
|
||||
>
|
||||
<q-btn
|
||||
@click="addStripeAllowedUser"
|
||||
dense
|
||||
flat
|
||||
icon="add"
|
||||
></q-btn>
|
||||
</q-input>
|
||||
<div>
|
||||
<q-chip
|
||||
v-for="user in formData.stripe_limits.allowed_users"
|
||||
@update:model-value="touchSettings()"
|
||||
:key="user"
|
||||
removable
|
||||
@remove="removeStripeAllowedUser(user)"
|
||||
color="primary"
|
||||
text-color="white"
|
||||
:label="user"
|
||||
class="ellipsis"
|
||||
>
|
||||
</q-chip>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-expansion-item>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
|
||||
<q-separator />
|
||||
|
||||
<q-expansion-item header-class="text-primary text-bold">
|
||||
<template v-slot:header>
|
||||
<q-item-section avatar>
|
||||
<q-avatar>
|
||||
<img
|
||||
:src="'{{ static_url_for('static', 'images/square_logo.png') }}'"
|
||||
/>
|
||||
</q-avatar>
|
||||
</q-item-section>
|
||||
|
||||
<q-item-section> Square </q-item-section>
|
||||
|
||||
<q-item-section side>
|
||||
<div class="row items-center">Disabled</div>
|
||||
</q-item-section>
|
||||
</template>
|
||||
|
||||
<q-card>
|
||||
<q-card-section> Coming Soon </q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
</q-list>
|
||||
</div>
|
||||
</div>
|
||||
</q-tab-panel>
|
||||
@@ -1,196 +0,0 @@
|
||||
<q-tab-panel name="funding">
|
||||
<q-card-section class="q-pa-none">
|
||||
<h6 class="q-my-none">
|
||||
<span v-text="$t('wallets_management')"></span>
|
||||
</h6>
|
||||
<br />
|
||||
<div>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<p>
|
||||
<span v-text="$t('funding_source_info')"></span>
|
||||
</p>
|
||||
<ul>
|
||||
<li
|
||||
v-text="$t('funding_source', {wallet_class: settings.lnbits_backend_wallet_class})"
|
||||
></li>
|
||||
<li
|
||||
v-text="$t('node_balance', {balance: (auditData.node_balance_sats || 0).toLocaleString()})"
|
||||
></li>
|
||||
<li
|
||||
v-text="$t('lnbits_balance', {balance: (auditData.lnbits_balance_sats || 0).toLocaleString()})"
|
||||
></li>
|
||||
<li
|
||||
v-text="$t('funding_reserve_percent', {
|
||||
percent: auditData.lnbits_balance_sats > 0
|
||||
? (auditData.node_balance_sats / auditData.lnbits_balance_sats * 100).toFixed(2)
|
||||
: 100
|
||||
})"
|
||||
></li>
|
||||
</ul>
|
||||
<br />
|
||||
</div>
|
||||
<div class="col">
|
||||
{% if LNBITS_NODE_UI_AVAILABLE %}
|
||||
<p><span v-text="$t('node_management')"></span></p>
|
||||
<q-toggle
|
||||
:label="$t('toggle_node_ui')"
|
||||
v-model="formData.lnbits_node_ui"
|
||||
></q-toggle>
|
||||
<q-toggle
|
||||
v-if="formData.lnbits_node_ui"
|
||||
:label="$t('toggle_public_node_ui')"
|
||||
v-model="formData.lnbits_public_node_ui"
|
||||
></q-toggle>
|
||||
<br />
|
||||
<q-toggle
|
||||
v-if="formData.lnbits_node_ui"
|
||||
:label="$t('toggle_transactions_node_ui')"
|
||||
v-model="formData.lnbits_node_ui_transactions"
|
||||
></q-toggle>
|
||||
{% else %}
|
||||
<p><span v-text="$t('node_management_not_supported')"></span></p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 col-md-3">
|
||||
<p><span v-text="$t('fee_reserve')"></span></p>
|
||||
|
||||
<q-input
|
||||
type="number"
|
||||
filled
|
||||
v-model="formData.lnbits_reserve_fee_min"
|
||||
:label="$t('fee_reserve_msats')"
|
||||
>
|
||||
</q-input>
|
||||
</div>
|
||||
<div class="col-12 col-md-3">
|
||||
<p><span v-text="$t('fee_reserve_percent')"></span></p>
|
||||
<q-input
|
||||
type="number"
|
||||
filled
|
||||
name="lnbits_reserve_fee_percent"
|
||||
v-model="formData.lnbits_reserve_fee_percent"
|
||||
:label="$t('reserve_fee_in_percent')"
|
||||
step="0.1"
|
||||
></q-input>
|
||||
</div>
|
||||
<div class="col-12 col-md-3">
|
||||
<p><span v-text="$t('invoice_expiry')"></span></p>
|
||||
<q-input
|
||||
filled
|
||||
v-model.number="formData.lightning_invoice_expiry"
|
||||
type="number"
|
||||
:label="$t('invoice_expiry_label')"
|
||||
mask="#######"
|
||||
>
|
||||
</q-input>
|
||||
</div>
|
||||
<div class="col-12 col-md-3">
|
||||
<p><span v-text="$t('payment_wait_time')"></span></p>
|
||||
<q-input
|
||||
type="number"
|
||||
filled
|
||||
name="lnbits_funding_source_pay_invoice_wait_seconds"
|
||||
v-model="formData.lnbits_funding_source_pay_invoice_wait_seconds"
|
||||
:label="$t('payment_wait_time')"
|
||||
:hint="$t('payment_wait_time_desc')"
|
||||
step="1"
|
||||
min="0"
|
||||
></q-input>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isSuperUser">
|
||||
<lnbits-funding-sources
|
||||
:form-data="formData"
|
||||
:allowed-funding-sources="settings.lnbits_allowed_funding_sources"
|
||||
/>
|
||||
</div>
|
||||
<q-separator></q-separator>
|
||||
<h6 class="q-mt-lg q-mb-sm">
|
||||
<p v-text="$t('watchdog')"></p>
|
||||
</h6>
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 col-md-6">
|
||||
<q-item tag="label" v-ripple>
|
||||
<q-item-section>
|
||||
<q-item-label v-text="$t('enable_watchdog')"></q-item-label>
|
||||
<q-item-label
|
||||
caption
|
||||
v-text="$t('enable_watchdog_desc')"
|
||||
></q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section avatar>
|
||||
<q-toggle
|
||||
size="md"
|
||||
v-model="formData.lnbits_watchdog_switch_to_voidwallet"
|
||||
checked-icon="check"
|
||||
color="green"
|
||||
unchecked-icon="clear"
|
||||
/>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<q-item tag="label" v-ripple>
|
||||
<q-item-section>
|
||||
<q-item-label
|
||||
v-text="$t('notification_watchdog_limit')"
|
||||
></q-item-label>
|
||||
<q-item-label
|
||||
caption
|
||||
v-text="$t('notification_watchdog_limit_desc')"
|
||||
></q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section avatar>
|
||||
<q-toggle
|
||||
size="md"
|
||||
v-model="formData.lnbits_notification_watchdog"
|
||||
checked-icon="check"
|
||||
color="green"
|
||||
unchecked-icon="clear"
|
||||
/>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<q-item tag="label" v-ripple>
|
||||
<q-item-section>
|
||||
<q-item-label v-text="$t('watchdog_interval')"></q-item-label>
|
||||
<q-item-label
|
||||
caption
|
||||
v-text="$t('watchdog_interval_desc')"
|
||||
></q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-input
|
||||
filled
|
||||
v-model="formData.lnbits_watchdog_interval_minutes"
|
||||
type="number"
|
||||
/>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<q-item tag="label" v-ripple>
|
||||
<q-item-section>
|
||||
<q-item-label v-text="$t('watchdog_delta')"></q-item-label>
|
||||
<q-item-label
|
||||
caption
|
||||
v-text="$t('watchdog_delta_desc')"
|
||||
></q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-input
|
||||
filled
|
||||
v-model="formData.lnbits_watchdog_delta"
|
||||
type="number"
|
||||
/>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-tab-panel>
|
||||
@@ -1,67 +0,0 @@
|
||||
<q-tab-panel name="library">
|
||||
<q-card-section class="q-pa-none">
|
||||
<h6 class="q-my-none q-mb-sm">
|
||||
<span v-text="$t('image_library')"></span>
|
||||
</h6>
|
||||
<q-btn
|
||||
color="primary"
|
||||
label="Add Image"
|
||||
@click="$refs.imageInput.click()"
|
||||
class="q-mb-md"
|
||||
/>
|
||||
<input
|
||||
type="file"
|
||||
ref="imageInput"
|
||||
accept="image/png, image/jpeg, image/gif"
|
||||
style="display: none"
|
||||
@change="onImageInput"
|
||||
/>
|
||||
</q-card-section>
|
||||
<div class="row q-col-gutter-sm q-pa-sm">
|
||||
<div
|
||||
v-for="image in library_images"
|
||||
:key="image.filename"
|
||||
class="col-6 col-sm-4 col-md-3 col-lg-2"
|
||||
style="max-width: 200px"
|
||||
>
|
||||
<q-card class="q-mb-sm">
|
||||
<q-img :src="image.url" style="height: 150px" />
|
||||
|
||||
<q-card-section
|
||||
class="q-pt-md q-pb-md row items-center justify-between"
|
||||
>
|
||||
<small
|
||||
><div
|
||||
class="text-caption ellipsis"
|
||||
style="max-width: 100px"
|
||||
:title="image.filename"
|
||||
v-text="image.filename"
|
||||
></div
|
||||
></small>
|
||||
<q-btn
|
||||
dense
|
||||
flat
|
||||
size="sm"
|
||||
icon="content_copy"
|
||||
@click="copyText(image.url)"
|
||||
:title="$t('copy')"
|
||||
><q-tooltip>Copy image link</q-tooltip></q-btn
|
||||
>
|
||||
<q-btn
|
||||
dense
|
||||
flat
|
||||
size="sm"
|
||||
icon="delete"
|
||||
color="negative"
|
||||
@click="deleteImage(image.filename)"
|
||||
:title="$t('delete')"
|
||||
><q-tooltip>Delete image</q-tooltip></q-btn
|
||||
>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="library_images.length === 0" class="q-pa-xl">
|
||||
<div class="text-subtitle2 text-grey">No images uploaded yet.</div>
|
||||
</div>
|
||||
</q-tab-panel>
|
||||
@@ -1,210 +0,0 @@
|
||||
<q-tab-panel name="site_customisation">
|
||||
<q-card-section class="q-pa-none">
|
||||
<h6 class="q-my-none"><span v-text="$t('ui_management')"></span></h6>
|
||||
<br />
|
||||
<div>
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 col-md-5">
|
||||
<p><span v-text="$t('ui_site_title')"></span></p>
|
||||
<q-input
|
||||
filled
|
||||
type="text"
|
||||
v-model="formData.lnbits_site_title"
|
||||
:label="$t('ui_site_title') + $t('ui_changing_remove_lnbits_elements')"
|
||||
></q-input>
|
||||
<br />
|
||||
</div>
|
||||
<div class="col-12 col-md-5">
|
||||
<p><span v-text="$t('ui_site_tagline')"></span></p>
|
||||
<q-input
|
||||
filled
|
||||
type="text"
|
||||
v-model="formData.lnbits_site_tagline"
|
||||
:label="$t('ui_site_tagline')"
|
||||
></q-input>
|
||||
<br />
|
||||
</div>
|
||||
<div class="col-12 col-md-2 q-mt-xl">
|
||||
<q-toggle
|
||||
:tip="$t('ui_toggle_elements_tip')"
|
||||
v-model="formData.lnbits_show_home_page_elements"
|
||||
:label="formData.lnbits_show_home_page_elements ? $t('ui_elements_enable') : $t('ui_elements_disable')"
|
||||
></q-toggle>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p><span v-text="$t('ui_site_description')"></span></p>
|
||||
<q-input
|
||||
v-model="formData.lnbits_site_description"
|
||||
filled
|
||||
type="textarea"
|
||||
:hint="$t('ui_site_description_hint')"
|
||||
/>
|
||||
</div>
|
||||
<br />
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 col-md-4">
|
||||
<p><span v-text="$t('ui_default_wallet_name')"></span></p>
|
||||
<q-input
|
||||
filled
|
||||
type="text"
|
||||
v-model="formData.lnbits_default_wallet_name"
|
||||
:label="$t('lnbits_wallet')"
|
||||
></q-input>
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<p><span v-text="$t('ui_qr_code_logo')"></span></p>
|
||||
<q-input
|
||||
filled
|
||||
type="text"
|
||||
v-model="formData.lnbits_qr_logo"
|
||||
label="https://example.com/image.svg"
|
||||
:hint="$t('ui_qr_code_logo_hint')"
|
||||
></q-input>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-col-gutter-md q-mt-md">
|
||||
<div class="col-12 col-md-6">
|
||||
<p><span v-text="$t('ui_custom_badge')"></span></p>
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 col-md-8">
|
||||
<q-input
|
||||
filled
|
||||
type="text"
|
||||
tip="Custom Badge"
|
||||
v-model.trim="formData.lnbits_custom_badge"
|
||||
:label="$t('ui_custom_badge_label')"
|
||||
></q-input>
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<q-select
|
||||
filled
|
||||
v-model="formData.lnbits_custom_badge_color"
|
||||
:options="colors"
|
||||
:label="$t('ui_custom_badge_color_label')"
|
||||
></q-select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<p><span v-text="$t('ui_custom_image')"></span></p>
|
||||
<q-input
|
||||
filled
|
||||
type="text"
|
||||
tip="Custom Image"
|
||||
v-model.trim="formData.lnbits_custom_image"
|
||||
:label="$t('ui_custom_image_label')"
|
||||
:hint="$t('ui_custom_image_hint')"
|
||||
></q-input>
|
||||
</div>
|
||||
</div>
|
||||
<br />
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 col-md-6">
|
||||
<p><span v-text="$t('themes')"></span></p>
|
||||
<q-select
|
||||
filled
|
||||
v-model="formData.lnbits_theme_options"
|
||||
multiple
|
||||
:hint="$t('themes_hint')"
|
||||
:options="lnbits_theme_options"
|
||||
:label="$t('themes')"
|
||||
></q-select>
|
||||
<br />
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<p><span v-text="$t('custom_logo')"></span></p>
|
||||
<q-input
|
||||
filled
|
||||
type="text"
|
||||
v-model="formData.lnbits_custom_logo"
|
||||
label="https://example.com/image.png"
|
||||
:hint="$t('custom_logo_hint')"
|
||||
></q-input>
|
||||
<br />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 col-md-6">
|
||||
<p><span v-text="$t('ad_space_title')"></span></p>
|
||||
<q-input
|
||||
filled
|
||||
type="text"
|
||||
v-model="formData.lnbits_ad_space_title"
|
||||
:label="$t('ad_space_title_label')"
|
||||
></q-input>
|
||||
<br />
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<p><span v-text="$t('ad_slots')"></span></p>
|
||||
<q-input
|
||||
class="q-mb-md"
|
||||
filled
|
||||
v-model="formData.lnbits_ad_space"
|
||||
type="text"
|
||||
:label="$t('ad_slots_label')"
|
||||
:hint="$t('ad_slots_hint')"
|
||||
><q-tooltip>
|
||||
format {url};{img-light};{img-dark},{url};{img-light};{img-dark}"
|
||||
</q-tooltip>
|
||||
</q-input>
|
||||
<q-toggle
|
||||
v-model="formData.lnbits_ad_space_enabled"
|
||||
:label="formData.lnbits_ad_space_enabled ? $t('ads_enabled') : $t('ads_disabled')"
|
||||
/>
|
||||
<br />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-col-gutter-md q-my-md">
|
||||
<div class="col-12" v-text="$t('ui_default_theme')"></div>
|
||||
<div class="col-12 col-sm-6 col-lg-3">
|
||||
<q-select
|
||||
v-model="formData.lnbits_default_border"
|
||||
:options="globalBorderOptions"
|
||||
label="Border"
|
||||
@update:model-value="applyGlobalBorder"
|
||||
>
|
||||
</q-select>
|
||||
</div>
|
||||
<div class="col-12 col-sm-6 col-lg-3">
|
||||
<q-select
|
||||
v-model="formData.lnbits_default_theme"
|
||||
:options="lnbits_theme_options"
|
||||
label="Theme"
|
||||
@update:model-value="applyGlobalTheme"
|
||||
>
|
||||
</q-select>
|
||||
</div>
|
||||
<div class="col-12 col-sm-6 col-lg-3">
|
||||
<q-select
|
||||
v-model="formData.lnbits_default_reaction"
|
||||
:options="reactionOptions"
|
||||
label="Payment reaction"
|
||||
@update:model-value="applyGlobalReaction"
|
||||
>
|
||||
</q-select>
|
||||
</div>
|
||||
<div class="col-12 col-sm-6 col-lg-3">
|
||||
<q-input
|
||||
type="text"
|
||||
v-model="formData.lnbits_default_bgimage"
|
||||
label="Background Image"
|
||||
@update:model-value="applyGlobalBgimage"
|
||||
hint="This must be a trusted source. It can change the content and it can log your IP address."
|
||||
>
|
||||
</q-input>
|
||||
</div>
|
||||
<div class="col-12 col-sm-6 col-lg-3">
|
||||
<q-toggle
|
||||
type="bool"
|
||||
v-model="formData.lnbits_default_gradient"
|
||||
color="primary"
|
||||
label="Gradient Background"
|
||||
>
|
||||
</q-toggle>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-tab-panel>
|
||||
@@ -1,83 +0,0 @@
|
||||
<q-tab-panel name="users">
|
||||
<q-card-section class="q-pa-none">
|
||||
<h6 class="q-my-none q-mb-sm">
|
||||
<span v-text="$t('user_management')"></span>
|
||||
</h6>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12 col-md-6 q-pr-sm">
|
||||
<p><span v-text="$t('admin_users')"></span></p>
|
||||
<q-input
|
||||
filled
|
||||
v-model="formAddAdmin"
|
||||
@keydown.enter="addAdminUser"
|
||||
type="text"
|
||||
:label="$t('admin_users_label')"
|
||||
:hint="$t('admin_users_hint')"
|
||||
>
|
||||
<q-btn @click="addAdminUser" dense flat icon="add"></q-btn>
|
||||
</q-input>
|
||||
<div>
|
||||
<q-chip
|
||||
v-for="user in formData.lnbits_admin_users"
|
||||
:key="user"
|
||||
removable
|
||||
@remove="removeAdminUser(user)"
|
||||
color="primary"
|
||||
text-color="white"
|
||||
:label="user"
|
||||
class="ellipsis"
|
||||
>
|
||||
</q-chip>
|
||||
</div>
|
||||
<br />
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<p><span v-text="$t('allowed_users')"></span></p>
|
||||
<q-input
|
||||
filled
|
||||
v-model="formAddUser"
|
||||
@keydown.enter="addAllowedUser"
|
||||
type="text"
|
||||
:label="$t('allowed_users_label')"
|
||||
:hint="$t('allowed_users_hint')"
|
||||
>
|
||||
<q-btn @click="addAllowedUser" dense flat icon="add"></q-btn>
|
||||
</q-input>
|
||||
<div>
|
||||
<q-chip
|
||||
v-for="user in formData.lnbits_allowed_users"
|
||||
:key="user"
|
||||
removable
|
||||
@remove="removeAllowedUser(user)"
|
||||
color="primary"
|
||||
text-color="white"
|
||||
:label="user"
|
||||
class="ellipsis"
|
||||
>
|
||||
</q-chip>
|
||||
</div>
|
||||
<br />
|
||||
<q-item tag="label" v-ripple>
|
||||
<q-item-section>
|
||||
<q-item-label v-text="$t('allow_creation_user')"></q-item-label>
|
||||
<q-item-label
|
||||
caption
|
||||
v-text="$t('allow_creation_user_desc')"
|
||||
></q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section avatar>
|
||||
<q-toggle
|
||||
size="md"
|
||||
v-model="formData.lnbits_allow_new_accounts"
|
||||
checked-icon="check"
|
||||
color="green"
|
||||
unchecked-icon="clear"
|
||||
/>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<br />
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-tab-panel>
|
||||
@@ -1,251 +0,0 @@
|
||||
{% if not ajax %} {% extends "base.html" %} {% endif %} {% from "macros.jinja"
|
||||
import window_vars with context %} {% block scripts %} {{ window_vars(user) }}
|
||||
{% endblock %} {% block page %}
|
||||
|
||||
<div class="row q-col-gutter-md q-mb-md">
|
||||
<div class="col-12">
|
||||
<q-card>
|
||||
<div class="q-pa-sm">
|
||||
<div class="row items-center justify-between q-gutter-xs">
|
||||
<div class="col">
|
||||
<q-btn
|
||||
:label="$t('save')"
|
||||
color="primary"
|
||||
@click="updateSettings"
|
||||
:disabled="!checkChanges"
|
||||
>
|
||||
<q-tooltip v-if="checkChanges">
|
||||
<span v-text="$t('save_tooltip')"></span>
|
||||
</q-tooltip>
|
||||
|
||||
<q-badge
|
||||
v-if="checkChanges"
|
||||
color="red"
|
||||
rounded
|
||||
floating
|
||||
style="padding: 6px; border-radius: 6px"
|
||||
/>
|
||||
</q-btn>
|
||||
|
||||
<q-btn
|
||||
v-if="isSuperUser"
|
||||
:label="$t('restart')"
|
||||
color="primary"
|
||||
@click="restartServer"
|
||||
class="q-ml-md"
|
||||
>
|
||||
<q-tooltip v-if="needsRestart">
|
||||
<span v-text="$t('restart_tooltip')"></span>
|
||||
</q-tooltip>
|
||||
|
||||
<q-badge
|
||||
v-if="needsRestart"
|
||||
color="red"
|
||||
rounded
|
||||
floating
|
||||
style="padding: 6px; border-radius: 6px"
|
||||
/>
|
||||
</q-btn>
|
||||
|
||||
<q-btn
|
||||
:label="$t('download_backup')"
|
||||
flat
|
||||
@click="downloadBackup"
|
||||
></q-btn>
|
||||
|
||||
<q-btn
|
||||
flat
|
||||
v-if="isSuperUser"
|
||||
:label="$t('reset_defaults')"
|
||||
color="primary"
|
||||
@click="deleteSettings"
|
||||
class="float-right"
|
||||
>
|
||||
<q-tooltip>
|
||||
<span v-text="$t('reset_defaults_tooltip')"></span>
|
||||
</q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
<div></div>
|
||||
</div>
|
||||
</div>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row q-col-gutter-md justify-center">
|
||||
<div class="col q-gutter-y-md">
|
||||
<q-card>
|
||||
<q-splitter>
|
||||
<template v-slot:before>
|
||||
<q-tabs
|
||||
@update:model-value="showExchangeProvidersTab"
|
||||
v-model="tab"
|
||||
vertical
|
||||
active-color="primary"
|
||||
>
|
||||
<q-tab
|
||||
name="funding"
|
||||
icon="account_balance_wallet"
|
||||
:label="$q.screen.gt.sm ? $t('funding') : null"
|
||||
@update="val => tab = val.name"
|
||||
><q-tooltip v-if="!$q.screen.gt.sm"
|
||||
><span v-text="$t('funding')"></span></q-tooltip
|
||||
></q-tab>
|
||||
<q-tab
|
||||
name="security"
|
||||
icon="security"
|
||||
:label="$q.screen.gt.sm ? $t('security') : null"
|
||||
@update="val => tab = val.name"
|
||||
><q-tooltip v-if="!$q.screen.gt.sm"
|
||||
><span v-text="$t('security')"></span></q-tooltip
|
||||
></q-tab>
|
||||
<q-tab
|
||||
name="server"
|
||||
icon="price_change"
|
||||
:label="$q.screen.gt.sm ? $t('payments') : null"
|
||||
@update="val => tab = val.name"
|
||||
><q-tooltip v-if="!$q.screen.gt.sm"
|
||||
><span v-text="$t('payments')"></span></q-tooltip
|
||||
></q-tab>
|
||||
<q-tab
|
||||
name="exchange_providers"
|
||||
icon="show_chart"
|
||||
:label="$q.screen.gt.sm ? $t('exchanges') : null"
|
||||
><q-tooltip v-if="!$q.screen.gt.sm"
|
||||
><span v-text="$t('exchanges')"></span></q-tooltip
|
||||
></q-tab>
|
||||
<q-tab
|
||||
name="fiat_providers"
|
||||
icon="credit_score"
|
||||
:label="$q.screen.gt.sm ? $t('fiat_providers') : null"
|
||||
><q-tooltip v-if="!$q.screen.gt.sm"
|
||||
><span v-text="$t('fiat_providers')"></span></q-tooltip
|
||||
></q-tab>
|
||||
<q-tab
|
||||
name="users"
|
||||
icon="group"
|
||||
:label="$q.screen.gt.sm ? $t('users') : null"
|
||||
@update="val => tab = val.name"
|
||||
><q-tooltip v-if="!$q.screen.gt.sm"
|
||||
><span v-text="$t('users')"></span></q-tooltip
|
||||
></q-tab>
|
||||
|
||||
<q-tab
|
||||
name="extensions"
|
||||
icon="extension"
|
||||
:label="$q.screen.gt.sm ? $t('extensions') : null"
|
||||
@update="val => tab = val.name"
|
||||
><q-tooltip v-if="!$q.screen.gt.sm"
|
||||
><span v-text="$t('extensions')"></span></q-tooltip
|
||||
></q-tab>
|
||||
|
||||
<q-tab
|
||||
name="notifications"
|
||||
icon="notifications"
|
||||
:label="$q.screen.gt.sm ? $t('notifications') : null"
|
||||
@update="val => tab = val.name"
|
||||
><q-tooltip v-if="!$q.screen.gt.sm"
|
||||
><span v-text="$t('notifications')"></span></q-tooltip
|
||||
></q-tab>
|
||||
<q-tab
|
||||
name="audit"
|
||||
icon="playlist_add_check_circle"
|
||||
:label="$q.screen.gt.sm ? $t('audit') : null"
|
||||
@update="val => tab = val.name"
|
||||
><q-tooltip v-if="!$q.screen.gt.sm"
|
||||
><span v-text="$t('audit')"></span></q-tooltip
|
||||
></q-tab>
|
||||
<q-tab
|
||||
name="library"
|
||||
icon="image"
|
||||
:label="$q.screen.gt.sm ? $t('library') : null"
|
||||
@update="val => tab = val.name"
|
||||
><q-tooltip v-if="!$q.screen.gt.sm"
|
||||
><span v-text="$t('library')"></span></q-tooltip
|
||||
></q-tab>
|
||||
<q-tab
|
||||
style="word-break: break-all"
|
||||
name="site_customisation"
|
||||
icon="language"
|
||||
:label="$q.screen.gt.sm ? $t('site_customisation') : null"
|
||||
@update="val => tab = val.name"
|
||||
><q-tooltip v-if="!$q.screen.gt.sm"
|
||||
><span v-text="$t('site_customisation')"></span></q-tooltip
|
||||
></q-tab>
|
||||
</q-tabs>
|
||||
</template>
|
||||
|
||||
<template v-slot:after>
|
||||
<q-form name="settings_form" id="settings_form">
|
||||
<q-scroll-area style="height: 100vh">
|
||||
<q-tab-panels
|
||||
v-model="tab"
|
||||
animated
|
||||
swipeable
|
||||
vertical
|
||||
scroll
|
||||
transition-prev="jump-up"
|
||||
transition-next="jump-up"
|
||||
>
|
||||
{% include "admin/_tab_funding.html" %} {% include
|
||||
"admin/_tab_users.html" %} {% include "admin/_tab_server.html"
|
||||
%} {% include "admin/_tab_exchange_providers.html" %}{% include
|
||||
"admin/_tab_fiat_providers.html" %} {% include
|
||||
"admin/_tab_extensions.html" %} {% include
|
||||
"admin/_tab_notifications.html" %} {% include
|
||||
"admin/_tab_security.html" %} {% include "admin/_tab_theme.html"
|
||||
%}{% include "admin/_tab_audit.html"%}{% include
|
||||
"admin/_tab_library.html"%}
|
||||
</q-tab-panels>
|
||||
</q-scroll-area>
|
||||
</q-form>
|
||||
</template>
|
||||
</q-splitter>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<q-dialog v-model="exchangeData.showTickerConversion" position="top">
|
||||
<q-card class="q-pa-md q-pt-md lnbits__dialog-card">
|
||||
<div class="q-mb-md">
|
||||
<strong v-text="$t('create_ticker_converter')"></strong>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-12 q-mb-md">
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
v-model="exchangeData.convertFromTicker"
|
||||
label="From Currency"
|
||||
:options="{{ currencies | safe }}"
|
||||
></q-select>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<q-input
|
||||
v-model="exchangeData.convertToTicker"
|
||||
dense
|
||||
filled
|
||||
label="New Ticker"
|
||||
hint="This ticker will be used for the exchange API calls."
|
||||
>
|
||||
</q-input>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
@click="addExchangeTickerConversion()"
|
||||
label="Add Ticker Conversion"
|
||||
color="primary"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
v-close-popup
|
||||
flat
|
||||
color="grey"
|
||||
class="q-ml-auto"
|
||||
v-text="$t('close')"
|
||||
></q-btn>
|
||||
</div>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
{% endblock %}
|
||||
@@ -1,201 +0,0 @@
|
||||
{% if not ajax %} {% extends "base.html" %} {% endif %}
|
||||
<!---->
|
||||
{% from "macros.jinja" import window_vars with context %}
|
||||
<!---->
|
||||
{% block scripts %} {{ window_vars(user) }} {% endblock %} {% block page %}
|
||||
<div class="row q-col-gutter-md q-mb-md">
|
||||
<div class="col-12">
|
||||
<q-card>
|
||||
<div class="q-pa-sm q-pl-lg">
|
||||
<div class="row items-center justify-between q-gutter-xs">
|
||||
<div class="col">
|
||||
<!-- Optional: Add content here if needed -->
|
||||
</div>
|
||||
<div>
|
||||
<q-btn
|
||||
v-if="g.user.admin"
|
||||
flat
|
||||
round
|
||||
icon="settings"
|
||||
to="/admin#audit"
|
||||
>
|
||||
<q-tooltip v-text="$t('admin_settings')"></q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row q-col-gutter-md justify-center q-mb-lg">
|
||||
<div class="col-lg-3 col-md-6 col-sm-12 text-center">
|
||||
<q-card class="q-pt-sm">
|
||||
<strong v-text="$t('components')"></strong>
|
||||
<div style="width: 250px" class="q-pa-sm">
|
||||
<canvas v-if="chartsReady" ref="componentUseChart"></canvas>
|
||||
</div>
|
||||
</q-card>
|
||||
</div>
|
||||
<div class="col-lg-3 col-md-6 col-sm-12 text-center">
|
||||
<q-card class="q-pt-sm">
|
||||
<strong v-text="$t('long_running_endpoints')"></strong>
|
||||
<div style="width: 250px; height: 250px" class="q-pa-sm">
|
||||
<canvas v-if="chartsReady" ref="longDurationChart"></canvas>
|
||||
</div>
|
||||
</q-card>
|
||||
</div>
|
||||
<div class="col-lg-3 col-md-6 col-sm-12 text-center">
|
||||
<q-card class="q-pt-sm">
|
||||
<strong v-text="$t('http_request_methods')"></strong>
|
||||
<div style="width: 250px; height: 250px" class="q-pa-sm">
|
||||
<canvas v-if="chartsReady" ref="requestMethodChart"></canvas>
|
||||
</div>
|
||||
</q-card>
|
||||
</div>
|
||||
<div class="col-lg-3 col-md-6 col-sm-12 text-center">
|
||||
<q-card class="q-pt-sm">
|
||||
<strong v-text="$t('http_response_codes')"></strong>
|
||||
<div style="width: 250px; height: 250px" class="q-pa-sm">
|
||||
<canvas v-if="chartsReady" ref="responseCodeChart"></canvas>
|
||||
</div>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row q-col-gutter-md justify-center">
|
||||
<div class="col">
|
||||
<q-card class="q-pa-md">
|
||||
<q-table
|
||||
row-key="id"
|
||||
:rows="auditEntries"
|
||||
:columns="auditTable.columns"
|
||||
v-model:pagination="auditTable.pagination"
|
||||
:filter="auditTable.search"
|
||||
:loading="auditTable.loading"
|
||||
@request="fetchAudit"
|
||||
>
|
||||
<template v-slot:header="props">
|
||||
<q-tr :props="props">
|
||||
<q-th v-for="col in props.cols" :key="col.name" :props="props">
|
||||
<q-input
|
||||
v-if="['ip_address', 'user_id', 'path',].includes(col.name)"
|
||||
v-model="searchData[col.name]"
|
||||
@keydown.enter="searchAuditBy()"
|
||||
@update:model-value="searchAuditBy()"
|
||||
dense
|
||||
type="text"
|
||||
filled
|
||||
clearable
|
||||
:label="col.label"
|
||||
>
|
||||
<template v-slot:append>
|
||||
<q-icon
|
||||
name="search"
|
||||
@click="searchAuditBy()"
|
||||
class="cursor-pointer"
|
||||
/>
|
||||
</template>
|
||||
</q-input>
|
||||
|
||||
<q-select
|
||||
v-else-if="['component', 'response_code','request_method'].includes(col.name)"
|
||||
v-model="searchData[col.name]"
|
||||
:options="searchOptions[col.name]"
|
||||
@update:model-value="searchAuditBy()"
|
||||
:label="col.label"
|
||||
clearable
|
||||
style="width: 100px"
|
||||
></q-select>
|
||||
|
||||
<span v-else v-text="col.label"></span>
|
||||
</q-th>
|
||||
</q-tr>
|
||||
</template>
|
||||
<template v-slot:body="props">
|
||||
<q-tr auto-width :props="props">
|
||||
<q-td v-for="col in props.cols" :key="col.name" :props="props">
|
||||
<div v-if="col.name == 'created_at'">
|
||||
<q-btn
|
||||
icon="description"
|
||||
:disable="!props.row.request_details"
|
||||
size="sm"
|
||||
flat
|
||||
class="cursor-pointer q-mr-xs"
|
||||
@click="showDetailsDialog(props.row)"
|
||||
>
|
||||
<q-tooltip
|
||||
><span v-text="$t('request_details')"></span
|
||||
></q-tooltip>
|
||||
</q-btn>
|
||||
|
||||
<span v-text="formatDate(props.row.created_at)"></span>
|
||||
<q-tooltip v-if="props.row.delete_at">
|
||||
<span
|
||||
v-text="'Will be deleted at: ' + formatDate(props.row.delete_at)"
|
||||
></span>
|
||||
</q-tooltip>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="['user_id', 'request_details'].includes(col.name)"
|
||||
>
|
||||
<q-btn
|
||||
v-if="props.row[col.name]"
|
||||
icon="content_copy"
|
||||
size="sm"
|
||||
flat
|
||||
class="cursor-pointer q-mr-xs"
|
||||
@click="copyText(props.row[col.name])"
|
||||
>
|
||||
<q-tooltip>Copy</q-tooltip>
|
||||
</q-btn>
|
||||
<span v-text="shortify(props.row[col.name])"> </span>
|
||||
<q-tooltip>
|
||||
<span v-text="props.row[col.name]"></span>
|
||||
</q-tooltip>
|
||||
</div>
|
||||
<span
|
||||
v-else
|
||||
v-text="props.row[col.name]"
|
||||
@click="searchAuditBy(col.name, props.row[col.name])"
|
||||
class="cursor-pointer"
|
||||
></span>
|
||||
</q-td>
|
||||
</q-tr>
|
||||
</template>
|
||||
</q-table>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<q-dialog v-model="auditDetailsDialog.show" position="top">
|
||||
<q-card class="q-pa-md q-pt-md lnbits__dialog-card">
|
||||
<strong v-text="$t('http_request_details')"></strong>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="auditDetailsDialog.data"
|
||||
type="textarea"
|
||||
rows="25"
|
||||
></q-input>
|
||||
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
@click="copyText(auditDetailsDialog.data)"
|
||||
icon="copy_content"
|
||||
color="grey"
|
||||
flat
|
||||
v-text="$t('copy')"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
v-close-popup
|
||||
flat
|
||||
color="grey"
|
||||
class="q-ml-auto"
|
||||
v-text="$t('close')"
|
||||
></q-btn>
|
||||
</div>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
{% endblock %}
|
||||
@@ -1,305 +0,0 @@
|
||||
<q-expansion-item
|
||||
group="extras"
|
||||
icon="vpn_key"
|
||||
:label="$t('api_keys_api_docs')"
|
||||
:content-inset-level="0.5"
|
||||
>
|
||||
<q-card-section>
|
||||
<q-list>
|
||||
<q-item dense class="q-pa-none">
|
||||
<q-item-section>
|
||||
<q-item-label>
|
||||
<strong>Node URL: </strong><em v-text="origin"></em>
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item dense class="q-pa-none">
|
||||
<q-item-section>
|
||||
<q-item-label>
|
||||
<strong>Wallet ID: </strong><em v-text="wallet.id"></em>
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section side>
|
||||
<q-icon
|
||||
name="content_copy"
|
||||
class="cursor-pointer"
|
||||
@click="copyText(wallet.id)"
|
||||
></q-icon>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item dense class="q-pa-none">
|
||||
<q-item-section>
|
||||
<q-item-label>
|
||||
<strong>Admin key: </strong
|
||||
><em
|
||||
v-text="adminkeyHidden ? '****************' : wallet.adminkey"
|
||||
></em>
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section side>
|
||||
<div>
|
||||
<q-icon
|
||||
:name="adminkeyHidden ? 'visibility_off' : 'visibility'"
|
||||
class="cursor-pointer"
|
||||
@click="adminkeyHidden = !adminkeyHidden"
|
||||
></q-icon>
|
||||
<q-icon
|
||||
name="content_copy"
|
||||
class="cursor-pointer q-ml-sm"
|
||||
@click="copyText(wallet.adminkey)"
|
||||
></q-icon>
|
||||
<q-icon name="qr_code" class="cursor-pointer q-ml-sm">
|
||||
<q-popup-proxy>
|
||||
<div class="q-pa-md">
|
||||
<lnbits-qrcode
|
||||
:value="wallet.adminkey"
|
||||
:show-buttons="false"
|
||||
></lnbits-qrcode>
|
||||
</div>
|
||||
</q-popup-proxy>
|
||||
</q-icon>
|
||||
</div>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item dense class="q-pa-none">
|
||||
<q-item-section>
|
||||
<q-item-label>
|
||||
<strong>Invoice/read key: </strong
|
||||
><em v-text="inkeyHidden ? '****************' : wallet.inkey"></em>
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section side>
|
||||
<div>
|
||||
<q-icon
|
||||
:name="inkeyHidden ? 'visibility_off' : 'visibility'"
|
||||
class="cursor-pointer"
|
||||
@click="inkeyHidden = !inkeyHidden"
|
||||
></q-icon>
|
||||
<q-icon
|
||||
name="content_copy"
|
||||
class="cursor-pointer q-ml-sm"
|
||||
@click="copyText(wallet.inkey)"
|
||||
></q-icon>
|
||||
<q-icon name="qr_code" class="cursor-pointer q-ml-sm">
|
||||
<q-popup-proxy>
|
||||
<div class="q-pa-md">
|
||||
<lnbits-qrcode
|
||||
:value="wallet.inkey"
|
||||
:show-buttons="false"
|
||||
></lnbits-qrcode>
|
||||
</div>
|
||||
</q-popup-proxy>
|
||||
</q-icon>
|
||||
</div>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-card-section>
|
||||
<q-expansion-item
|
||||
group="api"
|
||||
dense
|
||||
expand-separator
|
||||
label="Get wallet details"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code><span class="text-light-green">GET</span> /api/v1/wallet</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code
|
||||
>{"X-Api-Key": "<i
|
||||
v-text="inkeyHidden ? '****************' : wallet.inkey"
|
||||
></i
|
||||
>"}</code
|
||||
><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 200 OK (application/json)
|
||||
</h5>
|
||||
<code
|
||||
>{"id": <string>, "name": <string>, "balance":
|
||||
<int>}</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl <span v-text="baseUrl"></span>api/v1/wallet -H "X-Api-Key:
|
||||
<i v-text="inkeyHidden ? '****************' : wallet.inkey"></i
|
||||
>"</code
|
||||
>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
|
||||
<q-expansion-item
|
||||
group="api"
|
||||
dense
|
||||
expand-separator
|
||||
label="Create an invoice (incoming)"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code><span class="text-light-green">POST</span> /api/v1/payments</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code
|
||||
>{"X-Api-Key": "<i
|
||||
v-text="inkeyHidden ? '****************' : wallet.inkey"
|
||||
></i
|
||||
>"}</code
|
||||
><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||
<code
|
||||
>{"out": false, "amount": <int>, "memo": <string>,
|
||||
"expiry": <int>, "unit": <string>, "webhook":
|
||||
<url:string>, "internal": <bool>}</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 201 CREATED (application/json)
|
||||
</h5>
|
||||
<code
|
||||
>{"payment_hash": <string>, "payment_request":
|
||||
<string>}</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X POST <span v-text="baseUrl"></span>api/v1/payments -d
|
||||
'{"out": false, "amount": <int>, "memo": <string>}' -H
|
||||
"X-Api-Key:
|
||||
<i v-text="inkeyHidden ? '****************' : wallet.inkey"></i>" -H
|
||||
"Content-type: application/json"</code
|
||||
>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item
|
||||
group="api"
|
||||
dense
|
||||
expand-separator
|
||||
label="Pay an invoice (outgoing)"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-light-green">POST</span> /api/v1/payments (reveal
|
||||
admin keys
|
||||
<q-icon
|
||||
:name="adminkeyHidden ? 'visibility_off' : 'visibility'"
|
||||
class="cursor-pointer"
|
||||
@click="adminkeyHidden = !adminkeyHidden"
|
||||
></q-icon
|
||||
>)</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code
|
||||
>{"X-Api-Key": "<i
|
||||
v-text="adminkeyHidden ? '****************' : wallet.adminkey"
|
||||
></i
|
||||
>"}</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||
<code>{"out": true, "bolt11": <string>}</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 201 CREATED (application/json)
|
||||
</h5>
|
||||
<code>{"payment_hash": <string>}</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X POST <span v-text="baseUrl"></span>api/v1/payments -d
|
||||
'{"out": true, "bolt11": <string>}' -H "X-Api-Key:
|
||||
<i v-text="adminkeyHidden ? '****************' : wallet.adminkey"></i
|
||||
>" -H "Content-type: application/json"</code
|
||||
>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
|
||||
<q-expansion-item
|
||||
group="api"
|
||||
dense
|
||||
expand-separator
|
||||
label="Decode an invoice"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-light-green">POST</span>
|
||||
/api/v1/payments/decode</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||
<code>{"data": <string>}</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 200 (application/json)
|
||||
</h5>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X POST <span v-text="baseUrl"></span>api/v1/payments/decode -d
|
||||
'{"data": <bolt11/lnurl, string>}' -H "Content-type:
|
||||
application/json"</code
|
||||
>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item
|
||||
group="api"
|
||||
dense
|
||||
expand-separator
|
||||
label="Check an invoice (incoming or outgoing)"
|
||||
class="q-pb-md"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-light-blue">GET</span>
|
||||
/api/v1/payments/<payment_hash></code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code
|
||||
>{"X-Api-Key": "<i
|
||||
v-text="inkeyHidden ? '****************' : wallet.inkey"
|
||||
></i
|
||||
>"}</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 200 OK (application/json)
|
||||
</h5>
|
||||
<code>{"paid": <bool>}</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X GET
|
||||
<span v-text="baseUrl"></span>api/v1/payments/<payment_hash> -H
|
||||
"X-Api-Key:
|
||||
<i v-text="inkeyHidden ? '****************' : wallet.inkey"></i>" -H
|
||||
"Content-type: application/json"</code
|
||||
>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-pink">WS</span>
|
||||
/api/v1/ws/<invoice_key></code
|
||||
>
|
||||
<h5
|
||||
class="text-caption q-mt-sm q-mb-none"
|
||||
v-text="$t('websocket_example')"
|
||||
></h5>
|
||||
<code
|
||||
>wscat -c <span v-text="websocketUrl"></span>/<span
|
||||
v-text="inkeyHidden ? '****************' : wallet.inkey"
|
||||
></span
|
||||
></code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 200 OK (application/json)/payments
|
||||
</h5>
|
||||
<code>{"balance": <int>, "payment": <object>}</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-separator></q-separator>
|
||||
<q-card-section>
|
||||
<p v-text="$t('reset_wallet_keys_desc')"></p>
|
||||
<q-btn
|
||||
unelevated
|
||||
color="red-10"
|
||||
@click="resetKeys()"
|
||||
:label="$t('reset_wallet_keys')"
|
||||
></q-btn>
|
||||
</q-card-section>
|
||||
</q-expansion-item>
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,830 +0,0 @@
|
||||
{% if not ajax %} {% extends "base.html" %} {% endif %}
|
||||
<!---->
|
||||
{% from "macros.jinja" import window_vars with context %}
|
||||
<!---->
|
||||
{% block scripts %} {{ window_vars(user) }}{% endblock %} {% block page %}
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<q-stepper
|
||||
v-model="step"
|
||||
ref="stepper"
|
||||
color="primary"
|
||||
animated
|
||||
header-nav
|
||||
class="q-pt-sm"
|
||||
@update:model-value="onStepChange"
|
||||
>
|
||||
<q-step
|
||||
:name="1"
|
||||
title="Describe"
|
||||
icon="info"
|
||||
:done="step > 1"
|
||||
style="min-height: 100px"
|
||||
>
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12">
|
||||
<span class="text-h6">
|
||||
Tell us something about your extension:
|
||||
</span>
|
||||
<ul>
|
||||
<li>This is the first step, you can return and change it.</li>
|
||||
<li>
|
||||
The <code>`name`</code> and
|
||||
<code>`sort description`</code> fields are what the users will
|
||||
see when browsing the list of extensions.
|
||||
</li>
|
||||
<li>
|
||||
The <code>`id`</code> field is used internally and in the URL of
|
||||
your extension.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- todo: add icon -->
|
||||
|
||||
<div class="col-12">
|
||||
<div>
|
||||
<q-btn
|
||||
color="primary"
|
||||
label="Upload Existing config"
|
||||
@click="$refs.extensionDataInput.click()"
|
||||
class="q-mb-md"
|
||||
/>
|
||||
<input
|
||||
type="file"
|
||||
ref="extensionDataInput"
|
||||
accept="application/json"
|
||||
style="display: none"
|
||||
@change="onJsonDataInput"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<q-separator class="q-mt-sm"></q-separator>
|
||||
<div class="row q-col-gutter-md q-mt-md">
|
||||
<div class="col-md-4 col-sm-12">
|
||||
<q-input
|
||||
filled
|
||||
v-model="extensionData.name"
|
||||
label="Extension Name"
|
||||
hint="The name of your extension"
|
||||
>
|
||||
</q-input>
|
||||
</div>
|
||||
<div class="col-md-4 col-sm-12">
|
||||
<q-input
|
||||
filled
|
||||
v-model="extensionData.id"
|
||||
label="Extension Id"
|
||||
hint="Lowercase letters, numbers, and underscores only (snake_case). This will be used in the URL."
|
||||
>
|
||||
</q-input>
|
||||
</div>
|
||||
<div class="col-md-4 col-sm-12">
|
||||
<q-input
|
||||
filled
|
||||
v-model="extensionData.short_description"
|
||||
label="Short Description"
|
||||
hint="A short description that is shown in the extension list."
|
||||
>
|
||||
</q-input>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-mt-lg">
|
||||
<div class="col-12">
|
||||
<q-input
|
||||
filled
|
||||
v-model="extensionData.description"
|
||||
label="Description"
|
||||
hint="A detailed description of your extension."
|
||||
type="textarea"
|
||||
rows="3"
|
||||
maxlength="1000"
|
||||
>
|
||||
</q-input>
|
||||
</div>
|
||||
</div>
|
||||
</q-step>
|
||||
|
||||
<q-step
|
||||
:name="2"
|
||||
title="Settings"
|
||||
icon="settings"
|
||||
:done="step > 2"
|
||||
style="min-height: 100px"
|
||||
>
|
||||
<div class="row q-col-gutter-md q-mt-md">
|
||||
<div class="col-md-8 col-sm-12">
|
||||
<iframe
|
||||
ref="iframeStep2"
|
||||
class="full-width"
|
||||
height="400px"
|
||||
sandbox="allow-scripts allow-same-origin"
|
||||
></iframe>
|
||||
</div>
|
||||
<div class="col-md-4 col-sm-12">
|
||||
<q-btn
|
||||
@click="previewExtension('settings')"
|
||||
color="primary"
|
||||
outline
|
||||
label="Refresh Preview"
|
||||
class="full-width q-mb-md"
|
||||
></q-btn>
|
||||
<q-toggle
|
||||
v-model="extensionData.settings_data.enabled"
|
||||
label="Generate Settings Fields"
|
||||
size="md"
|
||||
color="green"
|
||||
/>
|
||||
<br />
|
||||
|
||||
<ul>
|
||||
<li>Define what settings your extension will have.</li>
|
||||
<li>
|
||||
You can choose if each user has its own settings or if the
|
||||
settings are global (set by the admin).
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<q-separator
|
||||
v-if="extensionData.settings_data.enabled"
|
||||
class="q-mt-sm"
|
||||
></q-separator>
|
||||
<div v-if="extensionData.settings_data.enabled" class="row q-mt-lg">
|
||||
<div class="col-md-2 col-sm-12">
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
emit-value
|
||||
map-options
|
||||
v-model="extensionData.settings_data.type"
|
||||
:options="settingsTypes"
|
||||
></q-select>
|
||||
</div>
|
||||
<div class="col-md-10 col-sm-12 q-pt-sm">
|
||||
<q-badge
|
||||
v-if="extensionData.settings_data.type === 'user'"
|
||||
outline
|
||||
class="text-caption q-ml-md"
|
||||
>Each user can set its own settings for this extension.</q-badge
|
||||
>
|
||||
<q-badge v-else outline class="text-caption q-ml-md"
|
||||
>Settings are set by the admin and apply to all users of the
|
||||
extension</q-badge
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="extensionData.settings_data.enabled" class="row q-mt-lg">
|
||||
<div class="col-12">
|
||||
<lnbits-data-fields
|
||||
:fields="extensionData.settings_data.fields"
|
||||
:hide-advanced="true"
|
||||
></lnbits-data-fields>
|
||||
</div>
|
||||
</div>
|
||||
</q-step>
|
||||
|
||||
<q-step
|
||||
:name="3"
|
||||
:done="step > 3"
|
||||
title="Owner Data"
|
||||
icon="list"
|
||||
style="min-height: 100px"
|
||||
>
|
||||
<div class="row q-col-gutter-md q-mt-md">
|
||||
<div class="col-md-8 col-sm-12">
|
||||
<iframe
|
||||
ref="iframeStep3"
|
||||
class="full-width"
|
||||
height="400px"
|
||||
sandbox="allow-scripts allow-same-origin"
|
||||
></iframe>
|
||||
</div>
|
||||
<div class="col-md-4 col-sm-12">
|
||||
<q-btn
|
||||
@click="previewExtension('owner_data')"
|
||||
color="primary"
|
||||
outline
|
||||
label="Refresh Preview"
|
||||
class="full-width q-mb-md"
|
||||
></q-btn>
|
||||
<q-input
|
||||
v-model="extensionData.owner_data.name"
|
||||
filled
|
||||
label="Owner Table Name"
|
||||
hint="CamelCase name for the owner data table (e.g. Campaign, PoS, etc.)"
|
||||
class="q-mb-xl"
|
||||
>
|
||||
</q-input>
|
||||
|
||||
<ul>
|
||||
<li>
|
||||
The owner of the extension manages this data. It can add, remove
|
||||
and update instances of it.
|
||||
</li>
|
||||
|
||||
<li>
|
||||
Some fileds are present by default, like
|
||||
<code>created_at</code>, <code>updated_at</code> and
|
||||
<code>extra</code>.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row q-mt-lg">
|
||||
<div class="col-12">
|
||||
<lnbits-data-fields
|
||||
:fields="extensionData.owner_data.fields"
|
||||
></lnbits-data-fields>
|
||||
</div>
|
||||
</div>
|
||||
</q-step>
|
||||
|
||||
<q-step
|
||||
:name="4"
|
||||
:done="step > 4"
|
||||
title="Client Data"
|
||||
icon="blur_linear"
|
||||
style="min-height: 100px"
|
||||
>
|
||||
<div class="row q-col-gutter-md q-mt-md">
|
||||
<div class="col-md-8 col-sm-12">
|
||||
<iframe
|
||||
ref="iframeStep4"
|
||||
class="full-width"
|
||||
height="400px"
|
||||
sandbox="allow-scripts allow-same-origin"
|
||||
></iframe>
|
||||
</div>
|
||||
<div class="col-md-4 col-sm-12">
|
||||
<q-btn
|
||||
@click="previewExtension('client_data')"
|
||||
color="primary"
|
||||
outline
|
||||
label="Refresh Preview"
|
||||
class="full-width q-mb-md"
|
||||
></q-btn>
|
||||
<!-- <q-toggle
|
||||
v-model="extensionData.client_data.enabled"
|
||||
label="Generate Client Table"
|
||||
disable
|
||||
size="md"
|
||||
color="green"
|
||||
/>
|
||||
|
||||
<br /> -->
|
||||
<q-input
|
||||
v-if="extensionData.client_data.enabled"
|
||||
v-model="extensionData.client_data.name"
|
||||
filled
|
||||
label="Client Table Name"
|
||||
hint="CamelCase name for the client data table (e.g. Donation, Payment, etc.)"
|
||||
class="q-mb-xl"
|
||||
>
|
||||
</q-input>
|
||||
<ul>
|
||||
<li>
|
||||
This data is created by users of the extension. Usually when
|
||||
they submit a form or make a payment.
|
||||
</li>
|
||||
<li>
|
||||
The owner of the extension can view this data, but should not
|
||||
modify it.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<q-separator
|
||||
v-if="extensionData.client_data.enabled"
|
||||
class="q-mt-sm"
|
||||
></q-separator>
|
||||
<div v-if="extensionData.client_data.enabled" class="row q-mt-lg">
|
||||
<div class="col-12">
|
||||
<lnbits-data-fields
|
||||
:fields="extensionData.client_data.fields"
|
||||
></lnbits-data-fields>
|
||||
</div>
|
||||
</div>
|
||||
</q-step>
|
||||
|
||||
<q-step
|
||||
:name="5"
|
||||
:done="step > 5"
|
||||
title="Public Pages"
|
||||
icon="link"
|
||||
style="min-height: 100px"
|
||||
>
|
||||
<div class="row q-col-gutter-md q-mt-md">
|
||||
<div class="col-md-8 col-sm-12">
|
||||
<iframe
|
||||
ref="iframeStep5"
|
||||
class="full-width"
|
||||
height="400px"
|
||||
sandbox="allow-scripts allow-same-origin"
|
||||
></iframe>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4 col-sm-12">
|
||||
<q-btn
|
||||
@click="previewExtension('public_page')"
|
||||
color="primary"
|
||||
outline
|
||||
label="Refresh Preview"
|
||||
class="full-width q-mb-md"
|
||||
></q-btn>
|
||||
<q-toggle
|
||||
v-model="extensionData.public_page.has_public_page"
|
||||
label="Generate Public Page"
|
||||
size="md"
|
||||
color="green"
|
||||
/>
|
||||
<br />
|
||||
|
||||
<ul>
|
||||
<li>
|
||||
Most extensions have a public page that can be shared (this page
|
||||
will still be accessible even if you have restricted access to
|
||||
your LNbits install).
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="extensionData.public_page.has_public_page">
|
||||
<div class="row q-col-gutter-md q-mt-md">
|
||||
<div class="col-12 col-md-6">
|
||||
<q-item tag="label" v-ripple>
|
||||
<q-item-section>
|
||||
<q-item-label>Public page title</q-item-label>
|
||||
<q-item-label caption
|
||||
>Select the field from the
|
||||
<code v-text="extensionData.owner_data.name"></code>
|
||||
(Owner Data) that will be used as a title for the public
|
||||
page.</q-item-label
|
||||
>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
emit-value
|
||||
map-options
|
||||
v-model="extensionData.public_page.owner_data_fields.name"
|
||||
:options="[''].concat(extensionData.owner_data.fields.map(f => f.name))"
|
||||
></q-select>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<q-item tag="label" v-ripple>
|
||||
<q-item-section>
|
||||
<q-item-label>Public page description</q-item-label>
|
||||
<q-item-label caption
|
||||
>Select the field from the
|
||||
<code v-text="extensionData.owner_data.name"></code>
|
||||
(Owner Data) that will be used as a description for the
|
||||
public page.</q-item-label
|
||||
>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
emit-value
|
||||
map-options
|
||||
v-model="extensionData.public_page.owner_data_fields.description"
|
||||
:options="[''].concat(extensionData.owner_data.fields.map(f => f.name))"
|
||||
></q-select>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<q-item tag="label" v-ripple>
|
||||
<q-item-section>
|
||||
<q-item-label>Public page inputs</q-item-label>
|
||||
<q-item-label caption>
|
||||
<ul class="q-pa-none q-ma-none">
|
||||
<li>
|
||||
Select the fields from the
|
||||
<code v-text="extensionData.client_data.name"></code
|
||||
> (Client Data) that will be shown as inputs in
|
||||
the public page form.
|
||||
</li>
|
||||
<li>You can select multiple fields.</li>
|
||||
<li>
|
||||
A corresponding input field will be created for each
|
||||
selected field.
|
||||
</li>
|
||||
</ul>
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
emit-value
|
||||
map-options
|
||||
multiple
|
||||
use-chips
|
||||
v-model="extensionData.public_page.client_data_fields.public_inputs"
|
||||
:options="extensionData.client_data.fields.map(f => f.name)"
|
||||
></q-select>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<q-item tag="label" v-ripple>
|
||||
<q-item-section>
|
||||
<q-item-label>Generate Action Button</q-item-label>
|
||||
<q-item-label caption>
|
||||
<ul class="q-pa-none q-ma-none">
|
||||
<li>
|
||||
If enabled, the public page will have a button to
|
||||
perform an action (e.g. generate a payment request).
|
||||
</li>
|
||||
<li>
|
||||
The action will use the selected input fields from
|
||||
<code v-text="extensionData.client_data.name"></code
|
||||
> (Client Data) as parameters.
|
||||
</li>
|
||||
<li>
|
||||
A corresponding REST API endpoint will be created.
|
||||
</li>
|
||||
</ul></q-item-label
|
||||
>
|
||||
</q-item-section>
|
||||
<q-item-section avatar>
|
||||
<q-toggle
|
||||
v-model="extensionData.public_page.action_fields.generate_action"
|
||||
size="md"
|
||||
color="green"
|
||||
/>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</div>
|
||||
</div>
|
||||
<q-separator
|
||||
v-if="extensionData.public_page.action_fields.generate_action"
|
||||
class="q-mt-sm"
|
||||
></q-separator>
|
||||
|
||||
<div v-if="extensionData.public_page.action_fields.generate_action">
|
||||
<div class="row q-col-gutter-md q-mt-md">
|
||||
<div class="col-12 col-md-6">
|
||||
<q-item tag="label" v-ripple>
|
||||
<q-item-section>
|
||||
<q-item-label>Generate Payment Logic</q-item-label>
|
||||
<q-item-label caption>
|
||||
<ul class="q-pa-none q-ma-none">
|
||||
<li>
|
||||
If enabled, the endpoint will create an invoice from
|
||||
the submitted data and the UI will show the QR code
|
||||
with the invoice.
|
||||
</li>
|
||||
<li>
|
||||
A listener will be created to check for the pay event.
|
||||
</li>
|
||||
<li>You must map the fieds.</li>
|
||||
</ul></q-item-label
|
||||
>
|
||||
</q-item-section>
|
||||
<q-item-section avatar>
|
||||
<q-toggle
|
||||
v-model="extensionData.public_page.action_fields.generate_payment_logic"
|
||||
size="md"
|
||||
color="green"
|
||||
/>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-6"></div>
|
||||
</div>
|
||||
<div
|
||||
v-if="extensionData.public_page.action_fields.generate_action && extensionData.public_page.action_fields.generate_payment_logic"
|
||||
class="row q-col-gutter-md q-mt-md"
|
||||
>
|
||||
<div class="col-12 col-md-6">
|
||||
<q-item tag="label" v-ripple>
|
||||
<q-item-section>
|
||||
<q-item-label>Wallet</q-item-label>
|
||||
<q-item-label caption>
|
||||
<ul class="q-pa-none q-ma-none">
|
||||
<li>
|
||||
Select the field from the
|
||||
<code v-text="extensionData.owner_data.name"></code
|
||||
> (Owner Data) that represents the wallet which
|
||||
will generate the invoice and receive the payments.
|
||||
</li>
|
||||
|
||||
<li>
|
||||
Only fields with the type <code>Wallet</code> will be
|
||||
shown.
|
||||
</li>
|
||||
</ul>
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
emit-value
|
||||
map-options
|
||||
v-model="extensionData.public_page.action_fields.wallet_id"
|
||||
:options="[''].concat(extensionData.owner_data.fields.filter(f => f.type === 'wallet').map(f => f.name))"
|
||||
></q-select>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-6">
|
||||
<q-item tag="label" v-ripple>
|
||||
<q-item-section>
|
||||
<q-item-label>Currency</q-item-label>
|
||||
<q-item-label caption>
|
||||
<ul class="q-pa-none q-ma-none">
|
||||
<li>
|
||||
Select the field from the
|
||||
<code v-text="extensionData.owner_data.name"></code
|
||||
> (Owner Data) that represents the currency
|
||||
which will be used to for the amount.
|
||||
</li>
|
||||
<li>
|
||||
Only fields with the type <code>Currency</code> will
|
||||
be shown.
|
||||
</li>
|
||||
<li>Empty if you want to use sats.</li>
|
||||
</ul>
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
emit-value
|
||||
map-options
|
||||
v-model="extensionData.public_page.action_fields.currency"
|
||||
:options="[''].concat(extensionData.owner_data.fields.filter(f => f.type === 'currency').map(f => f.name))"
|
||||
></q-select>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-6">
|
||||
<q-item tag="label" v-ripple>
|
||||
<q-item-section>
|
||||
<q-item-label>Amount</q-item-label>
|
||||
<q-item-label caption>
|
||||
<ul class="q-pa-none q-ma-none">
|
||||
<li>
|
||||
Select the field from the
|
||||
<code v-text="extensionData.owner_data.name"></code
|
||||
> (Owner Data) or
|
||||
<code v-text="extensionData.client_data.name"></code
|
||||
> (Client Data) that represents the amount (in
|
||||
the selected currency).
|
||||
</li>
|
||||
<li>
|
||||
Only fields with the type <code>Integer</code> and
|
||||
<code>Float</code> will be shown.
|
||||
</li>
|
||||
</ul>
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<div class="row">
|
||||
<div class="col-6">
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
emit-value
|
||||
map-options
|
||||
v-model="extensionData.public_page.action_fields.amount_source"
|
||||
:options="amountSource"
|
||||
class="q-mr-sm"
|
||||
></q-select>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
emit-value
|
||||
map-options
|
||||
v-model="extensionData.public_page.action_fields.amount"
|
||||
:options="paymentActionAmountFields"
|
||||
></q-select>
|
||||
</div>
|
||||
</div>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-6">
|
||||
<q-item tag="label" v-ripple>
|
||||
<q-item-section>
|
||||
<q-item-label>Paid Flag</q-item-label>
|
||||
<q-item-label caption>
|
||||
<ul class="q-pa-none q-ma-none">
|
||||
<li>
|
||||
Select the field from the
|
||||
<code v-text="extensionData.client_data.name"></code
|
||||
> (Client Data) that will be set to true when
|
||||
the invoice is paid.
|
||||
</li>
|
||||
<li>
|
||||
Only fields with the type <code>Boolean</code> will be
|
||||
shown.
|
||||
</li>
|
||||
</ul>
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
emit-value
|
||||
map-options
|
||||
v-model="extensionData.public_page.action_fields.paid_flag"
|
||||
:options="[''].concat(extensionData.client_data.fields.filter(f => f.type === 'bool').map(f => f.name))"
|
||||
></q-select>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</q-step>
|
||||
|
||||
<q-step
|
||||
:name="6"
|
||||
:done="step > 6"
|
||||
title="Publish"
|
||||
icon="publish"
|
||||
style="min-height: 100px"
|
||||
>
|
||||
<div v-if="g.user.admin" class="row">
|
||||
<div class="col-md-4 col-sm-12 col-xs-12">
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
emit-value
|
||||
map-options
|
||||
v-model="extensionData.stub_version"
|
||||
hint="The version of the extension stub. Make sure it is compatible with your LNbits install."
|
||||
:options="extensionStubVersions.map(f => f.version)"
|
||||
></q-select>
|
||||
</div>
|
||||
<div class="col-md-4 col-sm-12 col-xs-12">
|
||||
<q-btn
|
||||
@click="cleanCacheData()"
|
||||
color="grey"
|
||||
outline
|
||||
label="Clean Cache"
|
||||
class="q-ml-md"
|
||||
/>
|
||||
<q-icon
|
||||
name="info"
|
||||
size="md"
|
||||
color="primary"
|
||||
class="cursor-pointer q-ml-xs q-mb-xs"
|
||||
>
|
||||
<q-tooltip>
|
||||
<ul class="q-pl-sm">
|
||||
<li>
|
||||
The extension builder uses caching to speed up the build
|
||||
process.
|
||||
</li>
|
||||
<li>
|
||||
This action clears old data and redownloads the Extension
|
||||
Builder Stub release.
|
||||
</li>
|
||||
</ul>
|
||||
</q-tooltip>
|
||||
</q-icon>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="g.user.admin" class="row q-mt-md">
|
||||
<div class="col-md-4 col-sm-12 col-xs-12">
|
||||
<div class="row">
|
||||
<q-btn
|
||||
@click="buildExtensionAndDeploy()"
|
||||
color="primary"
|
||||
label="Build and Deploy (Admin Only)"
|
||||
class="col"
|
||||
/>
|
||||
<q-icon
|
||||
name="info"
|
||||
size="md"
|
||||
color="primary"
|
||||
class="cursor-pointer q-ml-sm self-center"
|
||||
>
|
||||
<q-tooltip>
|
||||
<ul class="q-pl-sm">
|
||||
<li>
|
||||
Installs the extension directly to this LNbits instance.
|
||||
</li>
|
||||
<li>
|
||||
The extension will be enabled by default, and available to
|
||||
all users.
|
||||
</li>
|
||||
</ul>
|
||||
</q-tooltip>
|
||||
</q-icon>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row q-mt-md">
|
||||
<div class="col-md-4 col-sm-12 col-xs-12">
|
||||
<div class="row">
|
||||
<q-btn
|
||||
@click="buildExtension()"
|
||||
outline
|
||||
color="gray"
|
||||
label="Download Extension Zip"
|
||||
icon="download"
|
||||
class="col"
|
||||
/>
|
||||
<q-icon
|
||||
name="info"
|
||||
size="md"
|
||||
color="primary"
|
||||
class="cursor-pointer q-ml-sm self-center"
|
||||
>
|
||||
<q-tooltip>
|
||||
Builds the extension and downloads a zip file with the code.
|
||||
You can then install it manually in your LNbits instance.
|
||||
</q-tooltip>
|
||||
</q-icon>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</q-step>
|
||||
|
||||
<template v-slot:navigation>
|
||||
<q-separator></q-separator>
|
||||
<div class="row">
|
||||
<div class="col-md-6 col-sm-12 q-pl-md q-pt-md">
|
||||
<q-btn
|
||||
v-if="step == 1"
|
||||
label="Clear All Data"
|
||||
color="negative"
|
||||
@click="clearAllData"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
v-else
|
||||
flat
|
||||
color="grey-8"
|
||||
class="q-mr-sm"
|
||||
@click="previousStep()"
|
||||
label="Back"
|
||||
icon="chevron_left"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-md-6 col-sm-12 q-pr-md q-pb-md">
|
||||
<q-stepper-navigation class="float-right">
|
||||
<q-btn
|
||||
v-if="step < 6"
|
||||
@click="nextStep()"
|
||||
color="primary"
|
||||
label="Next"
|
||||
></q-btn>
|
||||
<template v-else>
|
||||
<q-btn
|
||||
@click="exportJsonData()"
|
||||
color="primary"
|
||||
label="Export JSON Data"
|
||||
></q-btn>
|
||||
<q-icon
|
||||
name="info"
|
||||
size="md"
|
||||
color="primary"
|
||||
class="cursor-pointer q-ml-sm self-center"
|
||||
>
|
||||
<q-tooltip>
|
||||
<ul class="q-pl-sm">
|
||||
<li>
|
||||
Exports the config JSON so it can be later imported or
|
||||
shared.
|
||||
</li>
|
||||
<li>
|
||||
This JSON is also added to the zip in a file called
|
||||
`builder.json`.
|
||||
</li>
|
||||
</ul>
|
||||
</q-tooltip>
|
||||
</q-icon>
|
||||
</template>
|
||||
</q-stepper-navigation>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</q-stepper>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-col-gutter-md"></div>
|
||||
|
||||
{% endblock %}
|
||||
@@ -1,150 +0,0 @@
|
||||
{% extends "public.html" %} {% block page_container %}
|
||||
<q-page-container>
|
||||
<q-page class="q-px-md q-py-lg" :class="{'q-px-lg': $q.screen.gt.xs}">
|
||||
{% block page %}
|
||||
<div class="row q-col-gutter-md justify-center main">
|
||||
<div class="col-10 col-md-8 col-lg-6 q-gutter-y-md">
|
||||
<q-card>
|
||||
<q-card-section class="grid">
|
||||
<div>
|
||||
<h6 class="q-my-none text-center">
|
||||
<strong v-text="$t('welcome_lnbits')"></strong>
|
||||
<p><span v-text="$t('setup_su_account')"></span></p>
|
||||
</h6>
|
||||
<br />
|
||||
<q-form class="q-gutter-md">
|
||||
<q-input
|
||||
filled
|
||||
v-model="loginData.username"
|
||||
:label="$t('username')"
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
v-model.trim="loginData.password"
|
||||
:type="loginData.isPwd ? 'password' : 'text'"
|
||||
autocomplete="off"
|
||||
:label="$t('password')"
|
||||
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password')]"
|
||||
><template v-slot:append>
|
||||
<q-icon
|
||||
:name="loginData.isPwd ? 'visibility_off' : 'visibility'"
|
||||
class="cursor-pointer"
|
||||
@click="loginData.isPwd = !loginData.isPwd"
|
||||
/> </template
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
v-model.trim="loginData.passwordRepeat"
|
||||
:type="loginData.isPwdRepeat ? 'password' : 'text'"
|
||||
autocomplete="off"
|
||||
:label="$t('password_repeat')"
|
||||
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password'), (val) => val === loginData.password || $t('invalid_password_repeat')]"
|
||||
><template v-slot:append>
|
||||
<q-icon
|
||||
:name="loginData.isPwdRepeat ? 'visibility_off' : 'visibility'"
|
||||
class="cursor-pointer"
|
||||
@click="loginData.isPwdRepeat = !loginData.isPwdRepeat"
|
||||
/> </template
|
||||
></q-input>
|
||||
<q-btn
|
||||
@click="setPassword()"
|
||||
unelevated
|
||||
color="primary"
|
||||
:label="$t('login')"
|
||||
:disable="checkPasswordsMatch || !loginData.username || !loginData.password || !loginData.passwordRepeat"
|
||||
></q-btn>
|
||||
</q-form>
|
||||
</div>
|
||||
<div class="hero-wrapper">
|
||||
<div class="hero q-mx-auto"></div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %} {% block scripts %}
|
||||
<style>
|
||||
main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
.grid {
|
||||
display: block;
|
||||
}
|
||||
.hero-wrapper {
|
||||
display: none;
|
||||
}
|
||||
.hero {
|
||||
display: block;
|
||||
height: 100%;
|
||||
max-width: 250px;
|
||||
background-image: url("{{ static_url_for('static', 'images/logos/lnbits.svg') }}");
|
||||
background-position: center;
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
@media (min-width: 992px) {
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-gap: 1rem;
|
||||
}
|
||||
.hero-wrapper {
|
||||
display: block;
|
||||
position: relative;
|
||||
height: 100%;
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
window.app = Vue.createApp({
|
||||
el: '#vue',
|
||||
mixins: [window.windowMixin],
|
||||
data: function () {
|
||||
return {
|
||||
loginData: {
|
||||
isPwd: true,
|
||||
isPwdRepeat: true,
|
||||
username: '',
|
||||
password: '',
|
||||
passwordRepeat: ''
|
||||
}
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.hasAdminUI = '{{ LNBITS_ADMIN_UI | tojson}}'
|
||||
},
|
||||
computed: {
|
||||
checkPasswordsMatch() {
|
||||
return this.loginData.password !== this.loginData.passwordRepeat
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async setPassword() {
|
||||
try {
|
||||
await LNbits.api.request(
|
||||
'PUT',
|
||||
'/api/v1/auth/first_install',
|
||||
null,
|
||||
{
|
||||
username: this.loginData.username,
|
||||
password: this.loginData.password,
|
||||
password_repeat: this.loginData.passwordRepeat
|
||||
}
|
||||
)
|
||||
|
||||
window.location.href = '/admin'
|
||||
} catch (e) {
|
||||
LNbits.utils.notifyApiError(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
{% endblock %}
|
||||
</q-page>
|
||||
</q-page-container>
|
||||
{% endblock %}
|
||||
@@ -1,710 +0,0 @@
|
||||
{% extends "public.html" %} {% block scripts %}
|
||||
<style>
|
||||
:root {
|
||||
--size: 100px;
|
||||
--gap: 25px;
|
||||
}
|
||||
|
||||
.btn-fixed-width {
|
||||
/* width: 45%; */
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--gap);
|
||||
margin: auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.marquee {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
gap: var(--gap);
|
||||
height: max-content;
|
||||
mask-image: linear-gradient(
|
||||
to right,
|
||||
hsl(0 0% 0% / 0),
|
||||
hsl(0 0% 0% / 1) 20%,
|
||||
hsl(0 0% 0% / 1) 80%,
|
||||
hsl(0 0% 0% / 0)
|
||||
);
|
||||
}
|
||||
|
||||
.marquee__group {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-around;
|
||||
gap: var(--gap);
|
||||
min-width: 100%;
|
||||
animation: scroll-x 60s linear infinite;
|
||||
}
|
||||
|
||||
.marquee:hover .marquee__group {
|
||||
animation-play-state: paused;
|
||||
}
|
||||
|
||||
.marquee__group div {
|
||||
width: var(--size);
|
||||
}
|
||||
|
||||
@keyframes scroll-x {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
}
|
||||
to {
|
||||
transform: translateX(calc(-100% - var(--gap)));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<script src="{{ static_url_for('static', 'js/index.js') }}"></script>
|
||||
{% endblock %} {% block page_container %}
|
||||
<q-page-container>
|
||||
<q-page
|
||||
class="q-px-md q-py-lg content-center"
|
||||
:class="{'q-px-lg': $q.screen.gt.xs}"
|
||||
>
|
||||
{% block page %}
|
||||
<div
|
||||
class="row justify-center items-center"
|
||||
style="min-height: calc(100vh / 1.618)"
|
||||
>
|
||||
<div
|
||||
class="full-width"
|
||||
:style="`max-width: ${'{{ LNBITS_CUSTOM_IMAGE }}' ? '850' : '600'}px`"
|
||||
>
|
||||
<div class="row q-mb-md">
|
||||
<div class="col-12">
|
||||
<div>
|
||||
<h5
|
||||
class="q-my-none"
|
||||
v-if="'{{LNBITS_SHOW_HOME_PAGE_ELEMENTS}}' == 'True'"
|
||||
>
|
||||
{{SITE_TITLE}}
|
||||
</h5>
|
||||
<template v-if="$q.screen.gt.sm">
|
||||
<h6
|
||||
class="q-my-sm"
|
||||
v-if="'{{LNBITS_SHOW_HOME_PAGE_ELEMENTS}}' == 'True'"
|
||||
>
|
||||
{{SITE_TAGLINE}}
|
||||
</h6>
|
||||
<p
|
||||
class="q-my-sm"
|
||||
v-if="'{{LNBITS_SHOW_HOME_PAGE_ELEMENTS}}' == 'True'"
|
||||
>
|
||||
{{SITE_DESCRIPTION}}
|
||||
</p>
|
||||
</template>
|
||||
<!-- <div
|
||||
class="gt-sm"
|
||||
v-html="formatDescription"
|
||||
v-if="'{{LNBITS_SHOW_HOME_PAGE_ELEMENTS}}' == 'True'"
|
||||
></div> -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<q-badge
|
||||
v-if="isAccessTokenExpired"
|
||||
class="q-mx-auto q-mb-md"
|
||||
color="primary"
|
||||
rounded
|
||||
>
|
||||
<div class="text-h6">
|
||||
<span v-text="$t('session_has_expired')"></span>
|
||||
</div>
|
||||
</q-badge>
|
||||
<q-card bordered class="full-width q-py-md">
|
||||
<div class="row">
|
||||
<div
|
||||
class="col-12"
|
||||
:class="{'col-sm-7' : '{{ LNBITS_CUSTOM_IMAGE }}', 'col-lg-6' : '{{ LNBITS_CUSTOM_IMAGE }}'}"
|
||||
>
|
||||
{% if lnurl and LNBITS_NEW_ACCOUNTS_ALLOWED and ("user-id-only"
|
||||
in LNBITS_AUTH_METHODS)%}
|
||||
<div class="full-height content-center">
|
||||
<q-card-section>
|
||||
<div class="text-body1">
|
||||
<span v-text="$t('claim_desc')"></span>
|
||||
</div>
|
||||
</q-card-section>
|
||||
<q-card-section>
|
||||
<q-btn
|
||||
unelevated
|
||||
color="primary"
|
||||
@click="processing"
|
||||
type="a"
|
||||
href="/lnurlwallet?lightning={{ lnurl }}"
|
||||
v-text="$t('press_to_claim')"
|
||||
class="full-width"
|
||||
></q-btn>
|
||||
</q-card-section>
|
||||
</div>
|
||||
{%else%}
|
||||
<username-password
|
||||
v-if="authMethod != 'user-id-only'"
|
||||
:allowed_new_users="allowedRegister"
|
||||
:auth-methods="LNBITS_AUTH_METHODS"
|
||||
:auth-action="authAction"
|
||||
v-model:user-name="username"
|
||||
v-model:password_1="password"
|
||||
v-model:password_2="passwordRepeat"
|
||||
v-model:reset-key="reset_key"
|
||||
@login="login"
|
||||
@register="register"
|
||||
@reset="reset"
|
||||
>
|
||||
<div
|
||||
class="text-center text-grey-6"
|
||||
v-if="authAction !== 'reset'"
|
||||
>
|
||||
<p
|
||||
v-if="authAction === 'login' && allowedRegister"
|
||||
class="q-mb-none"
|
||||
>
|
||||
Not registered?
|
||||
<a
|
||||
href="#"
|
||||
class="text-secondary cursor-pointer"
|
||||
@click.prevent="showRegister('username-password')"
|
||||
>Create an Account</a
|
||||
>
|
||||
</p>
|
||||
<p
|
||||
v-else-if="authAction === 'login' && !allowedRegister"
|
||||
class="q-mb-none"
|
||||
>
|
||||
<span v-text="$t('new_user_not_allowed')"></span>
|
||||
</p>
|
||||
<p v-else-if="authAction === 'register'" class="q-mb-none">
|
||||
<span v-text="$t('existing_account_question')"></span>
|
||||
|
||||
<a
|
||||
href="#"
|
||||
class="text-secondary cursor-pointer q-ml-sm"
|
||||
@click.prevent="showLogin('username-password')"
|
||||
v-text="$t('login')"
|
||||
></a>
|
||||
</p>
|
||||
</div>
|
||||
</username-password>
|
||||
{% if "user-id-only" in LNBITS_AUTH_METHODS %}
|
||||
<user-id-only
|
||||
:allowed_new_users="allowedRegister"
|
||||
v-model:usr="usr"
|
||||
v-model:wallet="walletName"
|
||||
:auth-action="authAction"
|
||||
:auth-method="authMethod"
|
||||
@show-login="showLogin"
|
||||
@show-register="showRegister"
|
||||
@login-usr="loginUsr"
|
||||
@create-wallet="createWallet"
|
||||
>
|
||||
</user-id-only>
|
||||
{%endif%} {% endif %}
|
||||
</div>
|
||||
<div
|
||||
class="col-sm-5 col-lg-6 gt-xs"
|
||||
v-if="'{{ LNBITS_CUSTOM_IMAGE }}'"
|
||||
>
|
||||
<div class="full-height flex flex-center q-pa-lg">
|
||||
<q-img
|
||||
:src="'{{ LNBITS_CUSTOM_IMAGE }}'"
|
||||
:ratio="1"
|
||||
width="250px"
|
||||
></q-img>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="'{{ LNBITS_DENOMINATION }}' == 'sats' && '{{ SITE_TITLE }}' == 'LNbits' && '{{ LNBITS_SHOW_HOME_PAGE_ELEMENTS }}' == 'True'"
|
||||
class="full-width q-mb-lg q-mt-sm"
|
||||
>
|
||||
<div class="flex flex-center q-gutter-md q-py-md">
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
type="a"
|
||||
href="https://github.com/lnbits/lnbits"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
:label="$t('view_github')"
|
||||
class=""
|
||||
></q-btn>
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
type="a"
|
||||
href="https://demo.lnbits.com/lnurlp/link/fH59GD"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
:label="$t('donate')"
|
||||
class=""
|
||||
></q-btn>
|
||||
</div>
|
||||
</div>
|
||||
{% if AD_SPACE_ENABLED and AD_SPACE %}
|
||||
<div class="q-pt-md full-width">
|
||||
<div class="row justify-center q-mb-xl">
|
||||
<div class="full-width text-center">
|
||||
<span class="text-uppercase text-grey">{{ AD_SPACE_TITLE }}</span>
|
||||
</div>
|
||||
<div class="flex flex-center columm">
|
||||
{% for ADS in AD_SPACE %} {% set AD = ADS.split(';') %}
|
||||
<div class="flex flex-center column q-pr-sm">
|
||||
<a href="{{ AD[0] }}">
|
||||
<img
|
||||
v-if="($q.dark.isActive)"
|
||||
src="{{ AD[1] }}"
|
||||
style="max-width: 420px"
|
||||
class="full-width"
|
||||
/>
|
||||
<img
|
||||
v-else
|
||||
src="{{ AD[2] }}"
|
||||
style="max-width: 420px"
|
||||
class="full-width"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div
|
||||
v-if="'{{ LNBITS_DENOMINATION }}' == 'sats' && '{{ SITE_TITLE }}' == 'LNbits' && '{{ LNBITS_SHOW_HOME_PAGE_ELEMENTS }}' == 'True'"
|
||||
class="full-width"
|
||||
>
|
||||
<div class="wrapper">
|
||||
<div class="marquee">
|
||||
<div class="marquee__group">
|
||||
<div>
|
||||
<a
|
||||
href="https://github.com/ElementsProject/lightning"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/cln.png') }}' : '{{ static_url_for('static', 'images/clnl.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://github.com/lightningnetwork/lnd"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/lnd.png') }}' : '{{ static_url_for('static', 'images/lnd.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://opennode.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/opennode.png') }}' : '{{ static_url_for('static', 'images/opennodel.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://lnpay.co/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/lnpay.png') }}' : '{{ static_url_for('static', 'images/lnpayl.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://github.com/rootzoll/raspiblitz"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/blitz.png') }}' : '{{ static_url_for('static', 'images/blitzl.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://start9.com/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/start9.png') }}' : '{{ static_url_for('static', 'images/start9l.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://getumbrel.com/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/umbrel.png') }}' : '{{ static_url_for('static', 'images/umbrell.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://mynodebtc.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/mynode.png') }}' : '{{ static_url_for('static', 'images/mynodel.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://github.com/shesek/spark-wallet"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/spark.png') }}' : '{{ static_url_for('static', 'images/sparkl.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://voltage.cloud"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/voltage.png') }}' : '{{ static_url_for('static', 'images/voltagel.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://breez.technology/sdk/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/breez.png') }}' : '{{ static_url_for('static', 'images/breezl.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://blockstream.com/lightning/greenlight/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/greenlight.png') }}' : '{{ static_url_for('static', 'images/greenlightl.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://getalby.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/alby.png') }}' : '{{ static_url_for('static', 'images/albyl.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://zbd.gg"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/zbd.png') }}' : '{{ static_url_for('static', 'images/zbdl.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://phoenix.acinq.co/server"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/phoenixd.png') }}' : '{{ static_url_for('static', 'images/phoenixdl.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://boltz.exchange/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/boltz.svg') }}' : '{{ static_url_for('static', 'images/boltz.svg') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://www.blink.sv/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/blink_logo.png') }}' : '{{ static_url_for('static', 'images/blink_logol.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<!-- # -->
|
||||
</div>
|
||||
<div class="marquee__group">
|
||||
<div>
|
||||
<a
|
||||
href="https://github.com/ElementsProject/lightning"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/cln.png') }}' : '{{ static_url_for('static', 'images/clnl.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://github.com/lightningnetwork/lnd"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/lnd.png') }}' : '{{ static_url_for('static', 'images/lnd.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://opennode.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/opennode.png') }}' : '{{ static_url_for('static', 'images/opennodel.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://lnpay.co/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/lnpay.png') }}' : '{{ static_url_for('static', 'images/lnpayl.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://github.com/rootzoll/raspiblitz"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/blitz.png') }}' : '{{ static_url_for('static', 'images/blitzl.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://start9.com/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/start9.png') }}' : '{{ static_url_for('static', 'images/start9l.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://getumbrel.com/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/umbrel.png') }}' : '{{ static_url_for('static', 'images/umbrell.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://mynodebtc.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/mynode.png') }}' : '{{ static_url_for('static', 'images/mynodel.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://github.com/shesek/spark-wallet"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/spark.png') }}' : '{{ static_url_for('static', 'images/sparkl.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://voltage.cloud"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/voltage.png') }}' : '{{ static_url_for('static', 'images/voltagel.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://breez.technology/sdk/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/breez.png') }}' : '{{ static_url_for('static', 'images/breezl.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://blockstream.com/lightning/greenlight/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/greenlight.png') }}' : '{{ static_url_for('static', 'images/greenlightl.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://getalby.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/alby.png') }}' : '{{ static_url_for('static', 'images/albyl.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://zbd.gg"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/zbd.png') }}' : '{{ static_url_for('static', 'images/zbdl.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://phoenix.acinq.co/server"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/phoenixd.png') }}' : '{{ static_url_for('static', 'images/phoenixdl.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://boltz.exchange/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/boltz.svg') }}' : '{{ static_url_for('static', 'images/boltz.svg') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://www.blink.sv/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/blink_logo.png') }}' : '{{ static_url_for('static', 'images/blink_logol.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<!-- # -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
</q-page>
|
||||
</q-page-container>
|
||||
{% endblock %}
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user