Compare commits
244
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86a1313258 | ||
|
|
0fa8fca70e | ||
|
|
b33518f075 | ||
|
|
3dce95968e | ||
|
|
7c92788cd8 | ||
|
|
2e31d1fd91 | ||
|
|
6c353f366d | ||
|
|
902f3c7c6f | ||
|
|
7fbffa20d7 | ||
|
|
0185f1e465 | ||
|
|
42b3616b03 | ||
|
|
a446cd6c5b | ||
|
|
3c0c1ca930 | ||
|
|
1a4c706ed4 | ||
|
|
8969c0235d | ||
|
|
34ee5b273a | ||
|
|
c7c1af9465 | ||
|
|
f7ed194e02 | ||
|
|
fd4b6af1de | ||
|
|
8d1542d982 | ||
|
|
342bab5714 | ||
|
|
3d9a882066 | ||
|
|
146a74761a | ||
|
|
85b2f30a1a | ||
|
|
07eb0ecdde | ||
|
|
ea0e1e032e | ||
|
|
b23e8ea781 | ||
|
|
fa61016ed1 | ||
|
|
6f44b755e4 | ||
|
|
31664a4ba0 | ||
|
|
f985a1eb08 | ||
|
|
4e5b001901 | ||
|
|
3a2cf12052 | ||
|
|
f17ff3d12b | ||
|
|
32c042741d | ||
|
|
5997fd74fa | ||
|
|
fb5659de2a | ||
|
|
e13fd39e75 | ||
|
|
acd3b598b7 | ||
|
|
1a6671b4b3 | ||
|
|
7fc723c2dd | ||
|
|
468a786f78 | ||
|
|
f74a7cbe1f | ||
|
|
ce9b63c54e | ||
|
|
3dd1c6992b | ||
|
|
1c49430ece | ||
|
|
b14b3bbaad | ||
|
|
020f6b6b1b | ||
|
|
0314453100 | ||
|
|
37dca56ccc | ||
|
|
d234ab12d8 | ||
|
|
37187bfc2c | ||
|
|
368da935db | ||
|
|
81aef06f75 | ||
|
|
27241e01f3 | ||
|
|
3ff85363f2 | ||
|
|
b81b30c896 | ||
|
|
25d59e4142 | ||
|
|
5f8ccee5b8 | ||
|
|
740180d332 | ||
|
|
b66a8b3de9 | ||
|
|
b3351f2b17 | ||
|
|
36dbdaa159 | ||
|
|
524a4c9213 | ||
|
|
200b9b127c | ||
|
|
646c341840 | ||
|
|
826f8ad2ac | ||
|
|
1b9bb32ca0 | ||
|
|
61c71660b8 | ||
|
|
80ac158ca0 | ||
|
|
291c69e470 | ||
|
|
8ff4962e86 | ||
|
|
57e9d2271d | ||
|
|
c18816bf1b | ||
|
|
8f290a647d | ||
|
|
d1cc317044 | ||
|
|
5a1d660c7b | ||
|
|
7ee78248b7 | ||
|
|
5104cbb285 | ||
|
|
fa71219ce7 | ||
|
|
f5f43e7668 | ||
|
|
4b63d0924e | ||
|
|
dc22088dcd | ||
|
|
aa63879ff5 | ||
|
|
0547abe54a | ||
|
|
f5ccf5c157 | ||
|
|
e3d6b6befa | ||
|
|
fa8d7c665b | ||
|
|
f97f27121a | ||
|
|
af568d0f31 | ||
|
|
8c5c455f1c | ||
|
|
bbae4a01c1 | ||
|
|
edfd297bf3 | ||
|
|
09b1623bb0 | ||
|
|
fa18170ed7 | ||
|
|
3eece03715 | ||
|
|
dd972e2aaa | ||
|
|
ec9ad9f940 | ||
|
|
aced333c0b | ||
|
|
ba5f79da2d | ||
|
|
acb1b1ed91 | ||
|
|
2fa0a3c995 | ||
|
|
1dddc9e7f0 | ||
|
|
581f98b3a3 | ||
|
|
89a75ab641 | ||
|
|
f228198c20 | ||
|
|
2940cf97c5 | ||
|
|
ae4eda04ba | ||
|
|
13f2dd732f | ||
|
|
80ec9e1307 | ||
|
|
3b503eaa8a | ||
|
|
ecc62b0011 | ||
|
|
b83c2e9368 | ||
|
|
512c85592f | ||
|
|
76b01695a0 | ||
|
|
d7065392d1 | ||
|
|
a58deff70c | ||
|
|
e85a78854e | ||
|
|
a28e334c5f | ||
|
|
a4c000d7dc | ||
|
|
3a64cf5245 | ||
|
|
0b8da2b524 | ||
|
|
f062b3d5e5 | ||
|
|
b9e9da5da7 | ||
|
|
953a466119 | ||
|
|
7ad360d176 | ||
|
|
898f264951 | ||
|
|
75bcbdc18d | ||
|
|
3ac6bc3c3b | ||
|
|
04882e05a3 | ||
|
|
cf2aca3327 | ||
|
|
1d0547ea97 | ||
|
|
99c5f00c91 | ||
|
|
723d8ba98f | ||
|
|
daee2b3418 | ||
|
|
9d7e54f6b2 | ||
|
|
053ea20508 | ||
|
|
04aefc8077 | ||
|
|
21d87adc52 | ||
|
|
c637e8d31e | ||
|
|
d26e50ec9a | ||
|
|
d229b7a765 | ||
|
|
ceb43f384e | ||
|
|
22e6326bce | ||
|
|
5f4f1288d7 | ||
|
|
7a5e7fbd8c | ||
|
|
6c8d56e40c | ||
|
|
8aa1716e32 | ||
|
|
fbd22c1a22 | ||
|
|
e8a6870d7a | ||
|
|
937936ff33 | ||
|
|
ab3eb967b4 | ||
|
|
6341e1edaf | ||
|
|
69d518bac0 | ||
|
|
9db2429a45 | ||
|
|
dd90dec768 | ||
|
|
eb37a064ad | ||
|
|
28df100d9a | ||
|
|
6e6b387b7d | ||
|
|
209cf7fbe0 | ||
|
|
596167f443 | ||
|
|
4732c4b296 | ||
|
|
405a2f0776 | ||
|
|
304ad3035b | ||
|
|
8cffda5a55 | ||
|
|
cbe858b385 | ||
|
|
65ecca2507 | ||
|
|
1900cf9aa4 | ||
|
|
296b1dfa9a | ||
|
|
b14e0e4cc6 | ||
|
|
fb585402dd | ||
|
|
63f246908e | ||
|
|
8ac827f5a4 | ||
|
|
74d4ddd312 | ||
|
|
40ffa7dea0 | ||
|
|
ddb8fcb986 | ||
|
|
27b9e8254c | ||
|
|
daa4b92331 | ||
|
|
0015314e11 | ||
|
|
235f8a6c19 | ||
|
|
bab399f825 | ||
|
|
646a604221 | ||
|
|
7d8fad267a | ||
|
|
ce0aff206d | ||
|
|
80e7b9639d | ||
|
|
94caa2e1ba | ||
|
|
b0a66e8cf5 | ||
|
|
ffba71c0ce | ||
|
|
b41705167f | ||
|
|
0387db3b55 | ||
|
|
38ef1b0061 | ||
|
|
19c231a2f4 | ||
|
|
026c9b5155 | ||
|
|
c834929f8b | ||
|
|
97de4eda18 | ||
|
|
a844711530 | ||
|
|
e2522b358a | ||
|
|
8f761dfd0f | ||
|
|
b14d36a0aa | ||
|
|
dbb689c5c5 | ||
|
|
2167aa398f | ||
|
|
eb8d2f312f | ||
|
|
f9133760fc | ||
|
|
7298c4664b | ||
|
|
eda7e35c61 | ||
|
|
7d1e22c7de | ||
|
|
1bee84d419 | ||
|
|
a00292544f | ||
|
|
fe14c2cd83 | ||
|
|
760f11f1ce | ||
|
|
0e1090b717 | ||
|
|
b2564154cd | ||
|
|
fb17611207 | ||
|
|
7f628948c9 | ||
|
|
cbe0861439 | ||
|
|
e9d6160f4d | ||
|
|
eacdd432b2 | ||
|
|
76e8d72d0d | ||
|
|
dbacf7e8c1 | ||
|
|
b6d99b09cf | ||
|
|
5c21e7f9ed | ||
|
|
14e9c7d9dc | ||
|
|
b3368d89f4 | ||
|
|
83b89851a5 | ||
|
|
2db5a83f4e | ||
|
|
d72cf40439 | ||
|
|
7c68a02eee | ||
|
|
93965bc5b6 | ||
|
|
ae60b4517c | ||
|
|
b15596d045 | ||
|
|
07f0dc80f8 | ||
|
|
5f64c298c9 | ||
|
|
5b056ce07e | ||
|
|
44b458ebb8 | ||
|
|
d4da96597e | ||
|
|
6a0b645316 | ||
|
|
35bb3cc94b | ||
|
|
7a6c3646fb | ||
|
|
b84161c49d | ||
|
|
63ce506d29 | ||
|
|
5114bd4a47 | ||
|
|
05a244d8fd | ||
|
|
019995078c | ||
|
|
f37cb6481c |
@@ -3,6 +3,7 @@ data
|
||||
docker
|
||||
docs
|
||||
tests
|
||||
node_modules
|
||||
|
||||
lnbits/static/css/*
|
||||
lnbits/static/bundle.js
|
||||
|
||||
+30
-6
@@ -9,8 +9,8 @@
|
||||
# configurations defined in `ReadOnlySettings` will still be read from the environment variables.
|
||||
# The rest of the settings will be stored in your database and you will be able to change them
|
||||
# only through the Admin UI.
|
||||
# Disable this to make LNbits use this config file again.
|
||||
LNBITS_ADMIN_UI=false
|
||||
# Disable this and clear `settings` table from database to make LNbits use this config file again.
|
||||
LNBITS_ADMIN_UI=true
|
||||
|
||||
# Change theme
|
||||
LNBITS_SITE_TITLE="LNbits"
|
||||
@@ -28,7 +28,7 @@ PORT=5000
|
||||
######################################
|
||||
|
||||
# which fundingsources are allowed in the admin ui
|
||||
LNBITS_ALLOWED_FUNDING_SOURCES="VoidWallet, FakeWallet, CoreLightningWallet, CoreLightningRestWallet, LndRestWallet, EclairWallet, LndWallet, LnTipsWallet, LNPayWallet, LNbitsWallet, AlbyWallet, ZBDWallet, PhoenixdWallet, OpenNodeWallet"
|
||||
# LNBITS_ALLOWED_FUNDING_SOURCES="VoidWallet, FakeWallet, CoreLightningWallet, CoreLightningRestWallet, LndRestWallet, EclairWallet, LndWallet, LnTipsWallet, LNPayWallet, LNbitsWallet, BlinkWallet, AlbyWallet, ZBDWallet, PhoenixdWallet, OpenNodeWallet, NWCWallet, BreezSdkWallet, BoltzWallet"
|
||||
|
||||
LNBITS_BACKEND_WALLET_CLASS=VoidWallet
|
||||
# VoidWallet is just a fallback that works without any actual Lightning capabilities,
|
||||
@@ -58,7 +58,7 @@ CORELIGHTNING_REST_MACAROON="/path/to/clnrest/access.macaroon" # or BASE64/HEXS
|
||||
CORELIGHTNING_REST_CERT="/path/to/clnrest/tls.cert"
|
||||
|
||||
# LnbitsWallet
|
||||
LNBITS_ENDPOINT=https://legend.lnbits.com
|
||||
LNBITS_ENDPOINT=https://demo.lnbits.com
|
||||
LNBITS_KEY=LNBITS_ADMIN_KEY
|
||||
|
||||
# LndWallet
|
||||
@@ -87,10 +87,21 @@ LNPAY_WALLET_KEY=LNPAY_ADMIN_KEY
|
||||
ALBY_API_ENDPOINT=https://api.getalby.com/
|
||||
ALBY_ACCESS_TOKEN=ALBY_ACCESS_TOKEN
|
||||
|
||||
# BoltzWallet
|
||||
BOLTZ_CLIENT_ENDPOINT=127.0.0.1:9002
|
||||
BOLTZ_CLIENT_MACAROON="/home/bob/.boltz/macaroon" # or HEXSTRING
|
||||
BOLTZ_CLIENT_CERT="/home/bob/.boltz/tls.cert" # or HEXSTRING
|
||||
BOLTZ_CLIENT_WALLET="lnbits"
|
||||
|
||||
# ZBDWallet
|
||||
ZBD_API_ENDPOINT=https://api.zebedee.io/v0/
|
||||
ZBD_API_KEY=ZBD_ACCESS_TOKEN
|
||||
|
||||
# BlinkWallet
|
||||
BLINK_API_ENDPOINT=https://api.blink.sv/graphql
|
||||
BLINK_WS_ENDPOINT=wss://ws.blink.sv/graphql
|
||||
BLINK_TOKEN=BLINK_TOKEN
|
||||
|
||||
# PhoenixdWallet
|
||||
PHOENIXD_API_ENDPOINT=http://localhost:9740/
|
||||
PHOENIXD_API_PASSWORD=PHOENIXD_KEY
|
||||
@@ -107,18 +118,29 @@ LNBITS_DENOMINATION=sats
|
||||
ECLAIR_URL=http://127.0.0.1:8283
|
||||
ECLAIR_PASS=eclairpw
|
||||
|
||||
# NWCWalllet
|
||||
NWC_PAIRING_URL="nostr+walletconnect://000...000?relay=example.com&secret=123"
|
||||
|
||||
# LnTipsWallet
|
||||
# Enter /api in LightningTipBot to get your key
|
||||
LNTIPS_API_KEY=LNTIPS_ADMIN_KEY
|
||||
LNTIPS_API_ENDPOINT=https://ln.tips
|
||||
|
||||
# BreezSdkWallet
|
||||
BREEZ_API_KEY=KEY
|
||||
BREEZ_GREENLIGHT_SEED=SEED
|
||||
# A Greenlight invite code or Greenlight partner certificate/key can be used
|
||||
BREEZ_GREENLIGHT_INVITE_CODE=CODE
|
||||
BREEZ_GREENLIGHT_DEVICE_KEY="/path/to/breezsdk/device.pem" # or BASE64/HEXSTRING
|
||||
BREEZ_GREENLIGHT_DEVICE_CERT="/path/to/breezsdk/device.crt" # or BASE64/HEXSTRING
|
||||
|
||||
######################################
|
||||
####### Auth Configurations ##########
|
||||
######################################
|
||||
# Secret Key: will default to the hash of the super user. It is strongly recommended that you set your own value.
|
||||
AUTH_SECRET_KEY=""
|
||||
AUTH_TOKEN_EXPIRE_MINUTES=525600
|
||||
# Possible authorization methods: user-id-only, username-password, google-auth, github-auth, keycloak-auth
|
||||
# Possible authorization methods: user-id-only, username-password, nostr-auth-nip98, google-auth, github-auth, keycloak-auth
|
||||
AUTH_ALLOWED_METHODS="user-id-only, username-password"
|
||||
# Set this flag if HTTP is used for OAuth
|
||||
# OAUTHLIB_INSECURE_TRANSPORT="1"
|
||||
@@ -162,6 +184,8 @@ LNBITS_ADMIN_USERS=""
|
||||
|
||||
# Extensions only admin can access
|
||||
LNBITS_ADMIN_EXTENSIONS="ngrok, admin"
|
||||
# Extensions enabled by default when a user is created
|
||||
LNBITS_USER_DEFAULT_EXTENSIONS="lnurlp"
|
||||
|
||||
# Start LNbits core only. The extensions are not loaded.
|
||||
# LNBITS_EXTENSIONS_DEACTIVATE_ALL=true
|
||||
@@ -183,7 +207,7 @@ LNBITS_DEFAULT_WALLET_NAME="LNbits wallet"
|
||||
# csv ad space, format "<url>;<img-light>;<img-dark>, <url>;<img-light>;<img-dark>", extensions can choose to honor
|
||||
# LNBITS_AD_SPACE="https://shop.lnbits.com/;https://raw.githubusercontent.com/lnbits/lnbits/main/lnbits/static/images/lnbits-shop-light.png;https://raw.githubusercontent.com/lnbits/lnbits/main/lnbits/static/images/lnbits-shop-dark.png"
|
||||
# LNBITS_SHOW_HOME_PAGE_ELEMENTS=true # if set to true, the ad space will be displayed on the home page
|
||||
# LNBITS_CUSTOM_BADGE="USE WITH CAUTION - LNbits wallet is still in BETA"
|
||||
# LNBITS_CUSTOM_BADGE="USE WITH CAUTION - LNbits wallet is still in BETA"
|
||||
# LNBITS_CUSTOM_BADGE_COLOR="warning"
|
||||
|
||||
# Hides wallet api, extensions can choose to honor
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
custom: https://legend.lnbits.com/paywall/GAqKguK5S8f6w5VNjS9DfK
|
||||
custom: https://demo.lnbits.com/lnurlp/link/fH59GD
|
||||
|
||||
@@ -4,7 +4,7 @@ inputs:
|
||||
python-version:
|
||||
description: "Python Version"
|
||||
required: true
|
||||
default: "3.9"
|
||||
default: "3.10"
|
||||
poetry-version:
|
||||
description: "Poetry Version"
|
||||
default: "1.7.0"
|
||||
@@ -46,7 +46,11 @@ runs:
|
||||
|
||||
- name: Install the project dependencies
|
||||
shell: bash
|
||||
run: poetry install
|
||||
run: |
|
||||
poetry env use python${{ inputs.python-version }}
|
||||
poetry install
|
||||
# needed for conv tests
|
||||
poetry add psycopg2-binary
|
||||
|
||||
- name: Use Node.js ${{ inputs.node-version }}
|
||||
if: ${{ (inputs.npm == 'true') }}
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
needs: [ lint ]
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.9", "3.10"]
|
||||
python-version: ["3.10", "3.11", "3.12"]
|
||||
db-url: ["", "postgres://lnbits:lnbits@0.0.0.0:5432/lnbits"]
|
||||
uses: ./.github/workflows/tests.yml
|
||||
with:
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
needs: [ lint ]
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.9", "3.10"]
|
||||
python-version: ["3.10", "3.11", "3.12"]
|
||||
db-url: ["", "postgres://lnbits:lnbits@0.0.0.0:5432/lnbits"]
|
||||
uses: ./.github/workflows/tests.yml
|
||||
with:
|
||||
@@ -44,7 +44,7 @@ jobs:
|
||||
needs: [ lint ]
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.9", "3.10"]
|
||||
python-version: ["3.10", "3.11", "3.12"]
|
||||
db-url: ["", "postgres://lnbits:lnbits@0.0.0.0:5432/lnbits"]
|
||||
uses: ./.github/workflows/tests.yml
|
||||
with:
|
||||
@@ -58,7 +58,7 @@ jobs:
|
||||
needs: [ lint ]
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.9", "3.10"]
|
||||
python-version: ["3.10", "3.11", "3.12"]
|
||||
uses: ./.github/workflows/migration.yml
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
@@ -74,7 +74,7 @@ jobs:
|
||||
uses: ./.github/workflows/regtest.yml
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.9"]
|
||||
python-version: ["3.10"]
|
||||
backend-wallet-class: ["LndRestWallet", "LndWallet", "CoreLightningWallet", "CoreLightningRestWallet", "LNbitsWallet", "EclairWallet"]
|
||||
with:
|
||||
custom-pytest: "poetry run pytest tests/regtest"
|
||||
@@ -87,7 +87,7 @@ jobs:
|
||||
needs: [ lint ]
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.9"]
|
||||
python-version: ["3.10"]
|
||||
poetry-version: ["1.5.1"]
|
||||
uses: ./.github/workflows/jmeter.yml
|
||||
with:
|
||||
|
||||
@@ -10,7 +10,7 @@ on:
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
@@ -19,7 +19,7 @@ on:
|
||||
|
||||
jobs:
|
||||
push_to_dockerhub:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
@@ -6,7 +6,7 @@ on:
|
||||
python-version:
|
||||
description: "Python Version"
|
||||
required: true
|
||||
default: "3.9"
|
||||
default: "3.10"
|
||||
type: string
|
||||
poetry-version:
|
||||
description: "Poetry Version"
|
||||
@@ -16,7 +16,7 @@ on:
|
||||
|
||||
jobs:
|
||||
jmeter:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -31,9 +31,18 @@ jobs:
|
||||
LNBITS_BACKEND_WALLET_CLASS: FakeWallet
|
||||
run: |
|
||||
poetry run lnbits &
|
||||
sleep 5
|
||||
sleep 10
|
||||
|
||||
- name: setup java version
|
||||
run: |
|
||||
update-java-alternatives --list
|
||||
sudo update-java-alternatives --set /usr/lib/jvm/temurin-8-jdk-amd64
|
||||
java -version
|
||||
|
||||
- name: clone lnbits-extensions, install jmeter and run tests
|
||||
env:
|
||||
JAVA_HOME: /usr/lib/jvm/temurin-8-jdk-amd64
|
||||
EXTENSIONS_MANIFEST_PATH: "/lnbits/lnbits-extensions/refs/heads/main/extensions.json"
|
||||
run: |
|
||||
git clone https://github.com/lnbits/lnbits-extensions
|
||||
cd lnbits-extensions
|
||||
@@ -43,6 +52,13 @@ jobs:
|
||||
make start-mirror-server
|
||||
make test
|
||||
|
||||
- name: print lnbits log
|
||||
if: ${{ always() }}
|
||||
run: |
|
||||
# catch up time for lnbits
|
||||
sleep 1
|
||||
cat data/logs/debug.log
|
||||
|
||||
- name: upload jmeter test results
|
||||
uses: actions/upload-artifact@v4
|
||||
if: ${{ always() }}
|
||||
|
||||
@@ -8,7 +8,7 @@ jobs:
|
||||
uses: ./.github/workflows/make.yml
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.9", "3.10"]
|
||||
python-version: ["3.10", "3.11", "3.12"]
|
||||
with:
|
||||
make: checkblack
|
||||
python-version: ${{ matrix.python-version }}
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
uses: ./.github/workflows/make.yml
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.9", "3.10"]
|
||||
python-version: ["3.10", "3.11", "3.12"]
|
||||
with:
|
||||
make: checkruff
|
||||
python-version: ${{ matrix.python-version }}
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
uses: ./.github/workflows/make.yml
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.9", "3.10"]
|
||||
python-version: ["3.10", "3.11", "3.12"]
|
||||
with:
|
||||
make: mypy
|
||||
python-version: ${{ matrix.python-version }}
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
uses: ./.github/workflows/make.yml
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.9", "3.10"]
|
||||
python-version: ["3.10", "3.11", "3.12"]
|
||||
with:
|
||||
make: pyright
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
name: ${{ inputs.make }} (${{ inputs.python-version }})
|
||||
strategy:
|
||||
matrix:
|
||||
os-version: ["ubuntu-latest"]
|
||||
os-version: ["ubuntu-24.04"]
|
||||
node-version: ["18.x"]
|
||||
runs-on: ${{ matrix.os-version }}
|
||||
steps:
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
name: migration (${{ inputs.python-version }})
|
||||
strategy:
|
||||
matrix:
|
||||
os-version: ["ubuntu-latest"]
|
||||
os-version: ["ubuntu-24.04"]
|
||||
runs-on: ${{ matrix.os-version }}
|
||||
services:
|
||||
postgres:
|
||||
|
||||
@@ -25,14 +25,13 @@ on:
|
||||
|
||||
jobs:
|
||||
nix:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: cachix/install-nix-action@v24
|
||||
- uses: cachix/install-nix-action@v27
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-23.11
|
||||
- uses: cachix/cachix-action@v13
|
||||
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/cachix'
|
||||
nix_path: nixpkgs=channel:nixos-24.05
|
||||
- uses: cachix/cachix-action@v15
|
||||
with:
|
||||
name: lnbits
|
||||
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
||||
|
||||
@@ -8,10 +8,10 @@ on:
|
||||
required: true
|
||||
type: string
|
||||
python-version:
|
||||
default: "3.9"
|
||||
default: "3.10"
|
||||
type: string
|
||||
os-version:
|
||||
default: "ubuntu-latest"
|
||||
default: "ubuntu-24.04"
|
||||
type: string
|
||||
backend-wallet-class:
|
||||
required: true
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
name: release-rc
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "*-rc[0-9]"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
|
||||
docker:
|
||||
uses: ./.github/workflows/docker.yml
|
||||
with:
|
||||
tag: ${{ github.ref_name }}
|
||||
secrets:
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
||||
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
pypi:
|
||||
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
|
||||
with:
|
||||
pypi_token: ${{ secrets.PYPI_API_KEY }}
|
||||
@@ -12,7 +12,7 @@ permissions:
|
||||
jobs:
|
||||
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Create github release
|
||||
@@ -31,9 +31,22 @@ jobs:
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
||||
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
docker-latest:
|
||||
needs: [ release ]
|
||||
uses: ./.github/workflows/docker.yml
|
||||
with:
|
||||
tag: latest
|
||||
secrets:
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
||||
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
pypi:
|
||||
runs-on: ubuntu-latest
|
||||
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
|
||||
|
||||
@@ -8,10 +8,10 @@ on:
|
||||
required: true
|
||||
type: string
|
||||
python-version:
|
||||
default: "3.9"
|
||||
default: "3.10"
|
||||
type: string
|
||||
os-version:
|
||||
default: "ubuntu-latest"
|
||||
default: "ubuntu-24.04"
|
||||
type: string
|
||||
db-url:
|
||||
default: ""
|
||||
|
||||
+6
-2
@@ -35,6 +35,7 @@ __bundle__
|
||||
coverage.xml
|
||||
node_modules
|
||||
lnbits/static/bundle.js
|
||||
lnbits/static/bundle-components.js
|
||||
lnbits/static/bundle.css
|
||||
lnbits/static/bundle.min.js.old
|
||||
lnbits/static/bundle.min.css.old
|
||||
@@ -49,8 +50,11 @@ fly.toml
|
||||
lnbits-backup.zip
|
||||
|
||||
# Ignore extensions (post installable extension PR)
|
||||
extensions
|
||||
upgrades/
|
||||
/lnbits/extensions
|
||||
/upgrades/
|
||||
|
||||
# builded python package
|
||||
dist
|
||||
|
||||
# jetbrains
|
||||
.idea
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
**/lnbits/static/vendor
|
||||
**/lnbits/static/bundle.*
|
||||
**/lnbits/static/bundle-components.*
|
||||
**/lnbits/static/css/*
|
||||
|
||||
flake.lock
|
||||
|
||||
+34
-9
@@ -1,4 +1,4 @@
|
||||
FROM python:3.10-slim-bookworm
|
||||
FROM python:3.12-slim-bookworm AS builder
|
||||
|
||||
RUN apt-get clean
|
||||
RUN apt-get update
|
||||
@@ -7,19 +7,44 @@ RUN apt-get install -y curl pkg-config build-essential libnss-myhostname
|
||||
RUN curl -sSL https://install.python-poetry.org | python3 -
|
||||
ENV PATH="/root/.local/bin:$PATH"
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Only copy the files required to install the dependencies
|
||||
COPY pyproject.toml poetry.lock ./
|
||||
|
||||
RUN mkdir data
|
||||
|
||||
ENV POETRY_NO_INTERACTION=1 \
|
||||
POETRY_VIRTUALENVS_IN_PROJECT=1 \
|
||||
POETRY_VIRTUALENVS_CREATE=1 \
|
||||
POETRY_CACHE_DIR=/tmp/poetry_cache
|
||||
|
||||
RUN poetry install --only main
|
||||
|
||||
FROM python:3.12-slim-bookworm
|
||||
|
||||
# needed for backups postgresql-client version 14 (pg_dump)
|
||||
RUN apt install -y postgresql-common ca-certificates
|
||||
RUN install -d /usr/share/postgresql-common/pgdg
|
||||
RUN curl -o /usr/share/postgresql-common/pgdg/apt.postgresql.org.asc --fail https://www.postgresql.org/media/keys/ACCC4CF8.asc
|
||||
RUN sh -c 'echo "deb [signed-by=/usr/share/postgresql-common/pgdg/apt.postgresql.org.asc] https://apt.postgresql.org/pub/repos/apt bookworm-pgdg main" > /etc/apt/sources.list.d/pgdg.list'
|
||||
RUN apt-get update
|
||||
RUN apt-get install -y postgresql-client-14
|
||||
RUN apt-get update && apt-get -y upgrade && \
|
||||
apt-get -y install gnupg2 curl lsb-release && \
|
||||
sh -c 'echo "deb http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list' && \
|
||||
curl -s https://www.postgresql.org/media/keys/ACCC4CF8.asc | apt-key add - && \
|
||||
apt-get update && \
|
||||
apt-get -y install postgresql-client-14 postgresql-client-common && \
|
||||
apt-get clean all && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN curl -sSL https://install.python-poetry.org | python3 -
|
||||
ENV PATH="/root/.local/bin:$PATH"
|
||||
|
||||
ENV POETRY_NO_INTERACTION=1 \
|
||||
POETRY_VIRTUALENVS_IN_PROJECT=1 \
|
||||
POETRY_VIRTUALENVS_CREATE=1 \
|
||||
VIRTUAL_ENV=/app/.venv \
|
||||
PATH="/app/.venv/bin:$PATH"
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN mkdir data
|
||||
COPY --from=builder /app/.venv .venv
|
||||
|
||||
RUN poetry install --only main
|
||||
|
||||
|
||||
@@ -38,25 +38,32 @@ checkeditorconfig:
|
||||
dev:
|
||||
poetry run lnbits --reload
|
||||
|
||||
docker:
|
||||
docker build -t lnbits/lnbits .
|
||||
|
||||
test-wallets:
|
||||
LNBITS_DATA_FOLDER="./tests/data" \
|
||||
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
DEBUG=true \
|
||||
poetry run pytest tests/wallets
|
||||
|
||||
test-unit:
|
||||
LNBITS_DATA_FOLDER="./tests/data" \
|
||||
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
DEBUG=true \
|
||||
poetry run pytest tests/unit
|
||||
|
||||
test-api:
|
||||
LNBITS_DATA_FOLDER="./tests/data" \
|
||||
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
DEBUG=true \
|
||||
poetry run pytest tests/api
|
||||
|
||||
test-regtest:
|
||||
LNBITS_DATA_FOLDER="./tests/data" \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
DEBUG=true \
|
||||
poetry run pytest tests/regtest
|
||||
@@ -71,6 +78,7 @@ test-migration:
|
||||
HOST=0.0.0.0 \
|
||||
PORT=5002 \
|
||||
LNBITS_DATABASE_URL="postgres://lnbits:lnbits@localhost:5432/migration" \
|
||||
LNBITS_ADMIN_UI=False \
|
||||
timeout 5s poetry run lnbits --host 0.0.0.0 --port 5002 || code=$?; if [[ $code -ne 124 && $code -ne 0 ]]; then exit $code; fi
|
||||
LNBITS_DATA_FOLDER="./tests/data" \
|
||||
LNBITS_DATABASE_URL="postgres://lnbits:lnbits@localhost:5432/migration" \
|
||||
@@ -80,6 +88,7 @@ migration:
|
||||
poetry run python tools/conv.py
|
||||
|
||||
openapi:
|
||||
LNBITS_ADMIN_UI=False \
|
||||
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
|
||||
LNBITS_DATA_FOLDER="./tests/data" \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
@@ -99,24 +108,21 @@ sass:
|
||||
|
||||
bundle:
|
||||
npm install
|
||||
npm run sass
|
||||
npm run vendor_copy
|
||||
npm run vendor_json
|
||||
npm run bundle
|
||||
poetry run ./node_modules/.bin/prettier -w ./lnbits/static/vendor.json
|
||||
npm run vendor_bundle_css
|
||||
npm run vendor_minify_css
|
||||
npm run vendor_bundle_js
|
||||
npm run vendor_minify_js
|
||||
|
||||
checkbundle:
|
||||
cp lnbits/static/bundle.min.js lnbits/static/bundle.min.js.old
|
||||
cp lnbits/static/bundle.min.css lnbits/static/bundle.min.css.old
|
||||
cp lnbits/static/bundle-components.min.js lnbits/static/bundle-components.min.js.old
|
||||
make bundle
|
||||
diff -q lnbits/static/bundle.min.js lnbits/static/bundle.min.js.old || exit 1
|
||||
diff -q lnbits/static/bundle.min.css lnbits/static/bundle.min.css.old || exit 1
|
||||
diff -q lnbits/static/bundle-components.min.js lnbits/static/bundle-components.min.js.old || exit 1
|
||||
@echo "Bundle is OK"
|
||||
rm lnbits/static/bundle.min.js.old
|
||||
rm lnbits/static/bundle.min.css.old
|
||||
rm lnbits/static/bundle-components.min.js.old
|
||||
|
||||
install-pre-commit-hook:
|
||||
@echo "Installing pre-commit hook to git"
|
||||
|
||||
@@ -30,7 +30,7 @@ LNbits is inspired by all the great work of [opennode.com](https://www.opennode.
|
||||
|
||||
## Running LNbits
|
||||
|
||||
Test on our demo server [legend.lnbits.com](https://legend.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.
|
||||
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.
|
||||
|
||||
@@ -68,7 +68,7 @@ As well as working great in a browser, LNbits has native IoS and Android apps as
|
||||
|
||||
## Tip us
|
||||
|
||||
If you like this project [send some tip love](https://legend.lnbits.com/paywall/GAqKguK5S8f6w5VNjS9DfK)!
|
||||
If you like this project [send some tip love](https://demo.lnbits.com/lnurlp/link/fH59GD)!
|
||||
|
||||
[docs]: https://github.com/lnbits/lnbits/wiki
|
||||
[docs-badge]: https://img.shields.io/badge/docs-lnbits.org-673ab7.svg
|
||||
|
||||
+1
-1
@@ -7,4 +7,4 @@ nav_order: 3
|
||||
|
||||
# API reference
|
||||
|
||||
[Swagger Docs](https://legend.lnbits.com/docs)
|
||||
[Swagger Docs](https://demo.lnbits.com/docs)
|
||||
|
||||
@@ -11,13 +11,16 @@ Thanks for contributing :)
|
||||
|
||||
# Run
|
||||
|
||||
This starts the lnbits uvicorn server
|
||||
Follow the [Basic installation: Option 1 (recommended): poetry](https://docs.lnbits.org/guide/installation.html#option-1-recommended-poetry)
|
||||
guide to install poetry and other dependencies.
|
||||
|
||||
Then you can start LNbits uvicorn server with:
|
||||
|
||||
```bash
|
||||
poetry run lnbits
|
||||
```
|
||||
|
||||
This starts the lnbits uvicorn with hot reloading.
|
||||
Or you can use the following to start uvicorn with hot reloading enabled:
|
||||
|
||||
```bash
|
||||
make dev
|
||||
@@ -25,6 +28,15 @@ make dev
|
||||
poetry run lnbits --reload
|
||||
```
|
||||
|
||||
You might need the following extra dependencies on clean installation of Debian:
|
||||
|
||||
```
|
||||
sudo apt install nodejs
|
||||
sudo apt install npm
|
||||
npm install
|
||||
sudo apt-get install autoconf libtool libpg-dev
|
||||
```
|
||||
|
||||
# Precommit hooks
|
||||
|
||||
This ensures that all commits adhere to the formatting and linting rules.
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
window.onload = function () {
|
||||
// Begin Swagger UI call region
|
||||
const ui = SwaggerUIBundle({
|
||||
url: 'https://legend.lnbits.com/openapi.json', //Location of Open API spec in the repo
|
||||
url: 'https://demo.lnbits.com/openapi.json', //Location of Open API spec in the repo
|
||||
dom_id: '#swagger-ui',
|
||||
deepLinking: true,
|
||||
presets: [
|
||||
|
||||
@@ -9,7 +9,7 @@ nav_order: 2
|
||||
|
||||
`websockets` are a great way to add a two way instant data channel between server and client.
|
||||
|
||||
LNbits has a useful in built websocket tool. With a websocket client connect to (obv change `somespecificid`) `wss://legend.lnbits.com/api/v1/ws/somespecificid` (you can use an online websocket tester). Now make a get to `https://legend.lnbits.com/api/v1/ws/somespecificid/somedata`. You can send data to that websocket by using `from lnbits.core.services import websocketUpdater` and the function `websocketUpdater("somespecificid", "somdata")`.
|
||||
LNbits has a useful in built websocket tool. With a websocket client connect to (obv change `somespecificid`) `wss://demo.lnbits.com/api/v1/ws/somespecificid` (you can use an online websocket tester). Now make a get to `https://demo.lnbits.com/api/v1/ws/somespecificid/somedata`. You can send data to that websocket by using `from lnbits.core.services import websocketUpdater` and the function `websocketUpdater("somespecificid", "somdata")`.
|
||||
|
||||
Example vue-js function for listening to the websocket:
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ Account creation can be also disabled by setting `LNBITS_ALLOW_NEW_ACCOUNTS=fals
|
||||
|
||||
```
|
||||
$ sudo systemctl stop lnbits.service
|
||||
$ cd ~/lnbits-legend
|
||||
$ cd ~/lnbits
|
||||
$ sudo nano .env
|
||||
```
|
||||
|
||||
@@ -67,7 +67,7 @@ You can access your super user account at `/wallet?usr=super_user_id`. You just
|
||||
|
||||
After that you will find the **`Admin` / `Manage Server`** between `Wallets` and `Extensions`
|
||||
|
||||
Here you can design the interface, it has TOPUP to fill wallets 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.
|
||||
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.
|
||||
|
||||
Do not forget
|
||||
|
||||
|
||||
+1
-1
@@ -84,7 +84,7 @@ allow-self-payment=1
|
||||
<details><summary>Wallet-URL deleted, are my funds safu ?</summary>
|
||||
<ul>
|
||||
<li>
|
||||
<details><summary>Wallet on demo server legend.lnbits</summary>
|
||||
<details><summary>Wallet on demo server demo.lnbits.com</summary>
|
||||
<p>Always save a copy of your wallet-URL, Export2phone-QR or LNDhub for your own wallets in a safe place. LNbits CANNOT help you to recover them when lost.</p>
|
||||
</details>
|
||||
</li>
|
||||
|
||||
+52
-39
@@ -6,26 +6,25 @@ nav_order: 2
|
||||
|
||||
# Basic installation
|
||||
|
||||
You can choose between four package managers, `poetry` and `nix`
|
||||
The following sections explain how to install LNbits using varions package managers: `poetry`, `nix`, `Docker` and `Fly.io`.
|
||||
|
||||
By default, LNbits will use SQLite as its database. You can also use PostgreSQL which is recommended for applications with a high load (see guide below).
|
||||
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.
|
||||
|
||||
## Option 1 (recommended): poetry
|
||||
## Option 1 (recommended): Poetry
|
||||
|
||||
Mininum poetry version has is ^1.2, but it is recommended to use latest poetry. (including OSX)
|
||||
Make sure you have Python 3.9 or 3.10 installed.
|
||||
It is recommended to use the latest version of Poetry. Make sure you have Python version `3.12` installed.
|
||||
|
||||
### install python on ubuntu
|
||||
### Install Python 3.12
|
||||
|
||||
```sh
|
||||
# for making sure python 3.9 is installed, skip if installed. To check your installed version: python3 --version
|
||||
sudo apt update
|
||||
sudo apt install software-properties-common
|
||||
sudo add-apt-repository ppa:deadsnakes/ppa
|
||||
sudo apt install python3.9 python3.9-distutils
|
||||
sudo apt update
|
||||
sudo apt install python3.12
|
||||
sudo apt-get install python3.12-dev # ensure correct headers needed for secp256k1
|
||||
python3 --version
|
||||
```
|
||||
|
||||
### install poetry
|
||||
### Install Poetry
|
||||
|
||||
```sh
|
||||
curl -sSL https://install.python-poetry.org | python3 -
|
||||
@@ -33,21 +32,16 @@ curl -sSL https://install.python-poetry.org | python3 -
|
||||
export PATH="/home/user/.local/bin:$PATH"
|
||||
```
|
||||
|
||||
### install LNbits
|
||||
|
||||
```sh
|
||||
git clone https://github.com/lnbits/lnbits.git
|
||||
cd lnbits
|
||||
poetry env use 3.12
|
||||
git checkout main
|
||||
|
||||
# Next command, you can exchange with python3.10 or newer versions.
|
||||
# Identify your version with python3 --version and specify in the next line
|
||||
# command is only needed when your default python is not ^3.9 or ^3.10
|
||||
poetry env use python3.9
|
||||
poetry install --only main
|
||||
|
||||
mkdir data
|
||||
cp .env.example .env
|
||||
# set funding source amongst other options
|
||||
nano .env
|
||||
# Optional: to set funding source amongst other options via the env `nano .env`
|
||||
```
|
||||
|
||||
#### Running the server
|
||||
@@ -59,9 +53,16 @@ poetry run lnbits
|
||||
# Note that you have to add the line DEBUG=true in your .env file, too.
|
||||
```
|
||||
|
||||
#### LNbits-cli
|
||||
|
||||
```sh
|
||||
# A very useful terminal client for getting the supersuer ID, updating extensions, etc
|
||||
poetry run lnbits-cli --help
|
||||
```
|
||||
|
||||
#### Updating the server
|
||||
|
||||
```
|
||||
```sh
|
||||
cd lnbits
|
||||
# Stop LNbits with `ctrl + x`
|
||||
git pull
|
||||
@@ -70,7 +71,19 @@ poetry install --only main
|
||||
# Start LNbits with `poetry run lnbits`
|
||||
```
|
||||
|
||||
## Option 2: Nix
|
||||
## Option 2: Install script (on Debian/Ubuntu)
|
||||
|
||||
```sh
|
||||
wget https://raw.githubusercontent.com/lnbits/lnbits/main/lnbits.sh &&
|
||||
chmod +x lnbits.sh &&
|
||||
./lnbits.sh
|
||||
```
|
||||
|
||||
Now visit `0.0.0.0:5000` to make a super-user account.
|
||||
|
||||
`export PATH="/home/$USER/.local/bin:$PATH"` then `./lnbits.sh` can be used to run, but for more control `cd lnbits` and use `poetry run lnbits` (see previous option).
|
||||
|
||||
## Option 3: Nix
|
||||
|
||||
```sh
|
||||
# Install nix. If you have installed via another manager, remove and use this install (from https://nixos.org/download)
|
||||
@@ -108,7 +121,7 @@ LNBITS_ADMIN_UI=true ./result/bin/lnbits --port 9000
|
||||
SUPER_USER=be54db7f245346c8833eaa430e1e0405 LNBITS_ADMIN_UI=true ./result/bin/lnbits --port 9000
|
||||
```
|
||||
|
||||
## Option 3: Docker
|
||||
## Option 4: Docker
|
||||
|
||||
use latest version from docker hub
|
||||
|
||||
@@ -130,7 +143,7 @@ mkdir data
|
||||
docker run --detach --publish 5000:5000 --name lnbits --volume ${PWD}/.env:/app/.env --volume ${PWD}/data/:/app/data lnbits/lnbits
|
||||
```
|
||||
|
||||
## Option 4: Fly.io
|
||||
## Option 5: Fly.io
|
||||
|
||||
Fly.io is a docker container hosting platform that has a generous free tier. You can host LNbits for free on Fly.io for personal use.
|
||||
|
||||
@@ -163,7 +176,7 @@ 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://legend.lnbits.com` in `fly.toml` you should have `LNBITS_ENDPOINT="https://legend.lnbits.com"`.
|
||||
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"`.
|
||||
|
||||
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>`.
|
||||
|
||||
@@ -346,15 +359,6 @@ Assuming your LNbits is running on port `5000` add:
|
||||
|
||||
```
|
||||
yourdomain.com {
|
||||
handle /api/v1/payments/sse* {
|
||||
reverse_proxy 0.0.0.0:5000 {
|
||||
header_up X-Forwarded-Host yourdomain.com
|
||||
transport http {
|
||||
keepalive off
|
||||
compression off
|
||||
}
|
||||
}
|
||||
}
|
||||
reverse_proxy 0.0.0.0:5000 {
|
||||
header_up X-Forwarded-Host yourdomain.com
|
||||
}
|
||||
@@ -373,7 +377,7 @@ Install Apache2 and enable Apache2 mods:
|
||||
|
||||
```sh
|
||||
apt-get install apache2 certbot
|
||||
a2enmod headers ssl proxy proxy-http
|
||||
a2enmod headers ssl proxy proxy_http
|
||||
```
|
||||
|
||||
Create a SSL certificate with LetsEncrypt:
|
||||
@@ -412,7 +416,7 @@ EOF
|
||||
Restart Apache2:
|
||||
|
||||
```sh
|
||||
service restart apache2
|
||||
service apache2 restart
|
||||
```
|
||||
|
||||
## Running behind an Nginx reverse proxy over HTTPS
|
||||
@@ -444,6 +448,15 @@ server {
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_pass_request_headers on;
|
||||
|
||||
# WebSocket support
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "Upgrade";
|
||||
|
||||
listen [::]:443 ssl;
|
||||
listen 443 ssl;
|
||||
ssl_certificate /etc/letsencrypt/live/lnbits.org/fullchain.pem;
|
||||
@@ -457,7 +470,7 @@ EOF
|
||||
Restart nginx:
|
||||
|
||||
```sh
|
||||
service restart nginx
|
||||
service nginx restart
|
||||
```
|
||||
|
||||
## Using https without reverse proxy
|
||||
@@ -513,7 +526,7 @@ To install using docker you first need to build the docker image as:
|
||||
```
|
||||
git clone https://github.com/lnbits/lnbits.git
|
||||
cd lnbits
|
||||
docker build -t lnbits-legend .
|
||||
docker build -t lnbits/lnbits .
|
||||
```
|
||||
|
||||
You can launch the docker in a different directory, but make sure to copy `.env.example` from lnbits there
|
||||
@@ -533,7 +546,7 @@ mkdir data
|
||||
Then the image can be run as:
|
||||
|
||||
```
|
||||
docker run --detach --publish 5000:5000 --name lnbits-legend -e "LNBITS_BACKEND_WALLET_CLASS='FakeWallet'" --volume ${PWD}/.env:/app/.env --volume ${PWD}/data/:/app/data lnbits-legend
|
||||
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.
|
||||
|
||||
@@ -76,6 +76,15 @@ For the invoice to work you must have a publicly accessible URL in your LNbits.
|
||||
- `OPENNODE_API_ENDPOINT`: https://api.opennode.com/
|
||||
- `OPENNODE_KEY`: opennodeAdminApiKey
|
||||
|
||||
### Blink
|
||||
|
||||
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```
|
||||
|
||||
- `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 setting is necessary. You can generate an alby access token here: https://getalby.com/developer/access_tokens/new
|
||||
@@ -84,6 +93,18 @@ For the invoice to work you must have a publicly accessible URL in your LNbits.
|
||||
- `ALBY_API_ENDPOINT`: https://api.getalby.com/
|
||||
- `ALBY_ACCESS_TOKEN`: AlbyAccessToken
|
||||
|
||||
### Boltz
|
||||
|
||||
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`.
|
||||
|
||||
- `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 setting is necessary. You can generate an ZBD API Key here: https://zbd.dev/docs/dashboard/projects/api
|
||||
@@ -101,6 +122,26 @@ For the invoice to work you must have a publicly accessible URL in your LNbits.
|
||||
- `PHOENIXD_API_ENDPOINT`: http://localhost:9740/
|
||||
- `PHOENIXD_API_PASSWORD`: PhoenixdApiPassword
|
||||
|
||||
### Breez SDK
|
||||
|
||||
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.
|
||||
|
||||
- `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
|
||||
|
||||
### Cliche Wallet
|
||||
|
||||
- `CLICHE_ENDPOINT`: ws://127.0.0.1:12000
|
||||
|
||||
### Nostr Wallet Connect (NWC)
|
||||
|
||||
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 configure Nostr Wallet Connect in the admin ui or using the following environment variables:
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: **NWCWallet**
|
||||
- `NWC_PAIRING_URL`: **nostr+walletconnect://...your...pairing...secret...**
|
||||
|
||||
Generated
+16
-16
@@ -5,11 +5,11 @@
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1694529238,
|
||||
"narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=",
|
||||
"lastModified": 1710146030,
|
||||
"narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "ff7b65b44d01cf9ba6a71320833626af21126384",
|
||||
"rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -26,11 +26,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1698974481,
|
||||
"narHash": "sha256-yPncV9Ohdz1zPZxYHQf47S8S0VrnhV7nNhCawY46hDA=",
|
||||
"lastModified": 1703863825,
|
||||
"narHash": "sha256-rXwqjtwiGKJheXB43ybM8NwWB8rO2dSRrEqes0S7F5Y=",
|
||||
"owner": "nix-community",
|
||||
"repo": "nix-github-actions",
|
||||
"rev": "4bb5e752616262457bc7ca5882192a564c0472d2",
|
||||
"rev": "5163432afc817cf8bd1f031418d1869e4c9d5547",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -41,16 +41,16 @@
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1702233072,
|
||||
"narHash": "sha256-H5G2wgbim2Ku6G6w+NSaQaauv6B6DlPhY9fMvArKqRo=",
|
||||
"lastModified": 1723938990,
|
||||
"narHash": "sha256-9tUadhnZQbWIiYVXH8ncfGXGvkNq3Hag4RCBEMUk7MI=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "781e2a9797ecf0f146e81425c822dca69fe4a348",
|
||||
"rev": "c42fcfbdfeae23e68fc520f9182dde9f38ad1890",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nixos",
|
||||
"ref": "nixos-23.11",
|
||||
"ref": "nixos-24.05",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
@@ -66,11 +66,11 @@
|
||||
"treefmt-nix": "treefmt-nix"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1702334837,
|
||||
"narHash": "sha256-QZG6+zFshyY+L8m2tlOTm75U5m9y7z01g0josVK+8Os=",
|
||||
"lastModified": 1724134185,
|
||||
"narHash": "sha256-nDqpGjz7cq3ThdC98BPe1ANCNlsJds/LLZ3/MdIXjA0=",
|
||||
"owner": "nix-community",
|
||||
"repo": "poetry2nix",
|
||||
"rev": "1f4bcbf1be73abc232a972a77102a3e820485a99",
|
||||
"rev": "5ee730a8752264e463c0eaf06cc060fd07f6dae9",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -122,11 +122,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1699786194,
|
||||
"narHash": "sha256-3h3EH1FXQkIeAuzaWB+nK0XK54uSD46pp+dMD3gAcB4=",
|
||||
"lastModified": 1719749022,
|
||||
"narHash": "sha256-ddPKHcqaKCIFSFc/cvxS14goUhCOAwsM1PbMr0ZtHMg=",
|
||||
"owner": "numtide",
|
||||
"repo": "treefmt-nix",
|
||||
"rev": "e82f32aa7f06bbbd56d7b12186d555223dc399d1",
|
||||
"rev": "8df5ff62195d4e67e2264df0b7f5e8c9995fd0bd",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
description = "LNbits, free and open-source Lightning wallet and accounts system";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:nixos/nixpkgs/nixos-23.11";
|
||||
nixpkgs.url = "github:nixos/nixpkgs/nixos-24.05";
|
||||
poetry2nix = {
|
||||
url = "github:nix-community/poetry2nix";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
@@ -30,19 +30,10 @@
|
||||
meta.rev = self.dirtyRev or self.rev;
|
||||
meta.mainProgram = projectName;
|
||||
overrides = pkgs.poetry2nix.overrides.withDefaults (final: prev: {
|
||||
coincurve = prev.coincurve.override { preferWheel = true; };
|
||||
protobuf = prev.protobuf.override { preferWheel = true; };
|
||||
ruff = prev.ruff.override { preferWheel = true; };
|
||||
wallycore = prev.wallycore.override { preferWheel = true; };
|
||||
# remove the following override when https://github.com/nix-community/poetry2nix/pull/1563 is merged
|
||||
asgi-lifespan = prev.asgi-lifespan.overridePythonAttrs (
|
||||
old: { buildInputs = (old.buildInputs or []) ++ [ prev.setuptools ]; }
|
||||
);
|
||||
pytest-md = prev.pytest-md.overridePythonAttrs (
|
||||
old: { buildInputs = (old.buildInputs or []) ++ [ prev.setuptools ]; }
|
||||
);
|
||||
types-mock = prev.pytest-md.overridePythonAttrs (
|
||||
old: { buildInputs = (old.buildInputs or []) ++ [ prev.setuptools ]; }
|
||||
);
|
||||
});
|
||||
};
|
||||
});
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Check install has not already run
|
||||
if [ ! -d lnbits/data ]; then
|
||||
|
||||
# Update package list and install prerequisites non-interactively
|
||||
sudo apt update -y
|
||||
sudo apt install -y software-properties-common
|
||||
|
||||
# Add the deadsnakes PPA repository non-interactively
|
||||
sudo add-apt-repository -y ppa:deadsnakes/ppa
|
||||
|
||||
# Install Python 3.9 and distutils non-interactively
|
||||
sudo apt install -y python3.9 python3.9-distutils
|
||||
|
||||
# Install Poetry
|
||||
curl -sSL https://install.python-poetry.org | python3.9 -
|
||||
|
||||
# Add Poetry to PATH for the current session
|
||||
export PATH="/home/$USER/.local/bin:$PATH"
|
||||
|
||||
if [ ! -d lnbits/wallets ]; then
|
||||
# Clone the LNbits repository
|
||||
git clone https://github.com/lnbits/lnbits.git
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Failed to clone the repository ... FAIL"
|
||||
exit 1
|
||||
fi
|
||||
# Ensure we are in the lnbits directory
|
||||
cd lnbits || { echo "Failed to cd into lnbits ... FAIL"; exit 1; }
|
||||
fi
|
||||
|
||||
git checkout main
|
||||
# Make data folder
|
||||
mkdir data
|
||||
|
||||
# Copy the .env.example to .env
|
||||
cp .env.example .env
|
||||
|
||||
elif [ ! -d lnbits/wallets ]; then
|
||||
# cd into lnbits
|
||||
cd lnbits || { echo "Failed to cd into lnbits ... FAIL"; exit 1; }
|
||||
fi
|
||||
|
||||
# Set path for running after install
|
||||
export PATH="/home/$USER/.local/bin:$PATH"
|
||||
|
||||
# Install the dependencies using Poetry
|
||||
poetry env use python3.9
|
||||
poetry install --only main
|
||||
|
||||
# Set environment variables for LNbits
|
||||
export LNBITS_ADMIN_UI=true
|
||||
export HOST=0.0.0.0
|
||||
|
||||
# Run LNbits
|
||||
poetry run lnbits
|
||||
@@ -0,0 +1,24 @@
|
||||
from .core.services import create_invoice, pay_invoice
|
||||
from .decorators import (
|
||||
check_admin,
|
||||
check_super_user,
|
||||
check_user_exists,
|
||||
require_admin_key,
|
||||
require_invoice_key,
|
||||
)
|
||||
from .exceptions import InvoiceError, PaymentError
|
||||
|
||||
__all__ = [
|
||||
# decorators
|
||||
"require_admin_key",
|
||||
"require_invoice_key",
|
||||
"check_admin",
|
||||
"check_super_user",
|
||||
"check_user_exists",
|
||||
# services
|
||||
"pay_invoice",
|
||||
"create_invoice",
|
||||
# exceptions
|
||||
"PaymentError",
|
||||
"InvoiceError",
|
||||
]
|
||||
|
||||
+90
-50
@@ -6,7 +6,7 @@ import shutil
|
||||
import sys
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from typing import Callable, List, Optional
|
||||
from typing import Callable, Optional
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
@@ -14,15 +14,27 @@ from fastapi.staticfiles import StaticFiles
|
||||
from loguru import logger
|
||||
from slowapi import Limiter
|
||||
from slowapi.util import get_remote_address
|
||||
from starlette.middleware.gzip import GZipMiddleware
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
|
||||
from lnbits.core.crud import get_dbversions, get_installed_extensions
|
||||
from lnbits.core.crud import (
|
||||
get_db_version,
|
||||
get_installed_extensions,
|
||||
update_installed_extension_state,
|
||||
)
|
||||
from lnbits.core.crud.extensions import create_installed_extension
|
||||
from lnbits.core.helpers import migrate_extension_database
|
||||
from lnbits.core.services.extensions import deactivate_extension, get_valid_extensions
|
||||
from lnbits.core.tasks import ( # watchdog_task
|
||||
audit_queue,
|
||||
collect_exchange_rates_data,
|
||||
killswitch_task,
|
||||
purge_audit_data,
|
||||
wait_for_audit_data,
|
||||
wait_for_paid_invoices,
|
||||
)
|
||||
from lnbits.exceptions import register_exception_handlers
|
||||
from lnbits.helpers import version_parse
|
||||
from lnbits.settings import settings
|
||||
from lnbits.tasks import (
|
||||
cancel_all_tasks,
|
||||
@@ -40,17 +52,10 @@ from lnbits.wallets import get_funding_source, set_funding_source
|
||||
from .commands import migrate_databases
|
||||
from .core import init_core_routers
|
||||
from .core.db import core_app_extra
|
||||
from .core.models.extensions import Extension, ExtensionMeta, InstallableExtension
|
||||
from .core.services import check_admin_settings, check_webpush_settings
|
||||
from .core.views.extension_api import add_installed_extension
|
||||
from .core.views.generic import update_installed_extension_state
|
||||
from .extension_manager import (
|
||||
Extension,
|
||||
InstallableExtension,
|
||||
get_valid_extensions,
|
||||
version_parse,
|
||||
)
|
||||
from .middleware import (
|
||||
CustomGZipMiddleware,
|
||||
AuditMiddleware,
|
||||
ExtensionsRedirectMiddleware,
|
||||
InstalledExtensionMiddleware,
|
||||
add_first_install_middleware,
|
||||
@@ -75,6 +80,10 @@ async def startup(app: FastAPI):
|
||||
await check_admin_settings()
|
||||
await check_webpush_settings()
|
||||
|
||||
# check extensions after restart
|
||||
if not settings.lnbits_extensions_deactivate_all:
|
||||
await check_and_register_extensions(app)
|
||||
|
||||
log_server_info()
|
||||
|
||||
# initialize WALLET
|
||||
@@ -90,11 +99,6 @@ async def startup(app: FastAPI):
|
||||
# register core routes
|
||||
init_core_routers(app)
|
||||
|
||||
# check extensions after restart
|
||||
if not settings.lnbits_extensions_deactivate_all:
|
||||
await check_installed_extensions(app)
|
||||
register_all_ext_routes(app)
|
||||
|
||||
# initialize tasks
|
||||
register_async_tasks()
|
||||
|
||||
@@ -149,10 +153,9 @@ def create_app() -> FastAPI:
|
||||
app.add_middleware(
|
||||
CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]
|
||||
)
|
||||
app.add_middleware(GZipMiddleware, minimum_size=1000)
|
||||
|
||||
app.add_middleware(
|
||||
CustomGZipMiddleware, minimum_size=1000, exclude_paths=["/api/v1/payments/sse"]
|
||||
)
|
||||
app.add_middleware(AuditMiddleware, audit_queue=audit_queue)
|
||||
|
||||
# required for SSO login
|
||||
app.add_middleware(SessionMiddleware, secret_key=settings.auth_secret_key)
|
||||
@@ -207,7 +210,7 @@ async def check_funding_source() -> None:
|
||||
break
|
||||
|
||||
retry_counter += 1
|
||||
sleep_time = 0.25 * (2**retry_counter)
|
||||
sleep_time = min(0.25 * (2**retry_counter), 60)
|
||||
logger.warning(
|
||||
f"Retrying connection to backend in {sleep_time} seconds... "
|
||||
f"({retry_counter}/{max_retries})"
|
||||
@@ -230,7 +233,7 @@ async def check_installed_extensions(app: FastAPI):
|
||||
re-created. The 'data' directory (where the '.zip' files live) is expected to
|
||||
persist state. Zips that are missing will be re-downloaded.
|
||||
"""
|
||||
shutil.rmtree(os.path.join("lnbits", "upgrades"), True)
|
||||
|
||||
installed_extensions = await build_all_installed_extensions_list(False)
|
||||
|
||||
for ext in installed_extensions:
|
||||
@@ -244,6 +247,7 @@ async def check_installed_extensions(app: FastAPI):
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(e)
|
||||
await deactivate_extension(ext.id)
|
||||
logger.warning(
|
||||
f"Failed to re-install extension: {ext.id} ({ext.installed_version})"
|
||||
)
|
||||
@@ -255,16 +259,35 @@ async def check_installed_extensions(app: FastAPI):
|
||||
|
||||
async def build_all_installed_extensions_list(
|
||||
include_deactivated: Optional[bool] = True,
|
||||
) -> List[InstallableExtension]:
|
||||
) -> list[InstallableExtension]:
|
||||
"""
|
||||
Returns a list of all the installed extensions plus the extensions that
|
||||
MUST be installed by default (see LNBITS_EXTENSIONS_DEFAULT_INSTALL).
|
||||
"""
|
||||
installed_extensions = await get_installed_extensions()
|
||||
settings.lnbits_all_extensions_ids = {e.id for e in installed_extensions}
|
||||
|
||||
for ext_dir in Path(settings.lnbits_extensions_path, "extensions").iterdir():
|
||||
try:
|
||||
if not ext_dir.is_dir():
|
||||
continue
|
||||
ext_id = ext_dir.name
|
||||
if ext_id in settings.lnbits_all_extensions_ids:
|
||||
continue
|
||||
ext_info = InstallableExtension.from_ext_dir(ext_id)
|
||||
if not ext_info:
|
||||
continue
|
||||
|
||||
installed_extensions.append(ext_info)
|
||||
await create_installed_extension(ext_info)
|
||||
current_version = await get_db_version(ext_id)
|
||||
await migrate_extension_database(ext_info, current_version)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(e)
|
||||
|
||||
installed_extensions_ids = [e.id for e in installed_extensions]
|
||||
for ext_id in settings.lnbits_extensions_default_install:
|
||||
if ext_id in installed_extensions_ids:
|
||||
if ext_id in settings.lnbits_all_extensions_ids:
|
||||
continue
|
||||
|
||||
ext_releases = await InstallableExtension.get_extension_releases(ext_id)
|
||||
@@ -275,8 +298,13 @@ async def build_all_installed_extensions_list(
|
||||
release = next((e for e in ext_releases if e.is_version_compatible), None)
|
||||
|
||||
if release:
|
||||
ext_meta = ExtensionMeta(installed_release=release)
|
||||
ext_info = InstallableExtension(
|
||||
id=ext_id, name=ext_id, installed_release=release, icon=release.icon
|
||||
id=ext_id,
|
||||
name=ext_id,
|
||||
version=release.version,
|
||||
icon=release.icon,
|
||||
meta=ext_meta,
|
||||
)
|
||||
installed_extensions.append(ext_info)
|
||||
|
||||
@@ -307,19 +335,16 @@ async def check_installed_extension_files(ext: InstallableExtension) -> bool:
|
||||
|
||||
|
||||
async def restore_installed_extension(app: FastAPI, ext: InstallableExtension):
|
||||
await add_installed_extension(ext)
|
||||
await update_installed_extension_state(ext_id=ext.id, active=True)
|
||||
|
||||
extension = Extension.from_installable_ext(ext)
|
||||
register_ext_routes(app, extension)
|
||||
|
||||
current_version = (await get_dbversions()).get(ext.id, 0)
|
||||
await migrate_extension_database(extension, current_version)
|
||||
current_version = await get_db_version(ext.id)
|
||||
await migrate_extension_database(ext, current_version)
|
||||
|
||||
# mount routes for the new version
|
||||
core_app_extra.register_new_ext_routes(extension)
|
||||
if extension.upgrade_hash:
|
||||
ext.notify_upgrade()
|
||||
|
||||
|
||||
def register_custom_extensions_path():
|
||||
@@ -337,8 +362,14 @@ def register_custom_extensions_path():
|
||||
+ f" '{settings.lnbits_extensions_path}/extensions'"
|
||||
)
|
||||
|
||||
sys.path.append(str(Path(settings.lnbits_extensions_path, "extensions")))
|
||||
sys.path.append(str(Path(settings.lnbits_extensions_path, "upgrades")))
|
||||
extensions_dir = Path(settings.lnbits_extensions_path, "extensions")
|
||||
Path(extensions_dir).mkdir(parents=True, exist_ok=True)
|
||||
sys.path.append(str(extensions_dir))
|
||||
|
||||
upgrades_dir = Path(settings.lnbits_extensions_path, "upgrades")
|
||||
shutil.rmtree(upgrades_dir, True)
|
||||
Path(upgrades_dir).mkdir(parents=True, exist_ok=True)
|
||||
sys.path.append(str(upgrades_dir))
|
||||
|
||||
|
||||
def register_new_ext_routes(app: FastAPI) -> Callable:
|
||||
@@ -364,16 +395,21 @@ def register_new_ratelimiter(app: FastAPI) -> Callable:
|
||||
return register_new_ratelimiter_fn
|
||||
|
||||
|
||||
def register_ext_tasks(ext: Extension) -> None:
|
||||
"""Register extension async tasks."""
|
||||
ext_module = importlib.import_module(ext.module_name)
|
||||
|
||||
if hasattr(ext_module, f"{ext.code}_start"):
|
||||
ext_start_func = getattr(ext_module, f"{ext.code}_start")
|
||||
ext_start_func()
|
||||
|
||||
|
||||
def register_ext_routes(app: FastAPI, ext: Extension) -> None:
|
||||
"""Register FastAPI routes for extension."""
|
||||
ext_module = importlib.import_module(ext.module_name)
|
||||
|
||||
ext_route = getattr(ext_module, f"{ext.code}_ext")
|
||||
|
||||
if hasattr(ext_module, f"{ext.code}_start"):
|
||||
ext_start_func = getattr(ext_module, f"{ext.code}_start")
|
||||
ext_start_func()
|
||||
|
||||
if hasattr(ext_module, f"{ext.code}_static_files"):
|
||||
ext_statics = getattr(ext_module, f"{ext.code}_static_files")
|
||||
for s in ext_statics:
|
||||
@@ -382,43 +418,47 @@ def register_ext_routes(app: FastAPI, ext: Extension) -> None:
|
||||
)
|
||||
app.mount(s["path"], StaticFiles(directory=static_dir), s["name"])
|
||||
|
||||
if hasattr(ext_module, f"{ext.code}_redirect_paths"):
|
||||
ext_redirects = getattr(ext_module, f"{ext.code}_redirect_paths")
|
||||
settings.lnbits_extensions_redirects = [
|
||||
r for r in settings.lnbits_extensions_redirects if r["ext_id"] != ext.code
|
||||
]
|
||||
for r in ext_redirects:
|
||||
r["ext_id"] = ext.code
|
||||
settings.lnbits_extensions_redirects.append(r)
|
||||
ext_redirects = (
|
||||
getattr(ext_module, f"{ext.code}_redirect_paths")
|
||||
if hasattr(ext_module, f"{ext.code}_redirect_paths")
|
||||
else []
|
||||
)
|
||||
|
||||
logger.trace(f"adding route for extension {ext_module}")
|
||||
settings.activate_extension_paths(ext.code, ext.upgrade_hash, ext_redirects)
|
||||
|
||||
logger.trace(f"Adding route for extension {ext_module}.")
|
||||
prefix = f"/upgrades/{ext.upgrade_hash}" if ext.upgrade_hash != "" else ""
|
||||
app.include_router(router=ext_route, prefix=prefix)
|
||||
|
||||
|
||||
def register_all_ext_routes(app: FastAPI):
|
||||
for ext in get_valid_extensions(False):
|
||||
async def check_and_register_extensions(app: FastAPI):
|
||||
await check_installed_extensions(app)
|
||||
for ext in await get_valid_extensions(False):
|
||||
try:
|
||||
register_ext_routes(app, ext)
|
||||
except Exception as e:
|
||||
logger.error(f"Could not load extension `{ext.code}`: {e!s}")
|
||||
register_ext_tasks(ext)
|
||||
except Exception as exc:
|
||||
logger.error(f"Could not load extension `{ext.code}`: {exc!s}")
|
||||
|
||||
|
||||
def register_async_tasks():
|
||||
|
||||
create_permanent_task(wait_for_audit_data)
|
||||
create_permanent_task(check_pending_payments)
|
||||
create_permanent_task(invoice_listener)
|
||||
create_permanent_task(internal_invoice_listener)
|
||||
create_permanent_task(cache.invalidate_forever)
|
||||
|
||||
# core invoice listener
|
||||
invoice_queue = asyncio.Queue(5)
|
||||
invoice_queue: asyncio.Queue = asyncio.Queue()
|
||||
register_invoice_listener(invoice_queue, "core")
|
||||
create_permanent_task(lambda: wait_for_paid_invoices(invoice_queue))
|
||||
|
||||
# TODO: implement watchdog properly
|
||||
# create_permanent_task(watchdog_task)
|
||||
create_permanent_task(killswitch_task)
|
||||
create_permanent_task(purge_audit_data)
|
||||
create_permanent_task(collect_exchange_rates_data)
|
||||
|
||||
# server logs for websocket
|
||||
if settings.lnbits_admin_ui:
|
||||
|
||||
+51
-100
@@ -1,10 +1,10 @@
|
||||
import asyncio
|
||||
import importlib
|
||||
import sys
|
||||
import time
|
||||
from functools import wraps
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Tuple
|
||||
from urllib.parse import urlparse
|
||||
from typing import Optional
|
||||
|
||||
import click
|
||||
import httpx
|
||||
@@ -12,7 +12,27 @@ from fastapi.exceptions import HTTPException
|
||||
from loguru import logger
|
||||
from packaging import version
|
||||
|
||||
from lnbits.core.models import Payment, User
|
||||
from lnbits.core import db as core_db
|
||||
from lnbits.core.crud import (
|
||||
delete_accounts_no_wallets,
|
||||
delete_unused_wallets,
|
||||
delete_wallet_by_id,
|
||||
delete_wallet_payment,
|
||||
get_db_versions,
|
||||
get_installed_extension,
|
||||
get_installed_extensions,
|
||||
get_payment,
|
||||
get_payments,
|
||||
remove_deleted_wallets,
|
||||
update_payment,
|
||||
)
|
||||
from lnbits.core.helpers import is_valid_url, migrate_databases
|
||||
from lnbits.core.models import Payment, PaymentState
|
||||
from lnbits.core.models.extensions import (
|
||||
CreateExtension,
|
||||
ExtensionRelease,
|
||||
InstallableExtension,
|
||||
)
|
||||
from lnbits.core.services import check_admin_settings
|
||||
from lnbits.core.views.extension_api import (
|
||||
api_install_extension,
|
||||
@@ -21,30 +41,6 @@ from lnbits.core.views.extension_api import (
|
||||
from lnbits.settings import settings
|
||||
from lnbits.wallets.base import Wallet
|
||||
|
||||
from .core import db as core_db
|
||||
from .core import migrations as core_migrations
|
||||
from .core.crud import (
|
||||
delete_accounts_no_wallets,
|
||||
delete_unused_wallets,
|
||||
delete_wallet_by_id,
|
||||
delete_wallet_payment,
|
||||
get_dbversions,
|
||||
get_inactive_extensions,
|
||||
get_installed_extension,
|
||||
get_installed_extensions,
|
||||
get_payments,
|
||||
remove_deleted_wallets,
|
||||
update_payment_status,
|
||||
)
|
||||
from .core.helpers import migrate_extension_database, run_migration
|
||||
from .db import COCKROACH, POSTGRES, SQLITE
|
||||
from .extension_manager import (
|
||||
CreateExtension,
|
||||
ExtensionRelease,
|
||||
InstallableExtension,
|
||||
get_valid_extensions,
|
||||
)
|
||||
|
||||
|
||||
def coro(f):
|
||||
@wraps(f)
|
||||
@@ -123,53 +119,12 @@ def database_migrate():
|
||||
loop.run_until_complete(migrate_databases())
|
||||
|
||||
|
||||
async def db_migrate():
|
||||
task = asyncio.create_task(migrate_databases())
|
||||
await task
|
||||
|
||||
|
||||
async def migrate_databases():
|
||||
"""Creates the necessary databases if they don't exist already; or migrates them."""
|
||||
|
||||
async with core_db.connect() as conn:
|
||||
exists = False
|
||||
if conn.type == SQLITE:
|
||||
exists = await conn.fetchone(
|
||||
"SELECT * FROM sqlite_master WHERE type='table' AND name='dbversions'"
|
||||
)
|
||||
elif conn.type in {POSTGRES, COCKROACH}:
|
||||
exists = await conn.fetchone(
|
||||
"SELECT * FROM information_schema.tables WHERE table_schema = 'public'"
|
||||
" AND table_name = 'dbversions'"
|
||||
)
|
||||
|
||||
if not exists:
|
||||
await core_migrations.m000_create_migrations_table(conn)
|
||||
|
||||
current_versions = await get_dbversions(conn)
|
||||
core_version = current_versions.get("core", 0)
|
||||
await run_migration(conn, core_migrations, "core", core_version)
|
||||
|
||||
# here is the first place we can be sure that the
|
||||
# `installed_extensions` table has been created
|
||||
await load_disabled_extension_list()
|
||||
|
||||
for ext in get_valid_extensions(False):
|
||||
current_version = current_versions.get(ext.code, 0)
|
||||
try:
|
||||
await migrate_extension_database(ext, current_version)
|
||||
except Exception as e:
|
||||
logger.exception(f"Error migrating extension {ext.code}: {e}")
|
||||
|
||||
logger.info("✔️ All migrations done.")
|
||||
|
||||
|
||||
@db.command("versions")
|
||||
@coro
|
||||
async def db_versions():
|
||||
"""Show current database versions"""
|
||||
async with core_db.connect() as conn:
|
||||
click.echo(await get_dbversions(conn))
|
||||
click.echo(await get_db_versions(conn))
|
||||
|
||||
|
||||
@db.command("cleanup-wallets")
|
||||
@@ -216,10 +171,13 @@ async def database_delete_wallet_payment(wallet: str, checking_id: str):
|
||||
@db.command("mark-payment-pending")
|
||||
@click.option("-c", "--checking-id", required=True, help="Payment checking Id.")
|
||||
@coro
|
||||
async def database_revert_payment(checking_id: str, pending: bool = True):
|
||||
"""Mark wallet as deleted"""
|
||||
async def database_revert_payment(checking_id: str):
|
||||
"""Mark payment as pending"""
|
||||
async with core_db.connect() as conn:
|
||||
await update_payment_status(pending=pending, checking_id=checking_id, conn=conn)
|
||||
payment = await get_payment(checking_id=checking_id, conn=conn)
|
||||
payment.status = PaymentState.PENDING
|
||||
await update_payment(payment, conn=conn)
|
||||
click.echo(f"Payment '{checking_id}' marked as pending.")
|
||||
|
||||
|
||||
@db.command("cleanup-accounts")
|
||||
@@ -276,7 +234,7 @@ async def check_invalid_payments(
|
||||
click.echo("Funding source: " + str(funding_source))
|
||||
|
||||
# payments that are settled in the DB, but not at the Funding source level
|
||||
invalid_payments: List[Payment] = []
|
||||
invalid_payments: list[Payment] = []
|
||||
invalid_wallets = {}
|
||||
for db_payment in settled_db_payments:
|
||||
if verbose:
|
||||
@@ -313,12 +271,6 @@ async def check_invalid_payments(
|
||||
click.echo(" ".join([w, str(data[0]), str(data[1] / 1000).ljust(10)]))
|
||||
|
||||
|
||||
async def load_disabled_extension_list() -> None:
|
||||
"""Update list of extensions that have been explicitly disabled"""
|
||||
inactive_extensions = await get_inactive_extensions()
|
||||
settings.lnbits_deactivated_extensions += inactive_extensions
|
||||
|
||||
|
||||
@extensions.command("list")
|
||||
@coro
|
||||
async def extensions_list():
|
||||
@@ -328,8 +280,10 @@ async def extensions_list():
|
||||
from lnbits.app import build_all_installed_extensions_list
|
||||
|
||||
for ext in await build_all_installed_extensions_list():
|
||||
assert ext.installed_release, f"Extension {ext.id} has no installed_release"
|
||||
click.echo(f" - {ext.id} ({ext.installed_release.version})")
|
||||
assert (
|
||||
ext.meta and ext.meta.installed_release
|
||||
), f"Extension {ext.id} has no installed_release"
|
||||
click.echo(f" - {ext.id} ({ext.meta.installed_release.version})")
|
||||
|
||||
|
||||
@extensions.command("update")
|
||||
@@ -378,19 +332,24 @@ async def extensions_update(
|
||||
if extension and all_extensions:
|
||||
click.echo("Only one of extension ID or the '--all' flag must be specified")
|
||||
return
|
||||
if url and not _is_url(url):
|
||||
if url and not is_valid_url(url):
|
||||
click.echo(f"Invalid '--url' option value: {url}")
|
||||
return
|
||||
|
||||
if not await _can_run_operation(url):
|
||||
return
|
||||
|
||||
upgrades_dir = Path(settings.lnbits_extensions_path, "upgrades")
|
||||
Path(upgrades_dir).mkdir(parents=True, exist_ok=True)
|
||||
sys.path.append(str(upgrades_dir))
|
||||
|
||||
if extension:
|
||||
await update_extension(extension, repo_index, source_repo, url, admin_user)
|
||||
return
|
||||
|
||||
click.echo("Updating all extensions...")
|
||||
click.echo("Updating all extensions:")
|
||||
installed_extensions = await get_installed_extensions()
|
||||
click.echo(f" {[e.id for e in installed_extensions]}")
|
||||
updated_extensions = []
|
||||
for e in installed_extensions:
|
||||
try:
|
||||
@@ -452,7 +411,7 @@ async def extensions_install(
|
||||
):
|
||||
"""Install a extension"""
|
||||
click.echo(f"Installing {extension}... {repo_index}")
|
||||
if url and not _is_url(url):
|
||||
if url and not is_valid_url(url):
|
||||
click.echo(f"Invalid '--url' option value: {url}")
|
||||
return
|
||||
|
||||
@@ -480,7 +439,7 @@ async def extensions_uninstall(
|
||||
"""Uninstall a extension"""
|
||||
click.echo(f"Uninstalling '{extension}'...")
|
||||
|
||||
if url and not _is_url(url):
|
||||
if url and not is_valid_url(url):
|
||||
click.echo(f"Invalid '--url' option value: {url}")
|
||||
return
|
||||
|
||||
@@ -512,7 +471,7 @@ async def install_extension(
|
||||
source_repo: Optional[str] = None,
|
||||
url: Optional[str] = None,
|
||||
admin_user: Optional[str] = None,
|
||||
) -> Tuple[bool, str]:
|
||||
) -> tuple[bool, str]:
|
||||
try:
|
||||
release = await _select_release(extension, repo_index, source_repo)
|
||||
if not release:
|
||||
@@ -541,7 +500,7 @@ async def update_extension(
|
||||
source_repo: Optional[str] = None,
|
||||
url: Optional[str] = None,
|
||||
admin_user: Optional[str] = None,
|
||||
) -> Tuple[bool, str]:
|
||||
) -> tuple[bool, str]:
|
||||
try:
|
||||
click.echo(f"Updating '{extension}' extension.")
|
||||
installed_ext = await get_installed_extension(extension)
|
||||
@@ -554,7 +513,7 @@ async def update_extension(
|
||||
click.echo(f"Current '{extension}' version: {installed_ext.installed_version}.")
|
||||
|
||||
assert (
|
||||
installed_ext.installed_release
|
||||
installed_ext.meta and installed_ext.meta.installed_release
|
||||
), "Cannot find previously installed release. Please uninstall first."
|
||||
|
||||
release = await _select_release(extension, repo_index, source_repo)
|
||||
@@ -562,7 +521,7 @@ async def update_extension(
|
||||
return False, "No release selected."
|
||||
if (
|
||||
release.version == installed_ext.installed_version
|
||||
and release.source_repo == installed_ext.installed_release.source_repo
|
||||
and release.source_repo == installed_ext.meta.installed_release.source_repo
|
||||
):
|
||||
click.echo(f"Extension '{extension}' already up to date.")
|
||||
return False, "Already up to date"
|
||||
@@ -661,7 +620,7 @@ async def _call_install_extension(
|
||||
)
|
||||
resp.raise_for_status()
|
||||
else:
|
||||
await api_install_extension(data, User(id="mock_id"))
|
||||
await api_install_extension(data)
|
||||
|
||||
|
||||
async def _call_uninstall_extension(
|
||||
@@ -675,7 +634,7 @@ async def _call_uninstall_extension(
|
||||
)
|
||||
resp.raise_for_status()
|
||||
else:
|
||||
await api_uninstall_extension(extension, User(id="mock_id"))
|
||||
await api_uninstall_extension(extension)
|
||||
|
||||
|
||||
async def _can_run_operation(url) -> bool:
|
||||
@@ -694,7 +653,7 @@ async def _can_run_operation(url) -> bool:
|
||||
elif url:
|
||||
click.echo(
|
||||
"The option '--url' has been provided,"
|
||||
+ f" but no server found runnint at '{url}'"
|
||||
f" but no server found running at '{url}'"
|
||||
)
|
||||
return False
|
||||
|
||||
@@ -709,11 +668,3 @@ async def _is_lnbits_started(url: Optional[str]):
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _is_url(url):
|
||||
try:
|
||||
result = urlparse(url)
|
||||
return all([result.scheme, result.netloc])
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
@@ -3,14 +3,14 @@ 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.audit_api import audit_router
|
||||
from .views.auth_api import auth_router
|
||||
from .views.extension_api import extension_router
|
||||
|
||||
# this compat is needed for usermanager extension
|
||||
from .views.generic import generic_router, update_user_extension
|
||||
from .views.generic import generic_router
|
||||
from .views.node_api import node_router, public_node_router, super_node_router
|
||||
from .views.payment_api import payment_router
|
||||
from .views.public_api import public_router
|
||||
from .views.tinyurl_api import tinyurl_router
|
||||
from .views.user_api import users_router
|
||||
from .views.wallet_api import wallet_router
|
||||
@@ -30,7 +30,6 @@ def init_core_routers(app: FastAPI):
|
||||
app.include_router(extension_router)
|
||||
app.include_router(super_node_router)
|
||||
app.include_router(public_node_router)
|
||||
app.include_router(public_router)
|
||||
app.include_router(payment_router)
|
||||
app.include_router(wallet_router)
|
||||
app.include_router(api_router)
|
||||
@@ -38,3 +37,7 @@ def init_core_routers(app: FastAPI):
|
||||
app.include_router(tinyurl_router)
|
||||
app.include_router(webpush_router)
|
||||
app.include_router(users_router)
|
||||
app.include_router(audit_router)
|
||||
|
||||
|
||||
__all__ = ["core_app", "core_app_extra", "db"]
|
||||
|
||||
-1311
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,167 @@
|
||||
from .audit import create_audit_entry
|
||||
from .db_versions import (
|
||||
delete_dbversion,
|
||||
get_db_version,
|
||||
get_db_versions,
|
||||
update_migration_version,
|
||||
)
|
||||
from .extensions import (
|
||||
create_installed_extension,
|
||||
create_user_extension,
|
||||
delete_installed_extension,
|
||||
drop_extension_db,
|
||||
get_installed_extension,
|
||||
get_installed_extensions,
|
||||
get_user_active_extensions_ids,
|
||||
get_user_extension,
|
||||
get_user_extensions,
|
||||
update_installed_extension,
|
||||
update_installed_extension_state,
|
||||
update_user_extension,
|
||||
)
|
||||
from .payments import (
|
||||
DateTrunc,
|
||||
check_internal,
|
||||
create_payment,
|
||||
delete_expired_invoices,
|
||||
delete_wallet_payment,
|
||||
get_latest_payments_by_extension,
|
||||
get_payment,
|
||||
get_payments,
|
||||
get_payments_history,
|
||||
get_payments_paginated,
|
||||
get_standalone_payment,
|
||||
get_wallet_payment,
|
||||
is_internal_status_success,
|
||||
mark_webhook_sent,
|
||||
update_payment,
|
||||
update_payment_checking_id,
|
||||
update_payment_extra,
|
||||
)
|
||||
from .settings import (
|
||||
create_admin_settings,
|
||||
delete_admin_settings,
|
||||
get_admin_settings,
|
||||
get_super_settings,
|
||||
update_admin_settings,
|
||||
update_super_user,
|
||||
)
|
||||
from .tinyurl import create_tinyurl, delete_tinyurl, get_tinyurl, get_tinyurl_by_url
|
||||
from .users import (
|
||||
create_account,
|
||||
delete_account,
|
||||
delete_accounts_no_wallets,
|
||||
get_account,
|
||||
get_account_by_email,
|
||||
get_account_by_pubkey,
|
||||
get_account_by_username,
|
||||
get_account_by_username_or_email,
|
||||
get_accounts,
|
||||
get_user,
|
||||
get_user_from_account,
|
||||
update_account,
|
||||
)
|
||||
from .wallets import (
|
||||
create_wallet,
|
||||
delete_unused_wallets,
|
||||
delete_wallet,
|
||||
delete_wallet_by_id,
|
||||
force_delete_wallet,
|
||||
get_total_balance,
|
||||
get_wallet,
|
||||
get_wallet_for_key,
|
||||
get_wallets,
|
||||
remove_deleted_wallets,
|
||||
update_wallet,
|
||||
)
|
||||
from .webpush import (
|
||||
create_webpush_subscription,
|
||||
delete_webpush_subscription,
|
||||
delete_webpush_subscriptions,
|
||||
get_webpush_subscription,
|
||||
get_webpush_subscriptions_for_user,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# audit
|
||||
"create_audit_entry",
|
||||
# db_versions
|
||||
"get_db_version",
|
||||
"get_db_versions",
|
||||
"update_migration_version",
|
||||
"delete_dbversion",
|
||||
# extensions
|
||||
"create_installed_extension",
|
||||
"create_user_extension",
|
||||
"delete_installed_extension",
|
||||
"drop_extension_db",
|
||||
"get_installed_extension",
|
||||
"get_installed_extensions",
|
||||
"get_user_active_extensions_ids",
|
||||
"get_user_extension",
|
||||
"update_installed_extension",
|
||||
"update_installed_extension_state",
|
||||
"update_user_extension",
|
||||
"get_user_extensions",
|
||||
# payments
|
||||
"DateTrunc",
|
||||
"check_internal",
|
||||
"create_payment",
|
||||
"delete_expired_invoices",
|
||||
"delete_wallet_payment",
|
||||
"get_latest_payments_by_extension",
|
||||
"get_payment",
|
||||
"get_payments",
|
||||
"get_payments_history",
|
||||
"get_payments_paginated",
|
||||
"get_standalone_payment",
|
||||
"get_wallet_payment",
|
||||
"is_internal_status_success",
|
||||
"mark_webhook_sent",
|
||||
"update_payment",
|
||||
"update_payment_checking_id",
|
||||
"update_payment_extra",
|
||||
# settings
|
||||
"create_admin_settings",
|
||||
"delete_admin_settings",
|
||||
"get_admin_settings",
|
||||
"get_super_settings",
|
||||
"update_admin_settings",
|
||||
"update_super_user",
|
||||
# tinyurl
|
||||
"create_tinyurl",
|
||||
"delete_tinyurl",
|
||||
"get_tinyurl",
|
||||
"get_tinyurl_by_url",
|
||||
# users
|
||||
"create_account",
|
||||
"delete_account",
|
||||
"delete_accounts_no_wallets",
|
||||
"get_account",
|
||||
"get_account_by_email",
|
||||
"get_account_by_pubkey",
|
||||
"get_account_by_username",
|
||||
"get_account_by_username_or_email",
|
||||
"get_accounts",
|
||||
"get_user",
|
||||
"get_user_from_account",
|
||||
"update_account",
|
||||
# wallets
|
||||
"create_wallet",
|
||||
"delete_unused_wallets",
|
||||
"delete_wallet",
|
||||
"delete_wallet_by_id",
|
||||
"force_delete_wallet",
|
||||
"get_total_balance",
|
||||
"get_wallet",
|
||||
"get_wallet_for_key",
|
||||
"get_wallets",
|
||||
"remove_deleted_wallets",
|
||||
"update_wallet",
|
||||
# webpush
|
||||
"create_webpush_subscription",
|
||||
"delete_webpush_subscription",
|
||||
"delete_webpush_subscriptions",
|
||||
"get_webpush_subscription",
|
||||
"get_webpush_subscriptions_for_user",
|
||||
]
|
||||
@@ -0,0 +1,84 @@
|
||||
from typing import Optional
|
||||
|
||||
from lnbits.core.db import db
|
||||
from lnbits.core.models import AuditEntry, AuditFilters
|
||||
from lnbits.core.models.audit import AuditCountStat
|
||||
from lnbits.db import Connection, Filters, Page
|
||||
|
||||
|
||||
async def create_audit_entry(
|
||||
entry: AuditEntry,
|
||||
conn: Optional[Connection] = None,
|
||||
) -> None:
|
||||
await (conn or db).insert("audit", entry)
|
||||
|
||||
|
||||
async def get_audit_entries(
|
||||
filters: Optional[Filters[AuditFilters]] = None,
|
||||
conn: Optional[Connection] = None,
|
||||
) -> Page[AuditEntry]:
|
||||
return await (conn or db).fetch_page(
|
||||
"SELECT * from audit",
|
||||
[],
|
||||
{},
|
||||
filters=filters,
|
||||
model=AuditEntry,
|
||||
)
|
||||
|
||||
|
||||
async def delete_expired_audit_entries(
|
||||
conn: Optional[Connection] = None,
|
||||
):
|
||||
await (conn or db).execute(
|
||||
f"""
|
||||
DELETE from audit
|
||||
WHERE delete_at < {db.timestamp_now}
|
||||
""",
|
||||
)
|
||||
|
||||
|
||||
async def get_count_stats(
|
||||
field: str,
|
||||
filters: Optional[Filters[AuditFilters]] = None,
|
||||
conn: Optional[Connection] = None,
|
||||
) -> list[AuditCountStat]:
|
||||
if field not in ["request_method", "component", "response_code"]:
|
||||
return []
|
||||
if not filters:
|
||||
filters = Filters()
|
||||
clause = filters.where()
|
||||
data = await (conn or db).fetchall(
|
||||
query=f"""
|
||||
SELECT {field} as field, count({field}) as total
|
||||
FROM audit
|
||||
{clause}
|
||||
GROUP BY {field}
|
||||
ORDER BY {field}
|
||||
""",
|
||||
values=filters.values(),
|
||||
model=AuditCountStat,
|
||||
)
|
||||
|
||||
return data
|
||||
|
||||
|
||||
async def get_long_duration_stats(
|
||||
filters: Optional[Filters[AuditFilters]] = None,
|
||||
conn: Optional[Connection] = None,
|
||||
) -> list[AuditCountStat]:
|
||||
if not filters:
|
||||
filters = Filters()
|
||||
clause = filters.where()
|
||||
long_duration_paths = await (conn or db).fetchall(
|
||||
query=f"""
|
||||
SELECT path as field, max(duration) as total FROM audit
|
||||
{clause}
|
||||
GROUP BY path
|
||||
ORDER BY total DESC
|
||||
LIMIT 5
|
||||
""",
|
||||
values=filters.values(),
|
||||
model=AuditCountStat,
|
||||
)
|
||||
|
||||
return long_duration_paths
|
||||
@@ -0,0 +1,39 @@
|
||||
from typing import Optional
|
||||
|
||||
from lnbits.core.db import db
|
||||
from lnbits.db import Connection
|
||||
|
||||
from ..models import DbVersion
|
||||
|
||||
|
||||
async def get_db_version(
|
||||
ext_id: str, conn: Optional[Connection] = None
|
||||
) -> Optional[DbVersion]:
|
||||
return await (conn or db).fetchone(
|
||||
"SELECT * FROM dbversions WHERE db = :ext_id",
|
||||
{"ext_id": ext_id},
|
||||
model=DbVersion,
|
||||
)
|
||||
|
||||
|
||||
async def get_db_versions(conn: Optional[Connection] = None) -> list[DbVersion]:
|
||||
return await (conn or db).fetchall("SELECT * FROM dbversions", model=DbVersion)
|
||||
|
||||
|
||||
async def update_migration_version(conn, db_name, version):
|
||||
await (conn or db).execute(
|
||||
"""
|
||||
INSERT INTO dbversions (db, version) VALUES (:db, :version)
|
||||
ON CONFLICT (db) DO UPDATE SET version = :version
|
||||
""",
|
||||
{"db": db_name, "version": version},
|
||||
)
|
||||
|
||||
|
||||
async def delete_dbversion(*, ext_id: str, conn: Optional[Connection] = None) -> None:
|
||||
await (conn or db).execute(
|
||||
"""
|
||||
DELETE FROM dbversions WHERE db = :ext
|
||||
""",
|
||||
{"ext": ext_id},
|
||||
)
|
||||
@@ -0,0 +1,137 @@
|
||||
from typing import Optional
|
||||
|
||||
from lnbits.core.db import db
|
||||
from lnbits.core.models.extensions import (
|
||||
InstallableExtension,
|
||||
UserExtension,
|
||||
)
|
||||
from lnbits.db import Connection, Database
|
||||
|
||||
|
||||
async def create_installed_extension(
|
||||
ext: InstallableExtension,
|
||||
conn: Optional[Connection] = None,
|
||||
) -> None:
|
||||
await (conn or db).insert("installed_extensions", ext)
|
||||
|
||||
|
||||
async def update_installed_extension(
|
||||
ext: InstallableExtension,
|
||||
conn: Optional[Connection] = None,
|
||||
) -> None:
|
||||
await (conn or db).update("installed_extensions", ext)
|
||||
|
||||
|
||||
async def update_installed_extension_state(
|
||||
*, ext_id: str, active: bool, conn: Optional[Connection] = None
|
||||
) -> None:
|
||||
await (conn or db).execute(
|
||||
"""
|
||||
UPDATE installed_extensions SET active = :active WHERE id = :ext
|
||||
""",
|
||||
{"ext": ext_id, "active": active},
|
||||
)
|
||||
|
||||
|
||||
async def delete_installed_extension(
|
||||
*, ext_id: str, conn: Optional[Connection] = None
|
||||
) -> None:
|
||||
await (conn or db).execute(
|
||||
"""
|
||||
DELETE from installed_extensions WHERE id = :ext
|
||||
""",
|
||||
{"ext": ext_id},
|
||||
)
|
||||
|
||||
|
||||
async def drop_extension_db(ext_id: str, conn: Optional[Connection] = None) -> None:
|
||||
row: dict = await (conn or db).fetchone(
|
||||
"SELECT * FROM dbversions WHERE db = :id",
|
||||
{"id": ext_id},
|
||||
)
|
||||
# Check that 'ext_id' is a valid extension id and not a malicious string
|
||||
assert row, f"Extension '{ext_id}' db version cannot be found"
|
||||
|
||||
is_file_based_db = await Database.clean_ext_db_files(ext_id)
|
||||
if is_file_based_db:
|
||||
return
|
||||
|
||||
# String formatting is required, params are not accepted for 'DROP SCHEMA'.
|
||||
# The `ext_id` value is verified above.
|
||||
await (conn or db).execute(
|
||||
f"DROP SCHEMA IF EXISTS {ext_id} CASCADE",
|
||||
)
|
||||
|
||||
|
||||
async def get_installed_extension(
|
||||
ext_id: str, conn: Optional[Connection] = None
|
||||
) -> Optional[InstallableExtension]:
|
||||
extension = await (conn or db).fetchone(
|
||||
"SELECT * FROM installed_extensions WHERE id = :id",
|
||||
{"id": ext_id},
|
||||
InstallableExtension,
|
||||
)
|
||||
return extension
|
||||
|
||||
|
||||
async def get_installed_extensions(
|
||||
active: Optional[bool] = None,
|
||||
conn: Optional[Connection] = None,
|
||||
) -> list[InstallableExtension]:
|
||||
where = "WHERE active = :active" if active is not None else ""
|
||||
values = {"active": active} if active is not None else {}
|
||||
all_extensions = await (conn or db).fetchall(
|
||||
f"SELECT * FROM installed_extensions {where}",
|
||||
values,
|
||||
model=InstallableExtension,
|
||||
)
|
||||
return all_extensions
|
||||
|
||||
|
||||
async def get_user_extension(
|
||||
user_id: str, extension: str, conn: Optional[Connection] = None
|
||||
) -> Optional[UserExtension]:
|
||||
return await (conn or db).fetchone(
|
||||
"""
|
||||
SELECT * FROM extensions
|
||||
WHERE "user" = :user AND extension = :ext
|
||||
""",
|
||||
{"user": user_id, "ext": extension},
|
||||
model=UserExtension,
|
||||
)
|
||||
|
||||
|
||||
async def get_user_extensions(
|
||||
user_id: str, conn: Optional[Connection] = None
|
||||
) -> list[UserExtension]:
|
||||
return await (conn or db).fetchall(
|
||||
"""SELECT * FROM extensions WHERE "user" = :user""",
|
||||
{"user": user_id},
|
||||
model=UserExtension,
|
||||
)
|
||||
|
||||
|
||||
async def create_user_extension(
|
||||
user_extension: UserExtension, conn: Optional[Connection] = None
|
||||
) -> None:
|
||||
await (conn or db).insert("extensions", user_extension)
|
||||
|
||||
|
||||
async def update_user_extension(
|
||||
user_extension: UserExtension, conn: Optional[Connection] = None
|
||||
) -> None:
|
||||
where = """WHERE extension = :extension AND "user" = :user"""
|
||||
await (conn or db).update("extensions", user_extension, where)
|
||||
|
||||
|
||||
async def get_user_active_extensions_ids(
|
||||
user_id: str, conn: Optional[Connection] = None
|
||||
) -> list[str]:
|
||||
exts = await (conn or db).fetchall(
|
||||
"""
|
||||
SELECT * FROM extensions WHERE "user" = :user AND active
|
||||
""",
|
||||
{"user": user_id},
|
||||
UserExtension,
|
||||
)
|
||||
return [ext.extension for ext in exts]
|
||||
@@ -0,0 +1,396 @@
|
||||
from time import time
|
||||
from typing import Literal, Optional
|
||||
|
||||
from lnbits.core.crud.wallets import get_total_balance, get_wallet
|
||||
from lnbits.core.db import db
|
||||
from lnbits.core.models import PaymentState
|
||||
from lnbits.db import DB_TYPE, SQLITE, Connection, Filters, Page
|
||||
|
||||
from ..models import (
|
||||
CreatePayment,
|
||||
Payment,
|
||||
PaymentFilters,
|
||||
PaymentHistoryPoint,
|
||||
)
|
||||
|
||||
DateTrunc = Literal["hour", "day", "month"]
|
||||
sqlite_formats = {
|
||||
"hour": "%Y-%m-%d %H:00:00",
|
||||
"day": "%Y-%m-%d 00:00:00",
|
||||
"month": "%Y-%m-01 00:00:00",
|
||||
}
|
||||
|
||||
|
||||
def update_payment_extra():
|
||||
pass
|
||||
|
||||
|
||||
async def get_payment(checking_id: str, conn: Optional[Connection] = None) -> Payment:
|
||||
return await (conn or db).fetchone(
|
||||
"SELECT * FROM apipayments WHERE checking_id = :checking_id",
|
||||
{"checking_id": checking_id},
|
||||
Payment,
|
||||
)
|
||||
|
||||
|
||||
async def get_standalone_payment(
|
||||
checking_id_or_hash: str,
|
||||
conn: Optional[Connection] = None,
|
||||
incoming: Optional[bool] = False,
|
||||
wallet_id: Optional[str] = None,
|
||||
) -> Optional[Payment]:
|
||||
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,
|
||||
}
|
||||
if incoming:
|
||||
clause = f"({clause}) AND amount > 0"
|
||||
|
||||
if wallet_id:
|
||||
clause = f"({clause}) AND wallet_id = :wallet_id"
|
||||
|
||||
row = await (conn or db).fetchone(
|
||||
f"""
|
||||
SELECT * FROM apipayments
|
||||
WHERE {clause}
|
||||
ORDER BY amount LIMIT 1
|
||||
""",
|
||||
values,
|
||||
Payment,
|
||||
)
|
||||
return row
|
||||
|
||||
|
||||
async def get_wallet_payment(
|
||||
wallet_id: str, payment_hash: str, conn: Optional[Connection] = None
|
||||
) -> Optional[Payment]:
|
||||
payment = await (conn or db).fetchone(
|
||||
"""
|
||||
SELECT *
|
||||
FROM apipayments
|
||||
WHERE wallet_id = :wallet AND payment_hash = :hash
|
||||
""",
|
||||
{"wallet": wallet_id, "hash": payment_hash},
|
||||
Payment,
|
||||
)
|
||||
return payment
|
||||
|
||||
|
||||
async def get_latest_payments_by_extension(
|
||||
ext_name: str, ext_id: str, limit: int = 5
|
||||
) -> list[Payment]:
|
||||
return await db.fetchall(
|
||||
f"""
|
||||
SELECT * FROM apipayments
|
||||
WHERE status = '{PaymentState.SUCCESS}'
|
||||
AND extra LIKE :ext_name
|
||||
AND extra LIKE :ext_id
|
||||
ORDER BY time DESC LIMIT {limit}
|
||||
""",
|
||||
{"ext_name": f"%{ext_name}%", "ext_id": f"%{ext_id}%"},
|
||||
Payment,
|
||||
)
|
||||
|
||||
|
||||
async def get_payments_paginated(
|
||||
*,
|
||||
wallet_id: Optional[str] = None,
|
||||
complete: bool = False,
|
||||
pending: bool = False,
|
||||
outgoing: bool = False,
|
||||
incoming: bool = False,
|
||||
since: Optional[int] = None,
|
||||
exclude_uncheckable: bool = False,
|
||||
filters: Optional[Filters[PaymentFilters]] = None,
|
||||
conn: Optional[Connection] = None,
|
||||
) -> Page[Payment]:
|
||||
"""
|
||||
Filters payments to be returned by complete | pending | outgoing | incoming.
|
||||
"""
|
||||
|
||||
values: dict = {
|
||||
"wallet_id": wallet_id,
|
||||
"time": since,
|
||||
}
|
||||
clause: list[str] = []
|
||||
|
||||
if since is not None:
|
||||
clause.append(f"time > {db.timestamp_placeholder('time')}")
|
||||
|
||||
if wallet_id:
|
||||
clause.append("wallet_id = :wallet_id")
|
||||
|
||||
if complete and pending:
|
||||
clause.append(
|
||||
f"(status = '{PaymentState.SUCCESS}' OR status = '{PaymentState.PENDING}')"
|
||||
)
|
||||
elif complete:
|
||||
clause.append(
|
||||
f"""
|
||||
(
|
||||
status = '{PaymentState.SUCCESS}'
|
||||
OR (amount < 0 AND status = '{PaymentState.PENDING}')
|
||||
)
|
||||
"""
|
||||
)
|
||||
elif pending:
|
||||
clause.append(f"status = '{PaymentState.PENDING}'")
|
||||
|
||||
if outgoing and incoming:
|
||||
pass
|
||||
elif outgoing:
|
||||
clause.append("amount < 0")
|
||||
elif incoming:
|
||||
clause.append("amount > 0")
|
||||
else:
|
||||
pass
|
||||
|
||||
if exclude_uncheckable: # checkable means it has a checking_id that isn't internal
|
||||
clause.append("checking_id NOT LIKE 'temp_%'")
|
||||
clause.append("checking_id NOT LIKE 'internal_%'")
|
||||
|
||||
return await (conn or db).fetch_page(
|
||||
"SELECT * FROM apipayments",
|
||||
clause,
|
||||
values,
|
||||
filters=filters,
|
||||
model=Payment,
|
||||
)
|
||||
|
||||
|
||||
async def get_payments(
|
||||
*,
|
||||
wallet_id: Optional[str] = None,
|
||||
complete: bool = False,
|
||||
pending: bool = False,
|
||||
outgoing: bool = False,
|
||||
incoming: bool = False,
|
||||
since: Optional[int] = None,
|
||||
exclude_uncheckable: bool = False,
|
||||
filters: Optional[Filters[PaymentFilters]] = None,
|
||||
conn: Optional[Connection] = None,
|
||||
limit: Optional[int] = None,
|
||||
offset: Optional[int] = None,
|
||||
) -> list[Payment]:
|
||||
"""
|
||||
Filters payments to be returned by complete | pending | outgoing | incoming.
|
||||
"""
|
||||
|
||||
filters = filters or Filters()
|
||||
|
||||
filters.sortby = filters.sortby or "time"
|
||||
filters.direction = filters.direction or "desc"
|
||||
filters.limit = limit or filters.limit
|
||||
filters.offset = offset or filters.offset
|
||||
|
||||
page = await get_payments_paginated(
|
||||
wallet_id=wallet_id,
|
||||
complete=complete,
|
||||
pending=pending,
|
||||
outgoing=outgoing,
|
||||
incoming=incoming,
|
||||
since=since,
|
||||
exclude_uncheckable=exclude_uncheckable,
|
||||
filters=filters,
|
||||
conn=conn,
|
||||
)
|
||||
|
||||
return page.data
|
||||
|
||||
|
||||
async def delete_expired_invoices(
|
||||
conn: Optional[Connection] = None,
|
||||
) -> None:
|
||||
# first we delete all invoices older than one month
|
||||
|
||||
await (conn or db).execute(
|
||||
f"""
|
||||
DELETE FROM apipayments
|
||||
WHERE status = '{PaymentState.PENDING}' AND amount > 0
|
||||
AND time < {db.timestamp_placeholder("delta")}
|
||||
""",
|
||||
{"delta": int(time() - 2592000)},
|
||||
)
|
||||
# then we delete all invoices whose expiry date is in the past
|
||||
await (conn or db).execute(
|
||||
f"""
|
||||
DELETE FROM apipayments
|
||||
WHERE status = '{PaymentState.PENDING}' AND amount > 0
|
||||
AND expiry < {db.timestamp_placeholder("now")}
|
||||
""",
|
||||
{"now": int(time())},
|
||||
)
|
||||
|
||||
|
||||
async def create_payment(
|
||||
checking_id: str,
|
||||
data: CreatePayment,
|
||||
status: PaymentState = PaymentState.PENDING,
|
||||
conn: Optional[Connection] = None,
|
||||
) -> Payment:
|
||||
# we don't allow the creation of the same invoice twice
|
||||
# note: this can be removed if the db uniqueness constraints are set appropriately
|
||||
previous_payment = await get_standalone_payment(checking_id, conn=conn)
|
||||
assert previous_payment is None, "Payment already exists"
|
||||
|
||||
payment = Payment(
|
||||
checking_id=checking_id,
|
||||
status=status,
|
||||
wallet_id=data.wallet_id,
|
||||
payment_hash=data.payment_hash,
|
||||
bolt11=data.bolt11,
|
||||
amount=data.amount_msat,
|
||||
memo=data.memo,
|
||||
preimage=data.preimage,
|
||||
expiry=data.expiry,
|
||||
webhook=data.webhook,
|
||||
fee=data.fee,
|
||||
extra=data.extra or {},
|
||||
)
|
||||
|
||||
await (conn or db).insert("apipayments", payment)
|
||||
|
||||
return payment
|
||||
|
||||
|
||||
async def update_payment_checking_id(
|
||||
checking_id: str, new_checking_id: str, conn: Optional[Connection] = 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},
|
||||
)
|
||||
|
||||
|
||||
async def update_payment(
|
||||
payment: Payment,
|
||||
new_checking_id: Optional[str] = None,
|
||||
conn: Optional[Connection] = None,
|
||||
) -> None:
|
||||
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)
|
||||
|
||||
|
||||
async def get_payments_history(
|
||||
wallet_id: Optional[str] = None,
|
||||
group: DateTrunc = "day",
|
||||
filters: Optional[Filters] = None,
|
||||
) -> list[PaymentHistoryPoint]:
|
||||
if not filters:
|
||||
filters = Filters()
|
||||
|
||||
if DB_TYPE == SQLITE and group in sqlite_formats:
|
||||
date_trunc = f"strftime('{sqlite_formats[group]}', time, 'unixepoch')"
|
||||
elif group in ("day", "hour", "month"):
|
||||
date_trunc = f"date_trunc('{group}', time)"
|
||||
else:
|
||||
raise ValueError(f"Invalid group value: {group}")
|
||||
|
||||
values = {
|
||||
"wallet_id": wallet_id,
|
||||
}
|
||||
# count outgoing payments if they are still pending
|
||||
where = [
|
||||
f"""
|
||||
wallet_id = :wallet_id AND (
|
||||
status = '{PaymentState.SUCCESS}'
|
||||
OR (amount < 0 AND status = '{PaymentState.PENDING}')
|
||||
)
|
||||
"""
|
||||
]
|
||||
transactions: list[dict] = await db.fetchall(
|
||||
f"""
|
||||
SELECT {date_trunc} date,
|
||||
SUM(CASE WHEN amount > 0 THEN amount ELSE 0 END) income,
|
||||
SUM(CASE WHEN amount < 0 THEN abs(amount) + abs(fee) ELSE 0 END) spending
|
||||
FROM apipayments
|
||||
{filters.where(where)}
|
||||
GROUP BY date
|
||||
ORDER BY date DESC
|
||||
""",
|
||||
filters.values(values),
|
||||
)
|
||||
if wallet_id:
|
||||
wallet = await get_wallet(wallet_id)
|
||||
if wallet:
|
||||
balance = wallet.balance_msat
|
||||
else:
|
||||
raise ValueError("Unknown wallet")
|
||||
else:
|
||||
balance = await get_total_balance()
|
||||
|
||||
# since we dont know the balance at the starting point,
|
||||
# we take the current balance and walk backwards
|
||||
results: list[PaymentHistoryPoint] = []
|
||||
for row in transactions:
|
||||
results.insert(
|
||||
0,
|
||||
PaymentHistoryPoint(
|
||||
balance=balance,
|
||||
date=row.get("date", 0),
|
||||
income=row.get("income", 0),
|
||||
spending=row.get("spending", 0),
|
||||
),
|
||||
)
|
||||
balance -= row.get("income", 0) - row.get("spending", 0)
|
||||
return results
|
||||
|
||||
|
||||
async def delete_wallet_payment(
|
||||
checking_id: str, wallet_id: str, conn: Optional[Connection] = None
|
||||
) -> None:
|
||||
await (conn or db).execute(
|
||||
"DELETE FROM apipayments WHERE checking_id = :checking_id AND wallet = :wallet",
|
||||
{"checking_id": checking_id, "wallet": wallet_id},
|
||||
)
|
||||
|
||||
|
||||
async def check_internal(
|
||||
payment_hash: str, conn: Optional[Connection] = None
|
||||
) -> Optional[Payment]:
|
||||
"""
|
||||
Returns the checking_id of the internal payment if it exists,
|
||||
otherwise None
|
||||
"""
|
||||
return await (conn or db).fetchone(
|
||||
f"""
|
||||
SELECT * FROM apipayments
|
||||
WHERE payment_hash = :hash AND status = '{PaymentState.PENDING}' AND amount > 0
|
||||
""",
|
||||
{"hash": payment_hash},
|
||||
Payment,
|
||||
)
|
||||
|
||||
|
||||
async def is_internal_status_success(
|
||||
payment_hash: str, conn: Optional[Connection] = None
|
||||
) -> bool:
|
||||
"""
|
||||
Returns True if the internal payment was found and is successful,
|
||||
"""
|
||||
payment = await (conn or db).fetchone(
|
||||
"""
|
||||
SELECT * FROM apipayments
|
||||
WHERE payment_hash = :payment_hash AND amount > 0
|
||||
""",
|
||||
{"payment_hash": payment_hash},
|
||||
Payment,
|
||||
)
|
||||
if not payment:
|
||||
return False
|
||||
return payment.status == PaymentState.SUCCESS.value
|
||||
|
||||
|
||||
async def mark_webhook_sent(payment_hash: str, status: int) -> None:
|
||||
await db.execute(
|
||||
"""
|
||||
UPDATE apipayments SET webhook_status = :status
|
||||
WHERE payment_hash = :hash
|
||||
""",
|
||||
{"status": status, "hash": payment_hash},
|
||||
)
|
||||
@@ -0,0 +1,122 @@
|
||||
import json
|
||||
from typing import Any, Optional
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.core.db import db
|
||||
from lnbits.db import dict_to_model
|
||||
from lnbits.settings import (
|
||||
AdminSettings,
|
||||
EditableSettings,
|
||||
SettingsField,
|
||||
SuperSettings,
|
||||
settings,
|
||||
)
|
||||
|
||||
|
||||
async def get_super_settings() -> Optional[SuperSettings]:
|
||||
data = await get_settings_by_tag("core")
|
||||
if data:
|
||||
super_user = await get_settings_field("super_user")
|
||||
super_user_id = super_user.value if super_user else None
|
||||
settings_dict = {"super_user": super_user_id, **data}
|
||||
return dict_to_model(settings_dict, SuperSettings)
|
||||
return None
|
||||
|
||||
|
||||
async def get_admin_settings(is_super_user: bool = False) -> Optional[AdminSettings]:
|
||||
sets = await get_super_settings()
|
||||
if not sets:
|
||||
return None
|
||||
row_dict = dict(sets)
|
||||
row_dict.pop("super_user")
|
||||
row_dict.pop("auth_all_methods")
|
||||
|
||||
admin_settings = AdminSettings(
|
||||
is_super_user=is_super_user,
|
||||
lnbits_allowed_funding_sources=settings.lnbits_allowed_funding_sources,
|
||||
**row_dict,
|
||||
)
|
||||
return admin_settings
|
||||
|
||||
|
||||
async def update_admin_settings(
|
||||
data: EditableSettings, tag: Optional[str] = "core"
|
||||
) -> None:
|
||||
editable_settings = await get_settings_by_tag("core") or {}
|
||||
editable_settings.update(data.dict(exclude_unset=True))
|
||||
for key, value in editable_settings.items():
|
||||
try:
|
||||
await set_settings_field(key, value, tag)
|
||||
except Exception as exc:
|
||||
logger.warning(exc)
|
||||
logger.warning(f"Failed to update settings for '{tag}.{key}'.")
|
||||
|
||||
|
||||
async def update_super_user(super_user: str) -> SuperSettings:
|
||||
await set_settings_field("super_user", super_user)
|
||||
settings = await get_super_settings()
|
||||
assert settings, "updated super_user settings could not be retrieved"
|
||||
return settings
|
||||
|
||||
|
||||
async def delete_admin_settings(tag: Optional[str] = "core") -> None:
|
||||
await db.execute("DELETE FROM settings WHERE tag = :tag", {"tag": tag})
|
||||
|
||||
|
||||
async def create_admin_settings(super_user: str, new_settings: dict) -> SuperSettings:
|
||||
data = {"super_user": super_user, **new_settings}
|
||||
for key, value in data.items():
|
||||
await set_settings_field(key, value)
|
||||
|
||||
settings = await get_super_settings()
|
||||
assert settings, "created admin settings could not be retrieved"
|
||||
return settings
|
||||
|
||||
|
||||
async def get_settings_field(
|
||||
id_: str, tag: Optional[str] = "core"
|
||||
) -> Optional[SettingsField]:
|
||||
|
||||
row: dict = await db.fetchone(
|
||||
"""
|
||||
SELECT * FROM system_settings
|
||||
WHERE id = :id AND tag = :tag
|
||||
""",
|
||||
{"id": id_, "tag": tag},
|
||||
)
|
||||
if not row:
|
||||
return None
|
||||
return SettingsField(id=row["id"], value=json.loads(row["value"]), tag=row["tag"])
|
||||
|
||||
|
||||
async def set_settings_field(
|
||||
id_: str, value: Optional[Any], tag: Optional[str] = "core"
|
||||
):
|
||||
value = json.dumps(value) if value is not None else None
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO system_settings (id, value, tag)
|
||||
VALUES (:id, :value, :tag)
|
||||
ON CONFLICT (id, tag) DO UPDATE SET value = :value
|
||||
""",
|
||||
{"id": id_, "value": value, "tag": tag or "core"},
|
||||
)
|
||||
|
||||
|
||||
async def get_settings_by_tag(tag: str) -> Optional[dict[str, Any]]:
|
||||
rows: list[dict] = await db.fetchall(
|
||||
"SELECT * FROM system_settings WHERE tag = :tag", {"tag": tag}
|
||||
)
|
||||
if len(rows) == 0:
|
||||
return None
|
||||
data: dict[str, Any] = {}
|
||||
for row in rows:
|
||||
try:
|
||||
data[row["id"]] = json.loads(row["value"]) if row["value"] else None
|
||||
except Exception as _:
|
||||
logger.warning(
|
||||
f"""Failed to load settings value for '{tag}.{row["id"]}'."""
|
||||
)
|
||||
data.pop("super_user")
|
||||
return data
|
||||
@@ -0,0 +1,42 @@
|
||||
from typing import Optional
|
||||
|
||||
import shortuuid
|
||||
|
||||
from lnbits.core.db import db
|
||||
|
||||
from ..models import TinyURL
|
||||
|
||||
|
||||
async def create_tinyurl(domain: str, endless: bool, wallet: str):
|
||||
tinyurl_id = shortuuid.uuid()[:8]
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO tiny_url (id, url, endless, wallet)
|
||||
VALUES (:tinyurl, :domain, :endless, :wallet)
|
||||
""",
|
||||
{"tinyurl": tinyurl_id, "domain": domain, "endless": endless, "wallet": wallet},
|
||||
)
|
||||
return await get_tinyurl(tinyurl_id)
|
||||
|
||||
|
||||
async def get_tinyurl(tinyurl_id: str) -> Optional[TinyURL]:
|
||||
return await db.fetchone(
|
||||
"SELECT * FROM tiny_url WHERE id = :tinyurl",
|
||||
{"tinyurl": tinyurl_id},
|
||||
TinyURL,
|
||||
)
|
||||
|
||||
|
||||
async def get_tinyurl_by_url(url: str) -> list[TinyURL]:
|
||||
return await db.fetchall(
|
||||
"SELECT * FROM tiny_url WHERE url = :url",
|
||||
{"url": url},
|
||||
TinyURL,
|
||||
)
|
||||
|
||||
|
||||
async def delete_tinyurl(tinyurl_id: str):
|
||||
await db.execute(
|
||||
"DELETE FROM tiny_url WHERE id = :tinyurl",
|
||||
{"tinyurl": tinyurl_id},
|
||||
)
|
||||
@@ -0,0 +1,187 @@
|
||||
from datetime import datetime, timezone
|
||||
from time import time
|
||||
from typing import Any, Optional
|
||||
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.db import db
|
||||
from lnbits.db import Connection, Filters, Page
|
||||
|
||||
from ..models import (
|
||||
Account,
|
||||
AccountFilters,
|
||||
AccountOverview,
|
||||
User,
|
||||
)
|
||||
|
||||
|
||||
async def create_account(
|
||||
account: Optional[Account] = None,
|
||||
conn: Optional[Connection] = None,
|
||||
) -> Account:
|
||||
if account:
|
||||
account.validate_fields()
|
||||
else:
|
||||
now = datetime.now(timezone.utc)
|
||||
account = Account(id=uuid4().hex, created_at=now, updated_at=now)
|
||||
await (conn or db).insert("accounts", account)
|
||||
return account
|
||||
|
||||
|
||||
async def update_account(account: Account) -> Account:
|
||||
account.updated_at = datetime.now(timezone.utc)
|
||||
await db.update("accounts", account)
|
||||
return account
|
||||
|
||||
|
||||
async def delete_account(user_id: str, conn: Optional[Connection] = None) -> None:
|
||||
await (conn or db).execute(
|
||||
"DELETE from accounts WHERE id = :user",
|
||||
{"user": user_id},
|
||||
)
|
||||
|
||||
|
||||
async def get_accounts(
|
||||
filters: Optional[Filters[AccountFilters]] = None,
|
||||
conn: Optional[Connection] = None,
|
||||
) -> Page[AccountOverview]:
|
||||
where_clauses = []
|
||||
values: dict[str, Any] = {}
|
||||
|
||||
# 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"]
|
||||
|
||||
return await (conn or db).fetch_page(
|
||||
"""
|
||||
SELECT
|
||||
accounts.id,
|
||||
accounts.username,
|
||||
accounts.email,
|
||||
accounts.pubkey,
|
||||
SUM(COALESCE((
|
||||
SELECT balance FROM balances WHERE wallet_id = wallets.id
|
||||
), 0)) as balance_msat,
|
||||
SUM((
|
||||
SELECT COUNT(*) FROM apipayments WHERE wallet_id = wallets.id
|
||||
)) as transaction_count,
|
||||
(
|
||||
SELECT COUNT(*) FROM wallets WHERE wallets.user = accounts.id
|
||||
) as wallet_count,
|
||||
MAX((
|
||||
SELECT time FROM apipayments
|
||||
WHERE wallet_id = wallets.id ORDER BY time DESC LIMIT 1
|
||||
)) as last_payment
|
||||
FROM accounts LEFT JOIN wallets ON accounts.id = wallets.user
|
||||
""",
|
||||
where_clauses,
|
||||
values,
|
||||
filters=filters,
|
||||
model=AccountOverview,
|
||||
group_by=["accounts.id"],
|
||||
)
|
||||
|
||||
|
||||
async def get_account(
|
||||
user_id: str, conn: Optional[Connection] = None
|
||||
) -> Optional[Account]:
|
||||
return await (conn or db).fetchone(
|
||||
"SELECT * FROM accounts WHERE id = :id",
|
||||
{"id": user_id},
|
||||
Account,
|
||||
)
|
||||
|
||||
|
||||
async def delete_accounts_no_wallets(
|
||||
time_delta: int,
|
||||
conn: Optional[Connection] = None,
|
||||
) -> None:
|
||||
delta = int(time()) - time_delta
|
||||
await (conn or db).execute(
|
||||
f"""
|
||||
DELETE FROM accounts
|
||||
WHERE NOT EXISTS (
|
||||
SELECT wallets.id FROM wallets WHERE wallets.user = accounts.id
|
||||
) AND (
|
||||
(updated_at is null AND created_at < :delta)
|
||||
OR updated_at < {db.timestamp_placeholder("delta")}
|
||||
)
|
||||
""",
|
||||
{"delta": delta},
|
||||
)
|
||||
|
||||
|
||||
async def get_account_by_username(
|
||||
username: str, conn: Optional[Connection] = None
|
||||
) -> Optional[Account]:
|
||||
return await (conn or db).fetchone(
|
||||
"SELECT * FROM accounts WHERE username = :username",
|
||||
{"username": username},
|
||||
Account,
|
||||
)
|
||||
|
||||
|
||||
async def get_account_by_pubkey(
|
||||
pubkey: str, conn: Optional[Connection] = None
|
||||
) -> Optional[Account]:
|
||||
return await (conn or db).fetchone(
|
||||
"SELECT * FROM accounts WHERE pubkey = :pubkey",
|
||||
{"pubkey": pubkey},
|
||||
Account,
|
||||
)
|
||||
|
||||
|
||||
async def get_account_by_email(
|
||||
email: str, conn: Optional[Connection] = None
|
||||
) -> Optional[Account]:
|
||||
return await (conn or db).fetchone(
|
||||
"SELECT * FROM accounts WHERE email = :email",
|
||||
{"email": email},
|
||||
Account,
|
||||
)
|
||||
|
||||
|
||||
async def get_account_by_username_or_email(
|
||||
username_or_email: str, conn: Optional[Connection] = None
|
||||
) -> Optional[Account]:
|
||||
return await (conn or db).fetchone(
|
||||
"SELECT * FROM accounts WHERE email = :value or username = :value",
|
||||
{"value": username_or_email},
|
||||
Account,
|
||||
)
|
||||
|
||||
|
||||
async def get_user(user_id: str, conn: Optional[Connection] = None) -> Optional[User]:
|
||||
account = await get_account(user_id, conn)
|
||||
if not account:
|
||||
return None
|
||||
return await get_user_from_account(account, conn)
|
||||
|
||||
|
||||
async def get_user_from_account(
|
||||
account: Account, conn: Optional[Connection] = None
|
||||
) -> Optional[User]:
|
||||
extensions = await get_user_active_extensions_ids(account.id, conn)
|
||||
wallets = await get_wallets(account.id, False, conn=conn)
|
||||
return User(
|
||||
id=account.id,
|
||||
email=account.email,
|
||||
username=account.username,
|
||||
pubkey=account.pubkey,
|
||||
extra=account.extra,
|
||||
created_at=account.created_at,
|
||||
updated_at=account.updated_at,
|
||||
extensions=extensions,
|
||||
wallets=wallets,
|
||||
admin=account.is_admin,
|
||||
super_user=account.is_super_user,
|
||||
has_password=account.password_hash is not None,
|
||||
)
|
||||
@@ -0,0 +1,158 @@
|
||||
from datetime import datetime, timezone
|
||||
from time import time
|
||||
from typing import Optional
|
||||
from uuid import uuid4
|
||||
|
||||
from lnbits.core.db import db
|
||||
from lnbits.db import Connection
|
||||
from lnbits.settings import settings
|
||||
|
||||
from ..models import Wallet
|
||||
|
||||
|
||||
async def create_wallet(
|
||||
*,
|
||||
user_id: str,
|
||||
wallet_name: Optional[str] = None,
|
||||
conn: Optional[Connection] = None,
|
||||
) -> Wallet:
|
||||
wallet_id = uuid4().hex
|
||||
wallet = Wallet(
|
||||
id=wallet_id,
|
||||
name=wallet_name or settings.lnbits_default_wallet_name,
|
||||
user=user_id,
|
||||
adminkey=uuid4().hex,
|
||||
inkey=uuid4().hex,
|
||||
currency="USD",
|
||||
)
|
||||
await (conn or db).insert("wallets", wallet)
|
||||
return wallet
|
||||
|
||||
|
||||
async def update_wallet(
|
||||
wallet: Wallet,
|
||||
conn: Optional[Connection] = None,
|
||||
) -> Optional[Wallet]:
|
||||
wallet.updated_at = datetime.now(timezone.utc)
|
||||
await (conn or db).update("wallets", wallet)
|
||||
return wallet
|
||||
|
||||
|
||||
async def delete_wallet(
|
||||
*,
|
||||
user_id: str,
|
||||
wallet_id: str,
|
||||
deleted: bool = True,
|
||||
conn: Optional[Connection] = None,
|
||||
) -> None:
|
||||
now = int(time())
|
||||
await (conn or db).execute(
|
||||
f"""
|
||||
UPDATE wallets
|
||||
SET deleted = :deleted, updated_at = {db.timestamp_placeholder('now')}
|
||||
WHERE id = :wallet AND "user" = :user
|
||||
""",
|
||||
{"wallet": wallet_id, "user": user_id, "deleted": deleted, "now": now},
|
||||
)
|
||||
|
||||
|
||||
async def force_delete_wallet(
|
||||
wallet_id: str, conn: Optional[Connection] = None
|
||||
) -> None:
|
||||
await (conn or db).execute(
|
||||
"DELETE FROM wallets WHERE id = :wallet",
|
||||
{"wallet": wallet_id},
|
||||
)
|
||||
|
||||
|
||||
async def delete_wallet_by_id(
|
||||
wallet_id: str, conn: Optional[Connection] = None
|
||||
) -> Optional[int]:
|
||||
now = int(time())
|
||||
result = await (conn or db).execute(
|
||||
f"""
|
||||
UPDATE wallets
|
||||
SET deleted = true, updated_at = {db.timestamp_placeholder('now')}
|
||||
WHERE id = :wallet
|
||||
""",
|
||||
{"wallet": wallet_id, "now": now},
|
||||
)
|
||||
return result.rowcount
|
||||
|
||||
|
||||
async def remove_deleted_wallets(conn: Optional[Connection] = None) -> None:
|
||||
await (conn or db).execute("DELETE FROM wallets WHERE deleted = true")
|
||||
|
||||
|
||||
async def delete_unused_wallets(
|
||||
time_delta: int,
|
||||
conn: Optional[Connection] = None,
|
||||
) -> None:
|
||||
delta = int(time()) - time_delta
|
||||
await (conn or db).execute(
|
||||
"""
|
||||
DELETE FROM wallets
|
||||
WHERE (
|
||||
SELECT COUNT(*) FROM apipayments WHERE wallet_id = wallets.id
|
||||
) = 0 AND (
|
||||
(updated_at is null AND created_at < :delta)
|
||||
OR updated_at < :delta
|
||||
)
|
||||
""",
|
||||
{"delta": delta},
|
||||
)
|
||||
|
||||
|
||||
async def get_wallet(
|
||||
wallet_id: str, deleted: Optional[bool] = None, conn: Optional[Connection] = None
|
||||
) -> Optional[Wallet]:
|
||||
where = "AND deleted = :deleted" if deleted is not None else ""
|
||||
return await (conn or db).fetchone(
|
||||
f"""
|
||||
SELECT *, COALESCE((
|
||||
SELECT balance FROM balances WHERE wallet_id = wallets.id
|
||||
), 0) AS balance_msat FROM wallets
|
||||
WHERE id = :wallet {where}
|
||||
""",
|
||||
{"wallet": wallet_id, "deleted": deleted},
|
||||
Wallet,
|
||||
)
|
||||
|
||||
|
||||
async def get_wallets(
|
||||
user_id: str, deleted: Optional[bool] = None, conn: Optional[Connection] = None
|
||||
) -> list[Wallet]:
|
||||
where = "AND deleted = :deleted" if deleted is not None else ""
|
||||
return await (conn or db).fetchall(
|
||||
f"""
|
||||
SELECT *, COALESCE((
|
||||
SELECT balance FROM balances WHERE wallet_id = wallets.id
|
||||
), 0) AS balance_msat FROM wallets
|
||||
WHERE "user" = :user {where}
|
||||
""",
|
||||
{"user": user_id, "deleted": deleted},
|
||||
Wallet,
|
||||
)
|
||||
|
||||
|
||||
async def get_wallet_for_key(
|
||||
key: str,
|
||||
conn: Optional[Connection] = None,
|
||||
) -> Optional[Wallet]:
|
||||
return await (conn or db).fetchone(
|
||||
"""
|
||||
SELECT *, 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
|
||||
""",
|
||||
{"key": key},
|
||||
Wallet,
|
||||
)
|
||||
|
||||
|
||||
async def get_total_balance(conn: Optional[Connection] = None):
|
||||
result = await (conn or db).execute("SELECT SUM(balance) FROM balances")
|
||||
row = result.mappings().first()
|
||||
return row.get("balance", 0)
|
||||
@@ -0,0 +1,59 @@
|
||||
from typing import Optional
|
||||
|
||||
from lnbits.core.db import db
|
||||
|
||||
from ..models import WebPushSubscription
|
||||
|
||||
|
||||
async def get_webpush_subscription(
|
||||
endpoint: str, user: str
|
||||
) -> Optional[WebPushSubscription]:
|
||||
return await db.fetchone(
|
||||
"""
|
||||
SELECT * FROM webpush_subscriptions
|
||||
WHERE endpoint = :endpoint AND "user" = :user
|
||||
""",
|
||||
{"endpoint": endpoint, "user": user},
|
||||
WebPushSubscription,
|
||||
)
|
||||
|
||||
|
||||
async def get_webpush_subscriptions_for_user(user: str) -> list[WebPushSubscription]:
|
||||
return await db.fetchall(
|
||||
"""SELECT * FROM webpush_subscriptions WHERE "user" = :user""",
|
||||
{"user": user},
|
||||
WebPushSubscription,
|
||||
)
|
||||
|
||||
|
||||
async def create_webpush_subscription(
|
||||
endpoint: str, user: str, data: str, host: str
|
||||
) -> WebPushSubscription:
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO webpush_subscriptions (endpoint, "user", data, host)
|
||||
VALUES (:endpoint, :user, :data, :host)
|
||||
""",
|
||||
{"endpoint": endpoint, "user": user, "data": data, "host": host},
|
||||
)
|
||||
subscription = await get_webpush_subscription(endpoint, user)
|
||||
assert subscription, "Newly created webpush subscription couldn't be retrieved"
|
||||
return subscription
|
||||
|
||||
|
||||
async def delete_webpush_subscription(endpoint: str, user: str) -> int:
|
||||
resp = await db.execute(
|
||||
"""
|
||||
DELETE FROM webpush_subscriptions WHERE endpoint = :endpoint AND "user" = :user
|
||||
""",
|
||||
{"endpoint": endpoint, "user": user},
|
||||
)
|
||||
return resp.rowcount
|
||||
|
||||
|
||||
async def delete_webpush_subscriptions(endpoint: str) -> int:
|
||||
resp = await db.execute(
|
||||
"DELETE FROM webpush_subscriptions WHERE endpoint = :endpoint",
|
||||
{"endpoint": endpoint},
|
||||
)
|
||||
return resp.rowcount
|
||||
+83
-72
@@ -1,42 +1,52 @@
|
||||
import importlib
|
||||
import re
|
||||
from typing import Any, Optional
|
||||
from urllib.parse import urlparse
|
||||
from uuid import UUID
|
||||
|
||||
import httpx
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.core import migrations as core_migrations
|
||||
from lnbits.core.crud import (
|
||||
get_db_versions,
|
||||
get_installed_extensions,
|
||||
update_migration_version,
|
||||
)
|
||||
from lnbits.core.db import db as core_db
|
||||
from lnbits.db import Connection
|
||||
from lnbits.extension_manager import Extension
|
||||
from lnbits.core.models import DbVersion
|
||||
from lnbits.core.models.extensions import InstallableExtension
|
||||
from lnbits.db import COCKROACH, POSTGRES, SQLITE, Connection
|
||||
from lnbits.settings import settings
|
||||
|
||||
from .crud import update_migration_version
|
||||
|
||||
async def migrate_extension_database(
|
||||
ext: InstallableExtension, current_version: Optional[DbVersion] = None
|
||||
):
|
||||
|
||||
async def migrate_extension_database(ext: Extension, current_version):
|
||||
try:
|
||||
ext_migrations = importlib.import_module(f"{ext.module_name}.migrations")
|
||||
ext_db = importlib.import_module(ext.module_name).db
|
||||
except ImportError as exc:
|
||||
logger.error(exc)
|
||||
raise ImportError(
|
||||
f"Please make sure that the extension `{ext.code}` has a migrations file."
|
||||
) from exc
|
||||
raise ImportError(f"Cannot import module for extension '{ext.id}'.") from exc
|
||||
|
||||
async with ext_db.connect() as ext_conn:
|
||||
await run_migration(ext_conn, ext_migrations, ext.code, current_version)
|
||||
await run_migration(ext_conn, ext_migrations, ext.id, current_version)
|
||||
|
||||
|
||||
async def run_migration(
|
||||
db: Connection, migrations_module: Any, db_name: str, current_version: int
|
||||
db: Connection,
|
||||
migrations_module: Any,
|
||||
db_name: str,
|
||||
current_version: Optional[DbVersion] = None,
|
||||
):
|
||||
matcher = re.compile(r"^m(\d\d\d)_")
|
||||
for key, migrate in migrations_module.__dict__.items():
|
||||
|
||||
for key, migrate in list(migrations_module.__dict__.items()):
|
||||
match = matcher.match(key)
|
||||
if match:
|
||||
version = int(match.group(1))
|
||||
if version > current_version:
|
||||
if not current_version or version > current_version.version:
|
||||
logger.debug(f"running migration {db_name}.{version}")
|
||||
print(f"running migration {db_name}.{version}")
|
||||
await migrate(db)
|
||||
@@ -48,66 +58,6 @@ async def run_migration(
|
||||
await update_migration_version(conn, db_name, version)
|
||||
|
||||
|
||||
async def stop_extension_background_work(
|
||||
ext_id: str, user: str, access_token: Optional[str] = None
|
||||
):
|
||||
"""
|
||||
Stop background work for extension (like asyncio.Tasks, WebSockets, etc).
|
||||
Extensions SHOULD expose a `api_stop()` function and/or a DELETE enpoint
|
||||
at the root level of their API.
|
||||
"""
|
||||
stopped = await _stop_extension_background_work(ext_id)
|
||||
|
||||
if not stopped:
|
||||
# fallback to REST API call
|
||||
await _stop_extension_background_work_via_api(ext_id, user, access_token)
|
||||
|
||||
|
||||
async def _stop_extension_background_work(ext_id) -> bool:
|
||||
upgrade_hash = settings.extension_upgrade_hash(ext_id) or ""
|
||||
ext = Extension(ext_id, True, False, upgrade_hash=upgrade_hash)
|
||||
|
||||
try:
|
||||
logger.info(f"Stopping background work for extension '{ext.module_name}'.")
|
||||
old_module = importlib.import_module(ext.module_name)
|
||||
|
||||
# Extensions must expose an `{ext_id}_stop()` function at the module level
|
||||
# The `api_stop()` function is for backwards compatibility (will be deprecated)
|
||||
stop_fns = [f"{ext_id}_stop", "api_stop"]
|
||||
stop_fn_name = next((fn for fn in stop_fns if hasattr(old_module, fn)), None)
|
||||
assert stop_fn_name, "No stop function found for '{ext.module_name}'"
|
||||
|
||||
await getattr(old_module, stop_fn_name)()
|
||||
|
||||
logger.info(f"Stopped background work for extension '{ext.module_name}'.")
|
||||
except Exception as ex:
|
||||
logger.warning(f"Failed to stop background work for '{ext.module_name}'.")
|
||||
logger.warning(ex)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def _stop_extension_background_work_via_api(ext_id, user, access_token):
|
||||
logger.info(
|
||||
f"Stopping background work for extension '{ext_id}' using the REST API."
|
||||
)
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
url = f"http://{settings.host}:{settings.port}/{ext_id}/api/v1?usr={user}"
|
||||
headers = (
|
||||
{"Authorization": "Bearer " + access_token} if access_token else None
|
||||
)
|
||||
resp = await client.delete(url=url, headers=headers)
|
||||
resp.raise_for_status()
|
||||
logger.info(f"Stopped background work for extension '{ext_id}'.")
|
||||
except Exception as ex:
|
||||
logger.warning(
|
||||
f"Failed to stop background work for '{ext_id}' using the REST API."
|
||||
)
|
||||
logger.warning(ex)
|
||||
|
||||
|
||||
def to_valid_user_id(user_id: str) -> UUID:
|
||||
if len(user_id) < 32:
|
||||
raise ValueError("User ID must have at least 128 bits")
|
||||
@@ -117,3 +67,64 @@ def to_valid_user_id(user_id: str) -> UUID:
|
||||
raise ValueError("Invalid hex string for User ID.") from exc
|
||||
|
||||
return UUID(hex=user_id[:32], version=4)
|
||||
|
||||
|
||||
async def load_disabled_extension_list() -> None:
|
||||
"""Update list of extensions that have been explicitly disabled"""
|
||||
inactive_extensions = await get_installed_extensions(active=False)
|
||||
settings.lnbits_deactivated_extensions.update([e.id for e in inactive_extensions])
|
||||
|
||||
|
||||
async def migrate_databases():
|
||||
"""Creates the necessary databases if they don't exist already; or migrates them."""
|
||||
|
||||
async with core_db.connect() as conn:
|
||||
exists = False
|
||||
if conn.type == SQLITE:
|
||||
exists = await conn.fetchone(
|
||||
"SELECT * FROM sqlite_master WHERE type='table' AND name='dbversions'"
|
||||
)
|
||||
elif conn.type in {POSTGRES, COCKROACH}:
|
||||
exists = await conn.fetchone(
|
||||
"SELECT * FROM information_schema.tables WHERE table_schema = 'public'"
|
||||
" AND table_name = 'dbversions'"
|
||||
)
|
||||
|
||||
if not exists:
|
||||
await core_migrations.m000_create_migrations_table(conn)
|
||||
|
||||
current_versions = await get_db_versions(conn)
|
||||
core_version = next(
|
||||
(v for v in current_versions if v.db == "core"),
|
||||
DbVersion(db="core", version=0),
|
||||
)
|
||||
await run_migration(conn, core_migrations, "core", core_version)
|
||||
|
||||
# here is the first place we can be sure that the
|
||||
# `installed_extensions` table has been created
|
||||
await load_disabled_extension_list()
|
||||
|
||||
for ext in await get_installed_extensions():
|
||||
current_version = next(
|
||||
(v for v in current_versions if v.db == ext.id),
|
||||
DbVersion(db=ext.id, version=0),
|
||||
)
|
||||
if current_version is None:
|
||||
logger.warning(
|
||||
f"Extension {ext.id} has no migration version. This should not happen."
|
||||
)
|
||||
continue
|
||||
try:
|
||||
await migrate_extension_database(ext, current_version)
|
||||
except Exception as e:
|
||||
logger.exception(f"Error migrating extension {ext.id}: {e}")
|
||||
|
||||
logger.info("✔️ All migrations done.")
|
||||
|
||||
|
||||
def is_valid_url(url):
|
||||
try:
|
||||
result = urlparse(url)
|
||||
return all([result.scheme, result.netloc])
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
+248
-76
@@ -1,13 +1,15 @@
|
||||
import datetime
|
||||
import json
|
||||
from time import time
|
||||
from typing import Any
|
||||
|
||||
from loguru import logger
|
||||
from sqlalchemy.exc import OperationalError
|
||||
|
||||
from lnbits import bolt11
|
||||
from lnbits.db import Connection
|
||||
|
||||
|
||||
async def m000_create_migrations_table(db):
|
||||
async def m000_create_migrations_table(db: Connection):
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS dbversions (
|
||||
@@ -18,7 +20,7 @@ async def m000_create_migrations_table(db):
|
||||
)
|
||||
|
||||
|
||||
async def m001_initial(db):
|
||||
async def m001_initial(db: Connection):
|
||||
"""
|
||||
Initial LNbits tables.
|
||||
"""
|
||||
@@ -87,7 +89,7 @@ async def m001_initial(db):
|
||||
)
|
||||
|
||||
|
||||
async def m002_add_fields_to_apipayments(db):
|
||||
async def m002_add_fields_to_apipayments(db: Connection):
|
||||
"""
|
||||
Adding fields to apipayments for better accounting,
|
||||
and renaming payhash to checking_id since that is what it really is.
|
||||
@@ -100,9 +102,8 @@ async def m002_add_fields_to_apipayments(db):
|
||||
await db.execute("ALTER TABLE apipayments ADD COLUMN bolt11 TEXT")
|
||||
await db.execute("ALTER TABLE apipayments ADD COLUMN extra TEXT")
|
||||
|
||||
import json
|
||||
|
||||
rows = await (await db.execute("SELECT * FROM apipayments")).fetchall()
|
||||
result = await db.execute("SELECT * FROM apipayments")
|
||||
rows = result.mappings().all()
|
||||
for row in rows:
|
||||
if not row["memo"] or not row["memo"].startswith("#"):
|
||||
continue
|
||||
@@ -113,15 +114,15 @@ async def m002_add_fields_to_apipayments(db):
|
||||
new = row["memo"][len(prefix) :]
|
||||
await db.execute(
|
||||
"""
|
||||
UPDATE apipayments SET extra = ?, memo = ?
|
||||
WHERE checking_id = ? AND memo = ?
|
||||
UPDATE apipayments SET extra = :extra, memo = :memo1
|
||||
WHERE checking_id = :checking_id AND memo = :memo2
|
||||
""",
|
||||
(
|
||||
json.dumps({"tag": ext}),
|
||||
new,
|
||||
row["checking_id"],
|
||||
row["memo"],
|
||||
),
|
||||
{
|
||||
"extra": json.dumps({"tag": ext}),
|
||||
"memo1": new,
|
||||
"checking_id": row["checking_id"],
|
||||
"memo2": row["memo"],
|
||||
},
|
||||
)
|
||||
break
|
||||
except OperationalError:
|
||||
@@ -132,7 +133,7 @@ async def m002_add_fields_to_apipayments(db):
|
||||
pass
|
||||
|
||||
|
||||
async def m003_add_invoice_webhook(db):
|
||||
async def m003_add_invoice_webhook(db: Connection):
|
||||
"""
|
||||
Special column for webhook endpoints that can be assigned
|
||||
to each different invoice.
|
||||
@@ -142,7 +143,7 @@ async def m003_add_invoice_webhook(db):
|
||||
await db.execute("ALTER TABLE apipayments ADD COLUMN webhook_status TEXT")
|
||||
|
||||
|
||||
async def m004_ensure_fees_are_always_negative(db):
|
||||
async def m004_ensure_fees_are_always_negative(db: Connection):
|
||||
"""
|
||||
Use abs() so wallet backends don't have to care about the sign of the fees.
|
||||
"""
|
||||
@@ -167,7 +168,7 @@ async def m004_ensure_fees_are_always_negative(db):
|
||||
)
|
||||
|
||||
|
||||
async def m005_balance_check_balance_notify(db):
|
||||
async def m005_balance_check_balance_notify(db: Connection):
|
||||
"""
|
||||
Keep track of balanceCheck-enabled lnurl-withdrawals to be consumed by an
|
||||
LNbits wallet and of balanceNotify URLs supplied by users to empty their wallets.
|
||||
@@ -197,7 +198,7 @@ async def m005_balance_check_balance_notify(db):
|
||||
)
|
||||
|
||||
|
||||
async def m006_add_invoice_expiry_to_apipayments(db):
|
||||
async def m006_add_invoice_expiry_to_apipayments(db: Connection):
|
||||
"""
|
||||
Adds invoice expiry column to apipayments.
|
||||
"""
|
||||
@@ -207,24 +208,23 @@ async def m006_add_invoice_expiry_to_apipayments(db):
|
||||
pass
|
||||
|
||||
|
||||
async def m007_set_invoice_expiries(db):
|
||||
async def m007_set_invoice_expiries(db: Connection):
|
||||
"""
|
||||
Precomputes invoice expiry for existing pending incoming payments.
|
||||
"""
|
||||
try:
|
||||
rows = await (
|
||||
await db.execute(
|
||||
f"""
|
||||
SELECT bolt11, checking_id
|
||||
FROM apipayments
|
||||
WHERE pending = true
|
||||
AND amount > 0
|
||||
AND bolt11 IS NOT NULL
|
||||
AND expiry IS NULL
|
||||
AND time < {db.timestamp_now}
|
||||
"""
|
||||
)
|
||||
).fetchall()
|
||||
result = await db.execute(
|
||||
f"""
|
||||
SELECT bolt11, checking_id
|
||||
FROM apipayments
|
||||
WHERE pending = true
|
||||
AND amount > 0
|
||||
AND bolt11 IS NOT NULL
|
||||
AND expiry IS NULL
|
||||
AND time < {db.timestamp_now}
|
||||
"""
|
||||
)
|
||||
rows = result.mappings().all()
|
||||
if len(rows):
|
||||
logger.info(f"Migration: Checking expiry of {len(rows)} invoices")
|
||||
for i, (
|
||||
@@ -236,22 +236,17 @@ async def m007_set_invoice_expiries(db):
|
||||
if invoice.expiry is None:
|
||||
continue
|
||||
|
||||
expiration_date = datetime.datetime.fromtimestamp(
|
||||
invoice.date + invoice.expiry
|
||||
)
|
||||
expiration_date = invoice.date + invoice.expiry
|
||||
logger.info(
|
||||
f"Migration: {i+1}/{len(rows)} setting expiry of invoice"
|
||||
f" {invoice.payment_hash} to {expiration_date}"
|
||||
)
|
||||
await db.execute(
|
||||
"""
|
||||
UPDATE apipayments SET expiry = ?
|
||||
WHERE checking_id = ? AND amount > 0
|
||||
f"""
|
||||
UPDATE apipayments SET expiry = {db.timestamp_placeholder('expiry')}
|
||||
WHERE checking_id = :checking_id AND amount > 0
|
||||
""",
|
||||
(
|
||||
db.datetime_to_timestamp(expiration_date),
|
||||
checking_id,
|
||||
),
|
||||
{"expiry": expiration_date, "checking_id": checking_id},
|
||||
)
|
||||
except Exception:
|
||||
continue
|
||||
@@ -263,7 +258,7 @@ async def m007_set_invoice_expiries(db):
|
||||
pass
|
||||
|
||||
|
||||
async def m008_create_admin_settings_table(db):
|
||||
async def m008_create_admin_settings_table(db: Connection):
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
@@ -274,7 +269,7 @@ async def m008_create_admin_settings_table(db):
|
||||
)
|
||||
|
||||
|
||||
async def m009_create_tinyurl_table(db):
|
||||
async def m009_create_tinyurl_table(db: Connection):
|
||||
await db.execute(
|
||||
f"""
|
||||
CREATE TABLE IF NOT EXISTS tiny_url (
|
||||
@@ -288,7 +283,7 @@ async def m009_create_tinyurl_table(db):
|
||||
)
|
||||
|
||||
|
||||
async def m010_create_installed_extensions_table(db):
|
||||
async def m010_create_installed_extensions_table(db: Connection):
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS installed_extensions (
|
||||
@@ -305,7 +300,7 @@ async def m010_create_installed_extensions_table(db):
|
||||
)
|
||||
|
||||
|
||||
async def m011_optimize_balances_view(db):
|
||||
async def m011_optimize_balances_view(db: Connection):
|
||||
"""
|
||||
Make the calculation of the balance a single aggregation
|
||||
over the payments table instead of 2.
|
||||
@@ -322,7 +317,7 @@ async def m011_optimize_balances_view(db):
|
||||
)
|
||||
|
||||
|
||||
async def m012_add_currency_to_wallet(db):
|
||||
async def m012_add_currency_to_wallet(db: Connection):
|
||||
await db.execute(
|
||||
"""
|
||||
ALTER TABLE wallets ADD COLUMN currency TEXT
|
||||
@@ -330,7 +325,7 @@ async def m012_add_currency_to_wallet(db):
|
||||
)
|
||||
|
||||
|
||||
async def m013_add_deleted_to_wallets(db):
|
||||
async def m013_add_deleted_to_wallets(db: Connection):
|
||||
"""
|
||||
Adds deleted column to wallets.
|
||||
"""
|
||||
@@ -342,34 +337,39 @@ async def m013_add_deleted_to_wallets(db):
|
||||
pass
|
||||
|
||||
|
||||
async def m014_set_deleted_wallets(db):
|
||||
async def m014_set_deleted_wallets(db: Connection):
|
||||
"""
|
||||
Sets deleted column to wallets.
|
||||
"""
|
||||
try:
|
||||
rows = await (
|
||||
await db.execute(
|
||||
"""
|
||||
SELECT *
|
||||
FROM wallets
|
||||
WHERE user LIKE 'del:%'
|
||||
AND adminkey LIKE 'del:%'
|
||||
AND inkey LIKE 'del:%'
|
||||
"""
|
||||
)
|
||||
).fetchall()
|
||||
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:
|
||||
try:
|
||||
user = row[2].split(":")[1]
|
||||
adminkey = row[3].split(":")[1]
|
||||
inkey = row[4].split(":")[1]
|
||||
user = row["user"].split(":")[1]
|
||||
adminkey = row["adminkey"].split(":")[1]
|
||||
inkey = row["inkey"].split(":")[1]
|
||||
await db.execute(
|
||||
"""
|
||||
UPDATE wallets SET user = ?, adminkey = ?, inkey = ?, deleted = true
|
||||
WHERE id = ?
|
||||
UPDATE wallets SET
|
||||
"user" = :user, adminkey = :adminkey, inkey = :inkey, deleted = true
|
||||
WHERE id = :wallet
|
||||
""",
|
||||
(user, adminkey, inkey, row[0]),
|
||||
{
|
||||
"user": user,
|
||||
"adminkey": adminkey,
|
||||
"inkey": inkey,
|
||||
"wallet": row.get("id"),
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
continue
|
||||
@@ -381,7 +381,7 @@ async def m014_set_deleted_wallets(db):
|
||||
pass
|
||||
|
||||
|
||||
async def m015_create_push_notification_subscriptions_table(db):
|
||||
async def m015_create_push_notification_subscriptions_table(db: Connection):
|
||||
await db.execute(
|
||||
f"""
|
||||
CREATE TABLE IF NOT EXISTS webpush_subscriptions (
|
||||
@@ -396,7 +396,7 @@ async def m015_create_push_notification_subscriptions_table(db):
|
||||
)
|
||||
|
||||
|
||||
async def m016_add_username_column_to_accounts(db):
|
||||
async def m016_add_username_column_to_accounts(db: Connection):
|
||||
"""
|
||||
Adds username column to accounts.
|
||||
"""
|
||||
@@ -407,7 +407,7 @@ async def m016_add_username_column_to_accounts(db):
|
||||
pass
|
||||
|
||||
|
||||
async def m017_add_timestamp_columns_to_accounts_and_wallets(db):
|
||||
async def m017_add_timestamp_columns_to_accounts_and_wallets(db: Connection):
|
||||
"""
|
||||
Adds created_at and updated_at column to accounts and wallets.
|
||||
"""
|
||||
@@ -455,17 +455,17 @@ async def m017_add_timestamp_columns_to_accounts_and_wallets(db):
|
||||
now = int(time())
|
||||
await db.execute(
|
||||
f"""
|
||||
UPDATE wallets SET created_at = {db.timestamp_placeholder}
|
||||
UPDATE wallets SET created_at = {db.timestamp_placeholder('now')}
|
||||
WHERE created_at IS NULL
|
||||
""",
|
||||
(now,),
|
||||
{"now": now},
|
||||
)
|
||||
await db.execute(
|
||||
f"""
|
||||
UPDATE accounts SET created_at = {db.timestamp_placeholder}
|
||||
UPDATE accounts SET created_at = {db.timestamp_placeholder('now')}
|
||||
WHERE created_at IS NULL
|
||||
""",
|
||||
(now,),
|
||||
{"now": now},
|
||||
)
|
||||
|
||||
except OperationalError as exc:
|
||||
@@ -473,7 +473,7 @@ async def m017_add_timestamp_columns_to_accounts_and_wallets(db):
|
||||
pass
|
||||
|
||||
|
||||
async def m018_balances_view_exclude_deleted(db):
|
||||
async def m018_balances_view_exclude_deleted(db: Connection):
|
||||
"""
|
||||
Make deleted wallets not show up in the balances view.
|
||||
"""
|
||||
@@ -493,7 +493,7 @@ async def m018_balances_view_exclude_deleted(db):
|
||||
)
|
||||
|
||||
|
||||
async def m019_balances_view_based_on_wallets(db):
|
||||
async def m019_balances_view_based_on_wallets(db: Connection):
|
||||
"""
|
||||
Make deleted wallets not show up in the balances view.
|
||||
Important for querying whole lnbits balances.
|
||||
@@ -512,3 +512,175 @@ async def m019_balances_view_based_on_wallets(db):
|
||||
GROUP BY apipayments.wallet
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
async def m020_add_column_column_to_user_extensions(db: Connection):
|
||||
"""
|
||||
Adds extra column to user extensions.
|
||||
"""
|
||||
await db.execute("ALTER TABLE extensions ADD COLUMN extra TEXT")
|
||||
|
||||
|
||||
async def m021_add_success_failed_to_apipayments(db: Connection):
|
||||
"""
|
||||
Adds success and failed columns to apipayments.
|
||||
"""
|
||||
await db.execute("ALTER TABLE apipayments ADD COLUMN status TEXT DEFAULT 'pending'")
|
||||
# set all not pending to success true, failed payments were deleted until now
|
||||
await db.execute("UPDATE apipayments SET status = 'success' WHERE NOT pending")
|
||||
|
||||
await db.execute("DROP VIEW balances")
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE VIEW balances AS
|
||||
SELECT apipayments.wallet,
|
||||
SUM(apipayments.amount - ABS(apipayments.fee)) AS balance
|
||||
FROM wallets
|
||||
LEFT JOIN apipayments ON apipayments.wallet = wallets.id
|
||||
WHERE (wallets.deleted = false OR wallets.deleted is NULL)
|
||||
AND (
|
||||
(apipayments.status = 'success' AND apipayments.amount > 0)
|
||||
OR (apipayments.status IN ('success', 'pending') AND apipayments.amount < 0)
|
||||
)
|
||||
GROUP BY apipayments.wallet
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
async def m022_add_pubkey_to_accounts(db: Connection):
|
||||
"""
|
||||
Adds pubkey column to accounts.
|
||||
"""
|
||||
try:
|
||||
await db.execute("ALTER TABLE accounts ADD COLUMN pubkey TEXT")
|
||||
except OperationalError:
|
||||
pass
|
||||
|
||||
|
||||
async def m023_add_column_column_to_apipayments(db: Connection):
|
||||
"""
|
||||
renames hash to payment_hash and drops unused index
|
||||
"""
|
||||
await db.execute("DROP INDEX by_hash")
|
||||
await db.execute("ALTER TABLE apipayments RENAME COLUMN hash TO payment_hash")
|
||||
await db.execute("ALTER TABLE apipayments RENAME COLUMN wallet TO wallet_id")
|
||||
await db.execute("ALTER TABLE accounts RENAME COLUMN pass TO password_hash")
|
||||
|
||||
await db.execute("CREATE INDEX by_hash ON apipayments (payment_hash)")
|
||||
|
||||
|
||||
async def m024_drop_pending(db: Connection):
|
||||
await db.execute("ALTER TABLE apipayments DROP COLUMN pending")
|
||||
|
||||
|
||||
async def m025_refresh_view(db: Connection):
|
||||
await db.execute("DROP VIEW balances")
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE VIEW balances AS
|
||||
SELECT apipayments.wallet_id,
|
||||
SUM(apipayments.amount - ABS(apipayments.fee)) AS balance
|
||||
FROM wallets
|
||||
LEFT JOIN apipayments ON apipayments.wallet_id = wallets.id
|
||||
WHERE (wallets.deleted = false OR wallets.deleted is NULL)
|
||||
AND (
|
||||
(apipayments.status = 'success' AND apipayments.amount > 0)
|
||||
OR (apipayments.status IN ('success', 'pending') AND apipayments.amount < 0)
|
||||
)
|
||||
GROUP BY apipayments.wallet_id
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
async def m026_update_payment_table(db: Connection):
|
||||
await db.execute("ALTER TABLE apipayments ADD COLUMN tag TEXT")
|
||||
await db.execute("ALTER TABLE apipayments ADD COLUMN extension TEXT")
|
||||
await db.execute("ALTER TABLE apipayments ADD COLUMN created_at TIMESTAMP")
|
||||
await db.execute("ALTER TABLE apipayments ADD COLUMN updated_at TIMESTAMP")
|
||||
|
||||
|
||||
async def m027_update_apipayments_data(db: Connection):
|
||||
result = None
|
||||
try:
|
||||
result = await db.execute("SELECT * FROM apipayments")
|
||||
except Exception as exc:
|
||||
logger.warning("Could not select, trying again after cache cleared.")
|
||||
logger.debug(exc)
|
||||
await db.execute("COMMIT")
|
||||
|
||||
result = await db.execute("SELECT * FROM apipayments")
|
||||
|
||||
payments = result.mappings().all()
|
||||
for payment in payments:
|
||||
tag = None
|
||||
created_at = payment.get("time")
|
||||
if payment.get("extra"):
|
||||
extra = json.loads(payment.get("extra"))
|
||||
tag = extra.get("tag")
|
||||
tsph = db.timestamp_placeholder("created_at")
|
||||
await db.execute(
|
||||
f"""
|
||||
UPDATE apipayments
|
||||
SET tag = :tag, created_at = {tsph}, updated_at = {tsph}
|
||||
WHERE checking_id = :checking_id
|
||||
""",
|
||||
{
|
||||
"tag": tag,
|
||||
"created_at": created_at,
|
||||
"checking_id": payment.get("checking_id"),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def m028_update_settings(db: Connection):
|
||||
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS system_settings (
|
||||
id TEXT PRIMARY KEY,
|
||||
value TEXT,
|
||||
tag TEXT NOT NULL DEFAULT 'core',
|
||||
|
||||
UNIQUE (id, tag)
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
async def _insert_key_value(id_: str, value: Any):
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO system_settings (id, value, tag)
|
||||
VALUES (:id, :value, :tag)
|
||||
""",
|
||||
{"id": id_, "value": json.dumps(value), "tag": "core"},
|
||||
)
|
||||
|
||||
row: dict = await db.fetchone("SELECT * FROM settings")
|
||||
if row:
|
||||
await _insert_key_value("super_user", row["super_user"])
|
||||
editable_settings = json.loads(row["editable_settings"])
|
||||
|
||||
for key, value in editable_settings.items():
|
||||
await _insert_key_value(key, value)
|
||||
|
||||
await db.execute("drop table settings")
|
||||
|
||||
|
||||
async def m029_create_audit_table(db: Connection):
|
||||
await db.execute(
|
||||
f"""
|
||||
CREATE TABLE IF NOT EXISTS audit (
|
||||
component TEXT,
|
||||
ip_address TEXT,
|
||||
user_id TEXT,
|
||||
path TEXT,
|
||||
request_type TEXT,
|
||||
request_method TEXT,
|
||||
request_details TEXT,
|
||||
response_code TEXT,
|
||||
duration REAL NOT NULL,
|
||||
delete_at TIMESTAMP,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
@@ -1,455 +0,0 @@
|
||||
import datetime
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from sqlite3 import Row
|
||||
from typing import Callable, Dict, List, Optional
|
||||
|
||||
from ecdsa import SECP256k1, SigningKey
|
||||
from fastapi import Query
|
||||
from loguru import logger
|
||||
from pydantic import BaseModel
|
||||
|
||||
from lnbits.db import Connection, FilterModel, FromRowModel
|
||||
from lnbits.helpers import url_for
|
||||
from lnbits.lnurl import encode as lnurl_encode
|
||||
from lnbits.settings import settings
|
||||
from lnbits.wallets import get_funding_source
|
||||
from lnbits.wallets.base import PaymentPendingStatus, PaymentStatus
|
||||
|
||||
|
||||
class BaseWallet(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
adminkey: str
|
||||
inkey: str
|
||||
balance_msat: int
|
||||
|
||||
|
||||
class Wallet(BaseWallet):
|
||||
user: str
|
||||
currency: Optional[str]
|
||||
deleted: bool
|
||||
created_at: Optional[int] = None
|
||||
updated_at: Optional[int] = None
|
||||
|
||||
@property
|
||||
def balance(self) -> int:
|
||||
return self.balance_msat // 1000
|
||||
|
||||
@property
|
||||
def withdrawable_balance(self) -> int:
|
||||
from .services import fee_reserve
|
||||
|
||||
return self.balance_msat - 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 lnurlauth_key(self, domain: str) -> SigningKey:
|
||||
hashing_key = hashlib.sha256(self.id.encode()).digest()
|
||||
linking_key = hmac.digest(hashing_key, domain.encode(), "sha256")
|
||||
|
||||
return SigningKey.from_string(
|
||||
linking_key, curve=SECP256k1, hashfunc=hashlib.sha256
|
||||
)
|
||||
|
||||
async def get_payment(self, payment_hash: str) -> Optional["Payment"]:
|
||||
from .crud import get_standalone_payment
|
||||
|
||||
return await get_standalone_payment(payment_hash)
|
||||
|
||||
|
||||
class KeyType(Enum):
|
||||
admin = 0
|
||||
invoice = 1
|
||||
invalid = 2
|
||||
|
||||
# backwards compatibility
|
||||
def __eq__(self, other):
|
||||
return self.value == other
|
||||
|
||||
|
||||
@dataclass
|
||||
class WalletTypeInfo:
|
||||
key_type: KeyType
|
||||
wallet: Wallet
|
||||
|
||||
|
||||
class UserConfig(BaseModel):
|
||||
email_verified: Optional[bool] = False
|
||||
first_name: Optional[str] = None
|
||||
last_name: Optional[str] = None
|
||||
display_name: Optional[str] = None
|
||||
picture: Optional[str] = None
|
||||
# Auth provider, possible values:
|
||||
# - "env": the user was created automatically by the system
|
||||
# - "lnbits": the user was created via register form (username/pass or user_id only)
|
||||
# - "google | github | ...": the user was created using an SSO provider
|
||||
provider: Optional[str] = "lnbits" # auth provider
|
||||
|
||||
|
||||
class Account(FromRowModel):
|
||||
id: str
|
||||
is_super_user: Optional[bool] = False
|
||||
is_admin: Optional[bool] = False
|
||||
username: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
balance_msat: Optional[int] = 0
|
||||
transaction_count: Optional[int] = 0
|
||||
wallet_count: Optional[int] = 0
|
||||
last_payment: Optional[datetime.datetime] = None
|
||||
|
||||
|
||||
class AccountFilters(FilterModel):
|
||||
__search_fields__ = ["id", "email", "username"]
|
||||
__sort_fields__ = [
|
||||
"balance_msat",
|
||||
"email",
|
||||
"username",
|
||||
"transaction_count",
|
||||
"wallet_count",
|
||||
"last_payment",
|
||||
]
|
||||
|
||||
id: str
|
||||
last_payment: Optional[datetime.datetime] = None
|
||||
transaction_count: Optional[int] = None
|
||||
wallet_count: Optional[int] = None
|
||||
username: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
|
||||
|
||||
class User(BaseModel):
|
||||
id: str
|
||||
email: Optional[str] = None
|
||||
username: Optional[str] = None
|
||||
extensions: List[str] = []
|
||||
wallets: List[Wallet] = []
|
||||
admin: bool = False
|
||||
super_user: bool = False
|
||||
has_password: bool = False
|
||||
config: Optional[UserConfig] = None
|
||||
created_at: Optional[int] = None
|
||||
updated_at: Optional[int] = None
|
||||
|
||||
@property
|
||||
def wallet_ids(self) -> List[str]:
|
||||
return [wallet.id for wallet in self.wallets]
|
||||
|
||||
def get_wallet(self, wallet_id: str) -> Optional["Wallet"]:
|
||||
w = [wallet for wallet in self.wallets if wallet.id == wallet_id]
|
||||
return w[0] if w else None
|
||||
|
||||
@classmethod
|
||||
def is_extension_for_user(cls, ext: str, user: str) -> bool:
|
||||
if ext not in settings.lnbits_admin_extensions:
|
||||
return True
|
||||
if user == settings.super_user:
|
||||
return True
|
||||
if user in settings.lnbits_admin_users:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class CreateUser(BaseModel):
|
||||
email: Optional[str] = Query(default=None)
|
||||
username: str = Query(default=..., min_length=2, max_length=20)
|
||||
password: str = Query(default=..., min_length=8, max_length=50)
|
||||
password_repeat: str = Query(default=..., min_length=8, max_length=50)
|
||||
|
||||
|
||||
class UpdateUser(BaseModel):
|
||||
user_id: str
|
||||
email: Optional[str] = Query(default=None)
|
||||
username: Optional[str] = Query(default=..., min_length=2, max_length=20)
|
||||
config: Optional[UserConfig] = None
|
||||
|
||||
|
||||
class UpdateUserPassword(BaseModel):
|
||||
user_id: str
|
||||
password: str = Query(default=..., min_length=8, max_length=50)
|
||||
password_repeat: str = Query(default=..., min_length=8, max_length=50)
|
||||
password_old: Optional[str] = Query(default=None, min_length=8, max_length=50)
|
||||
username: Optional[str] = Query(default=..., min_length=2, max_length=20)
|
||||
|
||||
|
||||
class UpdateSuperuserPassword(BaseModel):
|
||||
username: str = Query(default=..., min_length=2, max_length=20)
|
||||
password: str = Query(default=..., min_length=8, max_length=50)
|
||||
password_repeat: str = Query(default=..., min_length=8, max_length=50)
|
||||
|
||||
|
||||
class LoginUsr(BaseModel):
|
||||
usr: str
|
||||
|
||||
|
||||
class LoginUsernamePassword(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
class Payment(FromRowModel):
|
||||
checking_id: str
|
||||
pending: bool
|
||||
amount: int
|
||||
fee: int
|
||||
memo: Optional[str]
|
||||
time: int
|
||||
bolt11: str
|
||||
preimage: str
|
||||
payment_hash: str
|
||||
expiry: Optional[float]
|
||||
extra: Dict = {}
|
||||
wallet_id: str
|
||||
webhook: Optional[str]
|
||||
webhook_status: Optional[int]
|
||||
|
||||
@classmethod
|
||||
def from_row(cls, row: Row):
|
||||
return cls(
|
||||
checking_id=row["checking_id"],
|
||||
payment_hash=row["hash"] or "0" * 64,
|
||||
bolt11=row["bolt11"] or "",
|
||||
preimage=row["preimage"] or "0" * 64,
|
||||
extra=json.loads(row["extra"] or "{}"),
|
||||
pending=row["pending"],
|
||||
amount=row["amount"],
|
||||
fee=row["fee"],
|
||||
memo=row["memo"],
|
||||
time=row["time"],
|
||||
expiry=row["expiry"],
|
||||
wallet_id=row["wallet"],
|
||||
webhook=row["webhook"],
|
||||
webhook_status=row["webhook_status"],
|
||||
)
|
||||
|
||||
@property
|
||||
def tag(self) -> Optional[str]:
|
||||
if self.extra is None:
|
||||
return ""
|
||||
return self.extra.get("tag")
|
||||
|
||||
@property
|
||||
def msat(self) -> int:
|
||||
return self.amount
|
||||
|
||||
@property
|
||||
def sat(self) -> int:
|
||||
return self.amount // 1000
|
||||
|
||||
@property
|
||||
def is_in(self) -> bool:
|
||||
return self.amount > 0
|
||||
|
||||
@property
|
||||
def is_out(self) -> bool:
|
||||
return self.amount < 0
|
||||
|
||||
@property
|
||||
def is_expired(self) -> bool:
|
||||
return self.expiry < time.time() if self.expiry else False
|
||||
|
||||
@property
|
||||
def is_uncheckable(self) -> bool:
|
||||
return self.checking_id.startswith("internal_")
|
||||
|
||||
async def update_status(
|
||||
self,
|
||||
status: PaymentStatus,
|
||||
conn: Optional[Connection] = None,
|
||||
) -> None:
|
||||
from .crud import update_payment_details
|
||||
|
||||
await update_payment_details(
|
||||
checking_id=self.checking_id,
|
||||
pending=status.pending,
|
||||
fee=status.fee_msat,
|
||||
preimage=status.preimage,
|
||||
conn=conn,
|
||||
)
|
||||
|
||||
async def set_pending(self, pending: bool) -> None:
|
||||
from .crud import update_payment_status
|
||||
|
||||
self.pending = pending
|
||||
|
||||
await update_payment_status(self.checking_id, pending)
|
||||
|
||||
async def check_status(
|
||||
self,
|
||||
conn: Optional[Connection] = None,
|
||||
) -> PaymentStatus:
|
||||
if self.is_uncheckable:
|
||||
return PaymentPendingStatus()
|
||||
|
||||
logger.debug(
|
||||
f"Checking {'outgoing' if self.is_out else 'incoming'} "
|
||||
f"pending payment {self.checking_id}"
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
logger.debug(f"Status: {status}")
|
||||
|
||||
if self.is_in and status.pending and self.is_expired and self.expiry:
|
||||
expiration_date = datetime.datetime.fromtimestamp(self.expiry)
|
||||
logger.debug(
|
||||
f"Deleting expired incoming pending payment {self.checking_id}: "
|
||||
f"expired {expiration_date}"
|
||||
)
|
||||
await self.delete(conn)
|
||||
# wait at least 15 minutes before deleting failed outgoing payments
|
||||
elif self.is_out and status.failed:
|
||||
if self.time + 900 < int(time.time()):
|
||||
logger.warning(
|
||||
f"Deleting outgoing failed payment {self.checking_id}: {status}"
|
||||
)
|
||||
await self.delete(conn)
|
||||
else:
|
||||
logger.warning(
|
||||
f"Tried to delete outgoing payment {self.checking_id}: "
|
||||
"skipping because it's not old enough"
|
||||
)
|
||||
elif not status.pending:
|
||||
logger.info(
|
||||
f"Marking '{'in' if self.is_in else 'out'}' "
|
||||
f"{self.checking_id} as not pending anymore: {status}"
|
||||
)
|
||||
await self.update_status(status, conn=conn)
|
||||
return status
|
||||
|
||||
async def delete(self, conn: Optional[Connection] = None) -> None:
|
||||
from .crud import delete_wallet_payment
|
||||
|
||||
await delete_wallet_payment(self.checking_id, self.wallet_id, conn=conn)
|
||||
|
||||
|
||||
class PaymentFilters(FilterModel):
|
||||
__search_fields__ = ["memo", "amount"]
|
||||
|
||||
checking_id: str
|
||||
amount: int
|
||||
fee: int
|
||||
memo: Optional[str]
|
||||
time: datetime.datetime
|
||||
bolt11: str
|
||||
preimage: str
|
||||
payment_hash: str
|
||||
expiry: Optional[datetime.datetime]
|
||||
extra: Dict = {}
|
||||
wallet_id: str
|
||||
webhook: Optional[str]
|
||||
webhook_status: Optional[int]
|
||||
|
||||
|
||||
class PaymentHistoryPoint(BaseModel):
|
||||
date: datetime.datetime
|
||||
income: int
|
||||
spending: int
|
||||
balance: int
|
||||
|
||||
|
||||
def _do_nothing(*_):
|
||||
pass
|
||||
|
||||
|
||||
class CoreAppExtra:
|
||||
register_new_ext_routes: Callable = _do_nothing
|
||||
register_new_ratelimiter: Callable
|
||||
|
||||
|
||||
class TinyURL(BaseModel):
|
||||
id: str
|
||||
url: str
|
||||
endless: bool
|
||||
wallet: str
|
||||
time: float
|
||||
|
||||
@classmethod
|
||||
def from_row(cls, row: Row):
|
||||
return cls(**dict(row))
|
||||
|
||||
|
||||
class ConversionData(BaseModel):
|
||||
from_: str = "sat"
|
||||
amount: float
|
||||
to: str = "usd"
|
||||
|
||||
|
||||
class Callback(BaseModel):
|
||||
callback: str
|
||||
|
||||
|
||||
class DecodePayment(BaseModel):
|
||||
data: str
|
||||
|
||||
|
||||
class CreateLnurl(BaseModel):
|
||||
description_hash: str
|
||||
callback: str
|
||||
amount: int
|
||||
comment: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
unit: Optional[str] = None
|
||||
|
||||
|
||||
class CreateInvoice(BaseModel):
|
||||
unit: str = "sat"
|
||||
internal: bool = False
|
||||
out: bool = True
|
||||
amount: float = Query(None, ge=0)
|
||||
memo: Optional[str] = None
|
||||
description_hash: Optional[str] = None
|
||||
unhashed_description: Optional[str] = None
|
||||
expiry: Optional[int] = None
|
||||
extra: Optional[dict] = None
|
||||
webhook: Optional[str] = None
|
||||
bolt11: Optional[str] = None
|
||||
lnurl_callback: Optional[str] = None
|
||||
|
||||
|
||||
class CreateTopup(BaseModel):
|
||||
id: str
|
||||
amount: int
|
||||
|
||||
|
||||
class CreateLnurlAuth(BaseModel):
|
||||
callback: str
|
||||
|
||||
|
||||
class CreateWallet(BaseModel):
|
||||
name: Optional[str] = None
|
||||
|
||||
|
||||
class CreateWebPushSubscription(BaseModel):
|
||||
subscription: str
|
||||
|
||||
|
||||
class WebPushSubscription(BaseModel):
|
||||
endpoint: str
|
||||
user: str
|
||||
data: str
|
||||
host: str
|
||||
timestamp: str
|
||||
|
||||
|
||||
class BalanceDelta(BaseModel):
|
||||
lnbits_balance_msats: int
|
||||
node_balance_msats: int
|
||||
|
||||
@property
|
||||
def delta_msats(self):
|
||||
return self.node_balance_msats - self.lnbits_balance_msats
|
||||
@@ -0,0 +1,97 @@
|
||||
from .audit import AuditEntry, AuditFilters
|
||||
from .lnurl import CreateLnurl, CreateLnurlAuth, PayLnurlWData
|
||||
from .misc import (
|
||||
BalanceDelta,
|
||||
Callback,
|
||||
ConversionData,
|
||||
CoreAppExtra,
|
||||
DbVersion,
|
||||
SimpleStatus,
|
||||
)
|
||||
from .payments import (
|
||||
CreateInvoice,
|
||||
CreatePayment,
|
||||
DecodePayment,
|
||||
PayInvoice,
|
||||
Payment,
|
||||
PaymentExtra,
|
||||
PaymentFilters,
|
||||
PaymentHistoryPoint,
|
||||
PaymentState,
|
||||
)
|
||||
from .tinyurl import TinyURL
|
||||
from .users import (
|
||||
AccessTokenPayload,
|
||||
Account,
|
||||
AccountFilters,
|
||||
AccountOverview,
|
||||
CreateUser,
|
||||
LoginUsernamePassword,
|
||||
LoginUsr,
|
||||
RegisterUser,
|
||||
ResetUserPassword,
|
||||
UpdateBalance,
|
||||
UpdateSuperuserPassword,
|
||||
UpdateUser,
|
||||
UpdateUserPassword,
|
||||
UpdateUserPubkey,
|
||||
User,
|
||||
UserExtra,
|
||||
)
|
||||
from .wallets import BaseWallet, CreateWallet, KeyType, Wallet, WalletTypeInfo
|
||||
from .webpush import CreateWebPushSubscription, WebPushSubscription
|
||||
|
||||
__all__ = [
|
||||
# audit
|
||||
"AuditEntry",
|
||||
"AuditFilters",
|
||||
# lnurl
|
||||
"CreateLnurl",
|
||||
"CreateLnurlAuth",
|
||||
"PayLnurlWData",
|
||||
# misc
|
||||
"BalanceDelta",
|
||||
"Callback",
|
||||
"ConversionData",
|
||||
"CoreAppExtra",
|
||||
"DbVersion",
|
||||
"SimpleStatus",
|
||||
# payments
|
||||
"CreateInvoice",
|
||||
"CreatePayment",
|
||||
"DecodePayment",
|
||||
"PayInvoice",
|
||||
"Payment",
|
||||
"PaymentExtra",
|
||||
"PaymentFilters",
|
||||
"PaymentHistoryPoint",
|
||||
"PaymentState",
|
||||
# tinyurl
|
||||
"TinyURL",
|
||||
# users
|
||||
"AccessTokenPayload",
|
||||
"Account",
|
||||
"AccountFilters",
|
||||
"AccountOverview",
|
||||
"CreateUser",
|
||||
"RegisterUser",
|
||||
"LoginUsernamePassword",
|
||||
"LoginUsr",
|
||||
"ResetUserPassword",
|
||||
"UpdateBalance",
|
||||
"UpdateSuperuserPassword",
|
||||
"UpdateUser",
|
||||
"UpdateUserPassword",
|
||||
"UpdateUserPubkey",
|
||||
"User",
|
||||
"UserExtra",
|
||||
# wallets
|
||||
"BaseWallet",
|
||||
"CreateWallet",
|
||||
"KeyType",
|
||||
"Wallet",
|
||||
"WalletTypeInfo",
|
||||
# webpush
|
||||
"CreateWebPushSubscription",
|
||||
"WebPushSubscription",
|
||||
]
|
||||
@@ -0,0 +1,62 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from lnbits.db import FilterModel
|
||||
from lnbits.settings import settings
|
||||
|
||||
|
||||
class AuditEntry(BaseModel):
|
||||
component: Optional[str] = None
|
||||
ip_address: Optional[str] = None
|
||||
user_id: Optional[str] = None
|
||||
path: Optional[str] = None
|
||||
request_type: Optional[str] = None
|
||||
request_method: Optional[str] = None
|
||||
request_details: Optional[str] = None
|
||||
response_code: Optional[str] = None
|
||||
duration: float
|
||||
delete_at: Optional[datetime] = None
|
||||
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
def __init__(self, **data):
|
||||
super().__init__(**data)
|
||||
retention_days = max(0, settings.lnbits_audit_retention_days) or 365
|
||||
self.delete_at = self.created_at + timedelta(days=retention_days)
|
||||
|
||||
|
||||
class AuditFilters(FilterModel):
|
||||
__search_fields__ = [
|
||||
"ip_address",
|
||||
"user_id",
|
||||
"path",
|
||||
"request_method",
|
||||
"response_code",
|
||||
"component",
|
||||
]
|
||||
__sort_fields__ = [
|
||||
"created_at",
|
||||
"duration",
|
||||
]
|
||||
|
||||
ip_address: Optional[str] = None
|
||||
user_id: Optional[str] = None
|
||||
path: Optional[str] = None
|
||||
request_method: Optional[str] = None
|
||||
response_code: Optional[str] = None
|
||||
component: Optional[str] = None
|
||||
|
||||
|
||||
class AuditCountStat(BaseModel):
|
||||
field: str = ""
|
||||
total: float = 0
|
||||
|
||||
|
||||
class AuditStats(BaseModel):
|
||||
request_method: list[AuditCountStat] = []
|
||||
response_code: list[AuditCountStat] = []
|
||||
component: list[AuditCountStat] = []
|
||||
long_duration: list[AuditCountStat] = []
|
||||
@@ -1,19 +1,24 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import Any, List, NamedTuple, Optional, Tuple
|
||||
from urllib import request
|
||||
from typing import Any, Optional
|
||||
|
||||
import httpx
|
||||
from loguru import logger
|
||||
from packaging import version
|
||||
from pydantic import BaseModel
|
||||
|
||||
from lnbits.helpers import (
|
||||
download_url,
|
||||
file_hash,
|
||||
is_lnbits_version_ok,
|
||||
version_parse,
|
||||
)
|
||||
from lnbits.settings import settings
|
||||
|
||||
|
||||
@@ -23,21 +28,21 @@ class ExplicitRelease(BaseModel):
|
||||
version: str
|
||||
archive: str
|
||||
hash: str
|
||||
dependencies: List[str] = []
|
||||
dependencies: list[str] = []
|
||||
repo: Optional[str]
|
||||
icon: Optional[str]
|
||||
short_description: Optional[str]
|
||||
min_lnbits_version: Optional[str]
|
||||
max_lnbits_version: Optional[str]
|
||||
html_url: Optional[str] # todo: release_url
|
||||
warning: Optional[str]
|
||||
info_notification: Optional[str]
|
||||
critical_notification: Optional[str]
|
||||
details_link: Optional[str]
|
||||
pay_link: Optional[str]
|
||||
|
||||
def is_version_compatible(self):
|
||||
if not self.min_lnbits_version:
|
||||
return True
|
||||
return version_parse(self.min_lnbits_version) <= version_parse(settings.version)
|
||||
return is_lnbits_version_ok(self.min_lnbits_version, self.max_lnbits_version)
|
||||
|
||||
|
||||
class GitHubRelease(BaseModel):
|
||||
@@ -47,9 +52,9 @@ class GitHubRelease(BaseModel):
|
||||
|
||||
|
||||
class Manifest(BaseModel):
|
||||
featured: List[str] = []
|
||||
extensions: List["ExplicitRelease"] = []
|
||||
repos: List["GitHubRelease"] = []
|
||||
featured: list[str] = []
|
||||
extensions: list[ExplicitRelease] = []
|
||||
repos: list[GitHubRelease] = []
|
||||
|
||||
|
||||
class GitHubRepoRelease(BaseModel):
|
||||
@@ -58,6 +63,9 @@ class GitHubRepoRelease(BaseModel):
|
||||
zipball_url: str
|
||||
html_url: str
|
||||
|
||||
def details_link(self, source_repo: str) -> str:
|
||||
return f"https://raw.githubusercontent.com/{source_repo}/{self.tag_name}/config.json"
|
||||
|
||||
|
||||
class GitHubRepo(BaseModel):
|
||||
stargazers_count: str
|
||||
@@ -71,11 +79,21 @@ class ExtensionConfig(BaseModel):
|
||||
tile: str = ""
|
||||
warning: Optional[str] = ""
|
||||
min_lnbits_version: Optional[str]
|
||||
max_lnbits_version: Optional[str]
|
||||
|
||||
def is_version_compatible(self):
|
||||
if not self.min_lnbits_version:
|
||||
return True
|
||||
return version_parse(self.min_lnbits_version) <= version_parse(settings.version)
|
||||
def is_version_compatible(self) -> bool:
|
||||
return is_lnbits_version_ok(self.min_lnbits_version, self.max_lnbits_version)
|
||||
|
||||
@classmethod
|
||||
async def fetch_github_release_config(
|
||||
cls, org: str, repo: str, tag_name: str
|
||||
) -> Optional[ExtensionConfig]:
|
||||
config_url = (
|
||||
f"https://raw.githubusercontent.com/{org}/{repo}/{tag_name}/config.json"
|
||||
)
|
||||
error_msg = "Cannot fetch GitHub extension config"
|
||||
config = await github_api_get(config_url, error_msg)
|
||||
return ExtensionConfig.parse_obj(config)
|
||||
|
||||
|
||||
class ReleasePaymentInfo(BaseModel):
|
||||
@@ -85,117 +103,46 @@ class ReleasePaymentInfo(BaseModel):
|
||||
payment_request: Optional[str] = None
|
||||
|
||||
|
||||
def download_url(url, save_path):
|
||||
with request.urlopen(url, timeout=60) as dl_file:
|
||||
with open(save_path, "wb") as out_file:
|
||||
out_file.write(dl_file.read())
|
||||
class PayToEnableInfo(BaseModel):
|
||||
amount: int
|
||||
required: bool = False
|
||||
wallet: Optional[str] = None
|
||||
|
||||
|
||||
def file_hash(filename):
|
||||
h = hashlib.sha256()
|
||||
b = bytearray(128 * 1024)
|
||||
mv = memoryview(b)
|
||||
with open(filename, "rb", buffering=0) as f:
|
||||
while n := f.readinto(mv):
|
||||
h.update(mv[:n])
|
||||
return h.hexdigest()
|
||||
class UserExtensionInfo(BaseModel):
|
||||
paid_to_enable: Optional[bool] = False
|
||||
payment_hash_to_enable: Optional[str] = None
|
||||
|
||||
|
||||
async def fetch_github_repo_info(
|
||||
org: str, repository: str
|
||||
) -> Tuple[GitHubRepo, GitHubRepoRelease, ExtensionConfig]:
|
||||
repo_url = f"https://api.github.com/repos/{org}/{repository}"
|
||||
error_msg = "Cannot fetch extension repo"
|
||||
repo = await github_api_get(repo_url, error_msg)
|
||||
github_repo = GitHubRepo.parse_obj(repo)
|
||||
class UserExtension(BaseModel):
|
||||
user: str
|
||||
extension: str
|
||||
active: bool
|
||||
extra: Optional[UserExtensionInfo] = None
|
||||
|
||||
lates_release_url = (
|
||||
f"https://api.github.com/repos/{org}/{repository}/releases/latest"
|
||||
)
|
||||
error_msg = "Cannot fetch extension releases"
|
||||
latest_release: Any = await github_api_get(lates_release_url, error_msg)
|
||||
@property
|
||||
def is_paid(self) -> bool:
|
||||
if not self.extra:
|
||||
return False
|
||||
return self.extra.paid_to_enable is True
|
||||
|
||||
config_url = f"https://raw.githubusercontent.com/{org}/{repository}/{github_repo.default_branch}/config.json"
|
||||
error_msg = "Cannot fetch config for extension"
|
||||
config = await github_api_get(config_url, error_msg)
|
||||
|
||||
return (
|
||||
github_repo,
|
||||
GitHubRepoRelease.parse_obj(latest_release),
|
||||
ExtensionConfig.parse_obj(config),
|
||||
)
|
||||
@classmethod
|
||||
def from_row(cls, data: dict) -> UserExtension:
|
||||
ext = UserExtension(**data)
|
||||
ext.extra = (
|
||||
UserExtensionInfo(**json.loads(data["_extra"] or "{}"))
|
||||
if "_extra" in data
|
||||
else None
|
||||
)
|
||||
return ext
|
||||
|
||||
|
||||
async def fetch_manifest(url) -> Manifest:
|
||||
error_msg = "Cannot fetch extensions manifest"
|
||||
manifest = await github_api_get(url, error_msg)
|
||||
return Manifest.parse_obj(manifest)
|
||||
|
||||
|
||||
async def fetch_github_releases(org: str, repo: str) -> List[GitHubRepoRelease]:
|
||||
releases_url = f"https://api.github.com/repos/{org}/{repo}/releases"
|
||||
error_msg = "Cannot fetch extension releases"
|
||||
releases = await github_api_get(releases_url, error_msg)
|
||||
return [GitHubRepoRelease.parse_obj(r) for r in releases]
|
||||
|
||||
|
||||
async def fetch_github_release_config(
|
||||
org: str, repo: str, tag_name: str
|
||||
) -> Optional[ExtensionConfig]:
|
||||
config_url = (
|
||||
f"https://raw.githubusercontent.com/{org}/{repo}/{tag_name}/config.json"
|
||||
)
|
||||
error_msg = "Cannot fetch GitHub extension config"
|
||||
config = await github_api_get(config_url, error_msg)
|
||||
return ExtensionConfig.parse_obj(config)
|
||||
|
||||
|
||||
async def github_api_get(url: str, error_msg: Optional[str]) -> Any:
|
||||
headers = {"User-Agent": settings.user_agent}
|
||||
if settings.lnbits_ext_github_token:
|
||||
headers["Authorization"] = f"Bearer {settings.lnbits_ext_github_token}"
|
||||
async with httpx.AsyncClient(headers=headers) as client:
|
||||
resp = await client.get(url)
|
||||
if resp.status_code != 200:
|
||||
logger.warning(f"{error_msg} ({url}): {resp.text}")
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
async def fetch_release_payment_info(
|
||||
url: str, amount: Optional[int] = None
|
||||
) -> Optional[ReleasePaymentInfo]:
|
||||
if amount:
|
||||
url = f"{url}?amount={amount}"
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(url)
|
||||
resp.raise_for_status()
|
||||
return ReleasePaymentInfo(**resp.json())
|
||||
except Exception as e:
|
||||
logger.warning(e)
|
||||
return None
|
||||
|
||||
|
||||
def icon_to_github_url(source_repo: str, path: Optional[str]) -> str:
|
||||
if not path:
|
||||
return ""
|
||||
_, _, *rest = path.split("/")
|
||||
tail = "/".join(rest)
|
||||
return f"https://github.com/{source_repo}/raw/main/{tail}"
|
||||
|
||||
|
||||
class Extension(NamedTuple):
|
||||
class Extension(BaseModel):
|
||||
code: str
|
||||
is_valid: bool
|
||||
is_admin_only: bool
|
||||
name: Optional[str] = None
|
||||
short_description: Optional[str] = None
|
||||
tile: Optional[str] = None
|
||||
contributors: Optional[List[str]] = None
|
||||
hidden: bool = False
|
||||
migration_module: Optional[str] = None
|
||||
db_name: Optional[str] = None
|
||||
upgrade_hash: Optional[str] = ""
|
||||
|
||||
@property
|
||||
@@ -214,59 +161,17 @@ class Extension(NamedTuple):
|
||||
return self.upgrade_hash != ""
|
||||
|
||||
@classmethod
|
||||
def from_installable_ext(cls, ext_info: "InstallableExtension") -> "Extension":
|
||||
def from_installable_ext(cls, ext_info: InstallableExtension) -> Extension:
|
||||
return Extension(
|
||||
code=ext_info.id,
|
||||
is_valid=True,
|
||||
is_admin_only=False, # todo: is admin only
|
||||
name=ext_info.name,
|
||||
upgrade_hash=ext_info.hash if ext_info.module_installed else "",
|
||||
short_description=ext_info.short_description,
|
||||
tile=ext_info.icon,
|
||||
upgrade_hash=ext_info.hash if ext_info.ext_upgrade_dir.is_dir() else "",
|
||||
)
|
||||
|
||||
|
||||
# All subdirectories in the current directory, not recursive.
|
||||
|
||||
|
||||
class ExtensionManager:
|
||||
def __init__(self) -> None:
|
||||
p = Path(settings.lnbits_extensions_path, "extensions")
|
||||
Path(p).mkdir(parents=True, exist_ok=True)
|
||||
self._extension_folders: List[Path] = [f for f in p.iterdir() if f.is_dir()]
|
||||
|
||||
@property
|
||||
def extensions(self) -> List[Extension]:
|
||||
output: List[Extension] = []
|
||||
|
||||
for extension_folder in self._extension_folders:
|
||||
extension_code = extension_folder.parts[-1]
|
||||
try:
|
||||
with open(extension_folder / "config.json") as json_file:
|
||||
config = json.load(json_file)
|
||||
is_valid = True
|
||||
is_admin_only = extension_code in settings.lnbits_admin_extensions
|
||||
except Exception:
|
||||
config = {}
|
||||
is_valid = False
|
||||
is_admin_only = False
|
||||
|
||||
output.append(
|
||||
Extension(
|
||||
extension_code,
|
||||
is_valid,
|
||||
is_admin_only,
|
||||
config.get("name"),
|
||||
config.get("short_description"),
|
||||
config.get("tile"),
|
||||
config.get("contributors"),
|
||||
config.get("hidden") or False,
|
||||
config.get("migration_module"),
|
||||
config.get("db_name"),
|
||||
)
|
||||
)
|
||||
|
||||
return output
|
||||
|
||||
|
||||
class ExtensionRelease(BaseModel):
|
||||
name: str
|
||||
version: str
|
||||
@@ -275,12 +180,14 @@ class ExtensionRelease(BaseModel):
|
||||
is_github_release: bool = False
|
||||
hash: Optional[str] = None
|
||||
min_lnbits_version: Optional[str] = None
|
||||
max_lnbits_version: Optional[str] = None
|
||||
is_version_compatible: Optional[bool] = True
|
||||
html_url: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
warning: Optional[str] = None
|
||||
repo: Optional[str] = None
|
||||
icon: Optional[str] = None
|
||||
details_link: Optional[str] = None
|
||||
|
||||
pay_link: Optional[str] = None
|
||||
cost_sats: Optional[int] = None
|
||||
@@ -299,13 +206,27 @@ class ExtensionRelease(BaseModel):
|
||||
if not self.pay_link:
|
||||
return
|
||||
|
||||
payment_info = await fetch_release_payment_info(self.pay_link)
|
||||
payment_info = await self.fetch_release_payment_info()
|
||||
self.cost_sats = payment_info.amount if payment_info else None
|
||||
|
||||
async def fetch_release_payment_info(
|
||||
self, amount: Optional[int] = None
|
||||
) -> Optional[ReleasePaymentInfo]:
|
||||
url = f"{self.pay_link}?amount={amount}" if amount else self.pay_link
|
||||
assert url, "Missing URL for payment info."
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(url)
|
||||
resp.raise_for_status()
|
||||
return ReleasePaymentInfo(**resp.json())
|
||||
except Exception as e:
|
||||
logger.warning(e)
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def from_github_release(
|
||||
cls, source_repo: str, r: "GitHubRepoRelease"
|
||||
) -> "ExtensionRelease":
|
||||
cls, source_repo: str, r: GitHubRepoRelease
|
||||
) -> ExtensionRelease:
|
||||
return ExtensionRelease(
|
||||
name=r.name,
|
||||
description=r.name,
|
||||
@@ -313,14 +234,15 @@ class ExtensionRelease(BaseModel):
|
||||
archive=r.zipball_url,
|
||||
source_repo=source_repo,
|
||||
is_github_release=True,
|
||||
details_link=r.details_link(source_repo),
|
||||
repo=f"https://github.com/{source_repo}",
|
||||
html_url=r.html_url,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_explicit_release(
|
||||
cls, source_repo: str, e: "ExplicitRelease"
|
||||
) -> "ExtensionRelease":
|
||||
cls, source_repo: str, e: ExplicitRelease
|
||||
) -> ExtensionRelease:
|
||||
return ExtensionRelease(
|
||||
name=e.name,
|
||||
version=e.version,
|
||||
@@ -329,18 +251,20 @@ class ExtensionRelease(BaseModel):
|
||||
source_repo=source_repo,
|
||||
description=e.short_description,
|
||||
min_lnbits_version=e.min_lnbits_version,
|
||||
max_lnbits_version=e.max_lnbits_version,
|
||||
is_version_compatible=e.is_version_compatible(),
|
||||
warning=e.warning,
|
||||
html_url=e.html_url,
|
||||
details_link=e.details_link,
|
||||
pay_link=e.pay_link,
|
||||
repo=e.repo,
|
||||
icon=e.icon,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def get_github_releases(cls, org: str, repo: str) -> List["ExtensionRelease"]:
|
||||
async def get_github_releases(cls, org: str, repo: str) -> list[ExtensionRelease]:
|
||||
try:
|
||||
github_releases = await fetch_github_releases(org, repo)
|
||||
github_releases = await cls.fetch_github_releases(org, repo)
|
||||
return [
|
||||
ExtensionRelease.from_github_release(f"{org}/{repo}", r)
|
||||
for r in github_releases
|
||||
@@ -349,28 +273,61 @@ class ExtensionRelease(BaseModel):
|
||||
logger.warning(e)
|
||||
return []
|
||||
|
||||
@classmethod
|
||||
async def fetch_github_releases(
|
||||
cls, org: str, repo: str
|
||||
) -> list[GitHubRepoRelease]:
|
||||
releases_url = f"https://api.github.com/repos/{org}/{repo}/releases"
|
||||
error_msg = "Cannot fetch extension releases"
|
||||
releases = await github_api_get(releases_url, error_msg)
|
||||
return [GitHubRepoRelease.parse_obj(r) for r in releases]
|
||||
|
||||
@classmethod
|
||||
async def fetch_release_details(cls, details_link: str) -> Optional[dict]:
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(details_link)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
if "description_md" in data:
|
||||
resp = await client.get(data["description_md"])
|
||||
if not resp.is_error:
|
||||
data["description_md"] = resp.text
|
||||
|
||||
return data
|
||||
except Exception as e:
|
||||
logger.warning(e)
|
||||
return None
|
||||
|
||||
|
||||
class ExtensionMeta(BaseModel):
|
||||
installed_release: Optional[ExtensionRelease] = None
|
||||
latest_release: Optional[ExtensionRelease] = None
|
||||
pay_to_enable: Optional[PayToEnableInfo] = None
|
||||
payments: list[ReleasePaymentInfo] = []
|
||||
dependencies: list[str] = []
|
||||
archive: Optional[str] = None
|
||||
featured: bool = False
|
||||
|
||||
|
||||
class InstallableExtension(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
version: str
|
||||
active: Optional[bool] = False
|
||||
short_description: Optional[str] = None
|
||||
icon: Optional[str] = None
|
||||
dependencies: List[str] = []
|
||||
is_admin_only: bool = False
|
||||
stars: int = 0
|
||||
featured = False
|
||||
latest_release: Optional[ExtensionRelease] = None
|
||||
installed_release: Optional[ExtensionRelease] = None
|
||||
payments: List[ReleasePaymentInfo] = []
|
||||
archive: Optional[str] = None
|
||||
meta: Optional[ExtensionMeta] = None
|
||||
|
||||
@property
|
||||
def hash(self) -> str:
|
||||
if self.installed_release:
|
||||
if self.installed_release.hash:
|
||||
return self.installed_release.hash
|
||||
if self.meta and self.meta.installed_release:
|
||||
if self.meta.installed_release.hash:
|
||||
return self.meta.installed_release.hash
|
||||
m = hashlib.sha256()
|
||||
m.update(f"{self.installed_release.archive}".encode())
|
||||
m.update(f"{self.meta.installed_release.archive}".encode())
|
||||
return m.hexdigest()
|
||||
return "not-installed"
|
||||
|
||||
@@ -392,14 +349,15 @@ class InstallableExtension(BaseModel):
|
||||
|
||||
@property
|
||||
def module_name(self) -> str:
|
||||
if self.ext_upgrade_dir.is_dir():
|
||||
if settings.has_default_extension_path:
|
||||
return f"lnbits.upgrades.{self.id}-{self.hash}"
|
||||
return f"{self.id}-{self.hash}"
|
||||
|
||||
if settings.has_default_extension_path:
|
||||
return f"lnbits.extensions.{self.id}"
|
||||
return self.id
|
||||
|
||||
@property
|
||||
def module_installed(self) -> bool:
|
||||
return self.module_name in sys.modules
|
||||
|
||||
@property
|
||||
def has_installed_version(self) -> bool:
|
||||
if not self.ext_dir.is_dir():
|
||||
@@ -408,22 +366,30 @@ class InstallableExtension(BaseModel):
|
||||
|
||||
@property
|
||||
def installed_version(self) -> str:
|
||||
if self.installed_release:
|
||||
return self.installed_release.version
|
||||
if self.meta and self.meta.installed_release:
|
||||
return self.meta.installed_release.version
|
||||
return ""
|
||||
|
||||
@property
|
||||
def requires_payment(self) -> bool:
|
||||
if not self.meta or not self.meta.pay_to_enable:
|
||||
return False
|
||||
return self.meta.pay_to_enable.required is True
|
||||
|
||||
async def download_archive(self):
|
||||
logger.info(f"Downloading extension {self.name} ({self.installed_version}).")
|
||||
ext_zip_file = self.zip_path
|
||||
if ext_zip_file.is_file():
|
||||
os.remove(ext_zip_file)
|
||||
try:
|
||||
assert self.installed_release, "installed_release is none."
|
||||
assert (
|
||||
self.meta and self.meta.installed_release
|
||||
), "installed_release is none."
|
||||
|
||||
self._restore_payment_info()
|
||||
|
||||
await asyncio.to_thread(
|
||||
download_url, self.installed_release.archive_url, ext_zip_file
|
||||
download_url, self.meta.installed_release.archive_url, ext_zip_file
|
||||
)
|
||||
|
||||
self._remember_payment_info()
|
||||
@@ -433,7 +399,11 @@ class InstallableExtension(BaseModel):
|
||||
raise AssertionError("Cannot fetch extension archive file") from exc
|
||||
|
||||
archive_hash = file_hash(ext_zip_file)
|
||||
if self.installed_release.hash and self.installed_release.hash != archive_hash:
|
||||
if (
|
||||
self.meta
|
||||
and self.meta.installed_release.hash
|
||||
and self.meta.installed_release.hash != archive_hash
|
||||
):
|
||||
# remove downloaded archive
|
||||
if ext_zip_file.is_file():
|
||||
os.remove(ext_zip_file)
|
||||
@@ -467,34 +437,18 @@ class InstallableExtension(BaseModel):
|
||||
self.short_description = config_json.get("short_description")
|
||||
|
||||
if (
|
||||
self.installed_release
|
||||
and self.installed_release.is_github_release
|
||||
self.meta
|
||||
and self.meta.installed_release
|
||||
and self.meta.installed_release.is_github_release
|
||||
and config_json.get("tile")
|
||||
):
|
||||
self.icon = icon_to_github_url(
|
||||
self.installed_release.source_repo, config_json.get("tile")
|
||||
self.meta.installed_release.source_repo, config_json.get("tile")
|
||||
)
|
||||
|
||||
shutil.rmtree(self.ext_dir, True)
|
||||
shutil.copytree(Path(self.ext_upgrade_dir), Path(self.ext_dir))
|
||||
logger.success(f"Extension {self.name} ({self.installed_version}) installed.")
|
||||
|
||||
def notify_upgrade(self) -> None:
|
||||
"""
|
||||
Update the list of upgraded extensions. The middleware will perform
|
||||
redirects based on this
|
||||
"""
|
||||
|
||||
clean_upgraded_exts = list(
|
||||
filter(
|
||||
lambda old_ext: not old_ext.endswith(f"/{self.id}"),
|
||||
settings.lnbits_upgraded_extensions,
|
||||
)
|
||||
)
|
||||
settings.lnbits_upgraded_extensions = [
|
||||
*clean_upgraded_exts,
|
||||
f"{self.hash}/{self.id}",
|
||||
]
|
||||
logger.info(f"Extension {self.name} ({self.installed_version}) extracted.")
|
||||
|
||||
def clean_extension_files(self):
|
||||
# remove downloaded archive
|
||||
@@ -509,84 +463,78 @@ class InstallableExtension(BaseModel):
|
||||
def check_latest_version(self, release: Optional[ExtensionRelease]):
|
||||
if not release:
|
||||
return
|
||||
if not self.latest_release:
|
||||
self.latest_release = release
|
||||
if not self.meta or not self.meta.latest_release:
|
||||
meta = self.meta or ExtensionMeta()
|
||||
meta.latest_release = release
|
||||
self.meta = meta
|
||||
return
|
||||
if version_parse(self.latest_release.version) < version_parse(release.version):
|
||||
self.latest_release = release
|
||||
if version_parse(self.meta.latest_release.version) < version_parse(
|
||||
release.version
|
||||
):
|
||||
self.meta.latest_release = release
|
||||
|
||||
def find_existing_payment(
|
||||
self, pay_link: Optional[str]
|
||||
) -> Optional[ReleasePaymentInfo]:
|
||||
if not pay_link:
|
||||
if not pay_link or not self.meta or not self.meta.payments:
|
||||
return None
|
||||
return next(
|
||||
(p for p in self.payments if p.pay_link == pay_link),
|
||||
(p for p in self.meta.payments if p.pay_link == pay_link),
|
||||
None,
|
||||
)
|
||||
|
||||
def _restore_payment_info(self):
|
||||
if not self.installed_release:
|
||||
if (
|
||||
not self.meta
|
||||
or not self.meta.installed_release
|
||||
or not self.meta.installed_release.pay_link
|
||||
or not self.meta.installed_release.payment_hash
|
||||
):
|
||||
return
|
||||
if not self.installed_release.pay_link:
|
||||
return
|
||||
if self.installed_release.payment_hash:
|
||||
return
|
||||
payment_info = self.find_existing_payment(self.installed_release.pay_link)
|
||||
payment_info = self.find_existing_payment(self.meta.installed_release.pay_link)
|
||||
if payment_info:
|
||||
self.installed_release.payment_hash = payment_info.payment_hash
|
||||
self.meta.installed_release.payment_hash = payment_info.payment_hash
|
||||
|
||||
def _remember_payment_info(self):
|
||||
if not self.installed_release or not self.installed_release.pay_link:
|
||||
if (
|
||||
not self.meta
|
||||
or not self.meta.installed_release
|
||||
or not self.meta.installed_release.pay_link
|
||||
):
|
||||
return
|
||||
payment_info = ReleasePaymentInfo(
|
||||
amount=self.installed_release.cost_sats,
|
||||
pay_link=self.installed_release.pay_link,
|
||||
payment_hash=self.installed_release.payment_hash,
|
||||
amount=self.meta.installed_release.cost_sats,
|
||||
pay_link=self.meta.installed_release.pay_link,
|
||||
payment_hash=self.meta.installed_release.payment_hash,
|
||||
)
|
||||
self.payments = [
|
||||
p for p in self.payments if p.pay_link != payment_info.pay_link
|
||||
self.meta.payments = [
|
||||
p for p in self.meta.payments if p.pay_link != payment_info.pay_link
|
||||
]
|
||||
self.payments.append(payment_info)
|
||||
|
||||
@classmethod
|
||||
def from_row(cls, data: dict) -> "InstallableExtension":
|
||||
meta = json.loads(data["meta"])
|
||||
ext = InstallableExtension(**data)
|
||||
if "installed_release" in meta:
|
||||
ext.installed_release = ExtensionRelease(**meta["installed_release"])
|
||||
if meta.get("payments"):
|
||||
ext.payments = [ReleasePaymentInfo(**p) for p in meta["payments"]]
|
||||
return ext
|
||||
|
||||
@classmethod
|
||||
def from_rows(
|
||||
cls, rows: Optional[List[Any]] = None
|
||||
) -> List["InstallableExtension"]:
|
||||
if rows is None:
|
||||
rows = []
|
||||
return [InstallableExtension.from_row(row) for row in rows]
|
||||
self.meta.payments.append(payment_info)
|
||||
|
||||
@classmethod
|
||||
async def from_github_release(
|
||||
cls, github_release: GitHubRelease
|
||||
) -> Optional["InstallableExtension"]:
|
||||
) -> Optional[InstallableExtension]:
|
||||
try:
|
||||
repo, latest_release, config = await fetch_github_repo_info(
|
||||
repo, latest_release, config = await cls.fetch_github_repo_info(
|
||||
github_release.organisation, github_release.repository
|
||||
)
|
||||
|
||||
source_repo = f"{github_release.organisation}/{github_release.repository}"
|
||||
return InstallableExtension(
|
||||
id=github_release.id,
|
||||
name=config.name,
|
||||
version=latest_release.tag_name,
|
||||
short_description=config.short_description,
|
||||
stars=int(repo.stargazers_count),
|
||||
icon=icon_to_github_url(
|
||||
f"{github_release.organisation}/{github_release.repository}",
|
||||
source_repo,
|
||||
config.tile,
|
||||
),
|
||||
latest_release=ExtensionRelease.from_github_release(
|
||||
repo.html_url, latest_release
|
||||
meta=ExtensionMeta(
|
||||
latest_release=ExtensionRelease.from_github_release(
|
||||
source_repo, latest_release
|
||||
),
|
||||
),
|
||||
)
|
||||
except Exception as e:
|
||||
@@ -594,26 +542,63 @@ class InstallableExtension(BaseModel):
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def from_explicit_release(cls, e: ExplicitRelease) -> "InstallableExtension":
|
||||
def from_explicit_release(cls, e: ExplicitRelease) -> InstallableExtension:
|
||||
meta = ExtensionMeta(archive=e.archive, dependencies=e.dependencies)
|
||||
return InstallableExtension(
|
||||
id=e.id,
|
||||
name=e.name,
|
||||
archive=e.archive,
|
||||
version=e.version,
|
||||
short_description=e.short_description,
|
||||
icon=e.icon,
|
||||
dependencies=e.dependencies,
|
||||
meta=meta,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_ext_dir(cls, ext_id: str) -> Optional[InstallableExtension]:
|
||||
try:
|
||||
conf_path = Path(
|
||||
settings.lnbits_extensions_path, "extensions", ext_id, "config.json"
|
||||
)
|
||||
if not conf_path.is_file():
|
||||
return None
|
||||
with open(conf_path, "r+") as json_file:
|
||||
config_json = json.load(json_file)
|
||||
version = config_json.get("version", "0.0")
|
||||
|
||||
return InstallableExtension(
|
||||
id=ext_id,
|
||||
name=config_json.get("name", ext_id),
|
||||
active=True,
|
||||
version=version,
|
||||
short_description=config_json.get("short_description"),
|
||||
icon=config_json.get("tile"),
|
||||
meta=ExtensionMeta(
|
||||
installed_release=ExtensionRelease(
|
||||
name=ext_id,
|
||||
version=version,
|
||||
archive=f"{conf_path}",
|
||||
source_repo=f"{conf_path}",
|
||||
min_lnbits_version=config_json.get("min_lnbits_version"),
|
||||
max_lnbits_version=config_json.get("max_lnbits_version"),
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(e)
|
||||
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
async def get_installable_extensions(
|
||||
cls,
|
||||
) -> List["InstallableExtension"]:
|
||||
extension_list: List[InstallableExtension] = []
|
||||
extension_id_list: List[str] = []
|
||||
) -> list[InstallableExtension]:
|
||||
extension_list: list[InstallableExtension] = []
|
||||
extension_id_list: list[str] = []
|
||||
|
||||
for url in settings.lnbits_extensions_manifests:
|
||||
try:
|
||||
manifest = await fetch_manifest(url)
|
||||
manifest = await cls.fetch_manifest(url)
|
||||
|
||||
for r in manifest.repos:
|
||||
ext = await InstallableExtension.from_github_release(r)
|
||||
@@ -622,11 +607,13 @@ class InstallableExtension(BaseModel):
|
||||
existing_ext = next(
|
||||
(ee for ee in extension_list if ee.id == r.id), None
|
||||
)
|
||||
if existing_ext:
|
||||
existing_ext.check_latest_version(ext.latest_release)
|
||||
if existing_ext and ext.meta:
|
||||
existing_ext.check_latest_version(ext.meta.latest_release)
|
||||
continue
|
||||
|
||||
ext.featured = ext.id in manifest.featured
|
||||
meta = ext.meta or ExtensionMeta()
|
||||
meta.featured = ext.id in manifest.featured
|
||||
ext.meta = meta
|
||||
extension_list += [ext]
|
||||
extension_id_list += [ext.id]
|
||||
|
||||
@@ -640,7 +627,9 @@ class InstallableExtension(BaseModel):
|
||||
continue
|
||||
ext = InstallableExtension.from_explicit_release(e)
|
||||
ext.check_latest_version(release)
|
||||
ext.featured = ext.id in manifest.featured
|
||||
meta = ext.meta or ExtensionMeta()
|
||||
meta.featured = ext.id in manifest.featured
|
||||
ext.meta = meta
|
||||
extension_list += [ext]
|
||||
extension_id_list += [e.id]
|
||||
except Exception as e:
|
||||
@@ -649,12 +638,12 @@ class InstallableExtension(BaseModel):
|
||||
return extension_list
|
||||
|
||||
@classmethod
|
||||
async def get_extension_releases(cls, ext_id: str) -> List["ExtensionRelease"]:
|
||||
extension_releases: List[ExtensionRelease] = []
|
||||
async def get_extension_releases(cls, ext_id: str) -> list[ExtensionRelease]:
|
||||
extension_releases: list[ExtensionRelease] = []
|
||||
|
||||
for url in settings.lnbits_extensions_manifests:
|
||||
try:
|
||||
manifest = await fetch_manifest(url)
|
||||
manifest = await cls.fetch_manifest(url)
|
||||
for r in manifest.repos:
|
||||
if r.id != ext_id:
|
||||
continue
|
||||
@@ -678,8 +667,8 @@ class InstallableExtension(BaseModel):
|
||||
@classmethod
|
||||
async def get_extension_release(
|
||||
cls, ext_id: str, source_repo: str, archive: str, version: str
|
||||
) -> Optional["ExtensionRelease"]:
|
||||
all_releases: List[ExtensionRelease] = (
|
||||
) -> Optional[ExtensionRelease]:
|
||||
all_releases: list[ExtensionRelease] = (
|
||||
await InstallableExtension.get_extension_releases(ext_id)
|
||||
)
|
||||
selected_release = [
|
||||
@@ -692,6 +681,37 @@ class InstallableExtension(BaseModel):
|
||||
|
||||
return selected_release[0] if len(selected_release) != 0 else None
|
||||
|
||||
@classmethod
|
||||
async def fetch_github_repo_info(
|
||||
cls, org: str, repository: str
|
||||
) -> tuple[GitHubRepo, GitHubRepoRelease, ExtensionConfig]:
|
||||
repo_url = f"https://api.github.com/repos/{org}/{repository}"
|
||||
error_msg = "Cannot fetch extension repo"
|
||||
repo = await github_api_get(repo_url, error_msg)
|
||||
github_repo = GitHubRepo.parse_obj(repo)
|
||||
|
||||
lates_release_url = (
|
||||
f"https://api.github.com/repos/{org}/{repository}/releases/latest"
|
||||
)
|
||||
error_msg = "Cannot fetch extension releases"
|
||||
latest_release: Any = await github_api_get(lates_release_url, error_msg)
|
||||
|
||||
config_url = f"https://raw.githubusercontent.com/{org}/{repository}/{github_repo.default_branch}/config.json"
|
||||
error_msg = "Cannot fetch config for extension"
|
||||
config = await github_api_get(config_url, error_msg)
|
||||
|
||||
return (
|
||||
github_repo,
|
||||
GitHubRepoRelease.parse_obj(latest_release),
|
||||
ExtensionConfig.parse_obj(config),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def fetch_manifest(cls, url) -> Manifest:
|
||||
error_msg = "Cannot fetch extensions manifest"
|
||||
manifest = await github_api_get(url, error_msg)
|
||||
return Manifest.parse_obj(manifest)
|
||||
|
||||
|
||||
class CreateExtension(BaseModel):
|
||||
ext_id: str
|
||||
@@ -702,30 +722,27 @@ class CreateExtension(BaseModel):
|
||||
payment_hash: Optional[str] = None
|
||||
|
||||
|
||||
def get_valid_extensions(include_deactivated: Optional[bool] = True) -> List[Extension]:
|
||||
valid_extensions = [
|
||||
extension for extension in ExtensionManager().extensions if extension.is_valid
|
||||
]
|
||||
|
||||
if include_deactivated:
|
||||
return valid_extensions
|
||||
|
||||
if settings.lnbits_extensions_deactivate_all:
|
||||
return []
|
||||
|
||||
return [
|
||||
e
|
||||
for e in valid_extensions
|
||||
if e.code not in settings.lnbits_deactivated_extensions
|
||||
]
|
||||
class ExtensionDetailsRequest(BaseModel):
|
||||
ext_id: str
|
||||
source_repo: str
|
||||
version: str
|
||||
|
||||
|
||||
def version_parse(v: str):
|
||||
"""
|
||||
Wrapper for version.parse() that does not throw if the version is invalid.
|
||||
Instead it return the lowest possible version ("0.0.0")
|
||||
"""
|
||||
try:
|
||||
return version.parse(v)
|
||||
except Exception:
|
||||
return version.parse("0.0.0")
|
||||
async def github_api_get(url: str, error_msg: Optional[str]) -> Any:
|
||||
headers = {"User-Agent": settings.user_agent}
|
||||
if settings.lnbits_ext_github_token:
|
||||
headers["Authorization"] = f"Bearer {settings.lnbits_ext_github_token}"
|
||||
async with httpx.AsyncClient(headers=headers) as client:
|
||||
resp = await client.get(url)
|
||||
if resp.status_code != 200:
|
||||
logger.warning(f"{error_msg} ({url}): {resp.text}")
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
def icon_to_github_url(source_repo: str, path: Optional[str]) -> str:
|
||||
if not path:
|
||||
return ""
|
||||
_, _, *rest = path.split("/")
|
||||
tail = "/".join(rest)
|
||||
return f"https://github.com/{source_repo}/raw/main/{tail}"
|
||||
@@ -0,0 +1,20 @@
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class CreateLnurl(BaseModel):
|
||||
description_hash: str
|
||||
callback: str
|
||||
amount: int
|
||||
comment: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
unit: Optional[str] = None
|
||||
|
||||
|
||||
class CreateLnurlAuth(BaseModel):
|
||||
callback: str
|
||||
|
||||
|
||||
class PayLnurlWData(BaseModel):
|
||||
lnurl_w: str
|
||||
@@ -0,0 +1,43 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Callable
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
def _do_nothing(*_):
|
||||
pass
|
||||
|
||||
|
||||
class CoreAppExtra:
|
||||
register_new_ext_routes: Callable = _do_nothing
|
||||
register_new_ratelimiter: Callable
|
||||
|
||||
|
||||
class ConversionData(BaseModel):
|
||||
from_: str = "sat"
|
||||
amount: float
|
||||
to: str = "usd"
|
||||
|
||||
|
||||
class Callback(BaseModel):
|
||||
callback: str
|
||||
|
||||
|
||||
class BalanceDelta(BaseModel):
|
||||
lnbits_balance_msats: int
|
||||
node_balance_msats: int
|
||||
|
||||
@property
|
||||
def delta_msats(self):
|
||||
return self.node_balance_msats - self.lnbits_balance_msats
|
||||
|
||||
|
||||
class SimpleStatus(BaseModel):
|
||||
success: bool
|
||||
message: str
|
||||
|
||||
|
||||
class DbVersion(BaseModel):
|
||||
db: str
|
||||
version: int
|
||||
@@ -0,0 +1,177 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import Query
|
||||
from pydantic import BaseModel, Field, validator
|
||||
|
||||
from lnbits.db import FilterModel
|
||||
from lnbits.utils.exchange_rates import allowed_currencies
|
||||
from lnbits.wallets import get_funding_source
|
||||
from lnbits.wallets.base import (
|
||||
PaymentFailedStatus,
|
||||
PaymentPendingStatus,
|
||||
PaymentStatus,
|
||||
PaymentSuccessStatus,
|
||||
)
|
||||
|
||||
|
||||
class PaymentState(str, Enum):
|
||||
PENDING = "pending"
|
||||
SUCCESS = "success"
|
||||
FAILED = "failed"
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.value
|
||||
|
||||
|
||||
class PaymentExtra(BaseModel):
|
||||
comment: Optional[str] = None
|
||||
success_action: Optional[str] = None
|
||||
lnurl_response: Optional[str] = None
|
||||
|
||||
|
||||
class PayInvoice(BaseModel):
|
||||
payment_request: str
|
||||
description: Optional[str] = None
|
||||
max_sat: Optional[int] = None
|
||||
extra: Optional[dict] = {}
|
||||
|
||||
|
||||
class CreatePayment(BaseModel):
|
||||
wallet_id: str
|
||||
payment_hash: str
|
||||
bolt11: str
|
||||
amount_msat: int
|
||||
memo: str
|
||||
extra: Optional[dict] = {}
|
||||
preimage: Optional[str] = None
|
||||
expiry: Optional[datetime] = None
|
||||
webhook: Optional[str] = None
|
||||
fee: int = 0
|
||||
|
||||
|
||||
class Payment(BaseModel):
|
||||
checking_id: str
|
||||
payment_hash: str
|
||||
wallet_id: str
|
||||
amount: int
|
||||
fee: int
|
||||
bolt11: str
|
||||
status: str = PaymentState.PENDING
|
||||
memo: Optional[str] = None
|
||||
expiry: Optional[datetime] = None
|
||||
webhook: Optional[str] = None
|
||||
webhook_status: Optional[int] = None
|
||||
preimage: Optional[str] = None
|
||||
tag: Optional[str] = None
|
||||
extension: Optional[str] = None
|
||||
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))
|
||||
extra: dict = {}
|
||||
|
||||
@property
|
||||
def pending(self) -> bool:
|
||||
return self.status == PaymentState.PENDING.value
|
||||
|
||||
@property
|
||||
def success(self) -> bool:
|
||||
return self.status == PaymentState.SUCCESS.value
|
||||
|
||||
@property
|
||||
def failed(self) -> bool:
|
||||
return self.status == PaymentState.FAILED.value
|
||||
|
||||
@property
|
||||
def msat(self) -> int:
|
||||
return self.amount
|
||||
|
||||
@property
|
||||
def sat(self) -> int:
|
||||
return self.amount // 1000
|
||||
|
||||
@property
|
||||
def is_in(self) -> bool:
|
||||
return self.amount > 0
|
||||
|
||||
@property
|
||||
def is_out(self) -> bool:
|
||||
return self.amount < 0
|
||||
|
||||
@property
|
||||
def is_expired(self) -> bool:
|
||||
return self.expiry < datetime.now(timezone.utc) if self.expiry else False
|
||||
|
||||
@property
|
||||
def is_internal(self) -> bool:
|
||||
return self.checking_id.startswith("internal_")
|
||||
|
||||
async def check_status(self) -> PaymentStatus:
|
||||
if self.is_internal:
|
||||
if self.success:
|
||||
return PaymentSuccessStatus()
|
||||
if self.failed:
|
||||
return PaymentFailedStatus()
|
||||
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
|
||||
|
||||
|
||||
class PaymentFilters(FilterModel):
|
||||
__search_fields__ = ["memo", "amount"]
|
||||
|
||||
status: str
|
||||
checking_id: str
|
||||
amount: int
|
||||
fee: int
|
||||
memo: Optional[str]
|
||||
time: datetime
|
||||
bolt11: str
|
||||
preimage: str
|
||||
payment_hash: str
|
||||
expiry: Optional[datetime]
|
||||
extra: dict = {}
|
||||
wallet_id: str
|
||||
webhook: Optional[str]
|
||||
webhook_status: Optional[int]
|
||||
|
||||
|
||||
class PaymentHistoryPoint(BaseModel):
|
||||
date: datetime
|
||||
income: int
|
||||
spending: int
|
||||
balance: int
|
||||
|
||||
|
||||
class DecodePayment(BaseModel):
|
||||
data: str
|
||||
filter_fields: Optional[list[str]] = []
|
||||
|
||||
|
||||
class CreateInvoice(BaseModel):
|
||||
unit: str = "sat"
|
||||
internal: bool = False
|
||||
out: bool = True
|
||||
amount: float = Query(None, ge=0)
|
||||
memo: Optional[str] = None
|
||||
description_hash: Optional[str] = None
|
||||
unhashed_description: Optional[str] = None
|
||||
expiry: Optional[int] = None
|
||||
extra: Optional[dict] = None
|
||||
webhook: Optional[str] = None
|
||||
bolt11: Optional[str] = None
|
||||
lnurl_callback: Optional[str] = None
|
||||
|
||||
@validator("unit")
|
||||
@classmethod
|
||||
def unit_is_from_allowed_currencies(cls, v):
|
||||
if v != "sat" and v not in allowed_currencies():
|
||||
raise ValueError("The provided unit is not supported")
|
||||
return v
|
||||
@@ -0,0 +1,9 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class TinyURL(BaseModel):
|
||||
id: str
|
||||
url: str
|
||||
endless: bool
|
||||
wallet: str
|
||||
time: float
|
||||
@@ -0,0 +1,200 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import Query
|
||||
from passlib.context import CryptContext
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from lnbits.db import FilterModel
|
||||
from lnbits.helpers import is_valid_email_address, is_valid_pubkey, is_valid_username
|
||||
from lnbits.settings import settings
|
||||
|
||||
from .wallets import Wallet
|
||||
|
||||
|
||||
class UserExtra(BaseModel):
|
||||
email_verified: Optional[bool] = False
|
||||
first_name: Optional[str] = None
|
||||
last_name: Optional[str] = None
|
||||
display_name: Optional[str] = None
|
||||
picture: Optional[str] = None
|
||||
# Auth provider, possible values:
|
||||
# - "env": the user was created automatically by the system
|
||||
# - "lnbits": the user was created via register form (username/pass or user_id only)
|
||||
# - "google | github | ...": the user was created using an SSO provider
|
||||
provider: Optional[str] = "lnbits" # auth provider
|
||||
|
||||
|
||||
class Account(BaseModel):
|
||||
id: str
|
||||
username: Optional[str] = None
|
||||
password_hash: Optional[str] = None
|
||||
pubkey: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
extra: UserExtra = UserExtra()
|
||||
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
is_super_user: bool = Field(default=False, no_database=True)
|
||||
is_admin: bool = Field(default=False, no_database=True)
|
||||
|
||||
def __init__(self, **data):
|
||||
super().__init__(**data)
|
||||
self.is_super_user = settings.is_super_user(self.id)
|
||||
self.is_admin = settings.is_admin_user(self.id)
|
||||
|
||||
def hash_password(self, password: str) -> str:
|
||||
"""sets and returns the hashed password"""
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
self.password_hash = pwd_context.hash(password)
|
||||
return self.password_hash
|
||||
|
||||
def verify_password(self, password: str) -> bool:
|
||||
"""returns True if the password matches the hash"""
|
||||
if not self.password_hash:
|
||||
return False
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
return pwd_context.verify(password, self.password_hash)
|
||||
|
||||
def validate_fields(self):
|
||||
if self.username and not is_valid_username(self.username):
|
||||
raise ValueError("Invalid username.")
|
||||
if self.email and not is_valid_email_address(self.email):
|
||||
raise ValueError("Invalid email.")
|
||||
if self.pubkey and not is_valid_pubkey(self.pubkey):
|
||||
raise ValueError("Invalid pubkey.")
|
||||
user_uuid4 = UUID(hex=self.id, version=4)
|
||||
if user_uuid4.hex != self.id:
|
||||
raise ValueError("User ID is not valid UUID4 hex string.")
|
||||
|
||||
|
||||
class AccountOverview(Account):
|
||||
transaction_count: Optional[int] = 0
|
||||
wallet_count: Optional[int] = 0
|
||||
balance_msat: Optional[int] = 0
|
||||
last_payment: Optional[datetime] = None
|
||||
|
||||
|
||||
class AccountFilters(FilterModel):
|
||||
__search_fields__ = ["user", "email", "username", "pubkey", "wallet_id"]
|
||||
__sort_fields__ = [
|
||||
"balance_msat",
|
||||
"email",
|
||||
"username",
|
||||
"transaction_count",
|
||||
"wallet_count",
|
||||
"last_payment",
|
||||
]
|
||||
|
||||
email: Optional[str] = None
|
||||
user: Optional[str] = None
|
||||
username: Optional[str] = None
|
||||
pubkey: Optional[str] = None
|
||||
wallet_id: Optional[str] = None
|
||||
|
||||
|
||||
class User(BaseModel):
|
||||
id: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
email: Optional[str] = None
|
||||
username: Optional[str] = None
|
||||
pubkey: Optional[str] = None
|
||||
extensions: list[str] = []
|
||||
wallets: list[Wallet] = []
|
||||
admin: bool = False
|
||||
super_user: bool = False
|
||||
has_password: bool = False
|
||||
extra: UserExtra = UserExtra()
|
||||
|
||||
@property
|
||||
def wallet_ids(self) -> list[str]:
|
||||
return [wallet.id for wallet in self.wallets]
|
||||
|
||||
def get_wallet(self, wallet_id: str) -> Optional[Wallet]:
|
||||
w = [wallet for wallet in self.wallets if wallet.id == wallet_id]
|
||||
return w[0] if w else None
|
||||
|
||||
@classmethod
|
||||
def is_extension_for_user(cls, ext: str, user: str) -> bool:
|
||||
if ext not in settings.lnbits_admin_extensions:
|
||||
return True
|
||||
if user == settings.super_user:
|
||||
return True
|
||||
if user in settings.lnbits_admin_users:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class RegisterUser(BaseModel):
|
||||
email: Optional[str] = Query(default=None)
|
||||
username: str = Query(default=..., min_length=2, max_length=20)
|
||||
password: str = Query(default=..., min_length=8, max_length=50)
|
||||
password_repeat: str = Query(default=..., min_length=8, max_length=50)
|
||||
|
||||
|
||||
class CreateUser(BaseModel):
|
||||
id: Optional[str] = Query(default=None)
|
||||
email: Optional[str] = Query(default=None)
|
||||
username: Optional[str] = Query(default=None, min_length=2, max_length=20)
|
||||
password: Optional[str] = Query(default=None, min_length=8, max_length=50)
|
||||
password_repeat: Optional[str] = Query(default=None, min_length=8, max_length=50)
|
||||
pubkey: str = Query(default=None, max_length=64)
|
||||
extensions: Optional[list[str]] = None
|
||||
extra: Optional[UserExtra] = None
|
||||
|
||||
|
||||
class UpdateUser(BaseModel):
|
||||
user_id: str
|
||||
email: Optional[str] = Query(default=None)
|
||||
username: Optional[str] = Query(default=..., min_length=2, max_length=20)
|
||||
extra: Optional[UserExtra] = None
|
||||
|
||||
|
||||
class UpdateUserPassword(BaseModel):
|
||||
user_id: str
|
||||
password_old: Optional[str] = None
|
||||
password: str = Query(default=..., min_length=8, max_length=50)
|
||||
password_repeat: str = Query(default=..., min_length=8, max_length=50)
|
||||
username: str = Query(default=..., min_length=2, max_length=20)
|
||||
|
||||
|
||||
class UpdateUserPubkey(BaseModel):
|
||||
user_id: str
|
||||
pubkey: str = Query(default=..., max_length=64)
|
||||
|
||||
|
||||
class ResetUserPassword(BaseModel):
|
||||
reset_key: str
|
||||
password: str = Query(default=..., min_length=8, max_length=50)
|
||||
password_repeat: str = Query(default=..., min_length=8, max_length=50)
|
||||
|
||||
|
||||
class UpdateSuperuserPassword(BaseModel):
|
||||
username: str = Query(default=..., min_length=2, max_length=20)
|
||||
password: str = Query(default=..., min_length=8, max_length=50)
|
||||
password_repeat: str = Query(default=..., min_length=8, max_length=50)
|
||||
|
||||
|
||||
class LoginUsr(BaseModel):
|
||||
usr: str
|
||||
|
||||
|
||||
class LoginUsernamePassword(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
class AccessTokenPayload(BaseModel):
|
||||
sub: str
|
||||
usr: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
auth_time: Optional[int] = 0
|
||||
|
||||
|
||||
class UpdateBalance(BaseModel):
|
||||
id: str
|
||||
amount: int
|
||||
@@ -0,0 +1,80 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
|
||||
from ecdsa import SECP256k1, SigningKey
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from lnbits.helpers import url_for
|
||||
from lnbits.lnurl import encode as lnurl_encode
|
||||
from lnbits.settings import settings
|
||||
|
||||
|
||||
class BaseWallet(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
adminkey: str
|
||||
inkey: str
|
||||
balance_msat: int
|
||||
|
||||
|
||||
class Wallet(BaseModel):
|
||||
id: str
|
||||
user: str
|
||||
name: str
|
||||
adminkey: str
|
||||
inkey: str
|
||||
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))
|
||||
currency: Optional[str] = None
|
||||
balance_msat: int = Field(default=0, no_database=True)
|
||||
|
||||
@property
|
||||
def balance(self) -> int:
|
||||
return int(self.balance_msat // 1000)
|
||||
|
||||
@property
|
||||
def withdrawable_balance(self) -> int:
|
||||
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 lnurlauth_key(self, domain: str) -> SigningKey:
|
||||
hashing_key = hashlib.sha256(self.id.encode()).digest()
|
||||
linking_key = hmac.digest(hashing_key, domain.encode(), "sha256")
|
||||
|
||||
return SigningKey.from_string(
|
||||
linking_key, curve=SECP256k1, hashfunc=hashlib.sha256
|
||||
)
|
||||
|
||||
|
||||
class CreateWallet(BaseModel):
|
||||
name: Optional[str] = None
|
||||
|
||||
|
||||
class KeyType(Enum):
|
||||
admin = 0
|
||||
invoice = 1
|
||||
invalid = 2
|
||||
|
||||
# backwards compatibility
|
||||
def __eq__(self, other):
|
||||
return self.value == other
|
||||
|
||||
|
||||
@dataclass
|
||||
class WalletTypeInfo:
|
||||
key_type: KeyType
|
||||
wallet: Wallet
|
||||
@@ -0,0 +1,15 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class CreateWebPushSubscription(BaseModel):
|
||||
subscription: str
|
||||
|
||||
|
||||
class WebPushSubscription(BaseModel):
|
||||
endpoint: str
|
||||
user: str
|
||||
data: str
|
||||
host: str
|
||||
timestamp: datetime
|
||||
@@ -1,808 +0,0 @@
|
||||
import asyncio
|
||||
import datetime
|
||||
import json
|
||||
import time
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Tuple, TypedDict
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import httpx
|
||||
from bolt11 import Bolt11
|
||||
from bolt11 import decode as bolt11_decode
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from fastapi import Depends, WebSocket
|
||||
from loguru import logger
|
||||
from py_vapid import Vapid
|
||||
from py_vapid.utils import b64urlencode
|
||||
|
||||
from lnbits.core.db import db
|
||||
from lnbits.db import Connection
|
||||
from lnbits.decorators import WalletTypeInfo, require_admin_key
|
||||
from lnbits.helpers import url_for
|
||||
from lnbits.lnurl import LnurlErrorResponse
|
||||
from lnbits.lnurl import decode as decode_lnurl
|
||||
from lnbits.settings import (
|
||||
EditableSettings,
|
||||
SuperSettings,
|
||||
readonly_variables,
|
||||
send_admin_user_to_saas,
|
||||
settings,
|
||||
)
|
||||
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis, satoshis_amount_as_fiat
|
||||
from lnbits.wallets import fake_wallet, get_funding_source, set_funding_source
|
||||
from lnbits.wallets.base import (
|
||||
PaymentPendingStatus,
|
||||
PaymentResponse,
|
||||
PaymentStatus,
|
||||
PaymentSuccessStatus,
|
||||
)
|
||||
|
||||
from .crud import (
|
||||
check_internal,
|
||||
check_internal_pending,
|
||||
create_account,
|
||||
create_admin_settings,
|
||||
create_payment,
|
||||
create_wallet,
|
||||
delete_wallet_payment,
|
||||
get_account,
|
||||
get_payments,
|
||||
get_standalone_payment,
|
||||
get_super_settings,
|
||||
get_total_balance,
|
||||
get_wallet,
|
||||
get_wallet_payment,
|
||||
update_admin_settings,
|
||||
update_payment_details,
|
||||
update_payment_status,
|
||||
update_super_user,
|
||||
)
|
||||
from .helpers import to_valid_user_id
|
||||
from .models import BalanceDelta, Payment, UserConfig, Wallet
|
||||
|
||||
|
||||
class PaymentError(Exception):
|
||||
def __init__(self, message: str, status: str = "pending"):
|
||||
self.message = message
|
||||
self.status = status
|
||||
|
||||
|
||||
class InvoiceError(Exception):
|
||||
def __init__(self, message: str, status: str = "pending"):
|
||||
self.message = message
|
||||
self.status = status
|
||||
|
||||
|
||||
async def calculate_fiat_amounts(
|
||||
amount: float,
|
||||
wallet_id: str,
|
||||
currency: Optional[str] = None,
|
||||
extra: Optional[Dict] = None,
|
||||
conn: Optional[Connection] = None,
|
||||
) -> Tuple[int, Optional[Dict]]:
|
||||
wallet = await get_wallet(wallet_id, conn=conn)
|
||||
assert wallet, "invalid wallet_id"
|
||||
wallet_currency = wallet.currency or settings.lnbits_default_accounting_currency
|
||||
|
||||
if currency and currency != "sat":
|
||||
amount_sat = await fiat_amount_as_satoshis(amount, currency)
|
||||
extra = extra or {}
|
||||
if currency != wallet_currency:
|
||||
extra["fiat_currency"] = currency
|
||||
extra["fiat_amount"] = round(amount, ndigits=3)
|
||||
extra["fiat_rate"] = amount_sat / amount
|
||||
else:
|
||||
amount_sat = int(amount)
|
||||
|
||||
if wallet_currency:
|
||||
if wallet_currency == currency:
|
||||
fiat_amount = amount
|
||||
else:
|
||||
fiat_amount = await satoshis_amount_as_fiat(amount_sat, wallet_currency)
|
||||
extra = extra or {}
|
||||
extra["wallet_fiat_currency"] = wallet_currency
|
||||
extra["wallet_fiat_amount"] = round(fiat_amount, ndigits=3)
|
||||
extra["wallet_fiat_rate"] = amount_sat / fiat_amount
|
||||
|
||||
logger.debug(
|
||||
f"Calculated fiat amounts {wallet.id=} {amount=} {currency=}: {extra=}"
|
||||
)
|
||||
|
||||
return amount_sat, extra
|
||||
|
||||
|
||||
async def create_invoice(
|
||||
*,
|
||||
wallet_id: str,
|
||||
amount: float,
|
||||
currency: Optional[str] = "sat",
|
||||
memo: str,
|
||||
description_hash: Optional[bytes] = None,
|
||||
unhashed_description: Optional[bytes] = None,
|
||||
expiry: Optional[int] = None,
|
||||
extra: Optional[Dict] = None,
|
||||
webhook: Optional[str] = None,
|
||||
internal: Optional[bool] = False,
|
||||
conn: Optional[Connection] = None,
|
||||
) -> Tuple[str, str]:
|
||||
if not amount > 0:
|
||||
raise InvoiceError("Amountless invoices not supported.", status="failed")
|
||||
|
||||
user_wallet = await get_wallet(wallet_id, conn=conn)
|
||||
if not user_wallet:
|
||||
raise InvoiceError(f"Could not fetch wallet '{wallet_id}'.", status="failed")
|
||||
|
||||
invoice_memo = None if description_hash else memo
|
||||
|
||||
# use the fake wallet if the invoice is for internal use only
|
||||
funding_source = fake_wallet if internal else get_funding_source()
|
||||
|
||||
amount_sat, extra = await calculate_fiat_amounts(
|
||||
amount, wallet_id, currency=currency, extra=extra, conn=conn
|
||||
)
|
||||
|
||||
if settings.is_wallet_max_balance_exceeded(
|
||||
user_wallet.balance_msat / 1000 + amount_sat
|
||||
):
|
||||
raise InvoiceError(
|
||||
f"Wallet balance cannot exceed "
|
||||
f"{settings.lnbits_wallet_limit_max_balance} sats.",
|
||||
status="failed",
|
||||
)
|
||||
|
||||
(
|
||||
ok,
|
||||
checking_id,
|
||||
payment_request,
|
||||
error_message,
|
||||
) = await funding_source.create_invoice(
|
||||
amount=amount_sat,
|
||||
memo=invoice_memo,
|
||||
description_hash=description_hash,
|
||||
unhashed_description=unhashed_description,
|
||||
expiry=expiry or settings.lightning_invoice_expiry,
|
||||
)
|
||||
if not ok or not payment_request or not checking_id:
|
||||
raise InvoiceError(
|
||||
error_message or "unexpected backend error.", status="pending"
|
||||
)
|
||||
|
||||
invoice = bolt11_decode(payment_request)
|
||||
|
||||
amount_msat = 1000 * amount_sat
|
||||
await create_payment(
|
||||
wallet_id=wallet_id,
|
||||
checking_id=checking_id,
|
||||
payment_request=payment_request,
|
||||
payment_hash=invoice.payment_hash,
|
||||
amount=amount_msat,
|
||||
expiry=get_bolt11_expiry(invoice),
|
||||
memo=memo,
|
||||
extra=extra,
|
||||
webhook=webhook,
|
||||
conn=conn,
|
||||
)
|
||||
|
||||
return invoice.payment_hash, payment_request
|
||||
|
||||
|
||||
async def pay_invoice(
|
||||
*,
|
||||
wallet_id: str,
|
||||
payment_request: str,
|
||||
max_sat: Optional[int] = None,
|
||||
extra: Optional[Dict] = None,
|
||||
description: str = "",
|
||||
conn: Optional[Connection] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Pay a Lightning invoice.
|
||||
First, we create a temporary payment in the database with fees set to the reserve
|
||||
fee. We then check whether the balance of the payer would go negative.
|
||||
We then attempt to pay the invoice through the backend. If the payment is
|
||||
successful, we update the payment in the database with the payment details.
|
||||
If the payment is unsuccessful, we delete the temporary payment.
|
||||
If the payment is still in flight, we hope that some other process
|
||||
will regularly check for the payment.
|
||||
"""
|
||||
try:
|
||||
invoice = bolt11_decode(payment_request)
|
||||
except Exception as exc:
|
||||
raise PaymentError("Bolt11 decoding failed.", status="failed") from exc
|
||||
|
||||
if not invoice.amount_msat or not invoice.amount_msat > 0:
|
||||
raise PaymentError("Amountless invoices not supported.", status="failed")
|
||||
if max_sat and invoice.amount_msat > max_sat * 1000:
|
||||
raise PaymentError("Amount in invoice is too high.", status="failed")
|
||||
|
||||
await check_wallet_limits(wallet_id, conn, invoice.amount_msat)
|
||||
|
||||
async with db.reuse_conn(conn) if conn else db.connect() as conn:
|
||||
temp_id = invoice.payment_hash
|
||||
internal_id = f"internal_{invoice.payment_hash}"
|
||||
|
||||
_, extra = await calculate_fiat_amounts(
|
||||
invoice.amount_msat / 1000, wallet_id, extra=extra, conn=conn
|
||||
)
|
||||
|
||||
# put all parameters that don't change here
|
||||
class PaymentKwargs(TypedDict):
|
||||
wallet_id: str
|
||||
payment_request: str
|
||||
payment_hash: str
|
||||
amount: int
|
||||
memo: str
|
||||
expiry: Optional[datetime.datetime]
|
||||
extra: Optional[Dict]
|
||||
|
||||
payment_kwargs: PaymentKwargs = PaymentKwargs(
|
||||
wallet_id=wallet_id,
|
||||
payment_request=payment_request,
|
||||
payment_hash=invoice.payment_hash,
|
||||
amount=-invoice.amount_msat,
|
||||
expiry=get_bolt11_expiry(invoice),
|
||||
memo=description or invoice.description or "",
|
||||
extra=extra,
|
||||
)
|
||||
|
||||
# we check if an internal invoice exists that has already been paid
|
||||
# (not pending anymore)
|
||||
if not await check_internal_pending(invoice.payment_hash, conn=conn):
|
||||
raise PaymentError("Internal invoice already paid.", status="failed")
|
||||
|
||||
# check_internal() returns the checking_id of the invoice we're waiting for
|
||||
# (pending only)
|
||||
internal_checking_id = await check_internal(invoice.payment_hash, conn=conn)
|
||||
if internal_checking_id:
|
||||
fee_reserve_total_msat = fee_reserve_total(
|
||||
invoice.amount_msat, internal=True
|
||||
)
|
||||
# perform additional checks on the internal payment
|
||||
# the payment hash is not enough to make sure that this is the same invoice
|
||||
internal_invoice = await get_standalone_payment(
|
||||
internal_checking_id, incoming=True, conn=conn
|
||||
)
|
||||
assert internal_invoice is not None
|
||||
if (
|
||||
internal_invoice.amount != invoice.amount_msat
|
||||
or internal_invoice.bolt11 != payment_request.lower()
|
||||
):
|
||||
raise PaymentError("Invalid invoice.", status="failed")
|
||||
|
||||
logger.debug(f"creating temporary internal payment with id {internal_id}")
|
||||
# create a new payment from this wallet
|
||||
new_payment = await create_payment(
|
||||
checking_id=internal_id,
|
||||
fee=0 + abs(fee_reserve_total_msat),
|
||||
pending=False,
|
||||
conn=conn,
|
||||
**payment_kwargs,
|
||||
)
|
||||
else:
|
||||
fee_reserve_total_msat = fee_reserve_total(
|
||||
invoice.amount_msat, internal=False
|
||||
)
|
||||
logger.debug(f"creating temporary payment with id {temp_id}")
|
||||
# create a temporary payment here so we can check if
|
||||
# the balance is enough in the next step
|
||||
try:
|
||||
new_payment = await create_payment(
|
||||
checking_id=temp_id,
|
||||
fee=-abs(fee_reserve_total_msat),
|
||||
conn=conn,
|
||||
**payment_kwargs,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error(f"could not create temporary payment: {exc}")
|
||||
# happens if the same wallet tries to pay an invoice twice
|
||||
raise PaymentError("Could not make payment.", status="failed") from exc
|
||||
|
||||
# do the balance check
|
||||
wallet = await get_wallet(wallet_id, conn=conn)
|
||||
assert wallet, "Wallet for balancecheck could not be fetched"
|
||||
if wallet.balance_msat < 0:
|
||||
logger.debug("balance is too low, deleting temporary payment")
|
||||
if (
|
||||
not internal_checking_id
|
||||
and wallet.balance_msat > -fee_reserve_total_msat
|
||||
):
|
||||
raise PaymentError(
|
||||
f"You must reserve at least ({round(fee_reserve_total_msat/1000)}"
|
||||
" sat) to cover potential routing fees.",
|
||||
status="failed",
|
||||
)
|
||||
raise PaymentError("Insufficient balance.", status="failed")
|
||||
|
||||
if internal_checking_id:
|
||||
service_fee_msat = service_fee(invoice.amount_msat, internal=True)
|
||||
logger.debug(f"marking temporary payment as not pending {internal_checking_id}")
|
||||
# mark the invoice from the other side as not pending anymore
|
||||
# so the other side only has access to his new money when we are sure
|
||||
# the payer has enough to deduct from
|
||||
async with db.connect() as conn:
|
||||
await update_payment_status(
|
||||
checking_id=internal_checking_id, pending=False, conn=conn
|
||||
)
|
||||
await send_payment_notification(wallet, new_payment)
|
||||
|
||||
# notify receiver asynchronously
|
||||
from lnbits.tasks import internal_invoice_queue
|
||||
|
||||
logger.debug(f"enqueuing internal invoice {internal_checking_id}")
|
||||
await internal_invoice_queue.put(internal_checking_id)
|
||||
else:
|
||||
fee_reserve_msat = fee_reserve(invoice.amount_msat, internal=False)
|
||||
service_fee_msat = service_fee(invoice.amount_msat, internal=False)
|
||||
logger.debug(f"backend: sending payment {temp_id}")
|
||||
# actually pay the external invoice
|
||||
funding_source = get_funding_source()
|
||||
payment: PaymentResponse = await funding_source.pay_invoice(
|
||||
payment_request, fee_reserve_msat
|
||||
)
|
||||
|
||||
if payment.checking_id and payment.checking_id != temp_id:
|
||||
logger.warning(
|
||||
f"backend sent unexpected checking_id (expected: {temp_id} got:"
|
||||
f" {payment.checking_id})"
|
||||
)
|
||||
|
||||
logger.debug(f"backend: pay_invoice finished {temp_id}")
|
||||
logger.debug(f"backend: pay_invoice response {payment}")
|
||||
if payment.checking_id and payment.ok is not False:
|
||||
# payment.ok can be True (paid) or None (pending)!
|
||||
logger.debug(f"updating payment {temp_id}")
|
||||
async with db.connect() as conn:
|
||||
await update_payment_details(
|
||||
checking_id=temp_id,
|
||||
pending=payment.ok is not True,
|
||||
fee=-(
|
||||
abs(payment.fee_msat if payment.fee_msat else 0)
|
||||
+ abs(service_fee_msat)
|
||||
),
|
||||
preimage=payment.preimage,
|
||||
new_checking_id=payment.checking_id,
|
||||
conn=conn,
|
||||
)
|
||||
wallet = await get_wallet(wallet_id, conn=conn)
|
||||
updated = await get_wallet_payment(
|
||||
wallet_id, payment.checking_id, conn=conn
|
||||
)
|
||||
if wallet and updated:
|
||||
await send_payment_notification(wallet, updated)
|
||||
logger.debug(f"payment successful {payment.checking_id}")
|
||||
elif payment.checking_id is None and payment.ok is False:
|
||||
# payment failed
|
||||
logger.warning("backend sent payment failure")
|
||||
async with db.connect() as conn:
|
||||
logger.debug(f"deleting temporary payment {temp_id}")
|
||||
await delete_wallet_payment(temp_id, wallet_id, conn=conn)
|
||||
raise PaymentError(
|
||||
f"Payment failed: {payment.error_message}"
|
||||
or "Payment failed, but backend didn't give us an error message.",
|
||||
status="failed",
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"didn't receive checking_id from backend, payment may be stuck in"
|
||||
f" database: {temp_id}"
|
||||
)
|
||||
|
||||
# credit service fee wallet
|
||||
if settings.lnbits_service_fee_wallet and service_fee_msat:
|
||||
new_payment = await create_payment(
|
||||
wallet_id=settings.lnbits_service_fee_wallet,
|
||||
fee=0,
|
||||
amount=abs(service_fee_msat),
|
||||
memo="Service fee",
|
||||
checking_id="service_fee" + temp_id,
|
||||
payment_request=payment_request,
|
||||
payment_hash=invoice.payment_hash,
|
||||
pending=False,
|
||||
)
|
||||
return invoice.payment_hash
|
||||
|
||||
|
||||
async def check_wallet_limits(wallet_id, conn, amount_msat):
|
||||
await check_time_limit_between_transactions(conn, wallet_id)
|
||||
await check_wallet_daily_withdraw_limit(conn, wallet_id, amount_msat)
|
||||
|
||||
|
||||
async def check_time_limit_between_transactions(conn, wallet_id):
|
||||
limit = settings.lnbits_wallet_limit_secs_between_trans
|
||||
if not limit or limit <= 0:
|
||||
return
|
||||
|
||||
payments = await get_payments(
|
||||
since=int(time.time()) - limit,
|
||||
wallet_id=wallet_id,
|
||||
limit=1,
|
||||
conn=conn,
|
||||
)
|
||||
|
||||
if len(payments) == 0:
|
||||
return
|
||||
|
||||
raise PaymentError(
|
||||
status="failed",
|
||||
message=f"The time limit of {limit} seconds between payments has been reached.",
|
||||
)
|
||||
|
||||
|
||||
async def check_wallet_daily_withdraw_limit(conn, wallet_id, amount_msat):
|
||||
limit = settings.lnbits_wallet_limit_daily_max_withdraw
|
||||
if not limit or limit <= 0:
|
||||
return
|
||||
|
||||
payments = await get_payments(
|
||||
since=int(time.time()) - 60 * 60 * 24,
|
||||
outgoing=True,
|
||||
wallet_id=wallet_id,
|
||||
limit=1,
|
||||
conn=conn,
|
||||
)
|
||||
if len(payments) == 0:
|
||||
return
|
||||
|
||||
total = 0
|
||||
for pay in payments:
|
||||
total += pay.amount
|
||||
total = total - amount_msat
|
||||
if limit * 1000 + total < 0:
|
||||
raise ValueError(
|
||||
"Daily withdrawal limit of "
|
||||
+ str(settings.lnbits_wallet_limit_daily_max_withdraw)
|
||||
+ " sats reached."
|
||||
)
|
||||
|
||||
|
||||
async def redeem_lnurl_withdraw(
|
||||
wallet_id: str,
|
||||
lnurl_request: str,
|
||||
memo: Optional[str] = None,
|
||||
extra: Optional[Dict] = None,
|
||||
wait_seconds: int = 0,
|
||||
conn: Optional[Connection] = None,
|
||||
) -> None:
|
||||
if not lnurl_request:
|
||||
return None
|
||||
|
||||
res = {}
|
||||
|
||||
headers = {"User-Agent": settings.user_agent}
|
||||
async with httpx.AsyncClient(headers=headers) as client:
|
||||
lnurl = decode_lnurl(lnurl_request)
|
||||
r = await client.get(str(lnurl))
|
||||
res = r.json()
|
||||
|
||||
try:
|
||||
_, payment_request = await create_invoice(
|
||||
wallet_id=wallet_id,
|
||||
amount=int(res["maxWithdrawable"] / 1000),
|
||||
memo=memo or res["defaultDescription"] or "",
|
||||
extra=extra,
|
||||
conn=conn,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
f"failed to create invoice on redeem_lnurl_withdraw "
|
||||
f"from {lnurl}. params: {res}"
|
||||
)
|
||||
return None
|
||||
|
||||
if wait_seconds:
|
||||
await asyncio.sleep(wait_seconds)
|
||||
|
||||
params = {"k1": res["k1"], "pr": payment_request}
|
||||
|
||||
try:
|
||||
params["balanceNotify"] = url_for(
|
||||
f"/withdraw/notify/{urlparse(lnurl_request).netloc}",
|
||||
external=True,
|
||||
wal=wallet_id,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
headers = {"User-Agent": settings.user_agent}
|
||||
async with httpx.AsyncClient(headers=headers) as client:
|
||||
try:
|
||||
await client.get(res["callback"], params=params)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def perform_lnurlauth(
|
||||
callback: str,
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||
) -> Optional[LnurlErrorResponse]:
|
||||
cb = urlparse(callback)
|
||||
|
||||
k1 = bytes.fromhex(parse_qs(cb.query)["k1"][0])
|
||||
|
||||
key = wallet.wallet.lnurlauth_key(cb.netloc)
|
||||
|
||||
def int_to_bytes_suitable_der(x: int) -> bytes:
|
||||
"""for strict DER we need to encode the integer with some quirks"""
|
||||
b = x.to_bytes((x.bit_length() + 7) // 8, "big")
|
||||
|
||||
if len(b) == 0:
|
||||
# ensure there's at least one byte when the int is zero
|
||||
return bytes([0])
|
||||
|
||||
if b[0] & 0x80 != 0:
|
||||
# ensure it doesn't start with a 0x80 and so it isn't
|
||||
# interpreted as a negative number
|
||||
return bytes([0]) + b
|
||||
|
||||
return b
|
||||
|
||||
def encode_strict_der(r: int, s: int, order: int):
|
||||
# if s > order/2 verification will fail sometimes
|
||||
# so we must fix it here see:
|
||||
# https://github.com/indutny/elliptic/blob/e71b2d9359c5fe9437fbf46f1f05096de447de57/lib/elliptic/ec/index.js#L146-L147
|
||||
if s > order // 2:
|
||||
s = order - s
|
||||
|
||||
# now we do the strict DER encoding copied from
|
||||
# https://github.com/KiriKiri/bip66 (without any checks)
|
||||
r_temp = int_to_bytes_suitable_der(r)
|
||||
s_temp = int_to_bytes_suitable_der(s)
|
||||
|
||||
r_len = len(r_temp)
|
||||
s_len = len(s_temp)
|
||||
sign_len = 6 + r_len + s_len
|
||||
|
||||
signature = BytesIO()
|
||||
signature.write(0x30.to_bytes(1, "big", signed=False))
|
||||
signature.write((sign_len - 2).to_bytes(1, "big", signed=False))
|
||||
signature.write(0x02.to_bytes(1, "big", signed=False))
|
||||
signature.write(r_len.to_bytes(1, "big", signed=False))
|
||||
signature.write(r_temp)
|
||||
signature.write(0x02.to_bytes(1, "big", signed=False))
|
||||
signature.write(s_len.to_bytes(1, "big", signed=False))
|
||||
signature.write(s_temp)
|
||||
|
||||
return signature.getvalue()
|
||||
|
||||
sig = key.sign_digest_deterministic(k1, sigencode=encode_strict_der)
|
||||
|
||||
headers = {"User-Agent": settings.user_agent}
|
||||
async with httpx.AsyncClient(headers=headers) as client:
|
||||
assert key.verifying_key, "LNURLauth verifying_key does not exist"
|
||||
r = await client.get(
|
||||
callback,
|
||||
params={
|
||||
"k1": k1.hex(),
|
||||
"key": key.verifying_key.to_string("compressed").hex(),
|
||||
"sig": sig.hex(),
|
||||
},
|
||||
)
|
||||
try:
|
||||
resp = json.loads(r.text)
|
||||
if resp["status"] == "OK":
|
||||
return None
|
||||
|
||||
return LnurlErrorResponse(reason=resp["reason"])
|
||||
except (KeyError, json.decoder.JSONDecodeError):
|
||||
return LnurlErrorResponse(
|
||||
reason=r.text[:200] + "..." if len(r.text) > 200 else r.text
|
||||
)
|
||||
|
||||
|
||||
async def check_transaction_status(
|
||||
wallet_id: str, payment_hash: str, conn: Optional[Connection] = None
|
||||
) -> PaymentStatus:
|
||||
payment: Optional[Payment] = await get_wallet_payment(
|
||||
wallet_id, payment_hash, conn=conn
|
||||
)
|
||||
if not payment:
|
||||
return PaymentPendingStatus()
|
||||
if not payment.pending:
|
||||
# note: before, we still checked the status of the payment again
|
||||
return PaymentSuccessStatus(fee_msat=payment.fee)
|
||||
|
||||
status: PaymentStatus = await payment.check_status()
|
||||
return status
|
||||
|
||||
|
||||
# WARN: this same value must be used for balance check and passed to
|
||||
# funding_source.pay_invoice(), it may cause a vulnerability if the values differ
|
||||
def fee_reserve(amount_msat: int, internal: bool = False) -> int:
|
||||
if internal:
|
||||
return 0
|
||||
reserve_min = settings.lnbits_reserve_fee_min
|
||||
reserve_percent = settings.lnbits_reserve_fee_percent
|
||||
return max(int(reserve_min), int(amount_msat * reserve_percent / 100.0))
|
||||
|
||||
|
||||
def service_fee(amount_msat: int, internal: bool = False) -> int:
|
||||
service_fee_percent = settings.lnbits_service_fee
|
||||
fee_max = settings.lnbits_service_fee_max * 1000
|
||||
if settings.lnbits_service_fee_wallet:
|
||||
if internal and settings.lnbits_service_fee_ignore_internal:
|
||||
return 0
|
||||
fee_percentage = int(amount_msat / 100 * service_fee_percent)
|
||||
if fee_max > 0 and fee_percentage > fee_max:
|
||||
return fee_max
|
||||
else:
|
||||
return fee_percentage
|
||||
else:
|
||||
return 0
|
||||
|
||||
|
||||
def fee_reserve_total(amount_msat: int, internal: bool = False) -> int:
|
||||
return fee_reserve(amount_msat, internal) + service_fee(amount_msat, internal)
|
||||
|
||||
|
||||
async def send_payment_notification(wallet: Wallet, payment: Payment):
|
||||
await websocket_updater(
|
||||
wallet.id,
|
||||
json.dumps(
|
||||
{
|
||||
"wallet_balance": wallet.balance,
|
||||
"payment": payment.dict(),
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def update_wallet_balance(wallet_id: str, amount: int):
|
||||
payment_hash, _ = await create_invoice(
|
||||
wallet_id=wallet_id,
|
||||
amount=amount,
|
||||
memo="Admin top up",
|
||||
internal=True,
|
||||
)
|
||||
async with db.connect() as conn:
|
||||
checking_id = await check_internal(payment_hash, conn=conn)
|
||||
assert checking_id, "newly created checking_id cannot be retrieved"
|
||||
await update_payment_status(checking_id=checking_id, pending=False, conn=conn)
|
||||
# notify receiver asynchronously
|
||||
from lnbits.tasks import internal_invoice_queue
|
||||
|
||||
await internal_invoice_queue.put(checking_id)
|
||||
|
||||
|
||||
async def check_admin_settings():
|
||||
if settings.super_user:
|
||||
settings.super_user = to_valid_user_id(settings.super_user).hex
|
||||
|
||||
if settings.lnbits_admin_ui:
|
||||
settings_db = await get_super_settings()
|
||||
if not settings_db:
|
||||
# create new settings if table is empty
|
||||
logger.warning("Settings DB empty. Inserting default settings.")
|
||||
settings_db = await init_admin_settings(settings.super_user)
|
||||
logger.warning("Initialized settings from environment variables.")
|
||||
|
||||
if settings.super_user and settings.super_user != settings_db.super_user:
|
||||
# .env super_user overwrites DB super_user
|
||||
settings_db = await update_super_user(settings.super_user)
|
||||
|
||||
update_cached_settings(settings_db.dict())
|
||||
|
||||
# saving superuser to {data_dir}/.super_user file
|
||||
with open(Path(settings.lnbits_data_folder) / ".super_user", "w") as file:
|
||||
file.write(settings.super_user)
|
||||
|
||||
# callback for saas
|
||||
if (
|
||||
settings.lnbits_saas_callback
|
||||
and settings.lnbits_saas_secret
|
||||
and settings.lnbits_saas_instance_id
|
||||
):
|
||||
send_admin_user_to_saas()
|
||||
|
||||
account = await get_account(settings.super_user)
|
||||
if account and account.config and account.config.provider == "env":
|
||||
settings.first_install = True
|
||||
|
||||
logger.success(
|
||||
"✔️ Admin UI is enabled. run `poetry run lnbits-cli superuser` "
|
||||
"to get the superuser."
|
||||
)
|
||||
|
||||
|
||||
async def check_webpush_settings():
|
||||
if not settings.lnbits_webpush_privkey:
|
||||
vapid = Vapid()
|
||||
vapid.generate_keys()
|
||||
privkey = vapid.private_pem()
|
||||
assert vapid.public_key, "VAPID public key does not exist"
|
||||
pubkey = b64urlencode(
|
||||
vapid.public_key.public_bytes(
|
||||
serialization.Encoding.X962,
|
||||
serialization.PublicFormat.UncompressedPoint,
|
||||
)
|
||||
)
|
||||
push_settings = {
|
||||
"lnbits_webpush_privkey": privkey.decode(),
|
||||
"lnbits_webpush_pubkey": pubkey,
|
||||
}
|
||||
update_cached_settings(push_settings)
|
||||
await update_admin_settings(EditableSettings(**push_settings))
|
||||
|
||||
logger.info("Initialized webpush settings with generated VAPID key pair.")
|
||||
logger.info(f"Pubkey: {settings.lnbits_webpush_pubkey}")
|
||||
|
||||
|
||||
def update_cached_settings(sets_dict: dict):
|
||||
for key, value in sets_dict.items():
|
||||
if key in readonly_variables:
|
||||
continue
|
||||
if key not in settings.dict().keys():
|
||||
continue
|
||||
try:
|
||||
setattr(settings, key, value)
|
||||
except Exception:
|
||||
logger.warning(f"Failed overriding setting: {key}, value: {value}")
|
||||
if "super_user" in sets_dict:
|
||||
settings.super_user = sets_dict["super_user"]
|
||||
|
||||
|
||||
async def init_admin_settings(super_user: Optional[str] = None) -> SuperSettings:
|
||||
account = None
|
||||
if super_user:
|
||||
account = await get_account(super_user)
|
||||
if not account:
|
||||
account = await create_account(
|
||||
user_id=super_user, user_config=UserConfig(provider="env")
|
||||
)
|
||||
if not account.wallets or len(account.wallets) == 0:
|
||||
await create_wallet(user_id=account.id)
|
||||
|
||||
editable_settings = EditableSettings.from_dict(settings.dict())
|
||||
|
||||
return await create_admin_settings(account.id, editable_settings.dict())
|
||||
|
||||
|
||||
class WebsocketConnectionManager:
|
||||
def __init__(self) -> None:
|
||||
self.active_connections: List[WebSocket] = []
|
||||
|
||||
async def connect(self, websocket: WebSocket, item_id: str):
|
||||
logger.debug(f"Websocket connected to {item_id}")
|
||||
await websocket.accept()
|
||||
self.active_connections.append(websocket)
|
||||
|
||||
def disconnect(self, websocket: WebSocket):
|
||||
self.active_connections.remove(websocket)
|
||||
|
||||
async def send_data(self, message: str, item_id: str):
|
||||
for connection in self.active_connections:
|
||||
if connection.path_params["item_id"] == item_id:
|
||||
await connection.send_text(message)
|
||||
|
||||
|
||||
websocket_manager = WebsocketConnectionManager()
|
||||
|
||||
|
||||
async def websocket_updater(item_id, data):
|
||||
return await websocket_manager.send_data(f"{data}", item_id)
|
||||
|
||||
|
||||
async def switch_to_voidwallet() -> None:
|
||||
funding_source = get_funding_source()
|
||||
if funding_source.__class__.__name__ == "VoidWallet":
|
||||
return
|
||||
set_funding_source("VoidWallet")
|
||||
settings.lnbits_backend_wallet_class = "VoidWallet"
|
||||
|
||||
|
||||
async def get_balance_delta() -> BalanceDelta:
|
||||
funding_source = get_funding_source()
|
||||
status = await funding_source.status()
|
||||
lnbits_balance = await get_total_balance()
|
||||
return BalanceDelta(
|
||||
lnbits_balance_msats=lnbits_balance,
|
||||
node_balance_msats=status.balance_msat,
|
||||
)
|
||||
|
||||
|
||||
def get_bolt11_expiry(invoice: Bolt11) -> datetime.datetime:
|
||||
if invoice.expiry:
|
||||
return datetime.datetime.fromtimestamp(invoice.date + invoice.expiry)
|
||||
else:
|
||||
# assume maximum bolt11 expiry of 31 days to be on the safe side
|
||||
return datetime.datetime.now() + datetime.timedelta(days=31)
|
||||
@@ -0,0 +1,65 @@
|
||||
from .funding_source import (
|
||||
get_balance_delta,
|
||||
switch_to_voidwallet,
|
||||
)
|
||||
from .lnurl import perform_lnurlauth, redeem_lnurl_withdraw
|
||||
from .payments import (
|
||||
calculate_fiat_amounts,
|
||||
check_transaction_status,
|
||||
check_wallet_limits,
|
||||
create_invoice,
|
||||
fee_reserve,
|
||||
fee_reserve_total,
|
||||
pay_invoice,
|
||||
send_payment_notification,
|
||||
service_fee,
|
||||
update_pending_payments,
|
||||
update_wallet_balance,
|
||||
)
|
||||
from .settings import (
|
||||
check_webpush_settings,
|
||||
update_cached_settings,
|
||||
)
|
||||
from .users import (
|
||||
check_admin_settings,
|
||||
create_user_account,
|
||||
create_user_account_no_ckeck,
|
||||
init_admin_settings,
|
||||
update_user_account,
|
||||
update_user_extensions,
|
||||
)
|
||||
from .websockets import websocket_manager, websocket_updater
|
||||
|
||||
__all__ = [
|
||||
# funding source
|
||||
"get_balance_delta",
|
||||
"switch_to_voidwallet",
|
||||
# lnurl
|
||||
"redeem_lnurl_withdraw",
|
||||
"perform_lnurlauth",
|
||||
# payments
|
||||
"calculate_fiat_amounts",
|
||||
"check_transaction_status",
|
||||
"check_wallet_limits",
|
||||
"create_invoice",
|
||||
"fee_reserve",
|
||||
"fee_reserve_total",
|
||||
"pay_invoice",
|
||||
"send_payment_notification",
|
||||
"service_fee",
|
||||
"update_pending_payments",
|
||||
"update_wallet_balance",
|
||||
# settings
|
||||
"check_webpush_settings",
|
||||
"update_cached_settings",
|
||||
# users
|
||||
"check_admin_settings",
|
||||
"create_user_account",
|
||||
"create_user_account_no_ckeck",
|
||||
"init_admin_settings",
|
||||
"update_user_account",
|
||||
"update_user_extensions",
|
||||
# websockets
|
||||
"websocket_manager",
|
||||
"websocket_updater",
|
||||
]
|
||||
@@ -0,0 +1,179 @@
|
||||
import asyncio
|
||||
import importlib
|
||||
from typing import Optional
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.core import core_app_extra
|
||||
from lnbits.core.crud import (
|
||||
create_installed_extension,
|
||||
delete_installed_extension,
|
||||
get_db_version,
|
||||
get_installed_extension,
|
||||
update_installed_extension_state,
|
||||
)
|
||||
from lnbits.core.crud.extensions import (
|
||||
get_installed_extensions,
|
||||
update_installed_extension,
|
||||
)
|
||||
from lnbits.core.helpers import migrate_extension_database
|
||||
from lnbits.settings import settings
|
||||
|
||||
from ..models.extensions import Extension, ExtensionMeta, InstallableExtension
|
||||
|
||||
|
||||
async def install_extension(ext_info: InstallableExtension) -> Extension:
|
||||
|
||||
ext_info.meta = ext_info.meta or ExtensionMeta()
|
||||
|
||||
if ext_info.meta.installed_release:
|
||||
assert (
|
||||
ext_info.meta.installed_release.is_version_compatible
|
||||
), "Incompatible extension version"
|
||||
|
||||
installed_ext = await get_installed_extension(ext_info.id)
|
||||
if installed_ext and installed_ext.meta:
|
||||
ext_info.meta.payments = installed_ext.meta.payments
|
||||
|
||||
await ext_info.download_archive()
|
||||
|
||||
ext_info.extract_archive()
|
||||
|
||||
db_version = await get_db_version(ext_info.id)
|
||||
await migrate_extension_database(ext_info, db_version)
|
||||
|
||||
# if the extensions does not exist in the installed extensions table, create it
|
||||
# if it does exist, it will be activated later in the code
|
||||
if not installed_ext:
|
||||
await create_installed_extension(ext_info)
|
||||
else:
|
||||
await update_installed_extension(ext_info)
|
||||
|
||||
extension = Extension.from_installable_ext(ext_info)
|
||||
if extension.is_upgrade_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)
|
||||
|
||||
return extension
|
||||
|
||||
|
||||
async def uninstall_extension(ext_id: str):
|
||||
await stop_extension_background_work(ext_id)
|
||||
|
||||
settings.deactivate_extension_paths(ext_id)
|
||||
|
||||
extension = await get_installed_extension(ext_id)
|
||||
if extension:
|
||||
extension.clean_extension_files()
|
||||
await delete_installed_extension(ext_id=ext_id)
|
||||
|
||||
|
||||
async def activate_extension(ext: Extension):
|
||||
core_app_extra.register_new_ext_routes(ext)
|
||||
await update_installed_extension_state(ext_id=ext.code, active=True)
|
||||
|
||||
|
||||
async def deactivate_extension(ext_id: str):
|
||||
settings.deactivate_extension_paths(ext_id)
|
||||
await update_installed_extension_state(ext_id=ext_id, active=False)
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
upgrade_hash = settings.extension_upgrade_hash(ext_id)
|
||||
ext = Extension(code=ext_id, is_valid=True, upgrade_hash=upgrade_hash)
|
||||
|
||||
try:
|
||||
logger.info(f"Stopping background work for extension '{ext.module_name}'.")
|
||||
old_module = importlib.import_module(ext.module_name)
|
||||
|
||||
stop_fn_name = f"{ext_id}_stop"
|
||||
assert hasattr(
|
||||
old_module, stop_fn_name
|
||||
), f"No stop function found for '{ext.module_name}'."
|
||||
|
||||
stop_fn = getattr(old_module, stop_fn_name)
|
||||
if stop_fn:
|
||||
if asyncio.iscoroutinefunction(stop_fn):
|
||||
await stop_fn()
|
||||
else:
|
||||
stop_fn()
|
||||
logger.info(f"Stopped background work for extension '{ext.module_name}'.")
|
||||
except Exception as ex:
|
||||
logger.warning(f"Failed to stop background work for '{ext.module_name}'.")
|
||||
logger.warning(ex)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def start_extension_background_work(ext_id: str) -> bool:
|
||||
"""
|
||||
Start background work for extension (like asyncio.Tasks, WebSockets, etc).
|
||||
Extension CAN expose a `myextension_start()` function if it is starting tasks.
|
||||
Extension MUST expose a `myextension_stop()` in that case.
|
||||
"""
|
||||
upgrade_hash = settings.extension_upgrade_hash(ext_id)
|
||||
ext = Extension(code=ext_id, is_valid=True, upgrade_hash=upgrade_hash)
|
||||
|
||||
try:
|
||||
logger.info(f"Starting background work for extension '{ext.module_name}'.")
|
||||
new_module = importlib.import_module(ext.module_name)
|
||||
start_fn_name = f"{ext_id}_start"
|
||||
|
||||
# start function is optional, return False if not found
|
||||
if not hasattr(new_module, start_fn_name):
|
||||
return False
|
||||
|
||||
start_fn = getattr(new_module, start_fn_name)
|
||||
if start_fn:
|
||||
if asyncio.iscoroutinefunction(start_fn):
|
||||
await start_fn()
|
||||
else:
|
||||
start_fn()
|
||||
logger.info(f"Started background work for extension '{ext.module_name}'.")
|
||||
return True
|
||||
except Exception as ex:
|
||||
logger.warning(f"Failed to start background work for '{ext.module_name}'.")
|
||||
logger.warning(ex)
|
||||
return False
|
||||
|
||||
|
||||
async def get_valid_extensions(
|
||||
include_deactivated: Optional[bool] = True,
|
||||
) -> list[Extension]:
|
||||
installed_extensions = await get_installed_extensions()
|
||||
valid_extensions = [Extension.from_installable_ext(e) for e in installed_extensions]
|
||||
|
||||
if include_deactivated:
|
||||
return valid_extensions
|
||||
|
||||
if settings.lnbits_extensions_deactivate_all:
|
||||
return []
|
||||
|
||||
return [
|
||||
e
|
||||
for e in valid_extensions
|
||||
if e.code not in settings.lnbits_deactivated_extensions
|
||||
]
|
||||
|
||||
|
||||
async def get_valid_extension(
|
||||
ext_id: str, include_deactivated: Optional[bool] = True
|
||||
) -> Optional[Extension]:
|
||||
ext = await get_installed_extension(ext_id)
|
||||
if not ext:
|
||||
return None
|
||||
|
||||
if include_deactivated:
|
||||
return Extension.from_installable_ext(ext)
|
||||
|
||||
if settings.lnbits_extensions_deactivate_all:
|
||||
return None
|
||||
|
||||
return Extension.from_installable_ext(ext)
|
||||
@@ -0,0 +1,23 @@
|
||||
from lnbits.settings import settings
|
||||
from lnbits.wallets import get_funding_source, set_funding_source
|
||||
|
||||
from ..crud import get_total_balance
|
||||
from ..models import BalanceDelta
|
||||
|
||||
|
||||
async def switch_to_voidwallet() -> None:
|
||||
funding_source = get_funding_source()
|
||||
if funding_source.__class__.__name__ == "VoidWallet":
|
||||
return
|
||||
set_funding_source("VoidWallet")
|
||||
settings.lnbits_backend_wallet_class = "VoidWallet"
|
||||
|
||||
|
||||
async def get_balance_delta() -> BalanceDelta:
|
||||
funding_source = get_funding_source()
|
||||
status = await funding_source.status()
|
||||
lnbits_balance = await get_total_balance()
|
||||
return BalanceDelta(
|
||||
lnbits_balance_msats=lnbits_balance,
|
||||
node_balance_msats=status.balance_msat,
|
||||
)
|
||||
@@ -0,0 +1,155 @@
|
||||
import asyncio
|
||||
import json
|
||||
from io import BytesIO
|
||||
from typing import Optional
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import httpx
|
||||
from fastapi import Depends
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.db import Connection
|
||||
from lnbits.decorators import (
|
||||
WalletTypeInfo,
|
||||
require_admin_key,
|
||||
)
|
||||
from lnbits.helpers import url_for
|
||||
from lnbits.lnurl import LnurlErrorResponse
|
||||
from lnbits.lnurl import decode as decode_lnurl
|
||||
from lnbits.settings import settings
|
||||
|
||||
from .payments import create_invoice
|
||||
|
||||
|
||||
async def redeem_lnurl_withdraw(
|
||||
wallet_id: str,
|
||||
lnurl_request: str,
|
||||
memo: Optional[str] = None,
|
||||
extra: Optional[dict] = None,
|
||||
wait_seconds: int = 0,
|
||||
conn: Optional[Connection] = None,
|
||||
) -> None:
|
||||
if not lnurl_request:
|
||||
return None
|
||||
|
||||
res = {}
|
||||
|
||||
headers = {"User-Agent": settings.user_agent}
|
||||
async with httpx.AsyncClient(headers=headers) as client:
|
||||
lnurl = decode_lnurl(lnurl_request)
|
||||
r = await client.get(str(lnurl))
|
||||
res = r.json()
|
||||
|
||||
try:
|
||||
_, payment_request = await create_invoice(
|
||||
wallet_id=wallet_id,
|
||||
amount=int(res["maxWithdrawable"] / 1000),
|
||||
memo=memo or res["defaultDescription"] or "",
|
||||
extra=extra,
|
||||
conn=conn,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
f"failed to create invoice on redeem_lnurl_withdraw "
|
||||
f"from {lnurl}. params: {res}"
|
||||
)
|
||||
return None
|
||||
|
||||
if wait_seconds:
|
||||
await asyncio.sleep(wait_seconds)
|
||||
|
||||
params = {"k1": res["k1"], "pr": payment_request}
|
||||
|
||||
try:
|
||||
params["balanceNotify"] = url_for(
|
||||
f"/withdraw/notify/{urlparse(lnurl_request).netloc}",
|
||||
external=True,
|
||||
wal=wallet_id,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
headers = {"User-Agent": settings.user_agent}
|
||||
async with httpx.AsyncClient(headers=headers) as client:
|
||||
try:
|
||||
await client.get(res["callback"], params=params)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def perform_lnurlauth(
|
||||
callback: str,
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||
) -> Optional[LnurlErrorResponse]:
|
||||
cb = urlparse(callback)
|
||||
|
||||
k1 = bytes.fromhex(parse_qs(cb.query)["k1"][0])
|
||||
|
||||
key = wallet.wallet.lnurlauth_key(cb.netloc)
|
||||
|
||||
def int_to_bytes_suitable_der(x: int) -> bytes:
|
||||
"""for strict DER we need to encode the integer with some quirks"""
|
||||
b = x.to_bytes((x.bit_length() + 7) // 8, "big")
|
||||
|
||||
if len(b) == 0:
|
||||
# ensure there's at least one byte when the int is zero
|
||||
return bytes([0])
|
||||
|
||||
if b[0] & 0x80 != 0:
|
||||
# ensure it doesn't start with a 0x80 and so it isn't
|
||||
# interpreted as a negative number
|
||||
return bytes([0]) + b
|
||||
|
||||
return b
|
||||
|
||||
def encode_strict_der(r: int, s: int, order: int):
|
||||
# if s > order/2 verification will fail sometimes
|
||||
# so we must fix it here see:
|
||||
# https://github.com/indutny/elliptic/blob/e71b2d9359c5fe9437fbf46f1f05096de447de57/lib/elliptic/ec/index.js#L146-L147
|
||||
if s > order // 2:
|
||||
s = order - s
|
||||
|
||||
# now we do the strict DER encoding copied from
|
||||
# https://github.com/KiriKiri/bip66 (without any checks)
|
||||
r_temp = int_to_bytes_suitable_der(r)
|
||||
s_temp = int_to_bytes_suitable_der(s)
|
||||
|
||||
r_len = len(r_temp)
|
||||
s_len = len(s_temp)
|
||||
sign_len = 6 + r_len + s_len
|
||||
|
||||
signature = BytesIO()
|
||||
signature.write(0x30.to_bytes(1, "big", signed=False))
|
||||
signature.write((sign_len - 2).to_bytes(1, "big", signed=False))
|
||||
signature.write(0x02.to_bytes(1, "big", signed=False))
|
||||
signature.write(r_len.to_bytes(1, "big", signed=False))
|
||||
signature.write(r_temp)
|
||||
signature.write(0x02.to_bytes(1, "big", signed=False))
|
||||
signature.write(s_len.to_bytes(1, "big", signed=False))
|
||||
signature.write(s_temp)
|
||||
|
||||
return signature.getvalue()
|
||||
|
||||
sig = key.sign_digest_deterministic(k1, sigencode=encode_strict_der)
|
||||
|
||||
headers = {"User-Agent": settings.user_agent}
|
||||
async with httpx.AsyncClient(headers=headers) as client:
|
||||
assert key.verifying_key, "LNURLauth verifying_key does not exist"
|
||||
r = await client.get(
|
||||
callback,
|
||||
params={
|
||||
"k1": k1.hex(),
|
||||
"key": key.verifying_key.to_string("compressed").hex(),
|
||||
"sig": sig.hex(),
|
||||
},
|
||||
)
|
||||
try:
|
||||
resp = json.loads(r.text)
|
||||
if resp["status"] == "OK":
|
||||
return None
|
||||
|
||||
return LnurlErrorResponse(reason=resp["reason"])
|
||||
except (KeyError, json.decoder.JSONDecodeError):
|
||||
return LnurlErrorResponse(
|
||||
reason=r.text[:200] + "..." if len(r.text) > 200 else r.text
|
||||
)
|
||||
@@ -0,0 +1,623 @@
|
||||
import json
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from bolt11 import Bolt11, MilliSatoshi, Tags
|
||||
from bolt11 import decode as bolt11_decode
|
||||
from bolt11 import encode as bolt11_encode
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.core.db import db
|
||||
from lnbits.db import Connection
|
||||
from lnbits.decorators import check_user_extension_access
|
||||
from lnbits.exceptions import InvoiceError, PaymentError
|
||||
from lnbits.settings import settings
|
||||
from lnbits.utils.crypto import fake_privkey, random_secret_and_hash
|
||||
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 (
|
||||
PaymentPendingStatus,
|
||||
PaymentResponse,
|
||||
PaymentStatus,
|
||||
PaymentSuccessStatus,
|
||||
)
|
||||
|
||||
from ..crud import (
|
||||
check_internal,
|
||||
create_payment,
|
||||
get_payments,
|
||||
get_standalone_payment,
|
||||
get_wallet,
|
||||
get_wallet_payment,
|
||||
is_internal_status_success,
|
||||
update_payment,
|
||||
)
|
||||
from ..models import (
|
||||
CreatePayment,
|
||||
Payment,
|
||||
PaymentState,
|
||||
Wallet,
|
||||
)
|
||||
from .websockets import websocket_manager
|
||||
|
||||
|
||||
async def pay_invoice(
|
||||
*,
|
||||
wallet_id: str,
|
||||
payment_request: str,
|
||||
max_sat: Optional[int] = None,
|
||||
extra: Optional[dict] = None,
|
||||
description: str = "",
|
||||
tag: str = "",
|
||||
conn: Optional[Connection] = None,
|
||||
) -> Payment:
|
||||
invoice = _validate_payment_request(payment_request, max_sat)
|
||||
assert invoice.amount_msat
|
||||
|
||||
async with db.reuse_conn(conn) if conn else db.connect() as conn:
|
||||
amount_msat = invoice.amount_msat
|
||||
wallet = await _check_wallet_for_payment(wallet_id, tag, amount_msat, conn)
|
||||
|
||||
if await is_internal_status_success(invoice.payment_hash, 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,
|
||||
bolt11=payment_request,
|
||||
payment_hash=invoice.payment_hash,
|
||||
amount_msat=-amount_msat,
|
||||
expiry=invoice.expiry_date,
|
||||
memo=description or invoice.description or "",
|
||||
extra=extra,
|
||||
)
|
||||
|
||||
payment = await _pay_invoice(wallet, create_payment_model, conn)
|
||||
await _credit_service_fee_wallet(payment, conn)
|
||||
|
||||
return payment
|
||||
|
||||
|
||||
async def create_invoice(
|
||||
*,
|
||||
wallet_id: str,
|
||||
amount: float,
|
||||
currency: Optional[str] = "sat",
|
||||
memo: str,
|
||||
description_hash: Optional[bytes] = None,
|
||||
unhashed_description: Optional[bytes] = None,
|
||||
expiry: Optional[int] = None,
|
||||
extra: Optional[dict] = None,
|
||||
webhook: Optional[str] = None,
|
||||
internal: Optional[bool] = False,
|
||||
conn: Optional[Connection] = None,
|
||||
) -> Payment:
|
||||
if not amount > 0:
|
||||
raise InvoiceError("Amountless invoices not supported.", status="failed")
|
||||
|
||||
user_wallet = await get_wallet(wallet_id, conn=conn)
|
||||
if not user_wallet:
|
||||
raise InvoiceError(f"Could not fetch wallet '{wallet_id}'.", status="failed")
|
||||
|
||||
invoice_memo = None if description_hash else memo
|
||||
|
||||
# use the fake wallet if the invoice is for internal use only
|
||||
funding_source = fake_wallet if internal else get_funding_source()
|
||||
|
||||
amount_sat, extra = await calculate_fiat_amounts(
|
||||
amount, user_wallet, currency, extra
|
||||
)
|
||||
|
||||
if settings.is_wallet_max_balance_exceeded(
|
||||
user_wallet.balance_msat / 1000 + amount_sat
|
||||
):
|
||||
raise InvoiceError(
|
||||
f"Wallet balance cannot exceed "
|
||||
f"{settings.lnbits_wallet_limit_max_balance} sats.",
|
||||
status="failed",
|
||||
)
|
||||
|
||||
(
|
||||
ok,
|
||||
checking_id,
|
||||
payment_request,
|
||||
error_message,
|
||||
) = await funding_source.create_invoice(
|
||||
amount=amount_sat,
|
||||
memo=invoice_memo,
|
||||
description_hash=description_hash,
|
||||
unhashed_description=unhashed_description,
|
||||
expiry=expiry or settings.lightning_invoice_expiry,
|
||||
)
|
||||
if not ok or not payment_request or not checking_id:
|
||||
raise InvoiceError(
|
||||
error_message or "unexpected backend error.", status="pending"
|
||||
)
|
||||
|
||||
invoice = bolt11_decode(payment_request)
|
||||
|
||||
create_payment_model = CreatePayment(
|
||||
wallet_id=wallet_id,
|
||||
bolt11=payment_request,
|
||||
payment_hash=invoice.payment_hash,
|
||||
amount_msat=amount_sat * 1000,
|
||||
expiry=invoice.expiry_date,
|
||||
memo=memo,
|
||||
extra=extra,
|
||||
webhook=webhook,
|
||||
)
|
||||
|
||||
payment = await create_payment(
|
||||
checking_id=checking_id,
|
||||
data=create_payment_model,
|
||||
conn=conn,
|
||||
)
|
||||
|
||||
return payment
|
||||
|
||||
|
||||
async def update_pending_payments(wallet_id: str):
|
||||
pending_payments = await get_payments(
|
||||
wallet_id=wallet_id,
|
||||
pending=True,
|
||||
exclude_uncheckable=True,
|
||||
)
|
||||
for payment in pending_payments:
|
||||
status = await payment.check_status()
|
||||
if status.failed:
|
||||
payment.status = PaymentState.FAILED
|
||||
await update_payment(payment)
|
||||
elif status.success:
|
||||
payment.status = PaymentState.SUCCESS
|
||||
await update_payment(payment)
|
||||
|
||||
|
||||
def fee_reserve_total(amount_msat: int, internal: bool = False) -> int:
|
||||
return fee_reserve(amount_msat, internal) + service_fee(amount_msat, internal)
|
||||
|
||||
|
||||
def fee_reserve(amount_msat: int, internal: bool = False) -> int:
|
||||
return settings.fee_reserve(amount_msat, internal)
|
||||
|
||||
|
||||
def service_fee(amount_msat: int, internal: bool = False) -> int:
|
||||
amount_msat = abs(amount_msat)
|
||||
service_fee_percent = settings.lnbits_service_fee
|
||||
fee_max = settings.lnbits_service_fee_max * 1000
|
||||
if settings.lnbits_service_fee_wallet:
|
||||
if internal and settings.lnbits_service_fee_ignore_internal:
|
||||
return 0
|
||||
fee_percentage = int(amount_msat / 100 * service_fee_percent)
|
||||
if fee_max > 0 and fee_percentage > fee_max:
|
||||
return fee_max
|
||||
else:
|
||||
return fee_percentage
|
||||
else:
|
||||
return 0
|
||||
|
||||
|
||||
async def update_wallet_balance(
|
||||
wallet: Wallet,
|
||||
amount: int,
|
||||
conn: Optional[Connection] = None,
|
||||
):
|
||||
if amount == 0:
|
||||
raise ValueError("Amount cannot be 0.")
|
||||
|
||||
# negative balance change
|
||||
if amount < 0:
|
||||
if wallet.balance + amount < 0:
|
||||
raise ValueError("Balance change failed, can not go into negative balance.")
|
||||
async with db.reuse_conn(conn) if conn else db.connect() as conn:
|
||||
payment_secret, payment_hash = random_secret_and_hash()
|
||||
invoice = Bolt11(
|
||||
currency="bc",
|
||||
amount_msat=MilliSatoshi(abs(amount) * 1000),
|
||||
date=int(time.time()),
|
||||
tags=Tags.from_dict(
|
||||
{
|
||||
"payment_hash": payment_hash,
|
||||
"payment_secret": payment_secret,
|
||||
"description": "Admin debit",
|
||||
}
|
||||
),
|
||||
)
|
||||
privkey = fake_privkey(settings.fake_wallet_secret)
|
||||
bolt11 = bolt11_encode(invoice, privkey)
|
||||
await create_payment(
|
||||
checking_id=f"internal_{payment_hash}",
|
||||
data=CreatePayment(
|
||||
wallet_id=wallet.id,
|
||||
bolt11=bolt11,
|
||||
payment_hash=payment_hash,
|
||||
amount_msat=amount * 1000,
|
||||
memo="Admin debit",
|
||||
),
|
||||
status=PaymentState.SUCCESS,
|
||||
conn=conn,
|
||||
)
|
||||
return None
|
||||
|
||||
# positive balance change
|
||||
if (
|
||||
settings.lnbits_wallet_limit_max_balance > 0
|
||||
and wallet.balance + amount > settings.lnbits_wallet_limit_max_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,
|
||||
amount=amount,
|
||||
memo="Admin credit",
|
||||
internal=True,
|
||||
conn=conn,
|
||||
)
|
||||
payment.status = PaymentState.SUCCESS
|
||||
await update_payment(payment, conn=conn)
|
||||
# notify receiver asynchronously
|
||||
from lnbits.tasks import internal_invoice_queue
|
||||
|
||||
await internal_invoice_queue.put(payment.checking_id)
|
||||
|
||||
|
||||
async def send_payment_notification(wallet: Wallet, payment: Payment):
|
||||
# TODO: websocket message should be a clean payment model
|
||||
# await websocket_manager.send_data(payment.json(), wallet.inkey)
|
||||
# TODO: figure out why we send the balance with the payment here.
|
||||
# cleaner would be to have a separate message for the balance
|
||||
# and send it with the id of the wallet so wallets can subscribe to it
|
||||
payment_notification = json.dumps(
|
||||
{
|
||||
"wallet_balance": wallet.balance,
|
||||
# use pydantic json serialization to get the correct datetime format
|
||||
"payment": json.loads(payment.json()),
|
||||
},
|
||||
)
|
||||
await websocket_manager.send_data(payment_notification, wallet.inkey)
|
||||
await websocket_manager.send_data(payment_notification, wallet.adminkey)
|
||||
|
||||
await websocket_manager.send_data(
|
||||
json.dumps({"pending": payment.pending}), payment.payment_hash
|
||||
)
|
||||
|
||||
|
||||
async def check_wallet_limits(
|
||||
wallet_id: str, amount_msat: int, conn: Optional[Connection] = None
|
||||
):
|
||||
await check_time_limit_between_transactions(wallet_id, conn)
|
||||
await check_wallet_daily_withdraw_limit(wallet_id, amount_msat, conn)
|
||||
|
||||
|
||||
async def check_time_limit_between_transactions(
|
||||
wallet_id: str, conn: Optional[Connection] = None
|
||||
):
|
||||
limit = settings.lnbits_wallet_limit_secs_between_trans
|
||||
if not limit or limit <= 0:
|
||||
return
|
||||
payments = await get_payments(
|
||||
since=int(time.time()) - limit,
|
||||
wallet_id=wallet_id,
|
||||
limit=1,
|
||||
conn=conn,
|
||||
)
|
||||
if len(payments) == 0:
|
||||
return
|
||||
raise PaymentError(
|
||||
status="failed",
|
||||
message=f"The time limit of {limit} seconds between payments has been reached.",
|
||||
)
|
||||
|
||||
|
||||
async def check_wallet_daily_withdraw_limit(
|
||||
wallet_id: str, amount_msat: int, conn: Optional[Connection] = None
|
||||
):
|
||||
limit = settings.lnbits_wallet_limit_daily_max_withdraw
|
||||
if not limit:
|
||||
return
|
||||
if limit < 0:
|
||||
raise ValueError("It is not allowed to spend funds from this server.")
|
||||
|
||||
payments = await get_payments(
|
||||
since=int(time.time()) - 60 * 60 * 24,
|
||||
outgoing=True,
|
||||
wallet_id=wallet_id,
|
||||
limit=1,
|
||||
conn=conn,
|
||||
)
|
||||
if len(payments) == 0:
|
||||
return
|
||||
|
||||
total = 0
|
||||
for pay in payments:
|
||||
total += pay.amount
|
||||
total = total - amount_msat
|
||||
if limit * 1000 + total < 0:
|
||||
raise ValueError(
|
||||
"Daily withdrawal limit of "
|
||||
+ str(settings.lnbits_wallet_limit_daily_max_withdraw)
|
||||
+ " sats reached."
|
||||
)
|
||||
|
||||
|
||||
async def calculate_fiat_amounts(
|
||||
amount: float,
|
||||
wallet: Wallet,
|
||||
currency: Optional[str] = None,
|
||||
extra: Optional[dict] = None,
|
||||
) -> tuple[int, dict]:
|
||||
wallet_currency = wallet.currency or settings.lnbits_default_accounting_currency
|
||||
fiat_amounts: dict = extra or {}
|
||||
if currency and currency != "sat":
|
||||
amount_sat = await fiat_amount_as_satoshis(amount, currency)
|
||||
if currency != wallet_currency:
|
||||
fiat_amounts["fiat_currency"] = currency
|
||||
fiat_amounts["fiat_amount"] = round(amount, ndigits=3)
|
||||
fiat_amounts["fiat_rate"] = amount_sat / amount
|
||||
else:
|
||||
amount_sat = int(amount)
|
||||
|
||||
if wallet_currency:
|
||||
if wallet_currency == currency:
|
||||
fiat_amount = amount
|
||||
else:
|
||||
fiat_amount = await satoshis_amount_as_fiat(amount_sat, wallet_currency)
|
||||
fiat_amounts["wallet_fiat_currency"] = wallet_currency
|
||||
fiat_amounts["wallet_fiat_amount"] = round(fiat_amount, ndigits=3)
|
||||
fiat_amounts["wallet_fiat_rate"] = amount_sat / fiat_amount
|
||||
|
||||
logger.debug(
|
||||
f"Calculated fiat amounts {wallet.id=} {amount=} {currency=}: {fiat_amounts=}"
|
||||
)
|
||||
|
||||
return amount_sat, fiat_amounts
|
||||
|
||||
|
||||
async def check_transaction_status(
|
||||
wallet_id: str, payment_hash: str, conn: Optional[Connection] = None
|
||||
) -> PaymentStatus:
|
||||
payment: Optional[Payment] = await get_wallet_payment(
|
||||
wallet_id, payment_hash, conn=conn
|
||||
)
|
||||
if not payment:
|
||||
return PaymentPendingStatus()
|
||||
|
||||
if payment.status == PaymentState.SUCCESS.value:
|
||||
return PaymentSuccessStatus(fee_msat=payment.fee)
|
||||
|
||||
return await payment.check_status()
|
||||
|
||||
|
||||
async def _pay_invoice(wallet, create_payment_model, conn):
|
||||
payment = await _pay_internal_invoice(wallet, create_payment_model, conn)
|
||||
if not payment:
|
||||
payment = await _pay_external_invoice(wallet, create_payment_model, conn)
|
||||
return payment
|
||||
|
||||
|
||||
async def _pay_internal_invoice(
|
||||
wallet: Wallet,
|
||||
create_payment_model: CreatePayment,
|
||||
conn: Optional[Connection] = None,
|
||||
) -> Optional[Payment]:
|
||||
"""
|
||||
Pay an internal payment.
|
||||
returns None if the payment is not internal.
|
||||
"""
|
||||
# check_internal() returns the payment of the invoice we're waiting for
|
||||
# (pending only)
|
||||
internal_payment = await check_internal(
|
||||
create_payment_model.payment_hash, conn=conn
|
||||
)
|
||||
if not internal_payment:
|
||||
return None
|
||||
|
||||
# perform additional checks on the internal payment
|
||||
# the payment hash is not enough to make sure that this is the same invoice
|
||||
internal_invoice = await get_standalone_payment(
|
||||
internal_payment.checking_id, incoming=True, conn=conn
|
||||
)
|
||||
if not internal_invoice:
|
||||
raise PaymentError("Internal payment not found.", status="failed")
|
||||
|
||||
amount_msat = create_payment_model.amount_msat
|
||||
if (
|
||||
internal_invoice.amount != abs(amount_msat)
|
||||
or internal_invoice.bolt11 != create_payment_model.bolt11.lower()
|
||||
):
|
||||
raise PaymentError("Invalid invoice. Bolt11 changed.", status="failed")
|
||||
|
||||
fee_reserve_total_msat = fee_reserve_total(abs(amount_msat), internal=True)
|
||||
create_payment_model.fee = abs(fee_reserve_total_msat)
|
||||
|
||||
if wallet.balance_msat < abs(amount_msat) + fee_reserve_total_msat:
|
||||
raise PaymentError("Insufficient balance.", status="failed")
|
||||
|
||||
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,
|
||||
status=PaymentState.SUCCESS,
|
||||
conn=conn,
|
||||
)
|
||||
|
||||
# mark the invoice from the other side as not pending anymore
|
||||
# so the other side only has access to his new money when we are sure
|
||||
# the payer has enough to deduct from
|
||||
internal_payment.status = PaymentState.SUCCESS
|
||||
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
|
||||
|
||||
logger.debug(f"enqueuing internal invoice {internal_payment.checking_id}")
|
||||
await internal_invoice_queue.put(internal_payment.checking_id)
|
||||
|
||||
return payment
|
||||
|
||||
|
||||
async def _pay_external_invoice(
|
||||
wallet: Wallet,
|
||||
create_payment_model: CreatePayment,
|
||||
conn: Optional[Connection] = None,
|
||||
) -> Payment:
|
||||
checking_id = create_payment_model.payment_hash
|
||||
amount_msat = create_payment_model.amount_msat
|
||||
|
||||
fee_reserve_total_msat = fee_reserve_total(amount_msat, internal=False)
|
||||
|
||||
if wallet.balance_msat < abs(amount_msat) + fee_reserve_total_msat:
|
||||
raise PaymentError(
|
||||
f"You must reserve at least ({round(fee_reserve_total_msat/1000)}"
|
||||
" sat) to cover potential routing fees.",
|
||||
status="failed",
|
||||
)
|
||||
# check if there is already a payment with the same checking_id
|
||||
old_payment = await get_standalone_payment(checking_id, conn=conn)
|
||||
if old_payment:
|
||||
return await _verify_external_payment(old_payment, conn)
|
||||
|
||||
create_payment_model.fee = -abs(fee_reserve_total_msat)
|
||||
payment = await create_payment(
|
||||
checking_id=checking_id,
|
||||
data=create_payment_model,
|
||||
conn=conn,
|
||||
)
|
||||
|
||||
fee_reserve_msat = fee_reserve(amount_msat, internal=False)
|
||||
service_fee_msat = service_fee(amount_msat, internal=False)
|
||||
|
||||
funding_source = get_funding_source()
|
||||
|
||||
logger.debug(f"fundingsource: sending payment {checking_id}")
|
||||
payment_response: PaymentResponse = await funding_source.pay_invoice(
|
||||
create_payment_model.bolt11, fee_reserve_msat
|
||||
)
|
||||
logger.debug(f"backend: pay_invoice finished {checking_id}, {payment_response}")
|
||||
if payment_response.checking_id and payment_response.checking_id != checking_id:
|
||||
logger.warning(
|
||||
f"backend sent unexpected checking_id (expected: {checking_id} got:"
|
||||
f" {payment_response.checking_id})"
|
||||
)
|
||||
if payment_response.checking_id and payment_response.ok is not False:
|
||||
# payment.ok can be True (paid) or None (pending)!
|
||||
logger.debug(f"updating payment {checking_id}")
|
||||
payment.status = (
|
||||
PaymentState.SUCCESS
|
||||
if payment_response.ok is True
|
||||
else PaymentState.PENDING
|
||||
)
|
||||
payment.fee = -(abs(payment_response.fee_msat or 0) + abs(service_fee_msat))
|
||||
payment.preimage = payment_response.preimage
|
||||
await update_payment(payment, payment_response.checking_id, conn=conn)
|
||||
payment.checking_id = payment_response.checking_id
|
||||
if payment.success:
|
||||
await send_payment_notification(wallet, payment)
|
||||
logger.success(f"payment successful {payment_response.checking_id}")
|
||||
elif payment_response.checking_id is None and payment_response.ok is False:
|
||||
# payment failed
|
||||
logger.debug(f"payment failed {checking_id}, {payment_response.error_message}")
|
||||
payment.status = PaymentState.FAILED
|
||||
await update_payment(payment, conn=conn)
|
||||
raise PaymentError(
|
||||
f"Payment failed: {payment_response.error_message}"
|
||||
or "Payment failed, but backend didn't give us an error message.",
|
||||
status="failed",
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"didn't receive checking_id from backend, payment may be stuck in"
|
||||
f" database: {checking_id}"
|
||||
)
|
||||
return payment
|
||||
|
||||
|
||||
async def _verify_external_payment(
|
||||
payment: Payment, conn: Optional[Connection] = None
|
||||
) -> Payment:
|
||||
# fail on pending payments
|
||||
if payment.pending:
|
||||
raise PaymentError("Payment is still pending.", status="pending")
|
||||
if payment.success:
|
||||
raise PaymentError("Payment already paid.", status="success")
|
||||
|
||||
# payment failed
|
||||
status = await payment.check_status()
|
||||
if status.failed:
|
||||
raise PaymentError(
|
||||
"Payment is failed node, retrying is not possible.", status="failed"
|
||||
)
|
||||
|
||||
if status.success:
|
||||
# payment was successful on the fundingsource
|
||||
payment.status = PaymentState.SUCCESS
|
||||
await update_payment(payment, conn=conn)
|
||||
raise PaymentError(
|
||||
"Failed payment was already paid on the fundingsource.",
|
||||
status="success",
|
||||
)
|
||||
|
||||
# status.pending fall through and try again
|
||||
return payment
|
||||
|
||||
|
||||
async def _check_wallet_for_payment(
|
||||
wallet_id: str,
|
||||
tag: str,
|
||||
amount_msat: int,
|
||||
conn: Optional[Connection] = None,
|
||||
):
|
||||
wallet = await get_wallet(wallet_id, conn=conn)
|
||||
if not wallet:
|
||||
raise PaymentError(f"Could not fetch wallet '{wallet_id}'.", status="failed")
|
||||
|
||||
# check if the payment is made for an extension that the user disabled
|
||||
status = await check_user_extension_access(wallet.user, tag, conn=conn)
|
||||
if not status.success:
|
||||
raise PaymentError(status.message)
|
||||
|
||||
await check_wallet_limits(wallet_id, amount_msat, conn)
|
||||
return wallet
|
||||
|
||||
|
||||
def _validate_payment_request(
|
||||
payment_request: str, max_sat: Optional[int] = None
|
||||
) -> Bolt11:
|
||||
try:
|
||||
invoice = bolt11_decode(payment_request)
|
||||
except Exception as exc:
|
||||
raise PaymentError("Bolt11 decoding failed.", status="failed") from exc
|
||||
|
||||
if not invoice.amount_msat or not invoice.amount_msat > 0:
|
||||
raise PaymentError("Amountless invoices not supported.", status="failed")
|
||||
|
||||
if max_sat and invoice.amount_msat > max_sat * 1000:
|
||||
raise PaymentError("Amount in invoice is too high.", status="failed")
|
||||
|
||||
return invoice
|
||||
|
||||
|
||||
async def _credit_service_fee_wallet(
|
||||
payment: Payment, conn: Optional[Connection] = None
|
||||
):
|
||||
service_fee_msat = service_fee(payment.amount, internal=payment.is_internal)
|
||||
if not settings.lnbits_service_fee_wallet or not service_fee_msat:
|
||||
return
|
||||
|
||||
create_payment_model = CreatePayment(
|
||||
wallet_id=settings.lnbits_service_fee_wallet,
|
||||
bolt11=payment.bolt11,
|
||||
payment_hash=payment.payment_hash,
|
||||
amount_msat=abs(service_fee_msat),
|
||||
memo="Service fee",
|
||||
)
|
||||
await create_payment(
|
||||
checking_id=f"service_fee_{payment.payment_hash}",
|
||||
data=create_payment_model,
|
||||
status=PaymentState.SUCCESS,
|
||||
conn=conn,
|
||||
)
|
||||
@@ -0,0 +1,53 @@
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from loguru import logger
|
||||
from py_vapid import Vapid
|
||||
from py_vapid.utils import b64urlencode
|
||||
|
||||
from lnbits.db import dict_to_model
|
||||
from lnbits.settings import (
|
||||
EditableSettings,
|
||||
readonly_variables,
|
||||
settings,
|
||||
)
|
||||
|
||||
from ..crud import update_admin_settings
|
||||
|
||||
|
||||
async def check_webpush_settings():
|
||||
if not settings.lnbits_webpush_privkey:
|
||||
vapid = Vapid()
|
||||
vapid.generate_keys()
|
||||
privkey = vapid.private_pem()
|
||||
assert vapid.public_key, "VAPID public key does not exist"
|
||||
pubkey = b64urlencode(
|
||||
vapid.public_key.public_bytes(
|
||||
serialization.Encoding.X962,
|
||||
serialization.PublicFormat.UncompressedPoint,
|
||||
)
|
||||
)
|
||||
push_settings = {
|
||||
"lnbits_webpush_privkey": privkey.decode(),
|
||||
"lnbits_webpush_pubkey": pubkey,
|
||||
}
|
||||
update_cached_settings(push_settings)
|
||||
if settings.lnbits_admin_ui:
|
||||
await update_admin_settings(EditableSettings(**push_settings))
|
||||
|
||||
logger.info("Initialized webpush settings with generated VAPID key pair.")
|
||||
logger.info(f"Pubkey: {settings.lnbits_webpush_pubkey}")
|
||||
|
||||
|
||||
def update_cached_settings(sets_dict: dict):
|
||||
editable_settings = dict_to_model(sets_dict, EditableSettings)
|
||||
for key in sets_dict.keys():
|
||||
if key in readonly_variables:
|
||||
continue
|
||||
if key not in settings.dict().keys():
|
||||
continue
|
||||
try:
|
||||
value = getattr(editable_settings, key)
|
||||
setattr(settings, key, value)
|
||||
except Exception:
|
||||
logger.warning(f"Failed overriding setting: {key}.")
|
||||
if "super_user" in sets_dict:
|
||||
settings.super_user = sets_dict["super_user"]
|
||||
@@ -0,0 +1,189 @@
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from uuid import uuid4
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.core.models.extensions import UserExtension
|
||||
from lnbits.settings import (
|
||||
EditableSettings,
|
||||
SuperSettings,
|
||||
send_admin_user_to_saas,
|
||||
settings,
|
||||
)
|
||||
|
||||
from ..crud import (
|
||||
create_account,
|
||||
create_admin_settings,
|
||||
create_user_extension,
|
||||
create_wallet,
|
||||
get_account,
|
||||
get_account_by_email,
|
||||
get_account_by_pubkey,
|
||||
get_account_by_username,
|
||||
get_super_settings,
|
||||
get_user_extensions,
|
||||
get_user_from_account,
|
||||
update_account,
|
||||
update_super_user,
|
||||
update_user_extension,
|
||||
)
|
||||
from ..helpers import to_valid_user_id
|
||||
from ..models import (
|
||||
Account,
|
||||
User,
|
||||
UserExtra,
|
||||
)
|
||||
from .settings import update_cached_settings
|
||||
|
||||
|
||||
async def create_user_account(
|
||||
account: Optional[Account] = None, wallet_name: Optional[str] = None
|
||||
) -> User:
|
||||
if not settings.new_accounts_allowed:
|
||||
raise ValueError("Account creation is disabled.")
|
||||
|
||||
return await create_user_account_no_ckeck(account, wallet_name)
|
||||
|
||||
|
||||
async def create_user_account_no_ckeck(
|
||||
account: Optional[Account] = None, wallet_name: Optional[str] = None
|
||||
) -> User:
|
||||
|
||||
if account:
|
||||
account.validate_fields()
|
||||
if account.username and await get_account_by_username(account.username):
|
||||
raise ValueError("Username already exists.")
|
||||
|
||||
if account.email and await get_account_by_email(account.email):
|
||||
raise ValueError("Email already exists.")
|
||||
|
||||
if account.pubkey and await get_account_by_pubkey(account.pubkey):
|
||||
raise ValueError("Pubkey already exists.")
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
for ext_id in settings.lnbits_user_default_extensions:
|
||||
user_ext = UserExtension(user=account.id, extension=ext_id, active=True)
|
||||
await update_user_extension(user_ext)
|
||||
|
||||
user = await get_user_from_account(account)
|
||||
assert user, "Cannot find user for account."
|
||||
|
||||
return user
|
||||
|
||||
|
||||
async def update_user_account(account: Account) -> Account:
|
||||
account.validate_fields()
|
||||
|
||||
existing_account = await get_account(account.id)
|
||||
if not existing_account:
|
||||
raise ValueError("User does not exist.")
|
||||
|
||||
account.password_hash = existing_account.password_hash
|
||||
|
||||
if existing_account.username and not account.username:
|
||||
raise ValueError("Cannot remove username.")
|
||||
|
||||
if account.username:
|
||||
existing_account = await get_account_by_username(account.username)
|
||||
if existing_account and existing_account.id != account.id:
|
||||
raise ValueError("Username already exists.")
|
||||
elif existing_account.username:
|
||||
raise ValueError("Cannot remove username.")
|
||||
|
||||
if account.email:
|
||||
existing_account = await get_account_by_email(account.email)
|
||||
if existing_account and existing_account.id != account.id:
|
||||
raise ValueError("Email already exists.")
|
||||
|
||||
if account.pubkey:
|
||||
existing_account = await get_account_by_pubkey(account.pubkey)
|
||||
if existing_account and existing_account.id != account.id:
|
||||
raise ValueError("Pubkey already exists.")
|
||||
|
||||
return await update_account(account)
|
||||
|
||||
|
||||
async def update_user_extensions(user_id: str, extensions: list[str]):
|
||||
user_extensions = await get_user_extensions(user_id)
|
||||
for user_ext in user_extensions:
|
||||
if user_ext.active:
|
||||
if user_ext.extension not in extensions:
|
||||
user_ext.active = False
|
||||
await update_user_extension(user_ext)
|
||||
else:
|
||||
if user_ext.extension in extensions:
|
||||
user_ext.active = True
|
||||
await update_user_extension(user_ext)
|
||||
|
||||
user_extension_ids = [ue.extension for ue in user_extensions]
|
||||
for ext in extensions:
|
||||
if ext in user_extension_ids:
|
||||
continue
|
||||
user_extension = UserExtension(user=user_id, extension=ext, active=True)
|
||||
await create_user_extension(user_extension)
|
||||
|
||||
|
||||
async def check_admin_settings():
|
||||
if settings.super_user:
|
||||
settings.super_user = to_valid_user_id(settings.super_user).hex
|
||||
|
||||
if settings.lnbits_admin_ui:
|
||||
settings_db = await get_super_settings()
|
||||
if not settings_db:
|
||||
# create new settings if table is empty
|
||||
logger.warning("Settings DB empty. Inserting default settings.")
|
||||
settings_db = await init_admin_settings(settings.super_user)
|
||||
logger.warning("Initialized settings from environment variables.")
|
||||
|
||||
if settings.super_user and settings.super_user != settings_db.super_user:
|
||||
# .env super_user overwrites DB super_user
|
||||
settings_db = await update_super_user(settings.super_user)
|
||||
|
||||
update_cached_settings(settings_db.dict())
|
||||
|
||||
# saving superuser to {data_dir}/.super_user file
|
||||
with open(Path(settings.lnbits_data_folder) / ".super_user", "w") as file:
|
||||
file.write(settings.super_user)
|
||||
|
||||
# callback for saas
|
||||
if (
|
||||
settings.lnbits_saas_callback
|
||||
and settings.lnbits_saas_secret
|
||||
and settings.lnbits_saas_instance_id
|
||||
):
|
||||
send_admin_user_to_saas()
|
||||
|
||||
account = await get_account(settings.super_user)
|
||||
if account and account.extra and account.extra.provider == "env":
|
||||
settings.first_install = True
|
||||
|
||||
logger.success(
|
||||
"✔️ Admin UI is enabled. run `poetry run lnbits-cli superuser` "
|
||||
"to get the superuser."
|
||||
)
|
||||
|
||||
|
||||
async def init_admin_settings(super_user: Optional[str] = None) -> SuperSettings:
|
||||
account = None
|
||||
if super_user:
|
||||
account = await get_account(super_user)
|
||||
if not account:
|
||||
account_id = super_user or uuid4().hex
|
||||
account = Account(
|
||||
id=account_id,
|
||||
extra=UserExtra(provider="env"),
|
||||
)
|
||||
await create_account(account)
|
||||
await create_wallet(user_id=account.id)
|
||||
|
||||
editable_settings = EditableSettings.from_dict(settings.dict())
|
||||
return await create_admin_settings(account.id, editable_settings.dict())
|
||||
@@ -0,0 +1,27 @@
|
||||
from fastapi import WebSocket
|
||||
from loguru import logger
|
||||
|
||||
|
||||
class WebsocketConnectionManager:
|
||||
def __init__(self) -> None:
|
||||
self.active_connections: list[WebSocket] = []
|
||||
|
||||
async def connect(self, websocket: WebSocket, item_id: str):
|
||||
logger.debug(f"Websocket connected to {item_id}")
|
||||
await websocket.accept()
|
||||
self.active_connections.append(websocket)
|
||||
|
||||
def disconnect(self, websocket: WebSocket):
|
||||
self.active_connections.remove(websocket)
|
||||
|
||||
async def send_data(self, message: str, item_id: str):
|
||||
for connection in self.active_connections:
|
||||
if connection.path_params["item_id"] == item_id:
|
||||
await connection.send_text(message)
|
||||
|
||||
|
||||
websocket_manager = WebsocketConnectionManager()
|
||||
|
||||
|
||||
async def websocket_updater(item_id: str, data: str):
|
||||
return await websocket_manager.send_data(data, item_id)
|
||||
+57
-20
@@ -1,15 +1,16 @@
|
||||
import asyncio
|
||||
from typing import Dict
|
||||
|
||||
import httpx
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.core.crud import (
|
||||
create_audit_entry,
|
||||
get_wallet,
|
||||
get_webpush_subscriptions_for_user,
|
||||
mark_webhook_sent,
|
||||
)
|
||||
from lnbits.core.models import Payment
|
||||
from lnbits.core.crud.audit import delete_expired_audit_entries
|
||||
from lnbits.core.models import AuditEntry, Payment
|
||||
from lnbits.core.services import (
|
||||
get_balance_delta,
|
||||
send_payment_notification,
|
||||
@@ -17,8 +18,9 @@ from lnbits.core.services import (
|
||||
)
|
||||
from lnbits.settings import get_funding_source, settings
|
||||
from lnbits.tasks import send_push_notification
|
||||
from lnbits.utils.exchange_rates import btc_rates
|
||||
|
||||
api_invoice_listeners: Dict[str, asyncio.Queue] = {}
|
||||
audit_queue: asyncio.Queue = asyncio.Queue()
|
||||
|
||||
|
||||
async def killswitch_task():
|
||||
@@ -81,8 +83,6 @@ async def wait_for_paid_invoices(invoice_paid_queue: asyncio.Queue):
|
||||
while settings.lnbits_running:
|
||||
payment = await invoice_paid_queue.get()
|
||||
logger.trace("received invoice paid event")
|
||||
# dispatch api_invoice_listeners
|
||||
await dispatch_api_invoice_listeners(payment)
|
||||
# payment notification
|
||||
wallet = await get_wallet(payment.wallet_id)
|
||||
if wallet:
|
||||
@@ -94,21 +94,6 @@ async def wait_for_paid_invoices(invoice_paid_queue: asyncio.Queue):
|
||||
await send_payment_push_notification(payment)
|
||||
|
||||
|
||||
async def dispatch_api_invoice_listeners(payment: Payment):
|
||||
"""
|
||||
Emits events to invoice listener subscribed from the API.
|
||||
"""
|
||||
for chan_name, send_channel in api_invoice_listeners.items():
|
||||
try:
|
||||
logger.debug(f"api invoice listener: sending paid event to {chan_name}")
|
||||
send_channel.put_nowait(payment)
|
||||
except asyncio.QueueFull:
|
||||
logger.error(
|
||||
f"api invoice listener: QueueFull, removing {send_channel}:{chan_name}"
|
||||
)
|
||||
api_invoice_listeners.pop(chan_name)
|
||||
|
||||
|
||||
async def dispatch_webhook(payment: Payment):
|
||||
"""
|
||||
Dispatches the webhook to the webhook url.
|
||||
@@ -157,3 +142,55 @@ async def send_payment_push_notification(payment: Payment):
|
||||
f"https://{subscription.host}/wallet?usr={wallet.user}&wal={wallet.id}"
|
||||
)
|
||||
await send_push_notification(subscription, title, body, url)
|
||||
|
||||
|
||||
async def wait_for_audit_data():
|
||||
"""
|
||||
Waits for audit entries to be pushed to the queue.
|
||||
Then it inserts the entries into the DB.
|
||||
"""
|
||||
while settings.lnbits_running:
|
||||
data: AuditEntry = await audit_queue.get()
|
||||
try:
|
||||
await create_audit_entry(data)
|
||||
except Exception as ex:
|
||||
logger.warning(ex)
|
||||
await asyncio.sleep(3)
|
||||
|
||||
|
||||
async def purge_audit_data():
|
||||
"""
|
||||
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():
|
||||
"""
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
<q-tab-panel name="audit">
|
||||
<q-card-section class="q-pa-none">
|
||||
<h6 class="q-my-none q-mb-sm">Audit</h6>
|
||||
|
||||
<div class="row q-mb-lg">
|
||||
<div class="col-md-6 col-sm-12 q-pr-sm">
|
||||
<q-item tag="label" v-ripple>
|
||||
<q-item-section avatar>
|
||||
<q-toggle
|
||||
size="md"
|
||||
v-model="formData.lnbits_audit_enabled"
|
||||
checked-icon="check"
|
||||
color="green"
|
||||
unchecked-icon="clear"
|
||||
/>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>Enable audit</q-item-label>
|
||||
<q-item-label caption
|
||||
>Record HTTP requests according with the specified
|
||||
filters</q-item-label
|
||||
>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</div>
|
||||
<div class="col-md-6 col-sm-12">
|
||||
<q-input
|
||||
filled
|
||||
v-model="formData.lnbits_audit_retention_days"
|
||||
type="number"
|
||||
label="Retention days"
|
||||
hint="Number of days to keep the audit entry."
|
||||
>
|
||||
</q-input>
|
||||
</div>
|
||||
</div>
|
||||
<q-separator class="q-mb-lg q-mt-sm"></q-separator>
|
||||
<div class="row">
|
||||
<div class="col-md-3 col-sm-12 q-pr-sm">
|
||||
<q-item tag="label" v-ripple>
|
||||
<q-item-section avatar>
|
||||
<q-toggle
|
||||
size="md"
|
||||
v-model="formData.lnbits_audit_log_request_body"
|
||||
checked-icon="check"
|
||||
color="green"
|
||||
unchecked-icon="clear"
|
||||
/>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>Record Request Body</q-item-label>
|
||||
<q-item-label caption
|
||||
>Warning:
|
||||
<ul>
|
||||
<li>confidential data (like passwords) will be logged</li>
|
||||
<li>the request body can have large size.</li>
|
||||
</ul>
|
||||
Use it with caution.
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</div>
|
||||
<div class="col-md-3 col-sm-12 q-pr-sm">
|
||||
<q-item tag="label" v-ripple>
|
||||
<q-item-section avatar>
|
||||
<q-toggle
|
||||
size="md"
|
||||
v-model="formData.lnbits_audit_log_ip_address"
|
||||
checked-icon="check"
|
||||
color="green"
|
||||
unchecked-icon="clear"
|
||||
/>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>Record IP Address</q-item-label>
|
||||
<q-item-label caption>Save the client IP address.</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</div>
|
||||
<div class="col-md-3 col-sm-12 q-pr-sm">
|
||||
<q-item tag="label" v-ripple>
|
||||
<q-item-section avatar>
|
||||
<q-toggle
|
||||
size="md"
|
||||
v-model="formData.lnbits_audit_log_path_params"
|
||||
checked-icon="check"
|
||||
color="green"
|
||||
unchecked-icon="clear"
|
||||
/>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>Record Path Parameters</q-item-label>
|
||||
<q-item-label caption>Recommended. </q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</div>
|
||||
<div class="col-md-3 col-sm-12 q-pr-sm">
|
||||
<q-item tag="label" v-ripple>
|
||||
<q-item-section avatar>
|
||||
<q-toggle
|
||||
size="md"
|
||||
v-model="formData.lnbits_audit_log_query_params"
|
||||
checked-icon="check"
|
||||
color="green"
|
||||
unchecked-icon="clear"
|
||||
/>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>Record Query Parameters</q-item-label>
|
||||
<q-item-label caption>Recommended.</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</div>
|
||||
</div>
|
||||
<q-separator class="q-mb-xl q-mt-sm"></q-separator>
|
||||
<div class="row q-mb-lg">
|
||||
<div class="col-md-6 col-sm-12 q-pr-sm">
|
||||
<p>Include HTTP Methods</p>
|
||||
<q-select
|
||||
filled
|
||||
v-model="formData.lnbits_audit_http_methods"
|
||||
multiple
|
||||
hint="List of HTTP methods to be included. Empty lists means all."
|
||||
label="HTTP Methods"
|
||||
:options="['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS']"
|
||||
></q-select>
|
||||
</div>
|
||||
<div class="col-md-6 col-sm-12 q-pr-sm">
|
||||
<p>Include HTTP Response Codes</p>
|
||||
<q-input
|
||||
filled
|
||||
v-model="formAddIncludeResponseCode"
|
||||
@keydown.enter="addIncludeResponseCode"
|
||||
type="text"
|
||||
label="HTTP Response code (regex)"
|
||||
hint="List of HTTP codes to be included (regex match). Empty lists means all. Eg: 4.*, 5.*"
|
||||
>
|
||||
<q-btn @click="addIncludeResponseCode" dense flat icon="add"></q-btn>
|
||||
</q-input>
|
||||
<div>
|
||||
<q-chip
|
||||
v-for="code in formData.lnbits_audit_http_response_codes"
|
||||
:key="code"
|
||||
removable
|
||||
@remove="removeIncludeResponseCode(code)"
|
||||
color="primary"
|
||||
text-color="white"
|
||||
:label="code"
|
||||
>
|
||||
</q-chip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 col-sm-12 q-pr-sm">
|
||||
<p>Include Paths</p>
|
||||
<q-input
|
||||
filled
|
||||
v-model="formAddIncludePath"
|
||||
@keydown.enter="addIncludePath"
|
||||
type="text"
|
||||
label="HTTP Path (regex)"
|
||||
hint="List of paths to be included (regex match). Empty list means all."
|
||||
>
|
||||
<q-btn @click="addIncludePath" dense flat icon="add"></q-btn>
|
||||
</q-input>
|
||||
<div>
|
||||
<q-chip
|
||||
v-for="path in formData.lnbits_audit_include_paths"
|
||||
:key="path"
|
||||
removable
|
||||
@remove="removeIncludePath(path)"
|
||||
color="primary"
|
||||
text-color="white"
|
||||
:label="path"
|
||||
>
|
||||
</q-chip>
|
||||
</div>
|
||||
<br />
|
||||
</div>
|
||||
<div class="col-md-6 col-sm-12">
|
||||
<p>Exclude Paths</p>
|
||||
<q-input
|
||||
filled
|
||||
v-model="formAddExcludePath"
|
||||
@keydown.enter="addExcludePath"
|
||||
type="text"
|
||||
label="HTTP Path (regex)"
|
||||
hint="List of paths to be excluded (regex match). Empty list means none."
|
||||
>
|
||||
<q-btn @click="addExcludePath" dense flat icon="add"></q-btn>
|
||||
</q-input>
|
||||
<div>
|
||||
<q-chip
|
||||
v-for="path in formData.lnbits_audit_exclude_paths"
|
||||
:key="path"
|
||||
removable
|
||||
@remove="removeExcludePath(path)"
|
||||
color="primary"
|
||||
text-color="white"
|
||||
:label="path"
|
||||
>
|
||||
</q-chip>
|
||||
</div>
|
||||
<br />
|
||||
|
||||
<br />
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-tab-panel>
|
||||
@@ -0,0 +1,195 @@
|
||||
<q-tab-panel name="exchange_providers">
|
||||
<h6 class="q-my-none q-mb-sm">Exchange Providers</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>
|
||||
@@ -0,0 +1,94 @@
|
||||
<q-tab-panel name="extensions">
|
||||
<q-card-section class="q-pa-none">
|
||||
<div>
|
||||
<h6 class="q-my-none">Extensions</h6>
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12">
|
||||
<p>Extension Sources</p>
|
||||
<q-input
|
||||
filled
|
||||
v-model="formAddExtensionsManifest"
|
||||
@keydown.enter="addExtensionsManifest"
|
||||
type="text"
|
||||
label="Source URL (only use the official LNbits extension source, and sources you can trust)"
|
||||
hint="Repositories from where the extensions can be downloaded"
|
||||
>
|
||||
<q-btn @click="addExtensionsManifest" dense flat icon="add"></q-btn>
|
||||
</q-input>
|
||||
<div>
|
||||
<q-chip
|
||||
v-for="manifestUrl in formData.lnbits_extensions_manifests"
|
||||
:key="manifestUrl"
|
||||
removable
|
||||
@remove="removeExtensionsManifest(manifestUrl)"
|
||||
color="primary"
|
||||
text-color="white"
|
||||
><span class="ellipsis" v-text="manifestUrl"></span
|
||||
></q-chip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 col-md-6">
|
||||
<p>Admin Extensions</p>
|
||||
<q-select
|
||||
filled
|
||||
v-model="formData.lnbits_admin_extensions"
|
||||
multiple
|
||||
hint="Extensions only user with admin privileges can use"
|
||||
label="Admin extensions"
|
||||
:options="g.extensions"
|
||||
></q-select>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-6">
|
||||
<p>User Default Extensions</p>
|
||||
<q-select
|
||||
filled
|
||||
v-model="formData.lnbits_user_default_extensions"
|
||||
multiple
|
||||
hint="Extensions that will be enabled by default for the users."
|
||||
label="User extensions"
|
||||
:options="g.extensions"
|
||||
></q-select>
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<p>Miscellaneous</p>
|
||||
<q-item tag="label" v-ripple>
|
||||
<q-item-section>
|
||||
<q-item-label>Disable Extensions</q-item-label>
|
||||
<q-item-label caption>Disables all extensions</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section avatar>
|
||||
<q-toggle
|
||||
size="md"
|
||||
v-model="formData.lnbits_extensions_deactivate_all"
|
||||
checked-icon="check"
|
||||
color="green"
|
||||
unchecked-icon="clear"
|
||||
/>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item tag="label" v-ripple>
|
||||
<q-item-section>
|
||||
<q-item-label>Hide API</q-item-label>
|
||||
<q-item-label caption
|
||||
>Hides wallet api, extensions can choose to honor</q-item-label
|
||||
>
|
||||
</q-item-section>
|
||||
<q-item-section avatar>
|
||||
<q-toggle
|
||||
size="md"
|
||||
v-model="formData.lnbits_hide_api"
|
||||
checked-icon="check"
|
||||
color="green"
|
||||
unchecked-icon="clear"
|
||||
/>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<br />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-tab-panel>
|
||||
@@ -0,0 +1,140 @@
|
||||
<q-tab-panel name="notifications">
|
||||
<q-card-section class="q-pa-none">
|
||||
<div>
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 col-md-6">
|
||||
<q-item tag="label" v-ripple>
|
||||
<q-item-section>
|
||||
<q-item-label v-text="$t('enable_notifications')"></q-item-label>
|
||||
<q-item-label
|
||||
caption
|
||||
v-text="$t('enable_notifications_desc')"
|
||||
></q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section avatar>
|
||||
<q-toggle
|
||||
size="md"
|
||||
v-model="formData.lnbits_notifications"
|
||||
checked-icon="check"
|
||||
color="green"
|
||||
unchecked-icon="clear"
|
||||
/>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<br />
|
||||
<p
|
||||
v-if="!formData.lnbits_notifications"
|
||||
v-text="$t('notifications_disabled')"
|
||||
></p>
|
||||
<div v-if="formData.lnbits_notifications">
|
||||
{% include "admin/_tab_security_notifications.html" %}
|
||||
</div>
|
||||
<br />
|
||||
<div>
|
||||
<p v-text="$t('notification_source')"></p>
|
||||
<q-input
|
||||
filled
|
||||
v-model="formData.lnbits_status_manifest"
|
||||
type="text"
|
||||
:label="$t('notification_source_label')"
|
||||
/>
|
||||
</div>
|
||||
<br />
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<p v-text="$t('killswitch')"></p>
|
||||
<q-item tag="label" v-ripple>
|
||||
<q-item-section>
|
||||
<q-item-label v-text="$t('enable_killswitch')"></q-item-label>
|
||||
<q-item-label
|
||||
caption
|
||||
v-text="$t('enable_killswitch_desc')"
|
||||
></q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section avatar>
|
||||
<q-toggle
|
||||
size="md"
|
||||
v-model="formData.lnbits_killswitch"
|
||||
checked-icon="check"
|
||||
color="green"
|
||||
unchecked-icon="clear"
|
||||
/>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item tag="label" v-ripple>
|
||||
<q-item-section>
|
||||
<q-item-label v-text="$t('killswitch_interval')"></q-item-label>
|
||||
<q-item-label
|
||||
caption
|
||||
v-text="$t('killswitch_interval_desc')"
|
||||
></q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-input
|
||||
filled
|
||||
v-model="formData.lnbits_killswitch_interval"
|
||||
type="number"
|
||||
/>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<br />
|
||||
<p v-text="$t('watchdog')"></p>
|
||||
<q-item disabled tag="label" v-ripple>
|
||||
<q-tooltip><span v-text="$t('coming_soon')"></span></q-tooltip>
|
||||
<q-item-section>
|
||||
<q-item-label v-text="$t('enable_watchdog')"></q-item-label>
|
||||
<q-item-label
|
||||
caption
|
||||
v-text="$t('enable_watchdog_desc')"
|
||||
></q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section avatar>
|
||||
<q-toggle
|
||||
size="md"
|
||||
v-model="formData.lnbits_watchdog"
|
||||
checked-icon="check"
|
||||
color="green"
|
||||
unchecked-icon="clear"
|
||||
/>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item disabled tag="label" v-ripple>
|
||||
<q-tooltip><span v-text="$t('coming_soon')"></span></q-tooltip>
|
||||
<q-item-section>
|
||||
<q-item-label v-text="$t('watchdog_interval')"></q-item-label>
|
||||
<q-item-label
|
||||
caption
|
||||
v-text="$t('watchdog_interval_desc')"
|
||||
></q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-input
|
||||
filled
|
||||
v-model="formData.lnbits_watchdog_interval"
|
||||
type="number"
|
||||
/>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item disabled tag="label" v-ripple>
|
||||
<q-tooltip><span v-text="$t('coming_soon')"></span></q-tooltip>
|
||||
<q-item-section>
|
||||
<q-item-label v-text="$t('watchdog_delta')"></q-item-label>
|
||||
<q-item-label
|
||||
caption
|
||||
v-text="$t('watchdog_delta_desc')"
|
||||
></q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-input
|
||||
filled
|
||||
v-model="formData.lnbits_watchdog_delta"
|
||||
type="number"
|
||||
/>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<br />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-tab-panel>
|
||||
@@ -1,8 +1,20 @@
|
||||
<q-tab-panel name="security">
|
||||
<q-card-section class="q-pa-none">
|
||||
<h6 class="q-my-none q-mb-sm">Authentication</h6>
|
||||
<h6 class="q-my-none">Server Management</h6>
|
||||
<div class="row">
|
||||
<div class="col-md-6 col-sm-12 q-pr-sm">
|
||||
<div class="col-md-6">
|
||||
<p>Base URL</p>
|
||||
<q-input
|
||||
filled
|
||||
v-model.number="formData.lnbits_baseurl"
|
||||
label="Static/Base url for the server"
|
||||
></q-input>
|
||||
<br />
|
||||
</div>
|
||||
</div>
|
||||
<h6 class="q-my-none q-mb-sm">Authentication</h6>
|
||||
<div class="row q-col-gutter-sm">
|
||||
<div class="col-12 col-sm-6">
|
||||
<q-input
|
||||
filled
|
||||
v-model="formData.auth_token_expire_minutes"
|
||||
@@ -12,7 +24,7 @@
|
||||
>
|
||||
</q-input>
|
||||
</div>
|
||||
<div class="col-md-6 col-sm-12 q-pr-sm">
|
||||
<div class="col-12 col-sm-6">
|
||||
<q-select
|
||||
filled
|
||||
v-model="formData.auth_allowed_methods"
|
||||
@@ -24,6 +36,40 @@
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
<q-card-section
|
||||
v-if="formData.auth_allowed_methods?.includes('nostr-auth-nip98')"
|
||||
class="q-pl-xl"
|
||||
>
|
||||
<strong class="q-my-none q-mb-sm">Nostr Auth</strong>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<q-input
|
||||
class="q-mb-sm"
|
||||
filled
|
||||
v-model="nostrAcceptedUrl"
|
||||
@keydown.enter="addNostrUrl"
|
||||
type="text"
|
||||
label="Nostr Request URL"
|
||||
hint="Absolute URL that the clients will use to login."
|
||||
>
|
||||
<q-btn @click="addNostrUrl" dense flat icon="add"></q-btn>
|
||||
</q-input>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<q-chip
|
||||
v-for="url in formData.nostr_absolute_request_urls"
|
||||
:key="url"
|
||||
removable
|
||||
@remove="removeNostrUrl(url)"
|
||||
color="primary"
|
||||
text-color="white"
|
||||
:label="url"
|
||||
class="ellipsis"
|
||||
></q-chip>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
<q-card-section
|
||||
v-if="formData.auth_allowed_methods?.includes('google-auth')"
|
||||
class="q-pl-xl"
|
||||
@@ -173,7 +219,8 @@
|
||||
@remove="removeBlockedIPs(blocked_ip)"
|
||||
color="primary"
|
||||
text-color="white"
|
||||
v-text="blocked_ip"
|
||||
:label="blocked_ip"
|
||||
class="ellipsis"
|
||||
></q-chip>
|
||||
</div>
|
||||
<br />
|
||||
@@ -202,7 +249,8 @@
|
||||
@remove="removeAllowedIPs(allowed_ip)"
|
||||
color="primary"
|
||||
text-color="white"
|
||||
v-text="allowed_ip"
|
||||
:label="allowed_ip"
|
||||
class="ellipsis"
|
||||
></q-chip>
|
||||
</div>
|
||||
<br />
|
||||
@@ -263,142 +311,5 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<br />
|
||||
<div>
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 col-md-6">
|
||||
<q-item tag="label" v-ripple>
|
||||
<q-item-section>
|
||||
<q-item-label v-text="$t('enable_notifications')"></q-item-label>
|
||||
<q-item-label
|
||||
caption
|
||||
v-text="$t('enable_notifications_desc')"
|
||||
></q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section avatar>
|
||||
<q-toggle
|
||||
size="md"
|
||||
v-model="formData.lnbits_notifications"
|
||||
checked-icon="check"
|
||||
color="green"
|
||||
unchecked-icon="clear"
|
||||
/>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<br />
|
||||
<p
|
||||
v-if="!formData.lnbits_notifications"
|
||||
v-text="$t('notifications_disabled')"
|
||||
></p>
|
||||
<div v-if="formData.lnbits_notifications">
|
||||
{% include "admin/_tab_security_notifications.html" %}
|
||||
</div>
|
||||
<br />
|
||||
<div>
|
||||
<p v-text="$t('notification_source')"></p>
|
||||
<q-input
|
||||
filled
|
||||
v-model="formData.lnbits_status_manifest"
|
||||
type="text"
|
||||
:label="$t('notification_source_label')"
|
||||
/>
|
||||
</div>
|
||||
<br />
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<p v-text="$t('killswitch')"></p>
|
||||
<q-item tag="label" v-ripple>
|
||||
<q-item-section>
|
||||
<q-item-label v-text="$t('enable_killswitch')"></q-item-label>
|
||||
<q-item-label
|
||||
caption
|
||||
v-text="$t('enable_killswitch_desc')"
|
||||
></q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section avatar>
|
||||
<q-toggle
|
||||
size="md"
|
||||
v-model="formData.lnbits_killswitch"
|
||||
checked-icon="check"
|
||||
color="green"
|
||||
unchecked-icon="clear"
|
||||
/>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item tag="label" v-ripple>
|
||||
<q-item-section>
|
||||
<q-item-label v-text="$t('killswitch_interval')"></q-item-label>
|
||||
<q-item-label
|
||||
caption
|
||||
v-text="$t('killswitch_interval_desc')"
|
||||
></q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-input
|
||||
filled
|
||||
v-model="formData.lnbits_killswitch_interval"
|
||||
type="number"
|
||||
/>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<br />
|
||||
<p v-text="$t('watchdog')"></p>
|
||||
<q-item disabled tag="label" v-ripple>
|
||||
<q-tooltip><span v-text="$t('coming_soon')"></span></q-tooltip>
|
||||
<q-item-section>
|
||||
<q-item-label v-text="$t('enable_watchdog')"></q-item-label>
|
||||
<q-item-label
|
||||
caption
|
||||
v-text="$t('enable_watchdog_desc')"
|
||||
></q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section avatar>
|
||||
<q-toggle
|
||||
size="md"
|
||||
v-model="formData.lnbits_watchdog"
|
||||
checked-icon="check"
|
||||
color="green"
|
||||
unchecked-icon="clear"
|
||||
/>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item disabled tag="label" v-ripple>
|
||||
<q-tooltip><span v-text="$t('coming_soon')"></span></q-tooltip>
|
||||
<q-item-section>
|
||||
<q-item-label v-text="$t('watchdog_interval')"></q-item-label>
|
||||
<q-item-label
|
||||
caption
|
||||
v-text="$t('watchdog_interval_desc')"
|
||||
></q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-input
|
||||
filled
|
||||
v-model="formData.lnbits_watchdog_interval"
|
||||
type="number"
|
||||
/>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item disabled tag="label" v-ripple>
|
||||
<q-tooltip><span v-text="$t('coming_soon')"></span></q-tooltip>
|
||||
<q-item-section>
|
||||
<q-item-label v-text="$t('watchdog_delta')"></q-item-label>
|
||||
<q-item-label
|
||||
caption
|
||||
v-text="$t('watchdog_delta_desc')"
|
||||
></q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-input
|
||||
filled
|
||||
v-model="formData.lnbits_watchdog_delta"
|
||||
type="number"
|
||||
/>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<br />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-tab-panel>
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
<q-table
|
||||
dense
|
||||
flat
|
||||
:data="statusData.notifications"
|
||||
:rows="statusData.notifications"
|
||||
:columns="statusDataTable.columns"
|
||||
:no-data-label="$t('no_notifications')"
|
||||
>
|
||||
|
||||
@@ -1,24 +1,7 @@
|
||||
<q-tab-panel name="server">
|
||||
<q-card-section class="q-pa-none">
|
||||
<h6 class="q-my-none">Server Management</h6>
|
||||
<br />
|
||||
<div>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<p>Server Info</p>
|
||||
<ul>
|
||||
<li
|
||||
v-if="settings.lnbits_data_folder"
|
||||
v-text="'SQlite: ' + settings.lnbits_data_folder"
|
||||
></li>
|
||||
<li
|
||||
v-if="settings.lnbits_database_url"
|
||||
v-text="'Postgres: ' + settings.lnbits_database_url"
|
||||
></li>
|
||||
</ul>
|
||||
<br />
|
||||
</div>
|
||||
</div>
|
||||
<h6 class="q-my-none">Currency Settings</h6>
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 col-md-6">
|
||||
<p>Allowed currencies</p>
|
||||
@@ -45,56 +28,7 @@
|
||||
<br />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 col-md-6">
|
||||
<p>Admin Extensions</p>
|
||||
<q-select
|
||||
filled
|
||||
v-model="formData.lnbits_admin_extensions"
|
||||
multiple
|
||||
hint="Extensions only user with admin privileges can use"
|
||||
label="Admin extensions"
|
||||
:options="g.extensions.map(e => e.code)"
|
||||
></q-select>
|
||||
<br />
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<p>Miscellaneous</p>
|
||||
<q-item tag="label" v-ripple>
|
||||
<q-item-section>
|
||||
<q-item-label>Disable Extensions</q-item-label>
|
||||
<q-item-label caption>Disables all extensions</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section avatar>
|
||||
<q-toggle
|
||||
size="md"
|
||||
v-model="formData.lnbits_extensions_deactivate_all"
|
||||
checked-icon="check"
|
||||
color="green"
|
||||
unchecked-icon="clear"
|
||||
/>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item tag="label" v-ripple>
|
||||
<q-item-section>
|
||||
<q-item-label>Hide API</q-item-label>
|
||||
<q-item-label caption
|
||||
>Hides wallet api, extensions can choose to honor</q-item-label
|
||||
>
|
||||
</q-item-section>
|
||||
<q-item-section avatar>
|
||||
<q-toggle
|
||||
size="md"
|
||||
v-model="formData.lnbits_hide_api"
|
||||
checked-icon="check"
|
||||
color="green"
|
||||
unchecked-icon="clear"
|
||||
/>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<br />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<br />
|
||||
<h6 class="q-my-none">Service Fee</h6>
|
||||
<div class="row q-col-gutter-md">
|
||||
@@ -154,33 +88,6 @@
|
||||
<br />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h6 class="q-my-none">Extensions</h6>
|
||||
<div>
|
||||
<p>Extension Sources</p>
|
||||
<q-input
|
||||
filled
|
||||
v-model="formAddExtensionsManifest"
|
||||
@keydown.enter="addExtensionsManifest"
|
||||
type="text"
|
||||
label="Source URL (only use the official LNbits extension source, and sources you can trust)"
|
||||
hint="Repositories from where the extensions can be downloaded"
|
||||
>
|
||||
<q-btn @click="addExtensionsManifest" dense flat icon="add"></q-btn>
|
||||
</q-input>
|
||||
<div>
|
||||
<q-chip
|
||||
v-for="manifestUrl in formData.lnbits_extensions_manifests"
|
||||
:key="manifestUrl"
|
||||
removable
|
||||
@remove="removeExtensionsManifest(manifestUrl)"
|
||||
color="primary"
|
||||
text-color="white"
|
||||
><span v-text="manifestUrl"></span
|
||||
></q-chip>
|
||||
</div>
|
||||
<br />
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-tab-panel>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<q-tab-panel name="theme">
|
||||
<q-tab-panel name="site_customisation">
|
||||
<q-card-section class="q-pa-none">
|
||||
<h6 class="q-my-none">UI Management</h6>
|
||||
<br />
|
||||
@@ -27,8 +27,8 @@
|
||||
<div class="col-12 col-md-2 q-mt-xl">
|
||||
<q-toggle
|
||||
tip="Remove homepage elements like 'runs on' etc"
|
||||
v-model="formData.LNBITS_SHOW_HOME_PAGE_ELEMENTS"
|
||||
:label="formData.LNBITS_SHOW_HOME_PAGE_ELEMENTS ? 'Enable elements on homepage' : 'Disable elements on homepage'"
|
||||
v-model="formData.lnbits_show_home_page_elements"
|
||||
:label="formData.lnbits_show_home_page_elements ? 'Enable elements on homepage' : 'Disable elements on homepage'"
|
||||
></q-toggle>
|
||||
</div>
|
||||
</div>
|
||||
@@ -83,7 +83,7 @@
|
||||
filled
|
||||
type="text"
|
||||
tip="Custom Badge"
|
||||
v-model="formData.lnbits_custom_badge"
|
||||
v-model.trim="formData.lnbits_custom_badge"
|
||||
label="Custom Badge 'USE WITH CAUTION - LNbits wallet is still in BETA'"
|
||||
></q-input>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<h6 class="q-my-none q-mb-sm">User Management</h6>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 col-sm-12 q-pr-sm">
|
||||
<div class="col-12 col-md-6 q-pr-sm">
|
||||
<p>Admin Users</p>
|
||||
<q-input
|
||||
filled
|
||||
@@ -23,13 +23,14 @@
|
||||
@remove="removeAdminUser(user)"
|
||||
color="primary"
|
||||
text-color="white"
|
||||
:label="user"
|
||||
class="ellipsis"
|
||||
>
|
||||
<span v-text="user"></span>
|
||||
</q-chip>
|
||||
</div>
|
||||
<br />
|
||||
</div>
|
||||
<div class="col-md-6 col-sm-12">
|
||||
<div class="col-12 col-md-6">
|
||||
<p>Allowed Users</p>
|
||||
<q-input
|
||||
filled
|
||||
@@ -49,8 +50,9 @@
|
||||
@remove="removeAllowedUser(user)"
|
||||
color="primary"
|
||||
text-color="white"
|
||||
:label="user"
|
||||
class="ellipsis"
|
||||
>
|
||||
<span v-text="user" />
|
||||
</q-chip>
|
||||
</div>
|
||||
<br />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
|
||||
%} {% block page %}
|
||||
<div class="row q-col-gutter-md justify-center">
|
||||
<div class="col q-my-md">
|
||||
<div class="col q-mb-md">
|
||||
<q-btn
|
||||
:label="$t('save')"
|
||||
color="primary"
|
||||
@@ -26,6 +26,7 @@
|
||||
:label="$t('restart')"
|
||||
color="primary"
|
||||
@click="restartServer"
|
||||
class="q-ml-md"
|
||||
>
|
||||
<q-tooltip v-if="needsRestart">
|
||||
<span v-text="$t('restart_tooltip')"></span>
|
||||
@@ -60,54 +61,157 @@
|
||||
<div class="row q-col-gutter-md justify-center">
|
||||
<div class="col q-gutter-y-md">
|
||||
<q-card>
|
||||
<div class="q-pa-md">
|
||||
<div class="q-gutter-y-md">
|
||||
<q-tabs v-model="tab" active-color="primary" align="justify">
|
||||
<q-splitter>
|
||||
<template v-slot:before>
|
||||
<q-tabs
|
||||
@update:model-value="showExchangeProvidersTab"
|
||||
v-model="tab"
|
||||
vertical
|
||||
active-color="primary"
|
||||
>
|
||||
<q-tab
|
||||
name="funding"
|
||||
:label="$t('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="users"
|
||||
:label="$t('users')"
|
||||
@update="val => tab = val.name"
|
||||
></q-tab>
|
||||
|
||||
<q-tab
|
||||
name="server"
|
||||
:label="$t('server')"
|
||||
@update="val => tab = val.name"
|
||||
></q-tab>
|
||||
|
||||
<q-tab
|
||||
name="security"
|
||||
:label="$t('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="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="theme"
|
||||
:label="$t('theme')"
|
||||
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
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<q-form name="settings_form" id="settings_form">
|
||||
<q-tab-panels v-model="tab" animated>
|
||||
{% include "admin/_tab_funding.html" %} {% include
|
||||
"admin/_tab_users.html" %} {% include "admin/_tab_server.html" %} {%
|
||||
include "admin/_tab_security.html" %} {% include
|
||||
"admin/_tab_theme.html" %}
|
||||
</q-tab-panels>
|
||||
</q-form>
|
||||
<template v-slot:after>
|
||||
<q-form name="settings_form" id="settings_form">
|
||||
<q-tab-panels
|
||||
v-model="tab"
|
||||
animated
|
||||
swipeable
|
||||
vertical
|
||||
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_extensions.html" %} {% include
|
||||
"admin/_tab_notifications.html" %} {% include
|
||||
"admin/_tab_security.html" %} {% include "admin/_tab_theme.html"
|
||||
%}{% include "admin/_tab_audit.html"%}
|
||||
</q-tab-panels>
|
||||
</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">
|
||||
<strong>Create Currecny Ticker Converter</strong>
|
||||
<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 %} {% block scripts %} {{ window_vars(user) }}
|
||||
<script src="{{ static_url_for('static', 'js/admin.js') }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
|
||||
%} {% block page %}
|
||||
|
||||
<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>Components</strong>
|
||||
<div style="width: 250px" class="q-pa-sm">
|
||||
<canvas 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>To 5 Long Running Endpoints</strong>
|
||||
<div style="width: 250px; height: 250px" class="q-pa-sm">
|
||||
<canvas 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>HTTP Request Methods</strong>
|
||||
<div style="width: 250px; height: 250px" class="q-pa-sm">
|
||||
<canvas 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>HTTP Response Codes</strong>
|
||||
<div style="width: 250px; height: 250px" class="q-pa-sm">
|
||||
<canvas 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-th>
|
||||
<q-btn flat round icon="settings" tag="a" href="/admin#audit"
|
||||
><q-tooltip v-text="$t('admin_settings')"></q-tooltip
|
||||
></q-btn>
|
||||
</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>Request Details</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>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 %} {% block scripts %} {{ window_vars(user) }}
|
||||
<script src="{{ static_url_for('static', 'js/audit.js') }}"></script>
|
||||
{% endblock %}
|
||||
@@ -5,10 +5,75 @@
|
||||
:content-inset-level="0.5"
|
||||
>
|
||||
<q-card-section>
|
||||
<strong>Node URL: </strong><em v-text="origin"></em><br />
|
||||
<strong>Wallet ID: </strong><em>{{ wallet.id }}</em><br />
|
||||
<strong>Admin key: </strong><em>{{ wallet.adminkey }}</em><br />
|
||||
<strong>Invoice/read key: </strong><em>{{ wallet.inkey }}</em>
|
||||
<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>
|
||||
</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>
|
||||
</div>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-card-section>
|
||||
<q-expansion-item
|
||||
group="api"
|
||||
@@ -20,7 +85,7 @@
|
||||
<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>{{ wallet.inkey }}</i>"}</code><br />
|
||||
<code>{"X-Api-Key": "<i v-text="wallet.inkey"></i>"}</code><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 200 OK (application/json)
|
||||
</h5>
|
||||
@@ -30,12 +95,13 @@
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl {{ request.base_url }}api/v1/wallet -H "X-Api-Key:
|
||||
<i>{{ wallet.inkey }}</i>"</code
|
||||
>curl <span v-text="baseUrl"></span>api/v1/wallet -H "X-Api-Key:
|
||||
<i v-text="wallet.inkey"></i>"</code
|
||||
>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
|
||||
<q-expansion-item
|
||||
group="api"
|
||||
dense
|
||||
@@ -46,7 +112,7 @@
|
||||
<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>{{ wallet.inkey }}</i>"}</code><br />
|
||||
<code>{"X-Api-Key": "<i v-text="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>,
|
||||
@@ -62,9 +128,10 @@
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X POST {{ request.base_url }}api/v1/payments -d '{"out": false,
|
||||
"amount": <int>, "memo": <string>}' -H "X-Api-Key:
|
||||
<i>{{ wallet.inkey }}</i>" -H "Content-type: application/json"</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="wallet.inkey"></i>" -H "Content-type:
|
||||
application/json"</code
|
||||
>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
@@ -77,9 +144,23 @@
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code><span class="text-light-green">POST</span> /api/v1/payments</code>
|
||||
<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": "{{ wallet.adminkey }}"}</code>
|
||||
<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">
|
||||
@@ -88,10 +169,10 @@
|
||||
<code>{"payment_hash": <string>}</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X POST {{ request.base_url }}api/v1/payments -d '{"out": true,
|
||||
"bolt11": <string>}' -H "X-Api-Key:
|
||||
<i>{{ wallet.adminkey }}"</i> -H "Content-type:
|
||||
application/json"</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>
|
||||
@@ -109,18 +190,16 @@
|
||||
><span class="text-light-green">POST</span>
|
||||
/api/v1/payments/decode</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": "<i>{{ wallet.inkey }}</i>"}</code><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||
<code>{"invoice": <string>}</code>
|
||||
<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 {{ request.base_url }}api/v1/payments/decode -d
|
||||
'{"data": <bolt11/lnurl, string>}' -H "X-Api-Key:
|
||||
<i>{{ wallet.inkey }}</i>" -H "Content-type: application/json"</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>
|
||||
@@ -146,9 +225,10 @@
|
||||
<code>{"paid": <bool>}</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X GET {{ request.base_url
|
||||
}}api/v1/payments/<payment_hash> -H "X-Api-Key:
|
||||
<i>{{ wallet.inkey }}"</i> -H "Content-type: application/json"</code
|
||||
>curl -X GET
|
||||
<span v-text="baseUrl"></span>api/v1/payments/<payment_hash> -H
|
||||
"X-Api-Key: <i v-text="wallet.inkey"></i>" -H "Content-type:
|
||||
application/json"</code
|
||||
>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
@@ -26,7 +26,9 @@
|
||||
</q-tabs>
|
||||
<q-tab-panels v-model="tab">
|
||||
<q-tab-panel name="user">
|
||||
<div v-if="passwordData.show">
|
||||
<div v-if="credentialsData.show">
|
||||
<q-separator></q-separator>
|
||||
|
||||
<q-card-section>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
@@ -36,19 +38,26 @@
|
||||
</div>
|
||||
<div class="col">
|
||||
<q-img
|
||||
v-if="user.config.picture"
|
||||
v-if="user.extra.picture"
|
||||
style="max-width: 100px"
|
||||
:src="user.config.picture"
|
||||
:src="user.extra.picture"
|
||||
class="float-right"
|
||||
></q-img>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
<q-separator></q-separator>
|
||||
<q-card-section>
|
||||
<q-input
|
||||
v-model="credentialsData.username"
|
||||
:label="$t('username')"
|
||||
filled
|
||||
dense
|
||||
:readonly="hasUsername"
|
||||
class="q-mb-md"
|
||||
></q-input>
|
||||
<q-input
|
||||
v-if="user.has_password"
|
||||
v-model="passwordData.oldPassword"
|
||||
v-model="credentialsData.oldPassword"
|
||||
type="password"
|
||||
autocomplete="off"
|
||||
label="Old Password"
|
||||
@@ -57,7 +66,7 @@
|
||||
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password')]"
|
||||
></q-input>
|
||||
<q-input
|
||||
v-model="passwordData.newPassword"
|
||||
v-model="credentialsData.newPassword"
|
||||
type="password"
|
||||
autocomplete="off"
|
||||
:label="$t('password')"
|
||||
@@ -66,7 +75,7 @@
|
||||
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password')]"
|
||||
></q-input>
|
||||
<q-input
|
||||
v-model="passwordData.newPasswordRepeat"
|
||||
v-model="credentialsData.newPasswordRepeat"
|
||||
type="password"
|
||||
autocomplete="off"
|
||||
:label="$t('password_repeat')"
|
||||
@@ -75,24 +84,47 @@
|
||||
class="q-mb-md"
|
||||
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password')]"
|
||||
></q-input>
|
||||
</q-card-section>
|
||||
<q-separator></q-separator>
|
||||
<q-card-section class="q-pb-lg">
|
||||
<q-btn
|
||||
@click="updatePassword"
|
||||
:disable="(!passwordData.newPassword || !passwordData.newPasswordRepeat) || passwordData.newPassword !== passwordData.newPasswordRepeat"
|
||||
:disable="disableUpdatePassword()"
|
||||
unelevated
|
||||
color="primary"
|
||||
class="float-right"
|
||||
:label="$t('change_password')"
|
||||
>
|
||||
</q-btn>
|
||||
</q-card-section>
|
||||
<q-separator class="q-mt-xl"></q-separator>
|
||||
<q-card-section>
|
||||
<div class="col q-mb-sm">
|
||||
<h4 class="q-my-none">
|
||||
<span v-text="$t('pubkey')"></span>
|
||||
</h4>
|
||||
</div>
|
||||
<q-input
|
||||
v-model="credentialsData.pubkey"
|
||||
type="text"
|
||||
label="Pubkey"
|
||||
filled
|
||||
dense
|
||||
></q-input>
|
||||
<q-btn
|
||||
@click="passwordData.show = false"
|
||||
@click="updatePubkey"
|
||||
unelevated
|
||||
color="primary"
|
||||
class="q-mt-md float-right"
|
||||
:label="$t('update_pubkey')"
|
||||
>
|
||||
</q-btn>
|
||||
</q-card-section>
|
||||
<q-separator class="q-mt-xl"></q-separator>
|
||||
<q-card-section class="q-pb-lg">
|
||||
<q-btn
|
||||
@click="credentialsData.show = false"
|
||||
:label="$t('back')"
|
||||
outline
|
||||
unelevated
|
||||
color="grey"
|
||||
class="float-right"
|
||||
></q-btn>
|
||||
</q-card-section>
|
||||
</div>
|
||||
@@ -101,9 +133,9 @@
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<q-img
|
||||
v-if="user.config.picture"
|
||||
v-if="user.extra.picture"
|
||||
style="max-width: 100px"
|
||||
:src="user.config.picture"
|
||||
:src="user.extra.picture"
|
||||
class="float-right"
|
||||
></q-img>
|
||||
</div>
|
||||
@@ -137,6 +169,15 @@
|
||||
class="q-mb-md"
|
||||
>
|
||||
</q-input>
|
||||
<q-input
|
||||
v-model="user.pubkey"
|
||||
:label="$t('pubkey')"
|
||||
filled
|
||||
dense
|
||||
readonly
|
||||
class="q-mb-md"
|
||||
>
|
||||
</q-input>
|
||||
<q-input
|
||||
v-model="user.email"
|
||||
:label="$t('email')"
|
||||
@@ -195,9 +236,9 @@
|
||||
</div>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-section v-if="user.config">
|
||||
<q-card-section v-if="user.extra">
|
||||
<q-input
|
||||
v-model="user.config.first_name"
|
||||
v-model="user.extra.first_name"
|
||||
:label="$t('first_name')"
|
||||
filled
|
||||
dense
|
||||
@@ -205,7 +246,7 @@
|
||||
>
|
||||
</q-input>
|
||||
<q-input
|
||||
v-model="user.config.last_name"
|
||||
v-model="user.extra.last_name"
|
||||
:label="$t('last_name')"
|
||||
filled
|
||||
dense
|
||||
@@ -213,7 +254,7 @@
|
||||
>
|
||||
</q-input>
|
||||
<q-input
|
||||
v-model="user.config.provider"
|
||||
v-model="user.extra.provider"
|
||||
:label="$t('auth_provider')"
|
||||
filled
|
||||
dense
|
||||
@@ -222,10 +263,9 @@
|
||||
>
|
||||
</q-input>
|
||||
<q-input
|
||||
v-model="user.config.picture"
|
||||
v-model="user.extra.picture"
|
||||
:label="$t('picture')"
|
||||
filled
|
||||
dense
|
||||
class="q-mb-md"
|
||||
>
|
||||
</q-input>
|
||||
@@ -236,17 +276,26 @@
|
||||
<span v-text="$t('update_account')"></span>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
@click="showChangePassword()"
|
||||
:label="user.has_password ? $t('change_password'): $t('set_password')"
|
||||
outline
|
||||
unelevated
|
||||
color="grey"
|
||||
@click="showUpdateCredentials()"
|
||||
:label="$t('update_credentials')"
|
||||
filled
|
||||
color="primary"
|
||||
class="float-right"
|
||||
></q-btn>
|
||||
</q-card-section>
|
||||
</div>
|
||||
</q-tab-panel>
|
||||
<q-tab-panel name="theme">
|
||||
<q-btn
|
||||
v-if="g.user.admin"
|
||||
class="absolute-top-right"
|
||||
flat
|
||||
round
|
||||
icon="settings"
|
||||
tag="a"
|
||||
href="/admin#site_customisation"
|
||||
><q-tooltip v-text="$t('admin_settings')"></q-tooltip
|
||||
></q-btn>
|
||||
<div class="row q-mb-md">
|
||||
<div class="col-4"><span v-text="$t('language')"></span></div>
|
||||
<div class="col-8">
|
||||
@@ -373,6 +422,43 @@
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-mb-md">
|
||||
<div class="col-4">
|
||||
<span v-text="$t('background_image')"></span>
|
||||
</div>
|
||||
<div class="col-8">
|
||||
<q-input
|
||||
v-model="backgroundImage"
|
||||
:label="$t('background_image')"
|
||||
@update:model-value="backgroundImageFunc"
|
||||
>
|
||||
<q-tooltip
|
||||
><span v-text="$t('background_image')"></span
|
||||
></q-tooltip>
|
||||
</q-input>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-mb-md">
|
||||
<div class="col-4">
|
||||
<span v-text="$t('gradient_background')"></span>
|
||||
</div>
|
||||
<div class="col-8">
|
||||
<q-btn
|
||||
dense
|
||||
flat
|
||||
round
|
||||
@click="toggleGradient"
|
||||
icon="gradient"
|
||||
size="sm"
|
||||
v-model="gradientChoice"
|
||||
>
|
||||
<q-tooltip
|
||||
><span v-text="$t('toggle_gradient')"></span
|
||||
></q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row q-mb-md">
|
||||
<div class="col-4">
|
||||
<span v-text="$t('toggle_darkmode')"></span>
|
||||
@@ -392,6 +478,23 @@
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-mb-md">
|
||||
<div class="col-4">
|
||||
<span v-text="$t('border_choices')"></span>
|
||||
</div>
|
||||
<div class="col-8">
|
||||
<q-select
|
||||
v-model="borderChoice"
|
||||
:options="borderOptions"
|
||||
label="Reactions"
|
||||
@update:model-value="applyBorder"
|
||||
>
|
||||
<q-tooltip
|
||||
><span v-text="$t('border_choices')"></span
|
||||
></q-tooltip>
|
||||
</q-select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-mb-md">
|
||||
<div class="col-4">Notifications</div>
|
||||
<div class="col-8">
|
||||
@@ -410,7 +513,7 @@
|
||||
v-model="reactionChoice"
|
||||
:options="reactionOptions"
|
||||
label="Reactions"
|
||||
@input="reactionChoiceFunc"
|
||||
@update:model-value="reactionChoiceFunc"
|
||||
>
|
||||
<q-tooltip
|
||||
><span v-text="$t('payment_reactions')"></span
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -97,9 +97,9 @@
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
new Vue({
|
||||
window.app = Vue.createApp({
|
||||
el: '#vue',
|
||||
mixins: [windowMixin],
|
||||
mixins: [window.windowMixin],
|
||||
data: function () {
|
||||
return {
|
||||
loginData: {
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
color="primary"
|
||||
@click="processing"
|
||||
type="a"
|
||||
href="{{ url_for('core.lnurlwallet') }}?lightning={{ lnurl }}"
|
||||
href="/lnurlwallet?lightning={{ lnurl }}"
|
||||
v-text="$t('press_to_claim')"
|
||||
class="full-width"
|
||||
></q-btn>
|
||||
@@ -184,6 +184,47 @@
|
||||
</div>
|
||||
</q-form>
|
||||
</q-card-section>
|
||||
<q-card-section
|
||||
v-if="authAction === 'reset' && authMethod === 'username-password'"
|
||||
>
|
||||
<b> <span v-text="$t('reset_password')"></span> </b><br /><br />
|
||||
<q-form @submit="reset" class="q-gutter-md">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
required
|
||||
:disable="true"
|
||||
v-model="reset_key"
|
||||
:label="$t('reset_key') + ' *'"
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model="password"
|
||||
:label="$t('password') + ' *'"
|
||||
type="password"
|
||||
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password')]"
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model="passwordRepeat"
|
||||
:label="$t('password_repeat') + ' *'"
|
||||
type="password"
|
||||
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password')]"
|
||||
></q-input>
|
||||
<div>
|
||||
<q-btn
|
||||
unelevated
|
||||
color="primary"
|
||||
:disable="!password || !passwordRepeat|| !reset_key || (password !== passwordRepeat)"
|
||||
type="submit"
|
||||
class="full-width"
|
||||
:label="$t('reset_password')"
|
||||
></q-btn>
|
||||
</div>
|
||||
</q-form>
|
||||
</q-card-section>
|
||||
{%endif%} {% if LNBITS_NEW_ACCOUNTS_ALLOWED %}
|
||||
<q-card-section
|
||||
v-if="authAction === 'register' && authMethod === 'user-id-only'"
|
||||
@@ -230,7 +271,28 @@
|
||||
v-if="authAction === 'login' && authMethod === 'username-password'"
|
||||
>
|
||||
<div class="row">
|
||||
{% if "google-auth" in LNBITS_AUTH_METHODS %}
|
||||
{% if "nostr-auth-nip98" in LNBITS_AUTH_METHODS %}
|
||||
<div class="col-12 full-width q-pa-sm">
|
||||
<q-btn
|
||||
@click="signInWithNostr"
|
||||
outline
|
||||
no-caps
|
||||
rounded
|
||||
color="grey"
|
||||
class="full-width"
|
||||
>
|
||||
<q-avatar size="32px" class="q-mr-md">
|
||||
<q-img
|
||||
class="bg-primary"
|
||||
:src="'{{ static_url_for('static', 'images/logos/nostr.svg') }}'"
|
||||
></q-img>
|
||||
</q-avatar>
|
||||
<div>
|
||||
<span v-text="$t('signin_with_nostr')"></span>
|
||||
</div>
|
||||
</q-btn>
|
||||
</div>
|
||||
{%endif%} {% if "google-auth" in LNBITS_AUTH_METHODS %}
|
||||
<div class="col-12 full-width q-pa-sm">
|
||||
<q-btn
|
||||
href="/api/v1/auth/google"
|
||||
@@ -338,7 +400,7 @@
|
||||
outline
|
||||
color="grey"
|
||||
type="a"
|
||||
href="https://legend.lnbits.com/paywall/GAqKguK5S8f6w5VNjS9DfK"
|
||||
href="https://demo.lnbits.com/lnurlp/link/fH59GD"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
:label="$t('donate')"
|
||||
@@ -484,6 +546,32 @@
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<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 class="col q-pl-md">
|
||||
<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>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<a
|
||||
@@ -519,7 +607,18 @@
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col q-pl-md"></div>
|
||||
<div class="col">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<script src="{{ static_url_for('static', 'js/wallet.js') }}"></script>
|
||||
{% endblock %}
|
||||
<!---->
|
||||
{% block title %} {{ wallet.name }} - {{ SITE_TITLE }} {% endblock %}
|
||||
{% block title %}{{ wallet_name }} - {{ SITE_TITLE }} {% endblock %}
|
||||
<!---->
|
||||
{% block page %}
|
||||
<div class="row q-col-gutter-md">
|
||||
@@ -25,42 +25,124 @@
|
||||
} : ''"
|
||||
>
|
||||
{% endif %}
|
||||
<div class="row">
|
||||
<div class="col-lg-9 col-12">
|
||||
<q-card
|
||||
:style="$q.screen.lt.md ? {
|
||||
background: $q.screen.lt.md ? 'none !important': ''
|
||||
, boxShadow: $q.screen.lt.md ? 'none !important': ''
|
||||
, margin: $q.screen.lt.md && mobileSimple ? 'auto !important': ''
|
||||
, width: $q.screen.lt.md && mobileSimple ? '90% !important': ''
|
||||
} : ''"
|
||||
, boxShadow: $q.screen.lt.md ? 'none !important': ''
|
||||
, width: $q.screen.lt.md && mobileSimple ? '90% !important': ''
|
||||
} : ''"
|
||||
>
|
||||
<q-card-section>
|
||||
<h3 class="q-my-none text-no-wrap">
|
||||
<strong v-text="formattedBalance"></strong>
|
||||
<small>{{LNBITS_DENOMINATION}}</small>
|
||||
<lnbits-update-balance
|
||||
v-if="'{{user.super_user}}' == 'True'"
|
||||
:wallet_id="this.g.wallet.id"
|
||||
flat
|
||||
:callback="updateBalanceCallback"
|
||||
round
|
||||
/>
|
||||
</h3>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<div v-if="g.wallet.currency">
|
||||
<div class="col-1" style="max-width: 30px">
|
||||
<q-btn
|
||||
v-if="update.currency"
|
||||
@click="swapBalancePriority"
|
||||
style="height: 50px"
|
||||
class="q-mt-lg"
|
||||
flat
|
||||
dense
|
||||
icon="swap_vert"
|
||||
></q-btn>
|
||||
</div>
|
||||
<div class="col-11">
|
||||
<div
|
||||
class="column"
|
||||
:class="{
|
||||
'q-pt-sm': update.currency,
|
||||
'q-pt-lg': !update.currency
|
||||
}"
|
||||
v-if="!isPrioritySwapped"
|
||||
style="height: 90px"
|
||||
>
|
||||
<div class="col-7">
|
||||
<div class="row">
|
||||
<div class="col-auto">
|
||||
<div class="text-h3 q-my-none text-no-wrap">
|
||||
<strong v-text="formattedBalance"></strong>
|
||||
<small> {{LNBITS_DENOMINATION}}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<lnbits-update-balance
|
||||
v-if="$q.screen.lt.lg"
|
||||
:wallet_id="this.g.wallet.id"
|
||||
:callback="updateBalanceCallback"
|
||||
:small_btn="true"
|
||||
></lnbits-update-balance>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-2">
|
||||
<div v-if="update.currency">
|
||||
<span
|
||||
class="text-h5 text-italic"
|
||||
v-text="formattedFiatAmount"
|
||||
style="opacity: 0.75"
|
||||
></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="column"
|
||||
v-if="isPrioritySwapped"
|
||||
:class="{
|
||||
'q-pt-sm': update.currency,
|
||||
'q-pt-lg': !update.currency
|
||||
}"
|
||||
style="height: 100px"
|
||||
>
|
||||
<div class="col-7">
|
||||
<div class="row">
|
||||
<div class="col-auto">
|
||||
<div
|
||||
v-if="update.currency"
|
||||
class="text-h3 q-my-none text-no-wrap"
|
||||
>
|
||||
<strong v-text="formattedFiatAmount"></strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<lnbits-update-balance
|
||||
v-if="$q.screen.lt.lg"
|
||||
:wallet_id="this.g.wallet.id"
|
||||
:callback="updateBalanceCallback"
|
||||
:small_btn="true"
|
||||
></lnbits-update-balance>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-2">
|
||||
<span
|
||||
class="text-h5 text-italic"
|
||||
style="opacity: 0.75"
|
||||
v-text="formattedBalance + ' {{LNBITS_DENOMINATION}}'"
|
||||
>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="absolute-right q-pa-md"
|
||||
v-if="$q.screen.gt.md && update.currency"
|
||||
>
|
||||
<div class="text-bold text-italic">BTC Price</div>
|
||||
<span
|
||||
class="text-h5 text-italic"
|
||||
v-text="formattedFiatBalance"
|
||||
style="opacity: 0.75"
|
||||
class="text-bold text-italic"
|
||||
v-text="formattedExchange"
|
||||
></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<q-btn
|
||||
v-if="$q.screen.lt.md"
|
||||
@click="mobileSimple = !mobileSimple"
|
||||
color="primary"
|
||||
class="float-right lt-md"
|
||||
class="q-ml-xl absolute-right"
|
||||
dense
|
||||
size="sm"
|
||||
style="height: 20px; margin-top: 75px"
|
||||
flat
|
||||
:icon="mobileSimple ? 'unfold_more' : 'unfold_less'"
|
||||
:label="mobileSimple ? $t('more') : $t('less')"
|
||||
@@ -73,53 +155,78 @@
|
||||
<q-btn
|
||||
unelevated
|
||||
color="primary"
|
||||
class="full-width"
|
||||
class="q-mr-md"
|
||||
@click="showParseDialog"
|
||||
:label="$t('paste_request')"
|
||||
></q-btn>
|
||||
</div>
|
||||
<div class="col">
|
||||
<q-btn
|
||||
unelevated
|
||||
color="primary"
|
||||
class="full-width"
|
||||
class="q-mr-md"
|
||||
@click="showReceiveDialog"
|
||||
:label="$t('create_invoice')"
|
||||
></q-btn>
|
||||
</div>
|
||||
<div class="col">
|
||||
<q-btn
|
||||
unelevated
|
||||
color="secondary"
|
||||
icon="photo_camera"
|
||||
@click="showCamera"
|
||||
:label="$t('scan')"
|
||||
>
|
||||
<q-btn unelevated color="secondary" icon="qr_code_scanner">
|
||||
<q-tooltip
|
||||
><span v-text="$t('camera_tooltip')"></span
|
||||
></q-tooltip>
|
||||
</q-btn>
|
||||
<lnbits-update-balance
|
||||
v-if="$q.screen.gt.md"
|
||||
:wallet_id="this.g.wallet.id"
|
||||
:callback="updateBalanceCallback"
|
||||
:small_btn="false"
|
||||
></lnbits-update-balance>
|
||||
</div>
|
||||
</div>
|
||||
</q-card>
|
||||
<payment-list
|
||||
:update="updatePayments"
|
||||
:wallet="this.g.wallet"
|
||||
:mobile-simple="mobileSimple"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-3" v-show="$q.screen.gt.md">
|
||||
<q-card class="q-ml-md q-pa-none" style="height: 172px">
|
||||
<q-card-section style="padding: 0">
|
||||
<canvas
|
||||
ref="transactionChart"
|
||||
style="
|
||||
height: 172px;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
transform: translate(-2%, 4%);
|
||||
"
|
||||
></canvas>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
<q-card
|
||||
:style="
|
||||
$q.screen.lt.md
|
||||
? {
|
||||
background: $q.screen.lt.md ? 'none !important' : '',
|
||||
boxShadow: $q.screen.lt.md ? 'none !important' : '',
|
||||
marginTop: $q.screen.lt.md ? '0px !important' : ''
|
||||
}
|
||||
: ''
|
||||
"
|
||||
>
|
||||
<q-card-section>
|
||||
<payment-list
|
||||
:update="updatePayments"
|
||||
:wallet="this.g.wallet"
|
||||
:mobile-simple="mobileSimple"
|
||||
></payment-list>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
{% if HIDE_API %}
|
||||
<div class="col-12 col-md-4 q-gutter-y-md">
|
||||
{% else %}
|
||||
<div
|
||||
v-if="!mobileSimple || $q.screen.gt.sm"
|
||||
class="col-12 col-md-5 q-gutter-y-md"
|
||||
>
|
||||
<div v-if="!mobileSimple || $q.screen.gt.sm" class="col-12 col-md-5 q-gutter-y-md">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<h6 class="text-subtitle1 q-mt-none q-mb-sm">
|
||||
{{ SITE_TITLE }} Wallet:
|
||||
<strong><em>{{wallet.name}}</em></strong>
|
||||
<strong><em>{{wallet_name}}</em></strong>
|
||||
</h6>
|
||||
</q-card-section>
|
||||
<q-card-section class="q-pa-none">
|
||||
@@ -154,11 +261,17 @@
|
||||
<q-card>
|
||||
<q-card-section class="text-center">
|
||||
<p v-text="$t('export_to_phone_desc')"></p>
|
||||
<qrcode
|
||||
:value="'{{request.base_url}}wallet?usr={{user.id}}&wal={{wallet.id}}'"
|
||||
:options="{width:240}"
|
||||
></qrcode>
|
||||
<lnbits-qrcode :value="exportUrl"></lnbits-qrcode>
|
||||
</q-card-section>
|
||||
<span v-text="exportWalletQR"></span>
|
||||
<q-card-actions class="flex-center q-pb-md">
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
:label="$t('copy_wallet_url')"
|
||||
@click="copyText(exportUrl)"
|
||||
></q-btn>
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-separator></q-separator>
|
||||
@@ -175,7 +288,6 @@
|
||||
v-model.trim="update.name"
|
||||
label="Name"
|
||||
dense
|
||||
@update:model-value="(e) => console.log(e)"
|
||||
/>
|
||||
</div>
|
||||
<q-btn
|
||||
@@ -198,24 +310,27 @@
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<div style="max-width: 360px">
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
clearable
|
||||
v-model="update.currency"
|
||||
type="text"
|
||||
:label="$t('currency')"
|
||||
:options="receive.units.filter((u) => u !== 'sat')"
|
||||
></q-select>
|
||||
<div class="row">
|
||||
<div class="col-auto">
|
||||
<q-toggle
|
||||
v-model="fiatTracking"
|
||||
color="primary"
|
||||
@update:model-value="handleFiatTracking"
|
||||
></q-toggle>
|
||||
</div>
|
||||
<div class="col">
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
v-model="update.currency"
|
||||
type="text"
|
||||
:label="!fiatTracking ? 'Disabled' : $t('currency')"
|
||||
:options="receive.units.filter((u) => u !== 'sat')"
|
||||
:disable="!fiatTracking"
|
||||
></q-select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<q-btn
|
||||
:disable="!update.name.length"
|
||||
unelevated
|
||||
class="q-mt-sm"
|
||||
color="primary"
|
||||
:label="$t('update_currency')"
|
||||
@click="updateWallet({ currency: update.currency || '' })"
|
||||
></q-btn>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
@@ -277,7 +392,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<q-dialog v-model="receive.show" position="top">
|
||||
<q-dialog
|
||||
v-model="receive.show"
|
||||
position="top"
|
||||
@hide="onReceiveDialogHide"
|
||||
>
|
||||
<q-card
|
||||
v-if="!receive.paymentReq"
|
||||
class="q-pa-lg q-pt-xl lnbits__dialog-card"
|
||||
@@ -311,13 +430,11 @@
|
||||
<q-input
|
||||
ref="setAmount"
|
||||
filled
|
||||
:pattern="receive.unit === 'sat' ? '\\d*' : '\\d*\\.?\\d*'"
|
||||
inputmode="numeric"
|
||||
dense
|
||||
v-model.number="receive.data.amount"
|
||||
:label="$t('amount') + ' (' + receive.unit + ') *'"
|
||||
:mask="receive.unit != 'sat' ? '#.##' : '#'"
|
||||
fill-mask="0"
|
||||
reverse-fill-mask
|
||||
:step="receive.unit != 'sat' ? '0.01' : '1'"
|
||||
:min="receive.minMax[0]"
|
||||
:max="receive.minMax[1]"
|
||||
:readonly="receive.lnurl && receive.lnurl.fixed"
|
||||
@@ -364,13 +481,28 @@
|
||||
>
|
||||
<div class="text-center q-mb-lg">
|
||||
<a :href="'lightning:' + receive.paymentReq">
|
||||
<q-responsive :ratio="1" class="q-mx-xl">
|
||||
<lnbits-qrcode
|
||||
:value="'lightning:' + receive.paymentReq.toUpperCase()"
|
||||
></lnbits-qrcode>
|
||||
</q-responsive>
|
||||
<lnbits-qrcode
|
||||
:value="'lightning:' + receive.paymentReq.toUpperCase()"
|
||||
></lnbits-qrcode>
|
||||
</a>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<h3 class="q-my-md">
|
||||
<span v-text="formattedAmount"></span>
|
||||
</h3>
|
||||
<h5 v-if="receive.unit != 'sat'" class="q-mt-none q-mb-sm">
|
||||
<span v-text="formattedSatAmount"></span>
|
||||
</h5>
|
||||
<q-chip v-if="hasNfc" outline square color="positive">
|
||||
<q-avatar
|
||||
icon="nfc"
|
||||
color="positive"
|
||||
text-color="white"
|
||||
></q-avatar>
|
||||
NFC supported
|
||||
</q-chip>
|
||||
<span v-else class="text-caption text-grey">NFC not supported</span>
|
||||
</div>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
outline
|
||||
@@ -621,8 +753,8 @@
|
||||
<div v-else>
|
||||
<q-responsive :ratio="1">
|
||||
<qrcode-stream
|
||||
@decode="decodeQR"
|
||||
@init="onInitQR"
|
||||
@detect="decodeQR"
|
||||
@camera-on="onInitQR"
|
||||
class="rounded-borders"
|
||||
></qrcode-stream>
|
||||
</q-responsive>
|
||||
@@ -645,8 +777,8 @@
|
||||
<q-card class="q-pa-lg q-pt-xl">
|
||||
<div class="text-center q-mb-lg">
|
||||
<qrcode-stream
|
||||
@decode="decodeQR"
|
||||
@init="onInitQR"
|
||||
@detect="decodeQR"
|
||||
@camera-on="onInitQR"
|
||||
class="rounded-borders"
|
||||
></qrcode-stream>
|
||||
</div>
|
||||
@@ -661,22 +793,28 @@
|
||||
</div>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<q-tabs
|
||||
<div
|
||||
class="lt-md fixed-bottom left-0 right-0 bg-primary text-white shadow-2 z-top"
|
||||
active-class="px-0"
|
||||
indicator-color="transparent"
|
||||
align="justify"
|
||||
>
|
||||
<q-tab
|
||||
icon="file_download"
|
||||
@click="showReceiveDialog"
|
||||
:label="$t('receive')"
|
||||
<q-tabs
|
||||
active-class="px-0"
|
||||
indicator-color="transparent"
|
||||
align="justify"
|
||||
>
|
||||
</q-tab>
|
||||
<q-tab
|
||||
icon="file_download"
|
||||
@click="showReceiveDialog"
|
||||
:label="$t('receive')"
|
||||
>
|
||||
</q-tab>
|
||||
|
||||
<q-tab @click="showParseDialog" icon="file_upload" :label="$t('send')">
|
||||
</q-tab>
|
||||
<q-tab
|
||||
@click="showParseDialog"
|
||||
icon="file_upload"
|
||||
:label="$t('send')"
|
||||
>
|
||||
</q-tab>
|
||||
</q-tabs>
|
||||
<q-btn
|
||||
round
|
||||
size="35px"
|
||||
@@ -686,8 +824,7 @@
|
||||
class="text-white bg-primary z-top vertical-bottom absolute-center absolute"
|
||||
>
|
||||
</q-btn>
|
||||
</q-tabs>
|
||||
|
||||
</div>
|
||||
<q-dialog v-model="disclaimerDialog.show" position="top">
|
||||
<q-card class="q-pa-lg">
|
||||
<h6
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<q-tab-panel name="channels">
|
||||
<q-dialog v-model="connectPeerDialog.show">
|
||||
<q-dialog v-model="connectPeerDialog.show" position="top">
|
||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<q-form class="q-gutter-md">
|
||||
<q-input
|
||||
@@ -29,7 +29,7 @@
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<q-dialog v-model="openChannelDialog.show">
|
||||
<q-dialog v-model="openChannelDialog.show" position="top">
|
||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<q-form class="q-gutter-md">
|
||||
<q-input
|
||||
@@ -89,7 +89,7 @@
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<q-dialog v-model="closeChannelDialog.show">
|
||||
<q-dialog v-model="closeChannelDialog.show" position="top">
|
||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<q-form class="q-gutter-md">
|
||||
<div>
|
||||
@@ -160,7 +160,7 @@
|
||||
<q-table
|
||||
dense
|
||||
flat
|
||||
:data="this.filteredChannels"
|
||||
:rows="this.filteredChannels"
|
||||
:filter="channels.filter"
|
||||
no-data-label="No channels opened"
|
||||
>
|
||||
@@ -239,7 +239,7 @@
|
||||
<q-table
|
||||
dense
|
||||
flat
|
||||
:data="peers.data"
|
||||
:rows="peers.data"
|
||||
:filter="peers.filter"
|
||||
no-data-label="No transactions made yet"
|
||||
>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<q-tab-panel name="transactions">
|
||||
<q-card-section class="q-pa-none">
|
||||
<q-dialog v-model="transactionDetailsDialog.show">
|
||||
<q-dialog v-model="transactionDetailsDialog.show" position="top">
|
||||
<q-card class="my-card">
|
||||
<q-card-section>
|
||||
<div class="text-center q-mb-lg">
|
||||
@@ -51,11 +51,11 @@
|
||||
>
|
||||
<a :href="'lightning:' + transactionDetailsDialog.data.bolt11">
|
||||
<q-responsive :ratio="1" class="q-mx-xl">
|
||||
<qrcode
|
||||
<qrcode-vue
|
||||
:value="'lightning:' + transactionDetailsDialog.data.bolt11.toUpperCase()"
|
||||
:options="{width: 340}"
|
||||
class="rounded-borders"
|
||||
></qrcode>
|
||||
></qrcode-vue>
|
||||
</q-responsive>
|
||||
</a>
|
||||
<q-btn
|
||||
@@ -94,9 +94,9 @@
|
||||
<q-table
|
||||
dense
|
||||
flat
|
||||
:data="paymentsTable.data"
|
||||
:rows="paymentsTable.data"
|
||||
:columns="paymentsTable.columns"
|
||||
:pagination.sync="paymentsTable.pagination"
|
||||
v-model:pagination="paymentsTable.pagination"
|
||||
row-key="payment_hash"
|
||||
no-data-label="No transactions made yet"
|
||||
:filter="paymentsTable.filter"
|
||||
@@ -120,7 +120,11 @@
|
||||
>
|
||||
<q-tooltip>Pending</q-tooltip>
|
||||
</q-icon>
|
||||
<q-dialog v-model="props.row.expand" :props="props">
|
||||
<q-dialog
|
||||
v-model="props.row.expand"
|
||||
:props="props"
|
||||
position="top"
|
||||
>
|
||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<div class="text-center q-mb-lg">
|
||||
<div v-if="props.row.isIn && props.row.pending">
|
||||
@@ -138,11 +142,11 @@
|
||||
>
|
||||
<a :href="'lightning:' + props.row.bolt11">
|
||||
<q-responsive :ratio="1" class="q-mx-xl">
|
||||
<qrcode
|
||||
<qrcode-vue
|
||||
:value="'lightning:' + props.row.bolt11.toUpperCase()"
|
||||
:options="{width: 340}"
|
||||
class="rounded-borders"
|
||||
></qrcode>
|
||||
></qrcode-vue>
|
||||
</q-responsive>
|
||||
</a>
|
||||
</div>
|
||||
@@ -256,9 +260,9 @@
|
||||
<q-table
|
||||
dense
|
||||
flat
|
||||
:data="invoiceTable.data"
|
||||
:rows="invoiceTable.data"
|
||||
:columns="invoiceTable.columns"
|
||||
:pagination.sync="invoiceTable.pagination"
|
||||
v-model:pagination="invoiceTable.pagination"
|
||||
no-data-label="No transactions made yet"
|
||||
:filter="invoiceTable.filter"
|
||||
@request="getInvoices"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
|
||||
%} {% block page %}
|
||||
|
||||
<q-dialog v-model="nodeInfoDialog.show">
|
||||
<q-dialog v-model="nodeInfoDialog.show" position="top">
|
||||
<lnbits-node-qrcode :info="nodeInfoDialog.data"></lnbits-node-qrcode>
|
||||
</q-dialog>
|
||||
|
||||
@@ -42,12 +42,8 @@
|
||||
</div>
|
||||
|
||||
{% endblock %} {% block scripts %} {{ window_vars(user) }}
|
||||
<script src="{{ static_url_for('static', 'js/node.js') }}"></script>
|
||||
<script>
|
||||
Vue.component(VueQrcode.name, VueQrcode)
|
||||
Vue.use(VueQrcodeReader)
|
||||
|
||||
new Vue({
|
||||
window.app = Vue.createApp({
|
||||
el: '#vue',
|
||||
config: {
|
||||
globalProperties: {
|
||||
@@ -55,7 +51,7 @@
|
||||
msg: 'hello'
|
||||
}
|
||||
},
|
||||
mixins: [windowMixin],
|
||||
mixins: [window.windowMixin],
|
||||
data: function () {
|
||||
return {
|
||||
isSuperUser: false,
|
||||
@@ -319,7 +315,7 @@
|
||||
.confirmDialog('Do you really wanna disconnect this peer?')
|
||||
.onOk(() => {
|
||||
this.api('DELETE', `/peers/${id}`).then(response => {
|
||||
this.$q.notify({
|
||||
Quasar.Notify.create({
|
||||
message: 'Disconnected',
|
||||
icon: null
|
||||
})
|
||||
@@ -367,10 +363,13 @@
|
||||
this.transactionDetailsDialog.data = details
|
||||
console.log('details', details)
|
||||
},
|
||||
exportCSV: function () {},
|
||||
shortenNodeId
|
||||
shortenNodeId(nodeId) {
|
||||
return nodeId
|
||||
? nodeId.substring(0, 5) + '...' + nodeId.substring(nodeId.length - 5)
|
||||
: '...'
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<script src="{{ static_url_for('static', 'js/node.js') }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -53,10 +53,7 @@ context %} {% block page %}
|
||||
{% endblock %} {% block scripts %} {{ window_vars(user) }}
|
||||
<script src="{{ static_url_for('static', 'js/node.js') }}"></script>
|
||||
<script>
|
||||
Vue.component(VueQrcode.name, VueQrcode)
|
||||
Vue.use(VueQrcodeReader)
|
||||
|
||||
new Vue({
|
||||
window.app = Vue.createApp({
|
||||
el: '#vue',
|
||||
config: {
|
||||
globalProperties: {
|
||||
@@ -64,7 +61,7 @@ context %} {% block page %}
|
||||
msg: 'hello'
|
||||
}
|
||||
},
|
||||
mixins: [windowMixin],
|
||||
mixins: [window.windowMixin],
|
||||
data: function () {
|
||||
return {
|
||||
isSuperUser: false,
|
||||
|
||||
@@ -30,9 +30,6 @@ self.addEventListener('activate', evt =>
|
||||
// from the network before returning it to the page.
|
||||
self.addEventListener('fetch', event => {
|
||||
if (
|
||||
!event.request.url.startsWith(
|
||||
self.location.origin + '/api/v1/payments/sse'
|
||||
) &&
|
||||
event.request.url.startsWith(self.location.origin) &&
|
||||
event.request.method == 'GET'
|
||||
) {
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
<q-dialog v-model="createUserDialog.show">
|
||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<p>Create User</p>
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<q-form @submit="createUser">
|
||||
<lnbits-dynamic-fields
|
||||
:options="createUserDialog.fields"
|
||||
v-model="createUserDialog.data"
|
||||
></lnbits-dynamic-fields>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn v-close-popup unelevated color="primary" type="submit"
|
||||
>Create</q-btn
|
||||
>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
|
||||
>Cancel</q-btn
|
||||
>
|
||||
</div>
|
||||
</q-form>
|
||||
</div>
|
||||
</div>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
@@ -1,22 +1,43 @@
|
||||
<q-dialog v-model="createWalletDialog.show">
|
||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<p>Create Wallet</p>
|
||||
<q-dialog v-model="createWalletDialog.show" position="top">
|
||||
<q-card class="q-pa-md q-pt-md lnbits__dialog-card">
|
||||
<strong>Create Wallet</strong>
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<q-form @submit="createWallet">
|
||||
<lnbits-dynamic-fields
|
||||
:options="createWalletDialog.fields"
|
||||
v-model="createUserDialog.data"
|
||||
></lnbits-dynamic-fields>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn v-close-popup unelevated color="primary" type="submit"
|
||||
>Create</q-btn
|
||||
>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
|
||||
>Cancel</q-btn
|
||||
<div class="row q-mt-lg">
|
||||
<div class="col">
|
||||
<q-input
|
||||
v-model="createWalletDialog.data.name"
|
||||
:label='$t("name_your_wallet")'
|
||||
filled
|
||||
dense
|
||||
class="q-mb-md"
|
||||
>
|
||||
</q-input>
|
||||
</div>
|
||||
</q-form>
|
||||
</div>
|
||||
<div class="row q-mt-lg">
|
||||
<div class="col">
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
v-model="createWalletDialog.data.currency"
|
||||
:options="{{ currencies | safe }}"
|
||||
></q-select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
v-close-popup
|
||||
@click="createWallet()"
|
||||
unelevated
|
||||
color="primary"
|
||||
type="submit"
|
||||
>Create</q-btn
|
||||
>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
|
||||
>Cancel</q-btn
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</q-card>
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
<div class="row q-mb-lg">
|
||||
<div class="col">
|
||||
<q-btn
|
||||
icon="arrow_back_ios"
|
||||
@click="backToUsersPage()"
|
||||
:label="$t('back')"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
v-if="activeUser.data.id"
|
||||
@click="updateUser()"
|
||||
color="primary"
|
||||
:label="$t('update_account')"
|
||||
class="q-ml-md"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
v-else
|
||||
@click="createUser()"
|
||||
:label="$t('create_account')"
|
||||
color="primary"
|
||||
class="float-right"
|
||||
></q-btn>
|
||||
</div>
|
||||
</div>
|
||||
<q-card v-if="activeUser.show" class="q-pa-md">
|
||||
<q-card-section>
|
||||
<div class="text-h6">
|
||||
<span v-if="activeUser.data.id" v-text="$t('update_account')"></span>
|
||||
<span v-else v-text="$t('create_account')"></span>
|
||||
</div>
|
||||
</q-card-section>
|
||||
<q-card-section>
|
||||
<q-input
|
||||
v-if="activeUser.data.id"
|
||||
v-model="activeUser.data.id"
|
||||
:label="$t('user_id')"
|
||||
filled
|
||||
dense
|
||||
readonly
|
||||
:type="activeUser.data.showUserId ? 'text': 'password'"
|
||||
class="q-mb-md"
|
||||
><q-btn
|
||||
@click="activeUser.data.showUserId = !activeUser.data.showUserId"
|
||||
dense
|
||||
flat
|
||||
:icon="activeUser.data.showUserId ? 'visibility_off' : 'visibility'"
|
||||
color="grey"
|
||||
></q-btn>
|
||||
</q-input>
|
||||
<q-input
|
||||
v-model="activeUser.data.username"
|
||||
:label="$t('username')"
|
||||
filled
|
||||
dense
|
||||
class="q-mb-md"
|
||||
>
|
||||
</q-input>
|
||||
<q-toggle
|
||||
size="xs"
|
||||
v-if="!activeUser.data.id"
|
||||
color="secondary"
|
||||
:label="$t('set_password')"
|
||||
v-model="activeUser.setPassword"
|
||||
>
|
||||
<q-tooltip>Toggle Admin</q-tooltip>
|
||||
</q-toggle>
|
||||
|
||||
<q-input
|
||||
v-if="activeUser.setPassword"
|
||||
v-model="activeUser.data.password"
|
||||
:type="activeUser.data.showPassword ? 'text': 'password'"
|
||||
autocomplete="off"
|
||||
:label="$t('password')"
|
||||
filled
|
||||
dense
|
||||
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password')]"
|
||||
>
|
||||
<q-btn
|
||||
@click="activeUser.data.showPassword = !activeUser.data.showPassword"
|
||||
dense
|
||||
flat
|
||||
:icon="activeUser.data.showPassword ? 'visibility_off' : 'visibility'"
|
||||
color="grey"
|
||||
></q-btn>
|
||||
</q-input>
|
||||
<q-input
|
||||
v-if="activeUser.setPassword"
|
||||
v-model="activeUser.data.password_repeat"
|
||||
:type="activeUser.data.showPassword ? 'text': 'password'"
|
||||
type="password"
|
||||
autocomplete="off"
|
||||
:label="$t('password_repeat')"
|
||||
filled
|
||||
dense
|
||||
class="q-mb-md"
|
||||
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password')]"
|
||||
>
|
||||
<q-btn
|
||||
@click="activeUser.data.showPassword = !activeUser.data.showPassword"
|
||||
dense
|
||||
flat
|
||||
:icon="activeUser.data.showPassword ? 'visibility_off' : 'visibility'"
|
||||
color="grey"
|
||||
></q-btn>
|
||||
</q-input>
|
||||
|
||||
<q-input
|
||||
v-model="activeUser.data.pubkey"
|
||||
:label="$t('pubkey')"
|
||||
filled
|
||||
dense
|
||||
class="q-mb-md"
|
||||
>
|
||||
</q-input>
|
||||
<q-input
|
||||
v-model="activeUser.data.email"
|
||||
:label="$t('email')"
|
||||
filled
|
||||
dense
|
||||
class="q-mb-md"
|
||||
>
|
||||
</q-input>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-section v-if="activeUser.data.extra">
|
||||
<q-input
|
||||
v-model="activeUser.data.extra.first_name"
|
||||
:label="$t('first_name')"
|
||||
filled
|
||||
dense
|
||||
class="q-mb-md"
|
||||
>
|
||||
</q-input>
|
||||
<q-input
|
||||
v-model="activeUser.data.extra.last_name"
|
||||
:label="$t('last_name')"
|
||||
filled
|
||||
dense
|
||||
class="q-mb-md"
|
||||
>
|
||||
</q-input>
|
||||
<q-input
|
||||
v-model="activeUser.data.extra.provider"
|
||||
:label="$t('auth_provider')"
|
||||
filled
|
||||
dense
|
||||
class="q-mb-md"
|
||||
>
|
||||
</q-input>
|
||||
<q-input
|
||||
v-model="activeUser.data.extra.picture"
|
||||
:label="$t('picture')"
|
||||
filled
|
||||
dense
|
||||
class="q-mb-md"
|
||||
>
|
||||
</q-input>
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
v-model="activeUser.data.extensions"
|
||||
multiple
|
||||
label="User extensions"
|
||||
:options="g.extensions"
|
||||
></q-select>
|
||||
</q-card-section>
|
||||
<q-card-section v-if="activeUser.data.id">
|
||||
<q-btn
|
||||
@click="resetPassword(activeUser.data.id)"
|
||||
:disable="activeUser.data.is_super_user"
|
||||
:label="$t('reset_password')"
|
||||
icon="refresh"
|
||||
color="primary"
|
||||
>
|
||||
<q-tooltip>Generate and copy password reset url</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
@click="deleteUser(activeUser.data.id)"
|
||||
:disable="activeUser.data.is_super_user"
|
||||
:label="$t('delete')"
|
||||
icon="delete"
|
||||
color="negative"
|
||||
class="float-right"
|
||||
>
|
||||
<q-tooltip>Delete User</q-tooltip></q-btn
|
||||
>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
+85
-44
@@ -1,12 +1,38 @@
|
||||
<q-dialog v-model="walletDialog.show">
|
||||
<q-card class="q-pa-lg" style="width: 700px; max-width: 80vw">
|
||||
<div v-if="paymentPage.show">
|
||||
<div class="row q-mb-lg">
|
||||
<div class="col">
|
||||
<q-btn
|
||||
icon="arrow_back_ios"
|
||||
@click="paymentPage.show = false"
|
||||
:label="$t('back')"
|
||||
></q-btn>
|
||||
</div>
|
||||
</div>
|
||||
<q-card class="q-pa-md">
|
||||
<q-card-section>
|
||||
<payment-list :wallet="paymentsWallet" />
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
<div v-else-if="activeWallet.show">
|
||||
<div class="row q-mb-lg">
|
||||
<div class="col">
|
||||
<q-btn
|
||||
icon="arrow_back_ios"
|
||||
@click="backToUsersPage()"
|
||||
:label="$t('back')"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
@click="createWalletDialog.show = true"
|
||||
:label="$t('create_new_wallet')"
|
||||
color="primary"
|
||||
class="q-ml-md"
|
||||
></q-btn>
|
||||
</div>
|
||||
</div>
|
||||
<q-card class="q-pa-md">
|
||||
<h2 class="text-h6 q-mb-md">Wallets</h2>
|
||||
<q-dialog v-model="paymentDialog.show">
|
||||
<q-card class="q-pa-lg" style="width: 700px; max-width: 80vw">
|
||||
<payment-list :wallet="activeWallet" />
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
<q-table :data="wallets" :columns="walletTable.columns">
|
||||
<q-table :rows="wallets" :columns="walletTable.columns">
|
||||
<template v-slot:header="props">
|
||||
<q-tr :props="props">
|
||||
<q-th auto-width></q-th>
|
||||
@@ -22,6 +48,11 @@
|
||||
<template v-slot:body="props">
|
||||
<q-tr :props="props">
|
||||
<q-td auto-width>
|
||||
<lnbits-update-balance
|
||||
:wallet_id="props.row.id"
|
||||
@credit-value="handleBalanceUpdate"
|
||||
class="q-mr-md"
|
||||
></lnbits-update-balance>
|
||||
<q-btn
|
||||
round
|
||||
icon="menu"
|
||||
@@ -31,27 +62,14 @@
|
||||
>
|
||||
<q-tooltip>Show Payments</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
v-if="!props.row.deleted"
|
||||
round
|
||||
icon="content_copy"
|
||||
size="sm"
|
||||
color="primary"
|
||||
@click="copyText(props.row.id)"
|
||||
>
|
||||
<q-tooltip>Copy Wallet ID</q-tooltip>
|
||||
</q-btn>
|
||||
<lnbits-update-balance
|
||||
v-if="!props.row.deleted"
|
||||
:wallet_id="props.row.id"
|
||||
:callback="topupCallback"
|
||||
></lnbits-update-balance>
|
||||
|
||||
<q-btn
|
||||
round
|
||||
v-if="!props.row.deleted"
|
||||
icon="vpn_key"
|
||||
size="sm"
|
||||
color="primary"
|
||||
class="q-ml-xs"
|
||||
@click="copyText(props.row.adminkey)"
|
||||
>
|
||||
<q-tooltip>Copy Admin Key</q-tooltip>
|
||||
@@ -62,45 +80,68 @@
|
||||
icon="vpn_key"
|
||||
size="sm"
|
||||
color="secondary"
|
||||
class="q-ml-xs"
|
||||
@click="copyText(props.row.inkey)"
|
||||
>
|
||||
<q-tooltip>Copy Invoice Key</q-tooltip>
|
||||
</q-btn>
|
||||
|
||||
<q-btn
|
||||
round
|
||||
icon="delete"
|
||||
size="sm"
|
||||
color="negative"
|
||||
class="q-ml-xs"
|
||||
@click="deleteUserWallet(props.row.user, props.row.id, props.row.deleted)"
|
||||
>
|
||||
<q-tooltip>Delete Wallet</q-tooltip>
|
||||
</q-btn>
|
||||
</q-td>
|
||||
<q-td auto-width>
|
||||
<q-btn
|
||||
icon="link"
|
||||
size="sm"
|
||||
flat
|
||||
class="cursor-pointer q-mr-xs"
|
||||
@click="copyWalletLink(props.row.id)"
|
||||
>
|
||||
<q-tooltip>Copy Wallet Link</q-tooltip>
|
||||
</q-btn>
|
||||
|
||||
<span v-text="props.row.name"></span>
|
||||
<q-btn
|
||||
round
|
||||
v-if="props.row.deleted"
|
||||
icon="toggle_off"
|
||||
size="sm"
|
||||
color="secondary"
|
||||
class="q-ml-xs"
|
||||
@click="undeleteUserWallet(props.row.user, props.row.id)"
|
||||
>
|
||||
<q-tooltip>Undelete Wallet</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
round
|
||||
icon="delete"
|
||||
size="sm"
|
||||
color="negative"
|
||||
@click="deleteUserWallet(props.row.user, props.row.id, props.row.deleted)"
|
||||
>
|
||||
<q-tooltip>Delete Wallet</q-tooltip>
|
||||
</q-btn>
|
||||
</q-td>
|
||||
<q-td auto-width v-text="props.row.name"></q-td>
|
||||
<q-td auto-width>
|
||||
<q-btn
|
||||
icon="content_copy"
|
||||
size="sm"
|
||||
flat
|
||||
class="cursor-pointer q-mr-xs"
|
||||
@click="copyText(props.row.id)"
|
||||
>
|
||||
<q-tooltip>Copy Wallet ID</q-tooltip>
|
||||
</q-btn>
|
||||
|
||||
<span
|
||||
v-text="props.row.id"
|
||||
:class="props.row.deleted ? 'text-strike' : ''"
|
||||
></span>
|
||||
</q-td>
|
||||
|
||||
<q-td auto-width v-text="props.row.currency"></q-td>
|
||||
<q-td auto-width v-text="formatSat(props.row.balance_msat)"></q-td>
|
||||
<q-td auto-width v-text="props.row.deleted"></q-td>
|
||||
</q-tr>
|
||||
</template>
|
||||
</q-table>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
v-close-popup
|
||||
flat
|
||||
color="grey"
|
||||
class="q-ml-auto"
|
||||
:label="$t('close')"
|
||||
></q-btn>
|
||||
</div>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</div>
|
||||
@@ -1,49 +0,0 @@
|
||||
<q-dialog v-model="topupDialog.show" position="top">
|
||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<q-form class="q-gutter-md">
|
||||
<p v-text="$t('topup_wallet')"></p>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<q-input
|
||||
dense
|
||||
type="text"
|
||||
filled
|
||||
v-model="wallet.id"
|
||||
label="Wallet ID"
|
||||
:hint="$t('topup_hint')"
|
||||
></q-input>
|
||||
|
||||
<br />
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<q-input
|
||||
dense
|
||||
type="number"
|
||||
filled
|
||||
v-model="wallet.amount"
|
||||
:label="$t('amount')"
|
||||
></q-input>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
:label="$t('topup')"
|
||||
color="primary"
|
||||
@click="topupWallet"
|
||||
v-close-popup
|
||||
></q-btn>
|
||||
|
||||
<q-btn
|
||||
v-close-popup
|
||||
flat
|
||||
color="grey"
|
||||
class="q-ml-auto"
|
||||
:label="$t('cancel')"
|
||||
></q-btn>
|
||||
</div>
|
||||
</q-form>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
@@ -1,33 +1,30 @@
|
||||
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
|
||||
%} {% block page %} {% include "users/_walletDialog.html" %} {% include
|
||||
"users/_topupDialog.html" %} {% include "users/_createUserDialog.html" %} {%
|
||||
include "users/_createWalletDialog.html" %}
|
||||
|
||||
<h3 class="text-subtitle q-my-none" v-text="$t('users')"></h3>
|
||||
%} {% block page %}
|
||||
|
||||
<div class="row q-col-gutter-md justify-center">
|
||||
<div class="col q-gutter-y-md" style="width: 300px">
|
||||
<div style="width: 600px">
|
||||
<canvas ref="chart1"></canvas>
|
||||
<div class="col">
|
||||
{% include "users/_manageWallet.html" %}
|
||||
<div v-if="activeUser.show" class="row">
|
||||
<div class="col-12 col-md-6">{%include "users/_manageUser.html" %}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-col-gutter-md justify-center">
|
||||
<div class="col q-gutter-y-md">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<div class="row items-center no-wrap q-mb-sm">
|
||||
<q-btn :label="$t('topup')" @click="topupDialog.show = true">
|
||||
<q-tooltip
|
||||
>{%raw%}{{ $t('add_funds_tooltip') }}{%endraw%}</q-tooltip
|
||||
>
|
||||
</q-btn>
|
||||
</div>
|
||||
<div v-else-if="activeWallet.show">
|
||||
{%include "users/_createWalletDialog.html" %}
|
||||
</div>
|
||||
<div v-else>
|
||||
<q-btn
|
||||
@click="showAccountPage()"
|
||||
:label="$t('create_account')"
|
||||
color="primary"
|
||||
class="q-mb-md"
|
||||
>
|
||||
</q-btn>
|
||||
|
||||
<q-card class="q-pa-md">
|
||||
<q-table
|
||||
:data="users"
|
||||
:row-key="usersTableRowKey"
|
||||
row-key="id"
|
||||
:rows="users"
|
||||
:columns="usersTable.columns"
|
||||
:pagination.sync="usersTable.pagination"
|
||||
v-model:pagination="usersTable.pagination"
|
||||
:no-data-label="$t('no_users')"
|
||||
:filter="usersTable.search"
|
||||
:loading="usersTable.loading"
|
||||
@@ -36,78 +33,118 @@ include "users/_createWalletDialog.html" %}
|
||||
<template v-slot:header="props">
|
||||
<q-tr :props="props">
|
||||
<q-th auto-width></q-th>
|
||||
<q-th
|
||||
v-for="col in props.cols"
|
||||
v-text="col.label"
|
||||
:key="col.name"
|
||||
:props="props"
|
||||
></q-th>
|
||||
<q-th v-for="col in props.cols" :key="col.name" :props="props">
|
||||
<q-input
|
||||
v-if="['user', 'username', 'email', 'pubkey', 'wallet_id'].includes(col.name)"
|
||||
v-model="searchData[col.name]"
|
||||
@keydown.enter="searchUserBy(col.name)"
|
||||
dense
|
||||
type="text"
|
||||
filled
|
||||
:label="col.label"
|
||||
>
|
||||
<template v-slot:append>
|
||||
<q-icon
|
||||
name="search"
|
||||
@click="searchUserBy(col.name)"
|
||||
class="cursor-pointer"
|
||||
/>
|
||||
</template>
|
||||
</q-input>
|
||||
|
||||
<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>
|
||||
<q-btn
|
||||
@click="showAccountPage(props.row.id)"
|
||||
round
|
||||
icon="edit"
|
||||
size="sm"
|
||||
color="secondary"
|
||||
class="q-ml-xs"
|
||||
>
|
||||
<q-tooltip>
|
||||
<span v-text="$t('update_account')"></span>
|
||||
</q-tooltip>
|
||||
</q-btn>
|
||||
</q-td>
|
||||
<q-td>
|
||||
<q-toggle
|
||||
size="xs"
|
||||
v-if="!props.row.is_super_user"
|
||||
color="secondary"
|
||||
v-model="props.row.is_admin"
|
||||
@update:model-value="toggleAdmin(props.row.id)"
|
||||
>
|
||||
<q-tooltip>Toggle Admin</q-tooltip>
|
||||
</q-toggle>
|
||||
<q-btn
|
||||
round
|
||||
v-if="props.row.is_super_user"
|
||||
icon="verified"
|
||||
size="sm"
|
||||
color="secondary"
|
||||
class="q-ml-xs"
|
||||
>
|
||||
<q-tooltip>Super User</q-tooltip>
|
||||
</q-btn>
|
||||
</q-td>
|
||||
|
||||
<q-td>
|
||||
<q-btn
|
||||
icon="list"
|
||||
size="sm"
|
||||
color="secondary"
|
||||
:label="props.row.wallet_count"
|
||||
@click="fetchWallets(props.row.id)"
|
||||
>
|
||||
<q-tooltip>Show Wallets</q-tooltip>
|
||||
</q-btn>
|
||||
</q-td>
|
||||
|
||||
<q-td>
|
||||
<q-btn
|
||||
round
|
||||
icon="content_copy"
|
||||
size="sm"
|
||||
color="primary"
|
||||
flat
|
||||
class="cursor-pointer q-mr-xs"
|
||||
@click="copyText(props.row.id)"
|
||||
>
|
||||
<q-tooltip>Copy User ID</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
round
|
||||
v-if="!props.row.is_super_user"
|
||||
icon="build"
|
||||
size="sm"
|
||||
:color="props.row.is_admin ? 'primary' : ''"
|
||||
@click="toggleAdmin(props.row.id)"
|
||||
>
|
||||
<q-tooltip>Toggle Admin</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
round
|
||||
v-if="props.row.is_super_user"
|
||||
icon="build"
|
||||
size="sm"
|
||||
color="positive"
|
||||
>
|
||||
<q-tooltip>Super User</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
round
|
||||
icon="delete"
|
||||
size="sm"
|
||||
color="negative"
|
||||
@click="deleteUser(props.row.id, props)"
|
||||
>
|
||||
<q-tooltip>Delete User</q-tooltip>
|
||||
</q-btn>
|
||||
<span v-text="shortify(props.row.id)"></span>
|
||||
</q-td>
|
||||
<q-td
|
||||
auto-width
|
||||
v-text="formatSat(props.row.balance_msat)"
|
||||
></q-td>
|
||||
<q-td auto-width v-text="props.row.wallet_count"></q-td>
|
||||
<q-td auto-width v-text="props.row.transaction_count"></q-td>
|
||||
<q-td auto-width v-text="props.row.username"></q-td>
|
||||
<q-td auto-width v-text="props.row.email"></q-td>
|
||||
<q-td auto-width v-text="props.row.last_payment"></q-td>
|
||||
<q-td v-text="props.row.username"></q-td>
|
||||
|
||||
<q-td v-text="props.row.email"></q-td>
|
||||
|
||||
<q-td>
|
||||
<q-btn
|
||||
v-if="props.row.pubkey"
|
||||
icon="content_copy"
|
||||
size="sm"
|
||||
flat
|
||||
class="cursor-pointer q-mr-xs"
|
||||
@click="copyText(props.row.pubkey)"
|
||||
>
|
||||
<q-tooltip>Copy Public Key</q-tooltip>
|
||||
</q-btn>
|
||||
<span v-text="shortify(props.row.pubkey)"></span>
|
||||
</q-td>
|
||||
<q-td v-text="formatSat(props.row.balance_msat)"></q-td>
|
||||
|
||||
<q-td v-text="props.row.transaction_count"></q-td>
|
||||
|
||||
<q-td v-text="formatDate(props.row.last_payment)"></q-td>
|
||||
</q-tr>
|
||||
</template>
|
||||
</q-table>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -14,10 +14,9 @@ from lnbits.core.services import (
|
||||
get_balance_delta,
|
||||
update_cached_settings,
|
||||
)
|
||||
from lnbits.core.tasks import api_invoice_listeners
|
||||
from lnbits.decorators import check_admin, check_super_user
|
||||
from lnbits.server import server_restart
|
||||
from lnbits.settings import AdminSettings, UpdateSettings, settings
|
||||
from lnbits.settings import AdminSettings, Settings, UpdateSettings, settings
|
||||
from lnbits.tasks import invoice_listeners
|
||||
|
||||
from .. import core_app_extra
|
||||
@@ -45,7 +44,6 @@ async def api_auditor():
|
||||
async def api_monitor():
|
||||
return {
|
||||
"invoice_listeners": list(invoice_listeners.keys()),
|
||||
"api_invoice_listeners": list(api_invoice_listeners.keys()),
|
||||
}
|
||||
|
||||
|
||||
@@ -70,6 +68,16 @@ async def api_update_settings(data: UpdateSettings, user: User = Depends(check_a
|
||||
return {"status": "Success"}
|
||||
|
||||
|
||||
@admin_router.get(
|
||||
"/api/v1/settings/default",
|
||||
status_code=HTTPStatus.OK,
|
||||
dependencies=[Depends(check_admin)],
|
||||
)
|
||||
async def api_reset_settings(field_name: str):
|
||||
default_settings = Settings()
|
||||
return {"default_value": getattr(default_settings, field_name)}
|
||||
|
||||
|
||||
@admin_router.delete(
|
||||
"/api/v1/settings",
|
||||
status_code=HTTPStatus.OK,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user