Compare commits
120
Commits
0.12.9
..
v1.0.0-rc6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
+21
-4
@@ -9,8 +9,8 @@
|
|||||||
# configurations defined in `ReadOnlySettings` will still be read from the environment variables.
|
# 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
|
# The rest of the settings will be stored in your database and you will be able to change them
|
||||||
# only through the Admin UI.
|
# only through the Admin UI.
|
||||||
# Disable this to make LNbits use this config file again.
|
# Disable this and clear `settings` table from database to make LNbits use this config file again.
|
||||||
LNBITS_ADMIN_UI=false
|
LNBITS_ADMIN_UI=true
|
||||||
|
|
||||||
# Change theme
|
# Change theme
|
||||||
LNBITS_SITE_TITLE="LNbits"
|
LNBITS_SITE_TITLE="LNbits"
|
||||||
@@ -28,7 +28,7 @@ PORT=5000
|
|||||||
######################################
|
######################################
|
||||||
|
|
||||||
# which fundingsources are allowed in the admin ui
|
# which fundingsources are allowed in the admin ui
|
||||||
LNBITS_ALLOWED_FUNDING_SOURCES="VoidWallet, FakeWallet, CoreLightningWallet, CoreLightningRestWallet, LndRestWallet, EclairWallet, LndWallet, LnTipsWallet, LNPayWallet, LNbitsWallet, BlinkWallet, AlbyWallet, ZBDWallet, PhoenixdWallet, OpenNodeWallet"
|
# 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
|
LNBITS_BACKEND_WALLET_CLASS=VoidWallet
|
||||||
# VoidWallet is just a fallback that works without any actual Lightning capabilities,
|
# VoidWallet is just a fallback that works without any actual Lightning capabilities,
|
||||||
@@ -87,6 +87,12 @@ LNPAY_WALLET_KEY=LNPAY_ADMIN_KEY
|
|||||||
ALBY_API_ENDPOINT=https://api.getalby.com/
|
ALBY_API_ENDPOINT=https://api.getalby.com/
|
||||||
ALBY_ACCESS_TOKEN=ALBY_ACCESS_TOKEN
|
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
|
# ZBDWallet
|
||||||
ZBD_API_ENDPOINT=https://api.zebedee.io/v0/
|
ZBD_API_ENDPOINT=https://api.zebedee.io/v0/
|
||||||
ZBD_API_KEY=ZBD_ACCESS_TOKEN
|
ZBD_API_KEY=ZBD_ACCESS_TOKEN
|
||||||
@@ -112,18 +118,29 @@ LNBITS_DENOMINATION=sats
|
|||||||
ECLAIR_URL=http://127.0.0.1:8283
|
ECLAIR_URL=http://127.0.0.1:8283
|
||||||
ECLAIR_PASS=eclairpw
|
ECLAIR_PASS=eclairpw
|
||||||
|
|
||||||
|
# NWCWalllet
|
||||||
|
NWC_PAIRING_URL="nostr+walletconnect://000...000?relay=example.com&secret=123"
|
||||||
|
|
||||||
# LnTipsWallet
|
# LnTipsWallet
|
||||||
# Enter /api in LightningTipBot to get your key
|
# Enter /api in LightningTipBot to get your key
|
||||||
LNTIPS_API_KEY=LNTIPS_ADMIN_KEY
|
LNTIPS_API_KEY=LNTIPS_ADMIN_KEY
|
||||||
LNTIPS_API_ENDPOINT=https://ln.tips
|
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 ##########
|
####### Auth Configurations ##########
|
||||||
######################################
|
######################################
|
||||||
# Secret Key: will default to the hash of the super user. It is strongly recommended that you set your own value.
|
# 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_SECRET_KEY=""
|
||||||
AUTH_TOKEN_EXPIRE_MINUTES=525600
|
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"
|
AUTH_ALLOWED_METHODS="user-id-only, username-password"
|
||||||
# Set this flag if HTTP is used for OAuth
|
# Set this flag if HTTP is used for OAuth
|
||||||
# OAUTHLIB_INSECURE_TRANSPORT="1"
|
# OAUTHLIB_INSECURE_TRANSPORT="1"
|
||||||
|
|||||||
@@ -46,7 +46,10 @@ runs:
|
|||||||
|
|
||||||
- name: Install the project dependencies
|
- name: Install the project dependencies
|
||||||
shell: bash
|
shell: bash
|
||||||
run: poetry install
|
run: |
|
||||||
|
poetry install
|
||||||
|
# needed for conv tests
|
||||||
|
poetry add psycopg2-binary
|
||||||
|
|
||||||
- name: Use Node.js ${{ inputs.node-version }}
|
- name: Use Node.js ${{ inputs.node-version }}
|
||||||
if: ${{ (inputs.npm == 'true') }}
|
if: ${{ (inputs.npm == 'true') }}
|
||||||
|
|||||||
@@ -28,11 +28,10 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: cachix/install-nix-action@v24
|
- uses: cachix/install-nix-action@v27
|
||||||
with:
|
with:
|
||||||
nix_path: nixpkgs=channel:nixos-23.11
|
nix_path: nixpkgs=channel:nixos-24.05
|
||||||
- uses: cachix/cachix-action@v13
|
- uses: cachix/cachix-action@v15
|
||||||
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/cachix'
|
|
||||||
with:
|
with:
|
||||||
name: lnbits
|
name: lnbits
|
||||||
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
||||||
|
|||||||
@@ -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-latest
|
||||||
|
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 }}
|
||||||
+6
-2
@@ -35,6 +35,7 @@ __bundle__
|
|||||||
coverage.xml
|
coverage.xml
|
||||||
node_modules
|
node_modules
|
||||||
lnbits/static/bundle.js
|
lnbits/static/bundle.js
|
||||||
|
lnbits/static/bundle-components.js
|
||||||
lnbits/static/bundle.css
|
lnbits/static/bundle.css
|
||||||
lnbits/static/bundle.min.js.old
|
lnbits/static/bundle.min.js.old
|
||||||
lnbits/static/bundle.min.css.old
|
lnbits/static/bundle.min.css.old
|
||||||
@@ -49,8 +50,11 @@ fly.toml
|
|||||||
lnbits-backup.zip
|
lnbits-backup.zip
|
||||||
|
|
||||||
# Ignore extensions (post installable extension PR)
|
# Ignore extensions (post installable extension PR)
|
||||||
extensions
|
/lnbits/extensions
|
||||||
upgrades/
|
/upgrades/
|
||||||
|
|
||||||
# builded python package
|
# builded python package
|
||||||
dist
|
dist
|
||||||
|
|
||||||
|
# jetbrains
|
||||||
|
.idea
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
|
|
||||||
**/lnbits/static/vendor
|
**/lnbits/static/vendor
|
||||||
**/lnbits/static/bundle.*
|
**/lnbits/static/bundle.*
|
||||||
|
**/lnbits/static/bundle-components.*
|
||||||
**/lnbits/static/css/*
|
**/lnbits/static/css/*
|
||||||
|
|
||||||
flake.lock
|
flake.lock
|
||||||
|
|||||||
@@ -38,25 +38,32 @@ checkeditorconfig:
|
|||||||
dev:
|
dev:
|
||||||
poetry run lnbits --reload
|
poetry run lnbits --reload
|
||||||
|
|
||||||
|
docker:
|
||||||
|
docker build -t lnbits/lnbits .
|
||||||
|
|
||||||
test-wallets:
|
test-wallets:
|
||||||
|
LNBITS_DATA_FOLDER="./tests/data" \
|
||||||
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
|
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
|
||||||
PYTHONUNBUFFERED=1 \
|
PYTHONUNBUFFERED=1 \
|
||||||
DEBUG=true \
|
DEBUG=true \
|
||||||
poetry run pytest tests/wallets
|
poetry run pytest tests/wallets
|
||||||
|
|
||||||
test-unit:
|
test-unit:
|
||||||
|
LNBITS_DATA_FOLDER="./tests/data" \
|
||||||
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
|
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
|
||||||
PYTHONUNBUFFERED=1 \
|
PYTHONUNBUFFERED=1 \
|
||||||
DEBUG=true \
|
DEBUG=true \
|
||||||
poetry run pytest tests/unit
|
poetry run pytest tests/unit
|
||||||
|
|
||||||
test-api:
|
test-api:
|
||||||
|
LNBITS_DATA_FOLDER="./tests/data" \
|
||||||
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
|
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
|
||||||
PYTHONUNBUFFERED=1 \
|
PYTHONUNBUFFERED=1 \
|
||||||
DEBUG=true \
|
DEBUG=true \
|
||||||
poetry run pytest tests/api
|
poetry run pytest tests/api
|
||||||
|
|
||||||
test-regtest:
|
test-regtest:
|
||||||
|
LNBITS_DATA_FOLDER="./tests/data" \
|
||||||
PYTHONUNBUFFERED=1 \
|
PYTHONUNBUFFERED=1 \
|
||||||
DEBUG=true \
|
DEBUG=true \
|
||||||
poetry run pytest tests/regtest
|
poetry run pytest tests/regtest
|
||||||
@@ -71,6 +78,7 @@ test-migration:
|
|||||||
HOST=0.0.0.0 \
|
HOST=0.0.0.0 \
|
||||||
PORT=5002 \
|
PORT=5002 \
|
||||||
LNBITS_DATABASE_URL="postgres://lnbits:lnbits@localhost:5432/migration" \
|
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
|
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_DATA_FOLDER="./tests/data" \
|
||||||
LNBITS_DATABASE_URL="postgres://lnbits:lnbits@localhost:5432/migration" \
|
LNBITS_DATABASE_URL="postgres://lnbits:lnbits@localhost:5432/migration" \
|
||||||
@@ -80,6 +88,7 @@ migration:
|
|||||||
poetry run python tools/conv.py
|
poetry run python tools/conv.py
|
||||||
|
|
||||||
openapi:
|
openapi:
|
||||||
|
LNBITS_ADMIN_UI=False \
|
||||||
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
|
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
|
||||||
LNBITS_DATA_FOLDER="./tests/data" \
|
LNBITS_DATA_FOLDER="./tests/data" \
|
||||||
PYTHONUNBUFFERED=1 \
|
PYTHONUNBUFFERED=1 \
|
||||||
@@ -99,24 +108,21 @@ sass:
|
|||||||
|
|
||||||
bundle:
|
bundle:
|
||||||
npm install
|
npm install
|
||||||
npm run sass
|
npm run bundle
|
||||||
npm run vendor_copy
|
|
||||||
npm run vendor_json
|
|
||||||
poetry run ./node_modules/.bin/prettier -w ./lnbits/static/vendor.json
|
poetry run ./node_modules/.bin/prettier -w ./lnbits/static/vendor.json
|
||||||
npm run vendor_bundle_css
|
|
||||||
npm run vendor_minify_css
|
|
||||||
npm run vendor_bundle_js
|
|
||||||
npm run vendor_minify_js
|
|
||||||
|
|
||||||
checkbundle:
|
checkbundle:
|
||||||
cp lnbits/static/bundle.min.js lnbits/static/bundle.min.js.old
|
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.min.css lnbits/static/bundle.min.css.old
|
||||||
|
cp lnbits/static/bundle-components.min.js lnbits/static/bundle-components.min.js.old
|
||||||
make bundle
|
make bundle
|
||||||
diff -q lnbits/static/bundle.min.js lnbits/static/bundle.min.js.old || exit 1
|
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.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"
|
@echo "Bundle is OK"
|
||||||
rm lnbits/static/bundle.min.js.old
|
rm lnbits/static/bundle.min.js.old
|
||||||
rm lnbits/static/bundle.min.css.old
|
rm lnbits/static/bundle.min.css.old
|
||||||
|
rm lnbits/static/bundle-components.min.js.old
|
||||||
|
|
||||||
install-pre-commit-hook:
|
install-pre-commit-hook:
|
||||||
@echo "Installing pre-commit hook to git"
|
@echo "Installing pre-commit hook to git"
|
||||||
|
|||||||
@@ -11,13 +11,16 @@ Thanks for contributing :)
|
|||||||
|
|
||||||
# Run
|
# 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
|
```bash
|
||||||
poetry run lnbits
|
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
|
```bash
|
||||||
make dev
|
make dev
|
||||||
@@ -25,6 +28,15 @@ make dev
|
|||||||
poetry run lnbits --reload
|
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
|
# Precommit hooks
|
||||||
|
|
||||||
This ensures that all commits adhere to the formatting and linting rules.
|
This ensures that all commits adhere to the formatting and linting rules.
|
||||||
|
|||||||
+23
-21
@@ -10,22 +10,17 @@ The following sections explain how to install LNbits using varions package manag
|
|||||||
|
|
||||||
Note that by default LNbits uses SQLite as its database, which is simple and effective but you can configure it to use PostgreSQL instead which is also described in a section below.
|
Note 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)
|
It is recommended to use the latest version of Poetry. Make sure you have Python version 3.9 or higher installed.
|
||||||
Make sure you have Python 3.9 or 3.10 installed.
|
|
||||||
|
|
||||||
### install python on ubuntu
|
### Verify Python version
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
# for making sure python 3.9 is installed, skip if installed. To check your installed version: python3 --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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### install poetry
|
### Install Poetry
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
curl -sSL https://install.python-poetry.org | python3 -
|
curl -sSL https://install.python-poetry.org | python3 -
|
||||||
@@ -38,13 +33,8 @@ git clone https://github.com/lnbits/lnbits.git
|
|||||||
cd lnbits
|
cd lnbits
|
||||||
git checkout main
|
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
|
poetry install --only main
|
||||||
|
|
||||||
mkdir data
|
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
# set funding source amongst other options
|
# set funding source amongst other options
|
||||||
nano .env
|
nano .env
|
||||||
@@ -70,7 +60,19 @@ poetry install --only main
|
|||||||
# Start LNbits with `poetry run lnbits`
|
# 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
|
```sh
|
||||||
# Install nix. If you have installed via another manager, remove and use this install (from https://nixos.org/download)
|
# Install nix. If you have installed via another manager, remove and use this install (from https://nixos.org/download)
|
||||||
@@ -108,7 +110,7 @@ LNBITS_ADMIN_UI=true ./result/bin/lnbits --port 9000
|
|||||||
SUPER_USER=be54db7f245346c8833eaa430e1e0405 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
|
use latest version from docker hub
|
||||||
|
|
||||||
@@ -130,7 +132,7 @@ mkdir data
|
|||||||
docker run --detach --publish 5000:5000 --name lnbits --volume ${PWD}/.env:/app/.env --volume ${PWD}/data/:/app/data lnbits/lnbits
|
docker run --detach --publish 5000:5000 --name lnbits --volume ${PWD}/.env:/app/.env --volume ${PWD}/data/:/app/data lnbits/lnbits
|
||||||
```
|
```
|
||||||
|
|
||||||
## 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.
|
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.
|
||||||
|
|
||||||
@@ -373,7 +375,7 @@ Install Apache2 and enable Apache2 mods:
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
apt-get install apache2 certbot
|
apt-get install apache2 certbot
|
||||||
a2enmod headers ssl proxy proxy-http
|
a2enmod headers ssl proxy proxy_http
|
||||||
```
|
```
|
||||||
|
|
||||||
Create a SSL certificate with LetsEncrypt:
|
Create a SSL certificate with LetsEncrypt:
|
||||||
@@ -412,7 +414,7 @@ EOF
|
|||||||
Restart Apache2:
|
Restart Apache2:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
service restart apache2
|
service apache2 restart
|
||||||
```
|
```
|
||||||
|
|
||||||
## Running behind an Nginx reverse proxy over HTTPS
|
## Running behind an Nginx reverse proxy over HTTPS
|
||||||
@@ -466,7 +468,7 @@ EOF
|
|||||||
Restart nginx:
|
Restart nginx:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
service restart nginx
|
service nginx restart
|
||||||
```
|
```
|
||||||
|
|
||||||
## Using https without reverse proxy
|
## Using https without reverse proxy
|
||||||
|
|||||||
@@ -93,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_API_ENDPOINT`: https://api.getalby.com/
|
||||||
- `ALBY_ACCESS_TOKEN`: AlbyAccessToken
|
- `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
|
### 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
|
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
|
||||||
@@ -110,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_ENDPOINT`: http://localhost:9740/
|
||||||
- `PHOENIXD_API_PASSWORD`: PhoenixdApiPassword
|
- `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 Wallet
|
||||||
|
|
||||||
- `CLICHE_ENDPOINT`: ws://127.0.0.1:12000
|
- `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"
|
"systems": "systems"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1694529238,
|
"lastModified": 1710146030,
|
||||||
"narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=",
|
"narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=",
|
||||||
"owner": "numtide",
|
"owner": "numtide",
|
||||||
"repo": "flake-utils",
|
"repo": "flake-utils",
|
||||||
"rev": "ff7b65b44d01cf9ba6a71320833626af21126384",
|
"rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -26,11 +26,11 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1698974481,
|
"lastModified": 1703863825,
|
||||||
"narHash": "sha256-yPncV9Ohdz1zPZxYHQf47S8S0VrnhV7nNhCawY46hDA=",
|
"narHash": "sha256-rXwqjtwiGKJheXB43ybM8NwWB8rO2dSRrEqes0S7F5Y=",
|
||||||
"owner": "nix-community",
|
"owner": "nix-community",
|
||||||
"repo": "nix-github-actions",
|
"repo": "nix-github-actions",
|
||||||
"rev": "4bb5e752616262457bc7ca5882192a564c0472d2",
|
"rev": "5163432afc817cf8bd1f031418d1869e4c9d5547",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -41,16 +41,16 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1702233072,
|
"lastModified": 1723938990,
|
||||||
"narHash": "sha256-H5G2wgbim2Ku6G6w+NSaQaauv6B6DlPhY9fMvArKqRo=",
|
"narHash": "sha256-9tUadhnZQbWIiYVXH8ncfGXGvkNq3Hag4RCBEMUk7MI=",
|
||||||
"owner": "nixos",
|
"owner": "nixos",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "781e2a9797ecf0f146e81425c822dca69fe4a348",
|
"rev": "c42fcfbdfeae23e68fc520f9182dde9f38ad1890",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
"owner": "nixos",
|
"owner": "nixos",
|
||||||
"ref": "nixos-23.11",
|
"ref": "nixos-24.05",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
@@ -66,11 +66,11 @@
|
|||||||
"treefmt-nix": "treefmt-nix"
|
"treefmt-nix": "treefmt-nix"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1702334837,
|
"lastModified": 1724134185,
|
||||||
"narHash": "sha256-QZG6+zFshyY+L8m2tlOTm75U5m9y7z01g0josVK+8Os=",
|
"narHash": "sha256-nDqpGjz7cq3ThdC98BPe1ANCNlsJds/LLZ3/MdIXjA0=",
|
||||||
"owner": "nix-community",
|
"owner": "nix-community",
|
||||||
"repo": "poetry2nix",
|
"repo": "poetry2nix",
|
||||||
"rev": "1f4bcbf1be73abc232a972a77102a3e820485a99",
|
"rev": "5ee730a8752264e463c0eaf06cc060fd07f6dae9",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -122,11 +122,11 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1699786194,
|
"lastModified": 1719749022,
|
||||||
"narHash": "sha256-3h3EH1FXQkIeAuzaWB+nK0XK54uSD46pp+dMD3gAcB4=",
|
"narHash": "sha256-ddPKHcqaKCIFSFc/cvxS14goUhCOAwsM1PbMr0ZtHMg=",
|
||||||
"owner": "numtide",
|
"owner": "numtide",
|
||||||
"repo": "treefmt-nix",
|
"repo": "treefmt-nix",
|
||||||
"rev": "e82f32aa7f06bbbd56d7b12186d555223dc399d1",
|
"rev": "8df5ff62195d4e67e2264df0b7f5e8c9995fd0bd",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
description = "LNbits, free and open-source Lightning wallet and accounts system";
|
description = "LNbits, free and open-source Lightning wallet and accounts system";
|
||||||
|
|
||||||
inputs = {
|
inputs = {
|
||||||
nixpkgs.url = "github:nixos/nixpkgs/nixos-23.11";
|
nixpkgs.url = "github:nixos/nixpkgs/nixos-24.05";
|
||||||
poetry2nix = {
|
poetry2nix = {
|
||||||
url = "github:nix-community/poetry2nix";
|
url = "github:nix-community/poetry2nix";
|
||||||
inputs.nixpkgs.follows = "nixpkgs";
|
inputs.nixpkgs.follows = "nixpkgs";
|
||||||
@@ -30,25 +30,10 @@
|
|||||||
meta.rev = self.dirtyRev or self.rev;
|
meta.rev = self.dirtyRev or self.rev;
|
||||||
meta.mainProgram = projectName;
|
meta.mainProgram = projectName;
|
||||||
overrides = pkgs.poetry2nix.overrides.withDefaults (final: prev: {
|
overrides = pkgs.poetry2nix.overrides.withDefaults (final: prev: {
|
||||||
|
coincurve = prev.coincurve.override { preferWheel = true; };
|
||||||
protobuf = prev.protobuf.override { preferWheel = true; };
|
protobuf = prev.protobuf.override { preferWheel = true; };
|
||||||
ruff = prev.ruff.override { preferWheel = true; };
|
ruff = prev.ruff.override { preferWheel = true; };
|
||||||
wallycore = prev.wallycore.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 ]; }
|
|
||||||
);
|
|
||||||
dnspython = prev.dnspython.overridePythonAttrs (
|
|
||||||
old: { buildInputs = (old.buildInputs or []) ++ [ prev.hatchling ]; }
|
|
||||||
);
|
|
||||||
jinja2 = prev.jinja2.overridePythonAttrs (
|
|
||||||
old: { buildInputs = (old.buildInputs or []) ++ [ prev.flit-core ]; }
|
|
||||||
);
|
|
||||||
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",
|
||||||
|
]
|
||||||
|
|||||||
+74
-40
@@ -6,7 +6,7 @@ import shutil
|
|||||||
import sys
|
import sys
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Callable, List, Optional
|
from typing import Callable, Optional
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
@@ -17,16 +17,22 @@ from slowapi.util import get_remote_address
|
|||||||
from starlette.middleware.sessions import SessionMiddleware
|
from starlette.middleware.sessions import SessionMiddleware
|
||||||
|
|
||||||
from lnbits.core.crud import (
|
from lnbits.core.crud import (
|
||||||
get_dbversions,
|
get_db_version,
|
||||||
get_installed_extensions,
|
get_installed_extensions,
|
||||||
update_installed_extension_state,
|
update_installed_extension_state,
|
||||||
)
|
)
|
||||||
|
from lnbits.core.crud.extensions import create_installed_extension
|
||||||
from lnbits.core.helpers import migrate_extension_database
|
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
|
from lnbits.core.tasks import ( # watchdog_task
|
||||||
|
audit_queue,
|
||||||
killswitch_task,
|
killswitch_task,
|
||||||
|
purge_audit_data,
|
||||||
|
wait_for_audit_data,
|
||||||
wait_for_paid_invoices,
|
wait_for_paid_invoices,
|
||||||
)
|
)
|
||||||
from lnbits.exceptions import register_exception_handlers
|
from lnbits.exceptions import register_exception_handlers
|
||||||
|
from lnbits.helpers import version_parse
|
||||||
from lnbits.settings import settings
|
from lnbits.settings import settings
|
||||||
from lnbits.tasks import (
|
from lnbits.tasks import (
|
||||||
cancel_all_tasks,
|
cancel_all_tasks,
|
||||||
@@ -44,15 +50,10 @@ from lnbits.wallets import get_funding_source, set_funding_source
|
|||||||
from .commands import migrate_databases
|
from .commands import migrate_databases
|
||||||
from .core import init_core_routers
|
from .core import init_core_routers
|
||||||
from .core.db import core_app_extra
|
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.services import check_admin_settings, check_webpush_settings
|
||||||
from .core.views.extension_api import add_installed_extension
|
|
||||||
from .extension_manager import (
|
|
||||||
Extension,
|
|
||||||
InstallableExtension,
|
|
||||||
get_valid_extensions,
|
|
||||||
version_parse,
|
|
||||||
)
|
|
||||||
from .middleware import (
|
from .middleware import (
|
||||||
|
AuditMiddleware,
|
||||||
CustomGZipMiddleware,
|
CustomGZipMiddleware,
|
||||||
ExtensionsRedirectMiddleware,
|
ExtensionsRedirectMiddleware,
|
||||||
InstalledExtensionMiddleware,
|
InstalledExtensionMiddleware,
|
||||||
@@ -63,6 +64,7 @@ from .middleware import (
|
|||||||
from .requestvars import g
|
from .requestvars import g
|
||||||
from .tasks import (
|
from .tasks import (
|
||||||
check_pending_payments,
|
check_pending_payments,
|
||||||
|
create_task,
|
||||||
internal_invoice_listener,
|
internal_invoice_listener,
|
||||||
invoice_listener,
|
invoice_listener,
|
||||||
)
|
)
|
||||||
@@ -93,13 +95,8 @@ async def startup(app: FastAPI):
|
|||||||
# register core routes
|
# register core routes
|
||||||
init_core_routers(app)
|
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
|
# initialize tasks
|
||||||
register_async_tasks()
|
register_async_tasks(app)
|
||||||
|
|
||||||
|
|
||||||
async def shutdown():
|
async def shutdown():
|
||||||
@@ -157,6 +154,8 @@ def create_app() -> FastAPI:
|
|||||||
CustomGZipMiddleware, minimum_size=1000, exclude_paths=["/api/v1/payments/sse"]
|
CustomGZipMiddleware, minimum_size=1000, exclude_paths=["/api/v1/payments/sse"]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
app.add_middleware(AuditMiddleware, audit_queue=audit_queue)
|
||||||
|
|
||||||
# required for SSO login
|
# required for SSO login
|
||||||
app.add_middleware(SessionMiddleware, secret_key=settings.auth_secret_key)
|
app.add_middleware(SessionMiddleware, secret_key=settings.auth_secret_key)
|
||||||
|
|
||||||
@@ -210,7 +209,7 @@ async def check_funding_source() -> None:
|
|||||||
break
|
break
|
||||||
|
|
||||||
retry_counter += 1
|
retry_counter += 1
|
||||||
sleep_time = 0.25 * (2**retry_counter)
|
sleep_time = min(0.25 * (2**retry_counter), 60)
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"Retrying connection to backend in {sleep_time} seconds... "
|
f"Retrying connection to backend in {sleep_time} seconds... "
|
||||||
f"({retry_counter}/{max_retries})"
|
f"({retry_counter}/{max_retries})"
|
||||||
@@ -233,7 +232,7 @@ async def check_installed_extensions(app: FastAPI):
|
|||||||
re-created. The 'data' directory (where the '.zip' files live) is expected to
|
re-created. The 'data' directory (where the '.zip' files live) is expected to
|
||||||
persist state. Zips that are missing will be re-downloaded.
|
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)
|
installed_extensions = await build_all_installed_extensions_list(False)
|
||||||
|
|
||||||
for ext in installed_extensions:
|
for ext in installed_extensions:
|
||||||
@@ -247,6 +246,7 @@ async def check_installed_extensions(app: FastAPI):
|
|||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(e)
|
logger.warning(e)
|
||||||
|
await deactivate_extension(ext.id)
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"Failed to re-install extension: {ext.id} ({ext.installed_version})"
|
f"Failed to re-install extension: {ext.id} ({ext.installed_version})"
|
||||||
)
|
)
|
||||||
@@ -258,7 +258,7 @@ async def check_installed_extensions(app: FastAPI):
|
|||||||
|
|
||||||
async def build_all_installed_extensions_list(
|
async def build_all_installed_extensions_list(
|
||||||
include_deactivated: Optional[bool] = True,
|
include_deactivated: Optional[bool] = True,
|
||||||
) -> List[InstallableExtension]:
|
) -> list[InstallableExtension]:
|
||||||
"""
|
"""
|
||||||
Returns a list of all the installed extensions plus the extensions that
|
Returns a list of all the installed extensions plus the extensions that
|
||||||
MUST be installed by default (see LNBITS_EXTENSIONS_DEFAULT_INSTALL).
|
MUST be installed by default (see LNBITS_EXTENSIONS_DEFAULT_INSTALL).
|
||||||
@@ -266,6 +266,25 @@ async def build_all_installed_extensions_list(
|
|||||||
installed_extensions = await get_installed_extensions()
|
installed_extensions = await get_installed_extensions()
|
||||||
settings.lnbits_all_extensions_ids = {e.id for e in 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)
|
||||||
|
|
||||||
for ext_id in settings.lnbits_extensions_default_install:
|
for ext_id in settings.lnbits_extensions_default_install:
|
||||||
if ext_id in settings.lnbits_all_extensions_ids:
|
if ext_id in settings.lnbits_all_extensions_ids:
|
||||||
continue
|
continue
|
||||||
@@ -278,8 +297,13 @@ async def build_all_installed_extensions_list(
|
|||||||
release = next((e for e in ext_releases if e.is_version_compatible), None)
|
release = next((e for e in ext_releases if e.is_version_compatible), None)
|
||||||
|
|
||||||
if release:
|
if release:
|
||||||
|
ext_meta = ExtensionMeta(installed_release=release)
|
||||||
ext_info = InstallableExtension(
|
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)
|
installed_extensions.append(ext_info)
|
||||||
|
|
||||||
@@ -310,18 +334,16 @@ async def check_installed_extension_files(ext: InstallableExtension) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
async def restore_installed_extension(app: FastAPI, ext: InstallableExtension):
|
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)
|
await update_installed_extension_state(ext_id=ext.id, active=True)
|
||||||
|
|
||||||
extension = Extension.from_installable_ext(ext)
|
extension = Extension.from_installable_ext(ext)
|
||||||
register_ext_routes(app, extension)
|
register_ext_routes(app, extension)
|
||||||
|
|
||||||
current_version = (await get_dbversions()).get(ext.id, 0)
|
current_version = await get_db_version(ext.id)
|
||||||
await migrate_extension_database(extension, current_version)
|
await migrate_extension_database(ext, current_version)
|
||||||
|
|
||||||
# mount routes for the new version
|
# mount routes for the new version
|
||||||
core_app_extra.register_new_ext_routes(extension)
|
core_app_extra.register_new_ext_routes(extension)
|
||||||
ext.notify_upgrade(extension.upgrade_hash)
|
|
||||||
|
|
||||||
|
|
||||||
def register_custom_extensions_path():
|
def register_custom_extensions_path():
|
||||||
@@ -339,8 +361,14 @@ def register_custom_extensions_path():
|
|||||||
+ f" '{settings.lnbits_extensions_path}/extensions'"
|
+ f" '{settings.lnbits_extensions_path}/extensions'"
|
||||||
)
|
)
|
||||||
|
|
||||||
sys.path.append(str(Path(settings.lnbits_extensions_path, "extensions")))
|
extensions_dir = Path(settings.lnbits_extensions_path, "extensions")
|
||||||
sys.path.append(str(Path(settings.lnbits_extensions_path, "upgrades")))
|
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:
|
def register_new_ext_routes(app: FastAPI) -> Callable:
|
||||||
@@ -384,43 +412,49 @@ def register_ext_routes(app: FastAPI, ext: Extension) -> None:
|
|||||||
)
|
)
|
||||||
app.mount(s["path"], StaticFiles(directory=static_dir), s["name"])
|
app.mount(s["path"], StaticFiles(directory=static_dir), s["name"])
|
||||||
|
|
||||||
if hasattr(ext_module, f"{ext.code}_redirect_paths"):
|
ext_redirects = (
|
||||||
ext_redirects = getattr(ext_module, f"{ext.code}_redirect_paths")
|
getattr(ext_module, f"{ext.code}_redirect_paths")
|
||||||
settings.lnbits_extensions_redirects = [
|
if hasattr(ext_module, f"{ext.code}_redirect_paths")
|
||||||
r for r in settings.lnbits_extensions_redirects if r["ext_id"] != ext.code
|
else []
|
||||||
]
|
)
|
||||||
for r in ext_redirects:
|
|
||||||
r["ext_id"] = ext.code
|
|
||||||
settings.lnbits_extensions_redirects.append(r)
|
|
||||||
|
|
||||||
logger.trace(f"adding route for extension {ext_module}")
|
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 ""
|
prefix = f"/upgrades/{ext.upgrade_hash}" if ext.upgrade_hash != "" else ""
|
||||||
app.include_router(router=ext_route, prefix=prefix)
|
app.include_router(router=ext_route, prefix=prefix)
|
||||||
|
|
||||||
|
|
||||||
def register_all_ext_routes(app: FastAPI):
|
async def check_and_register_extensions(app: FastAPI):
|
||||||
for ext in get_valid_extensions(False):
|
await check_installed_extensions(app)
|
||||||
|
for ext in await get_valid_extensions(False):
|
||||||
try:
|
try:
|
||||||
register_ext_routes(app, ext)
|
register_ext_routes(app, ext)
|
||||||
except Exception as e:
|
except Exception as exc:
|
||||||
logger.error(f"Could not load extension `{ext.code}`: {e!s}")
|
logger.error(f"Could not load extension `{ext.code}`: {exc!s}")
|
||||||
|
|
||||||
|
|
||||||
def register_async_tasks():
|
def register_async_tasks(app: FastAPI):
|
||||||
|
|
||||||
|
# check extensions after restart
|
||||||
|
if not settings.lnbits_extensions_deactivate_all:
|
||||||
|
create_task(check_and_register_extensions(app))
|
||||||
|
|
||||||
|
create_permanent_task(wait_for_audit_data)
|
||||||
create_permanent_task(check_pending_payments)
|
create_permanent_task(check_pending_payments)
|
||||||
create_permanent_task(invoice_listener)
|
create_permanent_task(invoice_listener)
|
||||||
create_permanent_task(internal_invoice_listener)
|
create_permanent_task(internal_invoice_listener)
|
||||||
create_permanent_task(cache.invalidate_forever)
|
create_permanent_task(cache.invalidate_forever)
|
||||||
|
|
||||||
# core invoice listener
|
# core invoice listener
|
||||||
invoice_queue = asyncio.Queue(5)
|
invoice_queue: asyncio.Queue = asyncio.Queue(5)
|
||||||
register_invoice_listener(invoice_queue, "core")
|
register_invoice_listener(invoice_queue, "core")
|
||||||
create_permanent_task(lambda: wait_for_paid_invoices(invoice_queue))
|
create_permanent_task(lambda: wait_for_paid_invoices(invoice_queue))
|
||||||
|
|
||||||
# TODO: implement watchdog properly
|
# TODO: implement watchdog properly
|
||||||
# create_permanent_task(watchdog_task)
|
# create_permanent_task(watchdog_task)
|
||||||
create_permanent_task(killswitch_task)
|
create_permanent_task(killswitch_task)
|
||||||
|
create_permanent_task(purge_audit_data)
|
||||||
|
|
||||||
# server logs for websocket
|
# server logs for websocket
|
||||||
if settings.lnbits_admin_ui:
|
if settings.lnbits_admin_ui:
|
||||||
|
|||||||
+44
-99
@@ -3,8 +3,7 @@ import importlib
|
|||||||
import time
|
import time
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import List, Optional, Tuple
|
from typing import Optional
|
||||||
from urllib.parse import urlparse
|
|
||||||
|
|
||||||
import click
|
import click
|
||||||
import httpx
|
import httpx
|
||||||
@@ -12,7 +11,27 @@ from fastapi.exceptions import HTTPException
|
|||||||
from loguru import logger
|
from loguru import logger
|
||||||
from packaging import version
|
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.services import check_admin_settings
|
||||||
from lnbits.core.views.extension_api import (
|
from lnbits.core.views.extension_api import (
|
||||||
api_install_extension,
|
api_install_extension,
|
||||||
@@ -21,29 +40,6 @@ from lnbits.core.views.extension_api import (
|
|||||||
from lnbits.settings import settings
|
from lnbits.settings import settings
|
||||||
from lnbits.wallets.base import Wallet
|
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_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):
|
def coro(f):
|
||||||
@wraps(f)
|
@wraps(f)
|
||||||
@@ -122,54 +118,12 @@ def database_migrate():
|
|||||||
loop.run_until_complete(migrate_databases())
|
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()
|
|
||||||
|
|
||||||
# todo: revisit, use installed extensions
|
|
||||||
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")
|
@db.command("versions")
|
||||||
@coro
|
@coro
|
||||||
async def db_versions():
|
async def db_versions():
|
||||||
"""Show current database versions"""
|
"""Show current database versions"""
|
||||||
async with core_db.connect() as conn:
|
async with core_db.connect() as conn:
|
||||||
click.echo(await get_dbversions(conn))
|
click.echo(await get_db_versions(conn))
|
||||||
|
|
||||||
|
|
||||||
@db.command("cleanup-wallets")
|
@db.command("cleanup-wallets")
|
||||||
@@ -216,10 +170,13 @@ async def database_delete_wallet_payment(wallet: str, checking_id: str):
|
|||||||
@db.command("mark-payment-pending")
|
@db.command("mark-payment-pending")
|
||||||
@click.option("-c", "--checking-id", required=True, help="Payment checking Id.")
|
@click.option("-c", "--checking-id", required=True, help="Payment checking Id.")
|
||||||
@coro
|
@coro
|
||||||
async def database_revert_payment(checking_id: str, pending: bool = True):
|
async def database_revert_payment(checking_id: str):
|
||||||
"""Mark wallet as deleted"""
|
"""Mark payment as pending"""
|
||||||
async with core_db.connect() as conn:
|
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")
|
@db.command("cleanup-accounts")
|
||||||
@@ -276,7 +233,7 @@ async def check_invalid_payments(
|
|||||||
click.echo("Funding source: " + str(funding_source))
|
click.echo("Funding source: " + str(funding_source))
|
||||||
|
|
||||||
# payments that are settled in the DB, but not at the Funding source level
|
# payments that are settled in the DB, but not at the Funding source level
|
||||||
invalid_payments: List[Payment] = []
|
invalid_payments: list[Payment] = []
|
||||||
invalid_wallets = {}
|
invalid_wallets = {}
|
||||||
for db_payment in settled_db_payments:
|
for db_payment in settled_db_payments:
|
||||||
if verbose:
|
if verbose:
|
||||||
@@ -313,12 +270,6 @@ async def check_invalid_payments(
|
|||||||
click.echo(" ".join([w, str(data[0]), str(data[1] / 1000).ljust(10)]))
|
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_installed_extensions(active=False)
|
|
||||||
settings.lnbits_deactivated_extensions.update([e.id for e in inactive_extensions])
|
|
||||||
|
|
||||||
|
|
||||||
@extensions.command("list")
|
@extensions.command("list")
|
||||||
@coro
|
@coro
|
||||||
async def extensions_list():
|
async def extensions_list():
|
||||||
@@ -328,8 +279,10 @@ async def extensions_list():
|
|||||||
from lnbits.app import build_all_installed_extensions_list
|
from lnbits.app import build_all_installed_extensions_list
|
||||||
|
|
||||||
for ext in await 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"
|
assert (
|
||||||
click.echo(f" - {ext.id} ({ext.installed_release.version})")
|
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")
|
@extensions.command("update")
|
||||||
@@ -378,7 +331,7 @@ async def extensions_update(
|
|||||||
if extension and all_extensions:
|
if extension and all_extensions:
|
||||||
click.echo("Only one of extension ID or the '--all' flag must be specified")
|
click.echo("Only one of extension ID or the '--all' flag must be specified")
|
||||||
return
|
return
|
||||||
if url and not _is_url(url):
|
if url and not is_valid_url(url):
|
||||||
click.echo(f"Invalid '--url' option value: {url}")
|
click.echo(f"Invalid '--url' option value: {url}")
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -452,7 +405,7 @@ async def extensions_install(
|
|||||||
):
|
):
|
||||||
"""Install a extension"""
|
"""Install a extension"""
|
||||||
click.echo(f"Installing {extension}... {repo_index}")
|
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}")
|
click.echo(f"Invalid '--url' option value: {url}")
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -480,7 +433,7 @@ async def extensions_uninstall(
|
|||||||
"""Uninstall a extension"""
|
"""Uninstall a extension"""
|
||||||
click.echo(f"Uninstalling '{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}")
|
click.echo(f"Invalid '--url' option value: {url}")
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -512,7 +465,7 @@ async def install_extension(
|
|||||||
source_repo: Optional[str] = None,
|
source_repo: Optional[str] = None,
|
||||||
url: Optional[str] = None,
|
url: Optional[str] = None,
|
||||||
admin_user: Optional[str] = None,
|
admin_user: Optional[str] = None,
|
||||||
) -> Tuple[bool, str]:
|
) -> tuple[bool, str]:
|
||||||
try:
|
try:
|
||||||
release = await _select_release(extension, repo_index, source_repo)
|
release = await _select_release(extension, repo_index, source_repo)
|
||||||
if not release:
|
if not release:
|
||||||
@@ -541,7 +494,7 @@ async def update_extension(
|
|||||||
source_repo: Optional[str] = None,
|
source_repo: Optional[str] = None,
|
||||||
url: Optional[str] = None,
|
url: Optional[str] = None,
|
||||||
admin_user: Optional[str] = None,
|
admin_user: Optional[str] = None,
|
||||||
) -> Tuple[bool, str]:
|
) -> tuple[bool, str]:
|
||||||
try:
|
try:
|
||||||
click.echo(f"Updating '{extension}' extension.")
|
click.echo(f"Updating '{extension}' extension.")
|
||||||
installed_ext = await get_installed_extension(extension)
|
installed_ext = await get_installed_extension(extension)
|
||||||
@@ -554,7 +507,7 @@ async def update_extension(
|
|||||||
click.echo(f"Current '{extension}' version: {installed_ext.installed_version}.")
|
click.echo(f"Current '{extension}' version: {installed_ext.installed_version}.")
|
||||||
|
|
||||||
assert (
|
assert (
|
||||||
installed_ext.installed_release
|
installed_ext.meta and installed_ext.meta.installed_release
|
||||||
), "Cannot find previously installed release. Please uninstall first."
|
), "Cannot find previously installed release. Please uninstall first."
|
||||||
|
|
||||||
release = await _select_release(extension, repo_index, source_repo)
|
release = await _select_release(extension, repo_index, source_repo)
|
||||||
@@ -562,7 +515,7 @@ async def update_extension(
|
|||||||
return False, "No release selected."
|
return False, "No release selected."
|
||||||
if (
|
if (
|
||||||
release.version == installed_ext.installed_version
|
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.")
|
click.echo(f"Extension '{extension}' already up to date.")
|
||||||
return False, "Already up to date"
|
return False, "Already up to date"
|
||||||
@@ -661,7 +614,7 @@ async def _call_install_extension(
|
|||||||
)
|
)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
else:
|
else:
|
||||||
await api_install_extension(data, User(id="mock_id"))
|
await api_install_extension(data)
|
||||||
|
|
||||||
|
|
||||||
async def _call_uninstall_extension(
|
async def _call_uninstall_extension(
|
||||||
@@ -675,7 +628,7 @@ async def _call_uninstall_extension(
|
|||||||
)
|
)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
else:
|
else:
|
||||||
await api_uninstall_extension(extension, User(id="mock_id"))
|
await api_uninstall_extension(extension)
|
||||||
|
|
||||||
|
|
||||||
async def _can_run_operation(url) -> bool:
|
async def _can_run_operation(url) -> bool:
|
||||||
@@ -694,7 +647,7 @@ async def _can_run_operation(url) -> bool:
|
|||||||
elif url:
|
elif url:
|
||||||
click.echo(
|
click.echo(
|
||||||
"The option '--url' has been provided,"
|
"The option '--url' has been provided,"
|
||||||
+ f" but no server found runnint at '{url}'"
|
f" but no server found running at '{url}'"
|
||||||
)
|
)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -709,11 +662,3 @@ async def _is_lnbits_started(url: Optional[str]):
|
|||||||
return True
|
return True
|
||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _is_url(url):
|
|
||||||
try:
|
|
||||||
result = urlparse(url)
|
|
||||||
return all([result.scheme, result.netloc])
|
|
||||||
except ValueError:
|
|
||||||
return False
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from fastapi import APIRouter, FastAPI
|
|||||||
from .db import core_app_extra, db
|
from .db import core_app_extra, db
|
||||||
from .views.admin_api import admin_router
|
from .views.admin_api import admin_router
|
||||||
from .views.api import api_router
|
from .views.api import api_router
|
||||||
|
from .views.audit_api import audit_router
|
||||||
from .views.auth_api import auth_router
|
from .views.auth_api import auth_router
|
||||||
from .views.extension_api import extension_router
|
from .views.extension_api import extension_router
|
||||||
|
|
||||||
@@ -38,3 +39,7 @@ def init_core_routers(app: FastAPI):
|
|||||||
app.include_router(tinyurl_router)
|
app.include_router(tinyurl_router)
|
||||||
app.include_router(webpush_router)
|
app.include_router(webpush_router)
|
||||||
app.include_router(users_router)
|
app.include_router(users_router)
|
||||||
|
app.include_router(audit_router)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["core_app", "core_app_extra", "db"]
|
||||||
|
|||||||
-1336
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,385 @@
|
|||||||
|
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:
|
||||||
|
pass
|
||||||
|
elif complete:
|
||||||
|
clause.append(
|
||||||
|
f"((amount > 0 AND status = '{PaymentState.SUCCESS}') OR amount < 0)"
|
||||||
|
)
|
||||||
|
elif pending:
|
||||||
|
clause.append(f"status = '{PaymentState.PENDING}'")
|
||||||
|
else:
|
||||||
|
pass
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
where = [
|
||||||
|
f"wallet_id = :wallet_id AND (status = '{PaymentState.SUCCESS}' OR amount < 0)"
|
||||||
|
]
|
||||||
|
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,120 @@
|
|||||||
|
import json
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from lnbits.core.db import db
|
||||||
|
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
|
||||||
|
return SuperSettings(**{"super_user": super_user_id, **data})
|
||||||
|
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,174 @@
|
|||||||
|
from datetime import datetime, timezone
|
||||||
|
from time import time
|
||||||
|
from typing import 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]:
|
||||||
|
return await (conn or db).fetch_page(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
accounts.id,
|
||||||
|
accounts.username,
|
||||||
|
accounts.email,
|
||||||
|
accounts.pubkey,
|
||||||
|
wallets.id as wallet_id,
|
||||||
|
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
|
||||||
|
""",
|
||||||
|
[],
|
||||||
|
{},
|
||||||
|
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,157 @@
|
|||||||
|
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,
|
||||||
|
)
|
||||||
|
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
-74
@@ -1,42 +1,52 @@
|
|||||||
import importlib
|
import importlib
|
||||||
import re
|
import re
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
from urllib.parse import urlparse
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
import httpx
|
|
||||||
from loguru import logger
|
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.core.db import db as core_db
|
||||||
from lnbits.db import Connection
|
from lnbits.core.models import DbVersion
|
||||||
from lnbits.extension_manager import Extension
|
from lnbits.core.models.extensions import InstallableExtension
|
||||||
|
from lnbits.db import COCKROACH, POSTGRES, SQLITE, Connection
|
||||||
from lnbits.settings import settings
|
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:
|
try:
|
||||||
ext_migrations = importlib.import_module(f"{ext.module_name}.migrations")
|
ext_migrations = importlib.import_module(f"{ext.module_name}.migrations")
|
||||||
ext_db = importlib.import_module(ext.module_name).db
|
ext_db = importlib.import_module(ext.module_name).db
|
||||||
except ImportError as exc:
|
except ImportError as exc:
|
||||||
logger.error(exc)
|
logger.error(exc)
|
||||||
raise ImportError(
|
raise ImportError(f"Cannot import module for extension '{ext.id}'.") from exc
|
||||||
f"Please make sure that the extension `{ext.code}` has a migrations file."
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
async with ext_db.connect() as ext_conn:
|
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(
|
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)_")
|
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)
|
match = matcher.match(key)
|
||||||
if match:
|
if match:
|
||||||
version = int(match.group(1))
|
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}")
|
logger.debug(f"running migration {db_name}.{version}")
|
||||||
print(f"running migration {db_name}.{version}")
|
print(f"running migration {db_name}.{version}")
|
||||||
await migrate(db)
|
await migrate(db)
|
||||||
@@ -48,68 +58,6 @@ async def run_migration(
|
|||||||
await update_migration_version(conn, db_name, version)
|
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}'"
|
|
||||||
|
|
||||||
stop_fn = getattr(old_module, stop_fn_name)
|
|
||||||
if stop_fn:
|
|
||||||
await 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 _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:
|
def to_valid_user_id(user_id: str) -> UUID:
|
||||||
if len(user_id) < 32:
|
if len(user_id) < 32:
|
||||||
raise ValueError("User ID must have at least 128 bits")
|
raise ValueError("User ID must have at least 128 bits")
|
||||||
@@ -119,3 +67,64 @@ def to_valid_user_id(user_id: str) -> UUID:
|
|||||||
raise ValueError("Invalid hex string for User ID.") from exc
|
raise ValueError("Invalid hex string for User ID.") from exc
|
||||||
|
|
||||||
return UUID(hex=user_id[:32], version=4)
|
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
|
||||||
|
|||||||
+220
-56
@@ -1,10 +1,12 @@
|
|||||||
import datetime
|
import json
|
||||||
from time import time
|
from time import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from sqlalchemy.exc import OperationalError
|
from sqlalchemy.exc import OperationalError
|
||||||
|
|
||||||
from lnbits import bolt11
|
from lnbits import bolt11
|
||||||
|
from lnbits.db import Connection
|
||||||
|
|
||||||
|
|
||||||
async def m000_create_migrations_table(db):
|
async def m000_create_migrations_table(db):
|
||||||
@@ -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 bolt11 TEXT")
|
||||||
await db.execute("ALTER TABLE apipayments ADD COLUMN extra TEXT")
|
await db.execute("ALTER TABLE apipayments ADD COLUMN extra TEXT")
|
||||||
|
|
||||||
import json
|
result = await db.execute("SELECT * FROM apipayments")
|
||||||
|
rows = result.mappings().all()
|
||||||
rows = await (await db.execute("SELECT * FROM apipayments")).fetchall()
|
|
||||||
for row in rows:
|
for row in rows:
|
||||||
if not row["memo"] or not row["memo"].startswith("#"):
|
if not row["memo"] or not row["memo"].startswith("#"):
|
||||||
continue
|
continue
|
||||||
@@ -113,15 +114,15 @@ async def m002_add_fields_to_apipayments(db):
|
|||||||
new = row["memo"][len(prefix) :]
|
new = row["memo"][len(prefix) :]
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"""
|
"""
|
||||||
UPDATE apipayments SET extra = ?, memo = ?
|
UPDATE apipayments SET extra = :extra, memo = :memo1
|
||||||
WHERE checking_id = ? AND memo = ?
|
WHERE checking_id = :checking_id AND memo = :memo2
|
||||||
""",
|
""",
|
||||||
(
|
{
|
||||||
json.dumps({"tag": ext}),
|
"extra": json.dumps({"tag": ext}),
|
||||||
new,
|
"memo1": new,
|
||||||
row["checking_id"],
|
"checking_id": row["checking_id"],
|
||||||
row["memo"],
|
"memo2": row["memo"],
|
||||||
),
|
},
|
||||||
)
|
)
|
||||||
break
|
break
|
||||||
except OperationalError:
|
except OperationalError:
|
||||||
@@ -212,19 +213,18 @@ async def m007_set_invoice_expiries(db):
|
|||||||
Precomputes invoice expiry for existing pending incoming payments.
|
Precomputes invoice expiry for existing pending incoming payments.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
rows = await (
|
result = await db.execute(
|
||||||
await db.execute(
|
f"""
|
||||||
f"""
|
SELECT bolt11, checking_id
|
||||||
SELECT bolt11, checking_id
|
FROM apipayments
|
||||||
FROM apipayments
|
WHERE pending = true
|
||||||
WHERE pending = true
|
AND amount > 0
|
||||||
AND amount > 0
|
AND bolt11 IS NOT NULL
|
||||||
AND bolt11 IS NOT NULL
|
AND expiry IS NULL
|
||||||
AND expiry IS NULL
|
AND time < {db.timestamp_now}
|
||||||
AND time < {db.timestamp_now}
|
"""
|
||||||
"""
|
)
|
||||||
)
|
rows = result.mappings().all()
|
||||||
).fetchall()
|
|
||||||
if len(rows):
|
if len(rows):
|
||||||
logger.info(f"Migration: Checking expiry of {len(rows)} invoices")
|
logger.info(f"Migration: Checking expiry of {len(rows)} invoices")
|
||||||
for i, (
|
for i, (
|
||||||
@@ -236,22 +236,17 @@ async def m007_set_invoice_expiries(db):
|
|||||||
if invoice.expiry is None:
|
if invoice.expiry is None:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
expiration_date = datetime.datetime.fromtimestamp(
|
expiration_date = invoice.date + invoice.expiry
|
||||||
invoice.date + invoice.expiry
|
|
||||||
)
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Migration: {i+1}/{len(rows)} setting expiry of invoice"
|
f"Migration: {i+1}/{len(rows)} setting expiry of invoice"
|
||||||
f" {invoice.payment_hash} to {expiration_date}"
|
f" {invoice.payment_hash} to {expiration_date}"
|
||||||
)
|
)
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"""
|
f"""
|
||||||
UPDATE apipayments SET expiry = ?
|
UPDATE apipayments SET expiry = {db.timestamp_placeholder('expiry')}
|
||||||
WHERE checking_id = ? AND amount > 0
|
WHERE checking_id = :checking_id AND amount > 0
|
||||||
""",
|
""",
|
||||||
(
|
{"expiry": expiration_date, "checking_id": checking_id},
|
||||||
db.datetime_to_timestamp(expiration_date),
|
|
||||||
checking_id,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
@@ -347,30 +342,34 @@ async def m014_set_deleted_wallets(db):
|
|||||||
Sets deleted column to wallets.
|
Sets deleted column to wallets.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
rows = await (
|
result = await db.execute(
|
||||||
await db.execute(
|
"""
|
||||||
"""
|
SELECT *
|
||||||
SELECT *
|
FROM wallets
|
||||||
FROM wallets
|
WHERE user LIKE 'del:%'
|
||||||
WHERE user LIKE 'del:%'
|
AND adminkey LIKE 'del:%'
|
||||||
AND adminkey LIKE 'del:%'
|
AND inkey LIKE 'del:%'
|
||||||
AND inkey LIKE 'del:%'
|
"""
|
||||||
"""
|
)
|
||||||
)
|
rows = result.mappings().all()
|
||||||
).fetchall()
|
|
||||||
|
|
||||||
for row in rows:
|
for row in rows:
|
||||||
try:
|
try:
|
||||||
user = row[2].split(":")[1]
|
user = row["user"].split(":")[1]
|
||||||
adminkey = row[3].split(":")[1]
|
adminkey = row["adminkey"].split(":")[1]
|
||||||
inkey = row[4].split(":")[1]
|
inkey = row["inkey"].split(":")[1]
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"""
|
"""
|
||||||
UPDATE wallets SET
|
UPDATE wallets SET
|
||||||
"user" = ?, adminkey = ?, inkey = ?, deleted = true
|
"user" = :user, adminkey = :adminkey, inkey = :inkey, deleted = true
|
||||||
WHERE id = ?
|
WHERE id = :wallet
|
||||||
""",
|
""",
|
||||||
(user, adminkey, inkey, row[0]),
|
{
|
||||||
|
"user": user,
|
||||||
|
"adminkey": adminkey,
|
||||||
|
"inkey": inkey,
|
||||||
|
"wallet": row.get("id"),
|
||||||
|
},
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
@@ -456,17 +455,17 @@ async def m017_add_timestamp_columns_to_accounts_and_wallets(db):
|
|||||||
now = int(time())
|
now = int(time())
|
||||||
await db.execute(
|
await db.execute(
|
||||||
f"""
|
f"""
|
||||||
UPDATE wallets SET created_at = {db.timestamp_placeholder}
|
UPDATE wallets SET created_at = {db.timestamp_placeholder('now')}
|
||||||
WHERE created_at IS NULL
|
WHERE created_at IS NULL
|
||||||
""",
|
""",
|
||||||
(now,),
|
{"now": now},
|
||||||
)
|
)
|
||||||
await db.execute(
|
await db.execute(
|
||||||
f"""
|
f"""
|
||||||
UPDATE accounts SET created_at = {db.timestamp_placeholder}
|
UPDATE accounts SET created_at = {db.timestamp_placeholder('now')}
|
||||||
WHERE created_at IS NULL
|
WHERE created_at IS NULL
|
||||||
""",
|
""",
|
||||||
(now,),
|
{"now": now},
|
||||||
)
|
)
|
||||||
|
|
||||||
except OperationalError as exc:
|
except OperationalError as exc:
|
||||||
@@ -520,3 +519,168 @@ async def m020_add_column_column_to_user_extensions(db):
|
|||||||
Adds extra column to user extensions.
|
Adds extra column to user extensions.
|
||||||
"""
|
"""
|
||||||
await db.execute("ALTER TABLE extensions ADD COLUMN extra TEXT")
|
await db.execute("ALTER TABLE extensions ADD COLUMN extra TEXT")
|
||||||
|
|
||||||
|
|
||||||
|
async def m021_add_success_failed_to_apipayments(db):
|
||||||
|
"""
|
||||||
|
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):
|
||||||
|
"""
|
||||||
|
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):
|
||||||
|
"""
|
||||||
|
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):
|
||||||
|
await db.execute("ALTER TABLE apipayments DROP COLUMN pending")
|
||||||
|
|
||||||
|
|
||||||
|
async def m025_refresh_view(db):
|
||||||
|
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):
|
||||||
|
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):
|
||||||
|
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,460 +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
|
|
||||||
|
|
||||||
|
|
||||||
class SimpleStatus(BaseModel):
|
|
||||||
success: bool
|
|
||||||
message: str
|
|
||||||
@@ -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,
|
||||||
|
CreateTopup,
|
||||||
|
CreateUser,
|
||||||
|
LoginUsernamePassword,
|
||||||
|
LoginUsr,
|
||||||
|
RegisterUser,
|
||||||
|
ResetUserPassword,
|
||||||
|
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",
|
||||||
|
"CreateTopup",
|
||||||
|
"CreateUser",
|
||||||
|
"RegisterUser",
|
||||||
|
"LoginUsernamePassword",
|
||||||
|
"LoginUsr",
|
||||||
|
"ResetUserPassword",
|
||||||
|
"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
|
||||||
|
|
||||||
|
|
||||||
|
class AuditStats(BaseModel):
|
||||||
|
request_method: list[AuditCountStat] = []
|
||||||
|
response_code: list[AuditCountStat] = []
|
||||||
|
component: list[AuditCountStat] = []
|
||||||
|
long_duration: list[AuditCountStat] = []
|
||||||
@@ -1,19 +1,23 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import sys
|
|
||||||
import zipfile
|
import zipfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, List, NamedTuple, Optional, Tuple
|
from typing import Any, Optional
|
||||||
from urllib import request
|
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from packaging import version
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from lnbits.helpers import (
|
||||||
|
download_url,
|
||||||
|
file_hash,
|
||||||
|
version_parse,
|
||||||
|
)
|
||||||
from lnbits.settings import settings
|
from lnbits.settings import settings
|
||||||
|
|
||||||
|
|
||||||
@@ -23,7 +27,7 @@ class ExplicitRelease(BaseModel):
|
|||||||
version: str
|
version: str
|
||||||
archive: str
|
archive: str
|
||||||
hash: str
|
hash: str
|
||||||
dependencies: List[str] = []
|
dependencies: list[str] = []
|
||||||
repo: Optional[str]
|
repo: Optional[str]
|
||||||
icon: Optional[str]
|
icon: Optional[str]
|
||||||
short_description: Optional[str]
|
short_description: Optional[str]
|
||||||
@@ -48,9 +52,9 @@ class GitHubRelease(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class Manifest(BaseModel):
|
class Manifest(BaseModel):
|
||||||
featured: List[str] = []
|
featured: list[str] = []
|
||||||
extensions: List["ExplicitRelease"] = []
|
extensions: list[ExplicitRelease] = []
|
||||||
repos: List["GitHubRelease"] = []
|
repos: list[GitHubRelease] = []
|
||||||
|
|
||||||
|
|
||||||
class GitHubRepoRelease(BaseModel):
|
class GitHubRepoRelease(BaseModel):
|
||||||
@@ -81,6 +85,17 @@ class ExtensionConfig(BaseModel):
|
|||||||
return True
|
return True
|
||||||
return version_parse(self.min_lnbits_version) <= version_parse(settings.version)
|
return version_parse(self.min_lnbits_version) <= version_parse(settings.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):
|
class ReleasePaymentInfo(BaseModel):
|
||||||
amount: Optional[int] = None
|
amount: Optional[int] = None
|
||||||
@@ -90,8 +105,8 @@ class ReleasePaymentInfo(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class PayToEnableInfo(BaseModel):
|
class PayToEnableInfo(BaseModel):
|
||||||
required: Optional[bool] = False
|
amount: int
|
||||||
amount: Optional[int] = None
|
required: bool = False
|
||||||
wallet: Optional[str] = None
|
wallet: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
@@ -101,6 +116,7 @@ class UserExtensionInfo(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class UserExtension(BaseModel):
|
class UserExtension(BaseModel):
|
||||||
|
user: str
|
||||||
extension: str
|
extension: str
|
||||||
active: bool
|
active: bool
|
||||||
extra: Optional[UserExtensionInfo] = None
|
extra: Optional[UserExtensionInfo] = None
|
||||||
@@ -112,7 +128,7 @@ class UserExtension(BaseModel):
|
|||||||
return self.extra.paid_to_enable is True
|
return self.extra.paid_to_enable is True
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_row(cls, data: dict) -> "UserExtension":
|
def from_row(cls, data: dict) -> UserExtension:
|
||||||
ext = UserExtension(**data)
|
ext = UserExtension(**data)
|
||||||
ext.extra = (
|
ext.extra = (
|
||||||
UserExtensionInfo(**json.loads(data["_extra"] or "{}"))
|
UserExtensionInfo(**json.loads(data["_extra"] or "{}"))
|
||||||
@@ -122,135 +138,12 @@ class UserExtension(BaseModel):
|
|||||||
return ext
|
return ext
|
||||||
|
|
||||||
|
|
||||||
def download_url(url, save_path):
|
class Extension(BaseModel):
|
||||||
with request.urlopen(url, timeout=60) as dl_file:
|
|
||||||
with open(save_path, "wb") as out_file:
|
|
||||||
out_file.write(dl_file.read())
|
|
||||||
|
|
||||||
|
|
||||||
def file_hash(filename):
|
|
||||||
h = hashlib.sha256()
|
|
||||||
b = bytearray(128 * 1024)
|
|
||||||
mv = memoryview(b)
|
|
||||||
with open(filename, "rb", buffering=0) as f:
|
|
||||||
while n := f.readinto(mv):
|
|
||||||
h.update(mv[:n])
|
|
||||||
return h.hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
async def fetch_github_repo_info(
|
|
||||||
org: str, repository: str
|
|
||||||
) -> Tuple[GitHubRepo, GitHubRepoRelease, ExtensionConfig]:
|
|
||||||
repo_url = f"https://api.github.com/repos/{org}/{repository}"
|
|
||||||
error_msg = "Cannot fetch extension repo"
|
|
||||||
repo = await github_api_get(repo_url, error_msg)
|
|
||||||
github_repo = GitHubRepo.parse_obj(repo)
|
|
||||||
|
|
||||||
lates_release_url = (
|
|
||||||
f"https://api.github.com/repos/{org}/{repository}/releases/latest"
|
|
||||||
)
|
|
||||||
error_msg = "Cannot fetch extension releases"
|
|
||||||
latest_release: Any = await github_api_get(lates_release_url, error_msg)
|
|
||||||
|
|
||||||
config_url = f"https://raw.githubusercontent.com/{org}/{repository}/{github_repo.default_branch}/config.json"
|
|
||||||
error_msg = "Cannot fetch config for extension"
|
|
||||||
config = await github_api_get(config_url, error_msg)
|
|
||||||
|
|
||||||
return (
|
|
||||||
github_repo,
|
|
||||||
GitHubRepoRelease.parse_obj(latest_release),
|
|
||||||
ExtensionConfig.parse_obj(config),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def fetch_manifest(url) -> Manifest:
|
|
||||||
error_msg = "Cannot fetch extensions manifest"
|
|
||||||
manifest = await github_api_get(url, error_msg)
|
|
||||||
return Manifest.parse_obj(manifest)
|
|
||||||
|
|
||||||
|
|
||||||
async def fetch_github_releases(org: str, repo: str) -> List[GitHubRepoRelease]:
|
|
||||||
releases_url = f"https://api.github.com/repos/{org}/{repo}/releases"
|
|
||||||
error_msg = "Cannot fetch extension releases"
|
|
||||||
releases = await github_api_get(releases_url, error_msg)
|
|
||||||
return [GitHubRepoRelease.parse_obj(r) for r in releases]
|
|
||||||
|
|
||||||
|
|
||||||
async def fetch_github_release_config(
|
|
||||||
org: str, repo: str, tag_name: str
|
|
||||||
) -> Optional[ExtensionConfig]:
|
|
||||||
config_url = (
|
|
||||||
f"https://raw.githubusercontent.com/{org}/{repo}/{tag_name}/config.json"
|
|
||||||
)
|
|
||||||
error_msg = "Cannot fetch GitHub extension config"
|
|
||||||
config = await github_api_get(config_url, error_msg)
|
|
||||||
return ExtensionConfig.parse_obj(config)
|
|
||||||
|
|
||||||
|
|
||||||
async def github_api_get(url: str, error_msg: Optional[str]) -> Any:
|
|
||||||
headers = {"User-Agent": settings.user_agent}
|
|
||||||
if settings.lnbits_ext_github_token:
|
|
||||||
headers["Authorization"] = f"Bearer {settings.lnbits_ext_github_token}"
|
|
||||||
async with httpx.AsyncClient(headers=headers) as client:
|
|
||||||
resp = await client.get(url)
|
|
||||||
if resp.status_code != 200:
|
|
||||||
logger.warning(f"{error_msg} ({url}): {resp.text}")
|
|
||||||
resp.raise_for_status()
|
|
||||||
return resp.json()
|
|
||||||
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
async def fetch_release_details(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
|
|
||||||
|
|
||||||
|
|
||||||
def icon_to_github_url(source_repo: str, path: Optional[str]) -> str:
|
|
||||||
if not path:
|
|
||||||
return ""
|
|
||||||
_, _, *rest = path.split("/")
|
|
||||||
tail = "/".join(rest)
|
|
||||||
return f"https://github.com/{source_repo}/raw/main/{tail}"
|
|
||||||
|
|
||||||
|
|
||||||
class Extension(NamedTuple):
|
|
||||||
code: str
|
code: str
|
||||||
is_valid: bool
|
is_valid: bool
|
||||||
is_admin_only: bool
|
|
||||||
name: Optional[str] = None
|
name: Optional[str] = None
|
||||||
short_description: Optional[str] = None
|
short_description: Optional[str] = None
|
||||||
tile: 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] = ""
|
upgrade_hash: Optional[str] = ""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -269,60 +162,17 @@ class Extension(NamedTuple):
|
|||||||
return self.upgrade_hash != ""
|
return self.upgrade_hash != ""
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_installable_ext(cls, ext_info: "InstallableExtension") -> "Extension":
|
def from_installable_ext(cls, ext_info: InstallableExtension) -> Extension:
|
||||||
return Extension(
|
return Extension(
|
||||||
code=ext_info.id,
|
code=ext_info.id,
|
||||||
is_valid=True,
|
is_valid=True,
|
||||||
is_admin_only=False, # todo: is admin only
|
|
||||||
name=ext_info.name,
|
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]:
|
|
||||||
# todo: remove this property somehow, it is too expensive
|
|
||||||
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):
|
class ExtensionRelease(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
version: str
|
version: str
|
||||||
@@ -356,13 +206,27 @@ class ExtensionRelease(BaseModel):
|
|||||||
if not self.pay_link:
|
if not self.pay_link:
|
||||||
return
|
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
|
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
|
@classmethod
|
||||||
def from_github_release(
|
def from_github_release(
|
||||||
cls, source_repo: str, r: "GitHubRepoRelease"
|
cls, source_repo: str, r: GitHubRepoRelease
|
||||||
) -> "ExtensionRelease":
|
) -> ExtensionRelease:
|
||||||
return ExtensionRelease(
|
return ExtensionRelease(
|
||||||
name=r.name,
|
name=r.name,
|
||||||
description=r.name,
|
description=r.name,
|
||||||
@@ -377,8 +241,8 @@ class ExtensionRelease(BaseModel):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_explicit_release(
|
def from_explicit_release(
|
||||||
cls, source_repo: str, e: "ExplicitRelease"
|
cls, source_repo: str, e: ExplicitRelease
|
||||||
) -> "ExtensionRelease":
|
) -> ExtensionRelease:
|
||||||
return ExtensionRelease(
|
return ExtensionRelease(
|
||||||
name=e.name,
|
name=e.name,
|
||||||
version=e.version,
|
version=e.version,
|
||||||
@@ -397,9 +261,9 @@ class ExtensionRelease(BaseModel):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@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:
|
try:
|
||||||
github_releases = await fetch_github_releases(org, repo)
|
github_releases = await cls.fetch_github_releases(org, repo)
|
||||||
return [
|
return [
|
||||||
ExtensionRelease.from_github_release(f"{org}/{repo}", r)
|
ExtensionRelease.from_github_release(f"{org}/{repo}", r)
|
||||||
for r in github_releases
|
for r in github_releases
|
||||||
@@ -408,30 +272,61 @@ class ExtensionRelease(BaseModel):
|
|||||||
logger.warning(e)
|
logger.warning(e)
|
||||||
return []
|
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):
|
class InstallableExtension(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
name: str
|
name: str
|
||||||
|
version: str
|
||||||
active: Optional[bool] = False
|
active: Optional[bool] = False
|
||||||
short_description: Optional[str] = None
|
short_description: Optional[str] = None
|
||||||
icon: Optional[str] = None
|
icon: Optional[str] = None
|
||||||
dependencies: List[str] = []
|
|
||||||
is_admin_only: bool = False
|
|
||||||
stars: int = 0
|
stars: int = 0
|
||||||
featured = False
|
meta: Optional[ExtensionMeta] = None
|
||||||
latest_release: Optional[ExtensionRelease] = None
|
|
||||||
installed_release: Optional[ExtensionRelease] = None
|
|
||||||
payments: List[ReleasePaymentInfo] = []
|
|
||||||
pay_to_enable: Optional[PayToEnableInfo] = None
|
|
||||||
archive: Optional[str] = None
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def hash(self) -> str:
|
def hash(self) -> str:
|
||||||
if self.installed_release:
|
if self.meta and self.meta.installed_release:
|
||||||
if self.installed_release.hash:
|
if self.meta.installed_release.hash:
|
||||||
return self.installed_release.hash
|
return self.meta.installed_release.hash
|
||||||
m = hashlib.sha256()
|
m = hashlib.sha256()
|
||||||
m.update(f"{self.installed_release.archive}".encode())
|
m.update(f"{self.meta.installed_release.archive}".encode())
|
||||||
return m.hexdigest()
|
return m.hexdigest()
|
||||||
return "not-installed"
|
return "not-installed"
|
||||||
|
|
||||||
@@ -457,10 +352,6 @@ class InstallableExtension(BaseModel):
|
|||||||
return f"lnbits.extensions.{self.id}"
|
return f"lnbits.extensions.{self.id}"
|
||||||
return self.id
|
return self.id
|
||||||
|
|
||||||
@property
|
|
||||||
def module_installed(self) -> bool:
|
|
||||||
return self.module_name in sys.modules
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def has_installed_version(self) -> bool:
|
def has_installed_version(self) -> bool:
|
||||||
if not self.ext_dir.is_dir():
|
if not self.ext_dir.is_dir():
|
||||||
@@ -469,15 +360,15 @@ class InstallableExtension(BaseModel):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def installed_version(self) -> str:
|
def installed_version(self) -> str:
|
||||||
if self.installed_release:
|
if self.meta and self.meta.installed_release:
|
||||||
return self.installed_release.version
|
return self.meta.installed_release.version
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def requires_payment(self) -> bool:
|
def requires_payment(self) -> bool:
|
||||||
if not self.pay_to_enable:
|
if not self.meta or not self.meta.pay_to_enable:
|
||||||
return False
|
return False
|
||||||
return self.pay_to_enable.required is True
|
return self.meta.pay_to_enable.required is True
|
||||||
|
|
||||||
async def download_archive(self):
|
async def download_archive(self):
|
||||||
logger.info(f"Downloading extension {self.name} ({self.installed_version}).")
|
logger.info(f"Downloading extension {self.name} ({self.installed_version}).")
|
||||||
@@ -485,12 +376,14 @@ class InstallableExtension(BaseModel):
|
|||||||
if ext_zip_file.is_file():
|
if ext_zip_file.is_file():
|
||||||
os.remove(ext_zip_file)
|
os.remove(ext_zip_file)
|
||||||
try:
|
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()
|
self._restore_payment_info()
|
||||||
|
|
||||||
await asyncio.to_thread(
|
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()
|
self._remember_payment_info()
|
||||||
@@ -500,7 +393,11 @@ class InstallableExtension(BaseModel):
|
|||||||
raise AssertionError("Cannot fetch extension archive file") from exc
|
raise AssertionError("Cannot fetch extension archive file") from exc
|
||||||
|
|
||||||
archive_hash = file_hash(ext_zip_file)
|
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
|
# remove downloaded archive
|
||||||
if ext_zip_file.is_file():
|
if ext_zip_file.is_file():
|
||||||
os.remove(ext_zip_file)
|
os.remove(ext_zip_file)
|
||||||
@@ -534,27 +431,18 @@ class InstallableExtension(BaseModel):
|
|||||||
self.short_description = config_json.get("short_description")
|
self.short_description = config_json.get("short_description")
|
||||||
|
|
||||||
if (
|
if (
|
||||||
self.installed_release
|
self.meta
|
||||||
and self.installed_release.is_github_release
|
and self.meta.installed_release
|
||||||
|
and self.meta.installed_release.is_github_release
|
||||||
and config_json.get("tile")
|
and config_json.get("tile")
|
||||||
):
|
):
|
||||||
self.icon = icon_to_github_url(
|
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.rmtree(self.ext_dir, True)
|
||||||
shutil.copytree(Path(self.ext_upgrade_dir), Path(self.ext_dir))
|
shutil.copytree(Path(self.ext_upgrade_dir), Path(self.ext_dir))
|
||||||
logger.success(f"Extension {self.name} ({self.installed_version}) installed.")
|
logger.info(f"Extension {self.name} ({self.installed_version}) extracted.")
|
||||||
|
|
||||||
def notify_upgrade(self, upgrade_hash: Optional[str]) -> None:
|
|
||||||
"""
|
|
||||||
Update the list of upgraded extensions. The middleware will perform
|
|
||||||
redirects based on this
|
|
||||||
"""
|
|
||||||
if upgrade_hash:
|
|
||||||
settings.lnbits_upgraded_extensions.add(f"{self.hash}/{self.id}")
|
|
||||||
|
|
||||||
settings.lnbits_all_extensions_ids.add(self.id)
|
|
||||||
|
|
||||||
def clean_extension_files(self):
|
def clean_extension_files(self):
|
||||||
# remove downloaded archive
|
# remove downloaded archive
|
||||||
@@ -569,87 +457,78 @@ class InstallableExtension(BaseModel):
|
|||||||
def check_latest_version(self, release: Optional[ExtensionRelease]):
|
def check_latest_version(self, release: Optional[ExtensionRelease]):
|
||||||
if not release:
|
if not release:
|
||||||
return
|
return
|
||||||
if not self.latest_release:
|
if not self.meta or not self.meta.latest_release:
|
||||||
self.latest_release = release
|
meta = self.meta or ExtensionMeta()
|
||||||
|
meta.latest_release = release
|
||||||
|
self.meta = meta
|
||||||
return
|
return
|
||||||
if version_parse(self.latest_release.version) < version_parse(release.version):
|
if version_parse(self.meta.latest_release.version) < version_parse(
|
||||||
self.latest_release = release
|
release.version
|
||||||
|
):
|
||||||
|
self.meta.latest_release = release
|
||||||
|
|
||||||
def find_existing_payment(
|
def find_existing_payment(
|
||||||
self, pay_link: Optional[str]
|
self, pay_link: Optional[str]
|
||||||
) -> Optional[ReleasePaymentInfo]:
|
) -> Optional[ReleasePaymentInfo]:
|
||||||
if not pay_link:
|
if not pay_link or not self.meta or not self.meta.payments:
|
||||||
return None
|
return None
|
||||||
return next(
|
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,
|
None,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _restore_payment_info(self):
|
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
|
return
|
||||||
if not self.installed_release.pay_link:
|
payment_info = self.find_existing_payment(self.meta.installed_release.pay_link)
|
||||||
return
|
|
||||||
if self.installed_release.payment_hash:
|
|
||||||
return
|
|
||||||
payment_info = self.find_existing_payment(self.installed_release.pay_link)
|
|
||||||
if payment_info:
|
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):
|
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
|
return
|
||||||
payment_info = ReleasePaymentInfo(
|
payment_info = ReleasePaymentInfo(
|
||||||
amount=self.installed_release.cost_sats,
|
amount=self.meta.installed_release.cost_sats,
|
||||||
pay_link=self.installed_release.pay_link,
|
pay_link=self.meta.installed_release.pay_link,
|
||||||
payment_hash=self.installed_release.payment_hash,
|
payment_hash=self.meta.installed_release.payment_hash,
|
||||||
)
|
)
|
||||||
self.payments = [
|
self.meta.payments = [
|
||||||
p for p in self.payments if p.pay_link != payment_info.pay_link
|
p for p in self.meta.payments if p.pay_link != payment_info.pay_link
|
||||||
]
|
]
|
||||||
self.payments.append(payment_info)
|
self.meta.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("pay_to_enable"):
|
|
||||||
ext.pay_to_enable = PayToEnableInfo(**meta["pay_to_enable"])
|
|
||||||
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]
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def from_github_release(
|
async def from_github_release(
|
||||||
cls, github_release: GitHubRelease
|
cls, github_release: GitHubRelease
|
||||||
) -> Optional["InstallableExtension"]:
|
) -> Optional[InstallableExtension]:
|
||||||
try:
|
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
|
github_release.organisation, github_release.repository
|
||||||
)
|
)
|
||||||
source_repo = f"{github_release.organisation}/{github_release.repository}"
|
source_repo = f"{github_release.organisation}/{github_release.repository}"
|
||||||
return InstallableExtension(
|
return InstallableExtension(
|
||||||
id=github_release.id,
|
id=github_release.id,
|
||||||
name=config.name,
|
name=config.name,
|
||||||
|
version=latest_release.tag_name,
|
||||||
short_description=config.short_description,
|
short_description=config.short_description,
|
||||||
stars=int(repo.stargazers_count),
|
stars=int(repo.stargazers_count),
|
||||||
icon=icon_to_github_url(
|
icon=icon_to_github_url(
|
||||||
source_repo,
|
source_repo,
|
||||||
config.tile,
|
config.tile,
|
||||||
),
|
),
|
||||||
latest_release=ExtensionRelease.from_github_release(
|
meta=ExtensionMeta(
|
||||||
source_repo, latest_release
|
latest_release=ExtensionRelease.from_github_release(
|
||||||
|
source_repo, latest_release
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -657,26 +536,62 @@ class InstallableExtension(BaseModel):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
@classmethod
|
@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(
|
return InstallableExtension(
|
||||||
id=e.id,
|
id=e.id,
|
||||||
name=e.name,
|
name=e.name,
|
||||||
archive=e.archive,
|
version=e.version,
|
||||||
short_description=e.short_description,
|
short_description=e.short_description,
|
||||||
icon=e.icon,
|
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"),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(e)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def get_installable_extensions(
|
async def get_installable_extensions(
|
||||||
cls,
|
cls,
|
||||||
) -> List["InstallableExtension"]:
|
) -> list[InstallableExtension]:
|
||||||
extension_list: List[InstallableExtension] = []
|
extension_list: list[InstallableExtension] = []
|
||||||
extension_id_list: List[str] = []
|
extension_id_list: list[str] = []
|
||||||
|
|
||||||
for url in settings.lnbits_extensions_manifests:
|
for url in settings.lnbits_extensions_manifests:
|
||||||
try:
|
try:
|
||||||
manifest = await fetch_manifest(url)
|
manifest = await cls.fetch_manifest(url)
|
||||||
|
|
||||||
for r in manifest.repos:
|
for r in manifest.repos:
|
||||||
ext = await InstallableExtension.from_github_release(r)
|
ext = await InstallableExtension.from_github_release(r)
|
||||||
@@ -685,11 +600,13 @@ class InstallableExtension(BaseModel):
|
|||||||
existing_ext = next(
|
existing_ext = next(
|
||||||
(ee for ee in extension_list if ee.id == r.id), None
|
(ee for ee in extension_list if ee.id == r.id), None
|
||||||
)
|
)
|
||||||
if existing_ext:
|
if existing_ext and ext.meta:
|
||||||
existing_ext.check_latest_version(ext.latest_release)
|
existing_ext.check_latest_version(ext.meta.latest_release)
|
||||||
continue
|
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_list += [ext]
|
||||||
extension_id_list += [ext.id]
|
extension_id_list += [ext.id]
|
||||||
|
|
||||||
@@ -703,7 +620,9 @@ class InstallableExtension(BaseModel):
|
|||||||
continue
|
continue
|
||||||
ext = InstallableExtension.from_explicit_release(e)
|
ext = InstallableExtension.from_explicit_release(e)
|
||||||
ext.check_latest_version(release)
|
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_list += [ext]
|
||||||
extension_id_list += [e.id]
|
extension_id_list += [e.id]
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -712,12 +631,12 @@ class InstallableExtension(BaseModel):
|
|||||||
return extension_list
|
return extension_list
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def get_extension_releases(cls, ext_id: str) -> List["ExtensionRelease"]:
|
async def get_extension_releases(cls, ext_id: str) -> list[ExtensionRelease]:
|
||||||
extension_releases: List[ExtensionRelease] = []
|
extension_releases: list[ExtensionRelease] = []
|
||||||
|
|
||||||
for url in settings.lnbits_extensions_manifests:
|
for url in settings.lnbits_extensions_manifests:
|
||||||
try:
|
try:
|
||||||
manifest = await fetch_manifest(url)
|
manifest = await cls.fetch_manifest(url)
|
||||||
for r in manifest.repos:
|
for r in manifest.repos:
|
||||||
if r.id != ext_id:
|
if r.id != ext_id:
|
||||||
continue
|
continue
|
||||||
@@ -741,8 +660,8 @@ class InstallableExtension(BaseModel):
|
|||||||
@classmethod
|
@classmethod
|
||||||
async def get_extension_release(
|
async def get_extension_release(
|
||||||
cls, ext_id: str, source_repo: str, archive: str, version: str
|
cls, ext_id: str, source_repo: str, archive: str, version: str
|
||||||
) -> Optional["ExtensionRelease"]:
|
) -> Optional[ExtensionRelease]:
|
||||||
all_releases: List[ExtensionRelease] = (
|
all_releases: list[ExtensionRelease] = (
|
||||||
await InstallableExtension.get_extension_releases(ext_id)
|
await InstallableExtension.get_extension_releases(ext_id)
|
||||||
)
|
)
|
||||||
selected_release = [
|
selected_release = [
|
||||||
@@ -755,6 +674,37 @@ class InstallableExtension(BaseModel):
|
|||||||
|
|
||||||
return selected_release[0] if len(selected_release) != 0 else None
|
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):
|
class CreateExtension(BaseModel):
|
||||||
ext_id: str
|
ext_id: str
|
||||||
@@ -771,30 +721,21 @@ class ExtensionDetailsRequest(BaseModel):
|
|||||||
version: str
|
version: str
|
||||||
|
|
||||||
|
|
||||||
def get_valid_extensions(include_deactivated: Optional[bool] = True) -> List[Extension]:
|
async def github_api_get(url: str, error_msg: Optional[str]) -> Any:
|
||||||
valid_extensions = [
|
headers = {"User-Agent": settings.user_agent}
|
||||||
extension for extension in ExtensionManager().extensions if extension.is_valid
|
if settings.lnbits_ext_github_token:
|
||||||
]
|
headers["Authorization"] = f"Bearer {settings.lnbits_ext_github_token}"
|
||||||
|
async with httpx.AsyncClient(headers=headers) as client:
|
||||||
if include_deactivated:
|
resp = await client.get(url)
|
||||||
return valid_extensions
|
if resp.status_code != 200:
|
||||||
|
logger.warning(f"{error_msg} ({url}): {resp.text}")
|
||||||
if settings.lnbits_extensions_deactivate_all:
|
resp.raise_for_status()
|
||||||
return []
|
return resp.json()
|
||||||
|
|
||||||
return [
|
|
||||||
e
|
|
||||||
for e in valid_extensions
|
|
||||||
if e.code not in settings.lnbits_deactivated_extensions
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def version_parse(v: str):
|
def icon_to_github_url(source_repo: str, path: Optional[str]) -> str:
|
||||||
"""
|
if not path:
|
||||||
Wrapper for version.parse() that does not throw if the version is invalid.
|
return ""
|
||||||
Instead it return the lowest possible version ("0.0.0")
|
_, _, *rest = path.split("/")
|
||||||
"""
|
tail = "/".join(rest)
|
||||||
try:
|
return f"https://github.com/{source_repo}/raw/main/{tail}"
|
||||||
return version.parse(v)
|
|
||||||
except Exception:
|
|
||||||
return version.parse("0.0.0")
|
|
||||||
@@ -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,176 @@
|
|||||||
|
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"]
|
||||||
|
|
||||||
|
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 CreateTopup(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,858 +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
|
|
||||||
from uuid import UUID, uuid4
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
from bolt11 import decode as bolt11_decode
|
|
||||||
from cryptography.hazmat.primitives import serialization
|
|
||||||
from fastapi import Depends, WebSocket
|
|
||||||
from loguru import logger
|
|
||||||
from passlib.context import CryptContext
|
|
||||||
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,
|
|
||||||
check_user_extension_access,
|
|
||||||
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_account_by_email,
|
|
||||||
get_account_by_username,
|
|
||||||
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,
|
|
||||||
update_user_extension,
|
|
||||||
)
|
|
||||||
from .helpers import to_valid_user_id
|
|
||||||
from .models import BalanceDelta, Payment, User, 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=invoice.expiry_date,
|
|
||||||
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=invoice.expiry_date,
|
|
||||||
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"
|
|
||||||
_check_wallet_balance(wallet, fee_reserve_total_msat, internal_checking_id)
|
|
||||||
|
|
||||||
if extra and "tag" in extra:
|
|
||||||
# check if the payment is made for an extension that the user disabled
|
|
||||||
status = await check_user_extension_access(wallet.user, extra["tag"])
|
|
||||||
if not status.success:
|
|
||||||
raise PaymentError(status.message)
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def _check_wallet_balance(
|
|
||||||
wallet: Wallet,
|
|
||||||
fee_reserve_total_msat: int,
|
|
||||||
internal_checking_id: Optional[str] = None,
|
|
||||||
):
|
|
||||||
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")
|
|
||||||
|
|
||||||
|
|
||||||
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.inkey,
|
|
||||||
json.dumps(
|
|
||||||
{
|
|
||||||
"wallet_balance": wallet.balance,
|
|
||||||
"payment": payment.dict(),
|
|
||||||
}
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
await websocket_updater(
|
|
||||||
payment.payment_hash, json.dumps({"pending": payment.pending})
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
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())
|
|
||||||
|
|
||||||
|
|
||||||
async def create_user_account(
|
|
||||||
user_id: Optional[str] = None,
|
|
||||||
email: Optional[str] = None,
|
|
||||||
username: Optional[str] = None,
|
|
||||||
password: Optional[str] = None,
|
|
||||||
wallet_name: Optional[str] = None,
|
|
||||||
user_config: Optional[UserConfig] = None,
|
|
||||||
) -> User:
|
|
||||||
if not settings.new_accounts_allowed:
|
|
||||||
raise ValueError("Account creation is disabled.")
|
|
||||||
if username and await get_account_by_username(username):
|
|
||||||
raise ValueError("Username already exists.")
|
|
||||||
|
|
||||||
if email and await get_account_by_email(email):
|
|
||||||
raise ValueError("Email already exists.")
|
|
||||||
|
|
||||||
if user_id:
|
|
||||||
user_uuid4 = UUID(hex=user_id, version=4)
|
|
||||||
assert user_uuid4.hex == user_id, "User ID is not valid UUID4 hex string"
|
|
||||||
else:
|
|
||||||
user_id = uuid4().hex
|
|
||||||
|
|
||||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
|
||||||
password = pwd_context.hash(password) if password else None
|
|
||||||
|
|
||||||
account = await create_account(user_id, username, email, password, user_config)
|
|
||||||
wallet = await create_wallet(user_id=account.id, wallet_name=wallet_name)
|
|
||||||
account.wallets = [wallet]
|
|
||||||
|
|
||||||
for ext_id in settings.lnbits_user_default_extensions:
|
|
||||||
await update_user_extension(user_id=account.id, extension=ext_id, active=True)
|
|
||||||
|
|
||||||
return account
|
|
||||||
|
|
||||||
|
|
||||||
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,
|
|
||||||
)
|
|
||||||
@@ -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,141 @@
|
|||||||
|
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_id = ext_info.id
|
||||||
|
extension = Extension.from_installable_ext(ext_info)
|
||||||
|
installed_ext = await get_installed_extension(ext_id)
|
||||||
|
if installed_ext and installed_ext.meta:
|
||||||
|
ext_info.meta = ext_info.meta or ExtensionMeta()
|
||||||
|
ext_info.meta.payments = installed_ext.meta.payments
|
||||||
|
|
||||||
|
await ext_info.download_archive()
|
||||||
|
|
||||||
|
ext_info.extract_archive()
|
||||||
|
|
||||||
|
db_version = await get_db_version(ext_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)
|
||||||
|
|
||||||
|
if extension.is_upgrade_extension:
|
||||||
|
# call stop while the old routes are still active
|
||||||
|
await stop_extension_background_work(ext_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).
|
||||||
|
Extensions SHOULD expose a `api_stop()` function.
|
||||||
|
"""
|
||||||
|
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)
|
||||||
|
|
||||||
|
# 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, 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 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,574 @@
|
|||||||
|
import json
|
||||||
|
import time
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from bolt11 import decode as bolt11_decode
|
||||||
|
from bolt11.types import Bolt11
|
||||||
|
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.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_id: str, amount: int):
|
||||||
|
async with db.connect() as conn:
|
||||||
|
payment = await create_invoice(
|
||||||
|
wallet_id=wallet_id,
|
||||||
|
amount=amount,
|
||||||
|
memo="Admin top up",
|
||||||
|
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
|
||||||
|
await websocket_manager.send_data(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"wallet_balance": wallet.balance,
|
||||||
|
# use pydantic json serialization to get the correct datetime format
|
||||||
|
"payment": json.loads(payment.json()),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
wallet.inkey,
|
||||||
|
)
|
||||||
|
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,50 @@
|
|||||||
|
from cryptography.hazmat.primitives import serialization
|
||||||
|
from loguru import logger
|
||||||
|
from py_vapid import Vapid
|
||||||
|
from py_vapid.utils import b64urlencode
|
||||||
|
|
||||||
|
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):
|
||||||
|
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"]
|
||||||
@@ -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)
|
||||||
+32
-1
@@ -5,11 +5,13 @@ import httpx
|
|||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from lnbits.core.crud import (
|
from lnbits.core.crud import (
|
||||||
|
create_audit_entry,
|
||||||
get_wallet,
|
get_wallet,
|
||||||
get_webpush_subscriptions_for_user,
|
get_webpush_subscriptions_for_user,
|
||||||
mark_webhook_sent,
|
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 (
|
from lnbits.core.services import (
|
||||||
get_balance_delta,
|
get_balance_delta,
|
||||||
send_payment_notification,
|
send_payment_notification,
|
||||||
@@ -19,6 +21,7 @@ from lnbits.settings import get_funding_source, settings
|
|||||||
from lnbits.tasks import send_push_notification
|
from lnbits.tasks import send_push_notification
|
||||||
|
|
||||||
api_invoice_listeners: Dict[str, asyncio.Queue] = {}
|
api_invoice_listeners: Dict[str, asyncio.Queue] = {}
|
||||||
|
audit_queue: asyncio.Queue = asyncio.Queue()
|
||||||
|
|
||||||
|
|
||||||
async def killswitch_task():
|
async def killswitch_task():
|
||||||
@@ -157,3 +160,31 @@ async def send_payment_push_notification(payment: Payment):
|
|||||||
f"https://{subscription.host}/wallet?usr={wallet.user}&wal={wallet.id}"
|
f"https://{subscription.host}/wallet?usr={wallet.user}&wal={wallet.id}"
|
||||||
)
|
)
|
||||||
await send_push_notification(subscription, title, body, url)
|
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)
|
||||||
|
|||||||
@@ -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,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-tab-panel name="security">
|
||||||
<q-card-section class="q-pa-none">
|
<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="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
|
<q-input
|
||||||
filled
|
filled
|
||||||
v-model="formData.auth_token_expire_minutes"
|
v-model="formData.auth_token_expire_minutes"
|
||||||
@@ -12,7 +24,7 @@
|
|||||||
>
|
>
|
||||||
</q-input>
|
</q-input>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6 col-sm-12 q-pr-sm">
|
<div class="col-12 col-sm-6">
|
||||||
<q-select
|
<q-select
|
||||||
filled
|
filled
|
||||||
v-model="formData.auth_allowed_methods"
|
v-model="formData.auth_allowed_methods"
|
||||||
@@ -24,6 +36,40 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</q-card-section>
|
</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
|
<q-card-section
|
||||||
v-if="formData.auth_allowed_methods?.includes('google-auth')"
|
v-if="formData.auth_allowed_methods?.includes('google-auth')"
|
||||||
class="q-pl-xl"
|
class="q-pl-xl"
|
||||||
@@ -173,7 +219,8 @@
|
|||||||
@remove="removeBlockedIPs(blocked_ip)"
|
@remove="removeBlockedIPs(blocked_ip)"
|
||||||
color="primary"
|
color="primary"
|
||||||
text-color="white"
|
text-color="white"
|
||||||
v-text="blocked_ip"
|
:label="blocked_ip"
|
||||||
|
class="ellipsis"
|
||||||
></q-chip>
|
></q-chip>
|
||||||
</div>
|
</div>
|
||||||
<br />
|
<br />
|
||||||
@@ -202,7 +249,8 @@
|
|||||||
@remove="removeAllowedIPs(allowed_ip)"
|
@remove="removeAllowedIPs(allowed_ip)"
|
||||||
color="primary"
|
color="primary"
|
||||||
text-color="white"
|
text-color="white"
|
||||||
v-text="allowed_ip"
|
:label="allowed_ip"
|
||||||
|
class="ellipsis"
|
||||||
></q-chip>
|
></q-chip>
|
||||||
</div>
|
</div>
|
||||||
<br />
|
<br />
|
||||||
@@ -263,142 +311,5 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<br />
|
|
||||||
<div>
|
|
||||||
<div class="row q-col-gutter-md">
|
|
||||||
<div class="col-12 col-md-6">
|
|
||||||
<q-item tag="label" v-ripple>
|
|
||||||
<q-item-section>
|
|
||||||
<q-item-label v-text="$t('enable_notifications')"></q-item-label>
|
|
||||||
<q-item-label
|
|
||||||
caption
|
|
||||||
v-text="$t('enable_notifications_desc')"
|
|
||||||
></q-item-label>
|
|
||||||
</q-item-section>
|
|
||||||
<q-item-section avatar>
|
|
||||||
<q-toggle
|
|
||||||
size="md"
|
|
||||||
v-model="formData.lnbits_notifications"
|
|
||||||
checked-icon="check"
|
|
||||||
color="green"
|
|
||||||
unchecked-icon="clear"
|
|
||||||
/>
|
|
||||||
</q-item-section>
|
|
||||||
</q-item>
|
|
||||||
<br />
|
|
||||||
<p
|
|
||||||
v-if="!formData.lnbits_notifications"
|
|
||||||
v-text="$t('notifications_disabled')"
|
|
||||||
></p>
|
|
||||||
<div v-if="formData.lnbits_notifications">
|
|
||||||
{% include "admin/_tab_security_notifications.html" %}
|
|
||||||
</div>
|
|
||||||
<br />
|
|
||||||
<div>
|
|
||||||
<p v-text="$t('notification_source')"></p>
|
|
||||||
<q-input
|
|
||||||
filled
|
|
||||||
v-model="formData.lnbits_status_manifest"
|
|
||||||
type="text"
|
|
||||||
:label="$t('notification_source_label')"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<br />
|
|
||||||
</div>
|
|
||||||
<div class="col-12 col-md-6">
|
|
||||||
<p v-text="$t('killswitch')"></p>
|
|
||||||
<q-item tag="label" v-ripple>
|
|
||||||
<q-item-section>
|
|
||||||
<q-item-label v-text="$t('enable_killswitch')"></q-item-label>
|
|
||||||
<q-item-label
|
|
||||||
caption
|
|
||||||
v-text="$t('enable_killswitch_desc')"
|
|
||||||
></q-item-label>
|
|
||||||
</q-item-section>
|
|
||||||
<q-item-section avatar>
|
|
||||||
<q-toggle
|
|
||||||
size="md"
|
|
||||||
v-model="formData.lnbits_killswitch"
|
|
||||||
checked-icon="check"
|
|
||||||
color="green"
|
|
||||||
unchecked-icon="clear"
|
|
||||||
/>
|
|
||||||
</q-item-section>
|
|
||||||
</q-item>
|
|
||||||
<q-item tag="label" v-ripple>
|
|
||||||
<q-item-section>
|
|
||||||
<q-item-label v-text="$t('killswitch_interval')"></q-item-label>
|
|
||||||
<q-item-label
|
|
||||||
caption
|
|
||||||
v-text="$t('killswitch_interval_desc')"
|
|
||||||
></q-item-label>
|
|
||||||
</q-item-section>
|
|
||||||
<q-item-section>
|
|
||||||
<q-input
|
|
||||||
filled
|
|
||||||
v-model="formData.lnbits_killswitch_interval"
|
|
||||||
type="number"
|
|
||||||
/>
|
|
||||||
</q-item-section>
|
|
||||||
</q-item>
|
|
||||||
<br />
|
|
||||||
<p v-text="$t('watchdog')"></p>
|
|
||||||
<q-item disabled tag="label" v-ripple>
|
|
||||||
<q-tooltip><span v-text="$t('coming_soon')"></span></q-tooltip>
|
|
||||||
<q-item-section>
|
|
||||||
<q-item-label v-text="$t('enable_watchdog')"></q-item-label>
|
|
||||||
<q-item-label
|
|
||||||
caption
|
|
||||||
v-text="$t('enable_watchdog_desc')"
|
|
||||||
></q-item-label>
|
|
||||||
</q-item-section>
|
|
||||||
<q-item-section avatar>
|
|
||||||
<q-toggle
|
|
||||||
size="md"
|
|
||||||
v-model="formData.lnbits_watchdog"
|
|
||||||
checked-icon="check"
|
|
||||||
color="green"
|
|
||||||
unchecked-icon="clear"
|
|
||||||
/>
|
|
||||||
</q-item-section>
|
|
||||||
</q-item>
|
|
||||||
<q-item disabled tag="label" v-ripple>
|
|
||||||
<q-tooltip><span v-text="$t('coming_soon')"></span></q-tooltip>
|
|
||||||
<q-item-section>
|
|
||||||
<q-item-label v-text="$t('watchdog_interval')"></q-item-label>
|
|
||||||
<q-item-label
|
|
||||||
caption
|
|
||||||
v-text="$t('watchdog_interval_desc')"
|
|
||||||
></q-item-label>
|
|
||||||
</q-item-section>
|
|
||||||
<q-item-section>
|
|
||||||
<q-input
|
|
||||||
filled
|
|
||||||
v-model="formData.lnbits_watchdog_interval"
|
|
||||||
type="number"
|
|
||||||
/>
|
|
||||||
</q-item-section>
|
|
||||||
</q-item>
|
|
||||||
<q-item disabled tag="label" v-ripple>
|
|
||||||
<q-tooltip><span v-text="$t('coming_soon')"></span></q-tooltip>
|
|
||||||
<q-item-section>
|
|
||||||
<q-item-label v-text="$t('watchdog_delta')"></q-item-label>
|
|
||||||
<q-item-label
|
|
||||||
caption
|
|
||||||
v-text="$t('watchdog_delta_desc')"
|
|
||||||
></q-item-label>
|
|
||||||
</q-item-section>
|
|
||||||
<q-item-section>
|
|
||||||
<q-input
|
|
||||||
filled
|
|
||||||
v-model="formData.lnbits_watchdog_delta"
|
|
||||||
type="number"
|
|
||||||
/>
|
|
||||||
</q-item-section>
|
|
||||||
</q-item>
|
|
||||||
<br />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-tab-panel>
|
</q-tab-panel>
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
<q-table
|
<q-table
|
||||||
dense
|
dense
|
||||||
flat
|
flat
|
||||||
:data="statusData.notifications"
|
:rows="statusData.notifications"
|
||||||
:columns="statusDataTable.columns"
|
:columns="statusDataTable.columns"
|
||||||
:no-data-label="$t('no_notifications')"
|
:no-data-label="$t('no_notifications')"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,24 +1,7 @@
|
|||||||
<q-tab-panel name="server">
|
<q-tab-panel name="server">
|
||||||
<q-card-section class="q-pa-none">
|
<q-card-section class="q-pa-none">
|
||||||
<h6 class="q-my-none">Server Management</h6>
|
|
||||||
<br />
|
|
||||||
<div>
|
<div>
|
||||||
<div class="row">
|
<h6 class="q-my-none">Currency Settings</h6>
|
||||||
<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>
|
|
||||||
<div class="row q-col-gutter-md">
|
<div class="row q-col-gutter-md">
|
||||||
<div class="col-12 col-md-6">
|
<div class="col-12 col-md-6">
|
||||||
<p>Allowed currencies</p>
|
<p>Allowed currencies</p>
|
||||||
@@ -105,95 +88,6 @@
|
|||||||
<br />
|
<br />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<q-separator></q-separator>
|
|
||||||
<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 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.map(e => e.code)"
|
|
||||||
></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.map(e => e.code)"
|
|
||||||
></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>
|
</div>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-tab-panel>
|
</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">
|
<q-card-section class="q-pa-none">
|
||||||
<h6 class="q-my-none">UI Management</h6>
|
<h6 class="q-my-none">UI Management</h6>
|
||||||
<br />
|
<br />
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<h6 class="q-my-none q-mb-sm">User Management</h6>
|
<h6 class="q-my-none q-mb-sm">User Management</h6>
|
||||||
|
|
||||||
<div class="row">
|
<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>
|
<p>Admin Users</p>
|
||||||
<q-input
|
<q-input
|
||||||
filled
|
filled
|
||||||
@@ -23,13 +23,14 @@
|
|||||||
@remove="removeAdminUser(user)"
|
@remove="removeAdminUser(user)"
|
||||||
color="primary"
|
color="primary"
|
||||||
text-color="white"
|
text-color="white"
|
||||||
|
:label="user"
|
||||||
|
class="ellipsis"
|
||||||
>
|
>
|
||||||
<span v-text="user"></span>
|
|
||||||
</q-chip>
|
</q-chip>
|
||||||
</div>
|
</div>
|
||||||
<br />
|
<br />
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6 col-sm-12">
|
<div class="col-12 col-md-6">
|
||||||
<p>Allowed Users</p>
|
<p>Allowed Users</p>
|
||||||
<q-input
|
<q-input
|
||||||
filled
|
filled
|
||||||
@@ -49,8 +50,9 @@
|
|||||||
@remove="removeAllowedUser(user)"
|
@remove="removeAllowedUser(user)"
|
||||||
color="primary"
|
color="primary"
|
||||||
text-color="white"
|
text-color="white"
|
||||||
|
:label="user"
|
||||||
|
class="ellipsis"
|
||||||
>
|
>
|
||||||
<span v-text="user" />
|
|
||||||
</q-chip>
|
</q-chip>
|
||||||
</div>
|
</div>
|
||||||
<br />
|
<br />
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
:label="$t('restart')"
|
:label="$t('restart')"
|
||||||
color="primary"
|
color="primary"
|
||||||
@click="restartServer"
|
@click="restartServer"
|
||||||
|
class="q-ml-md"
|
||||||
>
|
>
|
||||||
<q-tooltip v-if="needsRestart">
|
<q-tooltip v-if="needsRestart">
|
||||||
<span v-text="$t('restart_tooltip')"></span>
|
<span v-text="$t('restart_tooltip')"></span>
|
||||||
@@ -60,50 +61,98 @@
|
|||||||
<div class="row q-col-gutter-md justify-center">
|
<div class="row q-col-gutter-md justify-center">
|
||||||
<div class="col q-gutter-y-md">
|
<div class="col q-gutter-y-md">
|
||||||
<q-card>
|
<q-card>
|
||||||
<div class="q-pa-md">
|
<q-splitter>
|
||||||
<div class="q-gutter-y-md">
|
<template v-slot:before>
|
||||||
<q-tabs v-model="tab" active-color="primary" align="justify">
|
<q-tabs v-model="tab" vertical active-color="primary">
|
||||||
<q-tab
|
<q-tab
|
||||||
name="funding"
|
name="funding"
|
||||||
:label="$t('funding')"
|
icon="account_balance_wallet"
|
||||||
|
:label="$q.screen.gt.sm ? $t('funding') : null"
|
||||||
@update="val => tab = val.name"
|
@update="val => tab = val.name"
|
||||||
|
><q-tooltip v-if="!$q.screen.gt.sm"
|
||||||
|
><span v-text="$t('funding')"></span></q-tooltip
|
||||||
|
></q-tab>
|
||||||
|
<q-tab
|
||||||
|
name="security"
|
||||||
|
icon="security"
|
||||||
|
:label="$q.screen.gt.sm ? $t('security') : null"
|
||||||
|
@update="val => tab = val.name"
|
||||||
|
><q-tooltip v-if="!$q.screen.gt.sm"
|
||||||
|
><span v-text="$t('security')"></span></q-tooltip
|
||||||
></q-tab>
|
></q-tab>
|
||||||
|
|
||||||
<q-tab
|
<q-tab
|
||||||
name="users"
|
name="users"
|
||||||
:label="$t('users')"
|
icon="group"
|
||||||
|
:label="$q.screen.gt.sm ? $t('users') : null"
|
||||||
@update="val => tab = val.name"
|
@update="val => tab = val.name"
|
||||||
|
><q-tooltip v-if="!$q.screen.gt.sm"
|
||||||
|
><span v-text="$t('users')"></span></q-tooltip
|
||||||
|
></q-tab>
|
||||||
|
<q-tab
|
||||||
|
name="extensions"
|
||||||
|
icon="extension"
|
||||||
|
:label="$q.screen.gt.sm ? $t('extensions') : null"
|
||||||
|
@update="val => tab = val.name"
|
||||||
|
><q-tooltip v-if="!$q.screen.gt.sm"
|
||||||
|
><span v-text="$t('extensions')"></span></q-tooltip
|
||||||
></q-tab>
|
></q-tab>
|
||||||
|
|
||||||
<q-tab
|
<q-tab
|
||||||
name="server"
|
name="server"
|
||||||
:label="$t('server')"
|
icon="price_change"
|
||||||
|
:label="$q.screen.gt.sm ? $t('payments') : null"
|
||||||
@update="val => tab = val.name"
|
@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>
|
||||||
|
|
||||||
<q-tab
|
<q-tab
|
||||||
name="security"
|
name="notifications"
|
||||||
:label="$t('security')"
|
icon="notifications"
|
||||||
|
:label="$q.screen.gt.sm ? $t('notifications') : null"
|
||||||
@update="val => tab = val.name"
|
@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>
|
||||||
|
|
||||||
<q-tab
|
<q-tab
|
||||||
name="theme"
|
name="audit"
|
||||||
:label="$t('theme')"
|
icon="playlist_add_check_circle"
|
||||||
|
:label="$q.screen.gt.sm ? $t('audit') : null"
|
||||||
@update="val => tab = val.name"
|
@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-tab>
|
||||||
</q-tabs>
|
</q-tabs>
|
||||||
</div>
|
</template>
|
||||||
</div>
|
|
||||||
|
|
||||||
<q-form name="settings_form" id="settings_form">
|
<template v-slot:after>
|
||||||
<q-tab-panels v-model="tab" animated>
|
<q-form name="settings_form" id="settings_form">
|
||||||
{% include "admin/_tab_funding.html" %} {% include
|
<q-tab-panels
|
||||||
"admin/_tab_users.html" %} {% include "admin/_tab_server.html" %} {%
|
v-model="tab"
|
||||||
include "admin/_tab_security.html" %} {% include
|
animated
|
||||||
"admin/_tab_theme.html" %}
|
swipeable
|
||||||
</q-tab-panels>
|
vertical
|
||||||
</q-form>
|
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_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>
|
</q-card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
|
||||||
|
%} {% block page %}
|
||||||
|
|
||||||
|
<div class="row q-col-gutter-md justify-center q-mb-xl">
|
||||||
|
<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-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"
|
:content-inset-level="0.5"
|
||||||
>
|
>
|
||||||
<q-card-section>
|
<q-card-section>
|
||||||
<strong>Node URL: </strong><em v-text="origin"></em><br />
|
<q-list>
|
||||||
<strong>Wallet ID: </strong><em>{{ wallet.id }}</em><br />
|
<q-item dense class="q-pa-none">
|
||||||
<strong>Admin key: </strong><em>{{ wallet.adminkey }}</em><br />
|
<q-item-section>
|
||||||
<strong>Invoice/read key: </strong><em>{{ wallet.inkey }}</em>
|
<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-card-section>
|
||||||
<q-expansion-item
|
<q-expansion-item
|
||||||
group="api"
|
group="api"
|
||||||
@@ -20,7 +85,7 @@
|
|||||||
<q-card-section>
|
<q-card-section>
|
||||||
<code><span class="text-light-green">GET</span> /api/v1/wallet</code>
|
<code><span class="text-light-green">GET</span> /api/v1/wallet</code>
|
||||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
<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">
|
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||||
Returns 200 OK (application/json)
|
Returns 200 OK (application/json)
|
||||||
</h5>
|
</h5>
|
||||||
@@ -30,12 +95,13 @@
|
|||||||
>
|
>
|
||||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||||
<code
|
<code
|
||||||
>curl {{ request.base_url }}api/v1/wallet -H "X-Api-Key:
|
>curl <span v-text="baseUrl"></span>api/v1/wallet -H "X-Api-Key:
|
||||||
<i>{{ wallet.inkey }}</i>"</code
|
<i v-text="wallet.inkey"></i>"</code
|
||||||
>
|
>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-card>
|
</q-card>
|
||||||
</q-expansion-item>
|
</q-expansion-item>
|
||||||
|
|
||||||
<q-expansion-item
|
<q-expansion-item
|
||||||
group="api"
|
group="api"
|
||||||
dense
|
dense
|
||||||
@@ -46,7 +112,7 @@
|
|||||||
<q-card-section>
|
<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</code>
|
||||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
<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>
|
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||||
<code
|
<code
|
||||||
>{"out": false, "amount": <int>, "memo": <string>,
|
>{"out": false, "amount": <int>, "memo": <string>,
|
||||||
@@ -62,9 +128,10 @@
|
|||||||
>
|
>
|
||||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||||
<code
|
<code
|
||||||
>curl -X POST {{ request.base_url }}api/v1/payments -d '{"out": false,
|
>curl -X POST <span v-text="baseUrl"></span>api/v1/payments -d
|
||||||
"amount": <int>, "memo": <string>}' -H "X-Api-Key:
|
'{"out": false, "amount": <int>, "memo": <string>}' -H
|
||||||
<i>{{ wallet.inkey }}</i>" -H "Content-type: application/json"</code
|
"X-Api-Key: <i v-text="wallet.inkey"></i>" -H "Content-type:
|
||||||
|
application/json"</code
|
||||||
>
|
>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-card>
|
</q-card>
|
||||||
@@ -77,9 +144,23 @@
|
|||||||
>
|
>
|
||||||
<q-card>
|
<q-card>
|
||||||
<q-card-section>
|
<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>
|
<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>
|
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||||
<code>{"out": true, "bolt11": <string>}</code>
|
<code>{"out": true, "bolt11": <string>}</code>
|
||||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||||
@@ -88,10 +169,10 @@
|
|||||||
<code>{"payment_hash": <string>}</code>
|
<code>{"payment_hash": <string>}</code>
|
||||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||||
<code
|
<code
|
||||||
>curl -X POST {{ request.base_url }}api/v1/payments -d '{"out": true,
|
>curl -X POST <span v-text="baseUrl"></span>api/v1/payments -d
|
||||||
"bolt11": <string>}' -H "X-Api-Key:
|
'{"out": true, "bolt11": <string>}' -H "X-Api-Key:
|
||||||
<i>{{ wallet.adminkey }}"</i> -H "Content-type:
|
<i v-text="adminkeyHidden ? '****************' : wallet.adminkey"></i
|
||||||
application/json"</code
|
>" -H "Content-type: application/json"</code
|
||||||
>
|
>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-card>
|
</q-card>
|
||||||
@@ -109,18 +190,16 @@
|
|||||||
><span class="text-light-green">POST</span>
|
><span class="text-light-green">POST</span>
|
||||||
/api/v1/payments/decode</code
|
/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>
|
<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">
|
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||||
Returns 200 (application/json)
|
Returns 200 (application/json)
|
||||||
</h5>
|
</h5>
|
||||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||||
<code
|
<code
|
||||||
>curl -X POST {{ request.base_url }}api/v1/payments/decode -d
|
>curl -X POST <span v-text="baseUrl"></span>api/v1/payments/decode -d
|
||||||
'{"data": <bolt11/lnurl, string>}' -H "X-Api-Key:
|
'{"data": <bolt11/lnurl, string>}' -H "Content-type:
|
||||||
<i>{{ wallet.inkey }}</i>" -H "Content-type: application/json"</code
|
application/json"</code
|
||||||
>
|
>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-card>
|
</q-card>
|
||||||
@@ -146,9 +225,10 @@
|
|||||||
<code>{"paid": <bool>}</code>
|
<code>{"paid": <bool>}</code>
|
||||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||||
<code
|
<code
|
||||||
>curl -X GET {{ request.base_url
|
>curl -X GET
|
||||||
}}api/v1/payments/<payment_hash> -H "X-Api-Key:
|
<span v-text="baseUrl"></span>api/v1/payments/<payment_hash> -H
|
||||||
<i>{{ wallet.inkey }}"</i> -H "Content-type: application/json"</code
|
"X-Api-Key: <i v-text="wallet.inkey"></i>" -H "Content-type:
|
||||||
|
application/json"</code
|
||||||
>
|
>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-card>
|
</q-card>
|
||||||
|
|||||||
@@ -26,7 +26,9 @@
|
|||||||
</q-tabs>
|
</q-tabs>
|
||||||
<q-tab-panels v-model="tab">
|
<q-tab-panels v-model="tab">
|
||||||
<q-tab-panel name="user">
|
<q-tab-panel name="user">
|
||||||
<div v-if="passwordData.show">
|
<div v-if="credentialsData.show">
|
||||||
|
<q-separator></q-separator>
|
||||||
|
|
||||||
<q-card-section>
|
<q-card-section>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
@@ -36,19 +38,26 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<q-img
|
<q-img
|
||||||
v-if="user.config.picture"
|
v-if="user.extra.picture"
|
||||||
style="max-width: 100px"
|
style="max-width: 100px"
|
||||||
:src="user.config.picture"
|
:src="user.extra.picture"
|
||||||
class="float-right"
|
class="float-right"
|
||||||
></q-img>
|
></q-img>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
<q-separator></q-separator>
|
|
||||||
<q-card-section>
|
<q-card-section>
|
||||||
|
<q-input
|
||||||
|
v-model="credentialsData.username"
|
||||||
|
:label="$t('username')"
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
:readonly="hasUsername"
|
||||||
|
class="q-mb-md"
|
||||||
|
></q-input>
|
||||||
<q-input
|
<q-input
|
||||||
v-if="user.has_password"
|
v-if="user.has_password"
|
||||||
v-model="passwordData.oldPassword"
|
v-model="credentialsData.oldPassword"
|
||||||
type="password"
|
type="password"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
label="Old Password"
|
label="Old Password"
|
||||||
@@ -57,7 +66,7 @@
|
|||||||
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password')]"
|
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password')]"
|
||||||
></q-input>
|
></q-input>
|
||||||
<q-input
|
<q-input
|
||||||
v-model="passwordData.newPassword"
|
v-model="credentialsData.newPassword"
|
||||||
type="password"
|
type="password"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
:label="$t('password')"
|
:label="$t('password')"
|
||||||
@@ -66,7 +75,7 @@
|
|||||||
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password')]"
|
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password')]"
|
||||||
></q-input>
|
></q-input>
|
||||||
<q-input
|
<q-input
|
||||||
v-model="passwordData.newPasswordRepeat"
|
v-model="credentialsData.newPasswordRepeat"
|
||||||
type="password"
|
type="password"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
:label="$t('password_repeat')"
|
:label="$t('password_repeat')"
|
||||||
@@ -75,24 +84,47 @@
|
|||||||
class="q-mb-md"
|
class="q-mb-md"
|
||||||
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password')]"
|
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password')]"
|
||||||
></q-input>
|
></q-input>
|
||||||
</q-card-section>
|
|
||||||
<q-separator></q-separator>
|
|
||||||
<q-card-section class="q-pb-lg">
|
|
||||||
<q-btn
|
<q-btn
|
||||||
@click="updatePassword"
|
@click="updatePassword"
|
||||||
:disable="(!passwordData.newPassword || !passwordData.newPasswordRepeat) || passwordData.newPassword !== passwordData.newPasswordRepeat"
|
:disable="disableUpdatePassword()"
|
||||||
unelevated
|
unelevated
|
||||||
color="primary"
|
color="primary"
|
||||||
|
class="float-right"
|
||||||
:label="$t('change_password')"
|
:label="$t('change_password')"
|
||||||
>
|
>
|
||||||
</q-btn>
|
</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
|
<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')"
|
:label="$t('back')"
|
||||||
outline
|
outline
|
||||||
unelevated
|
unelevated
|
||||||
color="grey"
|
color="grey"
|
||||||
class="float-right"
|
|
||||||
></q-btn>
|
></q-btn>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
</div>
|
</div>
|
||||||
@@ -101,9 +133,9 @@
|
|||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<q-img
|
<q-img
|
||||||
v-if="user.config.picture"
|
v-if="user.extra.picture"
|
||||||
style="max-width: 100px"
|
style="max-width: 100px"
|
||||||
:src="user.config.picture"
|
:src="user.extra.picture"
|
||||||
class="float-right"
|
class="float-right"
|
||||||
></q-img>
|
></q-img>
|
||||||
</div>
|
</div>
|
||||||
@@ -137,6 +169,15 @@
|
|||||||
class="q-mb-md"
|
class="q-mb-md"
|
||||||
>
|
>
|
||||||
</q-input>
|
</q-input>
|
||||||
|
<q-input
|
||||||
|
v-model="user.pubkey"
|
||||||
|
:label="$t('pubkey')"
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
readonly
|
||||||
|
class="q-mb-md"
|
||||||
|
>
|
||||||
|
</q-input>
|
||||||
<q-input
|
<q-input
|
||||||
v-model="user.email"
|
v-model="user.email"
|
||||||
:label="$t('email')"
|
:label="$t('email')"
|
||||||
@@ -195,9 +236,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
|
|
||||||
<q-card-section v-if="user.config">
|
<q-card-section v-if="user.extra">
|
||||||
<q-input
|
<q-input
|
||||||
v-model="user.config.first_name"
|
v-model="user.extra.first_name"
|
||||||
:label="$t('first_name')"
|
:label="$t('first_name')"
|
||||||
filled
|
filled
|
||||||
dense
|
dense
|
||||||
@@ -205,7 +246,7 @@
|
|||||||
>
|
>
|
||||||
</q-input>
|
</q-input>
|
||||||
<q-input
|
<q-input
|
||||||
v-model="user.config.last_name"
|
v-model="user.extra.last_name"
|
||||||
:label="$t('last_name')"
|
:label="$t('last_name')"
|
||||||
filled
|
filled
|
||||||
dense
|
dense
|
||||||
@@ -213,7 +254,7 @@
|
|||||||
>
|
>
|
||||||
</q-input>
|
</q-input>
|
||||||
<q-input
|
<q-input
|
||||||
v-model="user.config.provider"
|
v-model="user.extra.provider"
|
||||||
:label="$t('auth_provider')"
|
:label="$t('auth_provider')"
|
||||||
filled
|
filled
|
||||||
dense
|
dense
|
||||||
@@ -222,10 +263,9 @@
|
|||||||
>
|
>
|
||||||
</q-input>
|
</q-input>
|
||||||
<q-input
|
<q-input
|
||||||
v-model="user.config.picture"
|
v-model="user.extra.picture"
|
||||||
:label="$t('picture')"
|
:label="$t('picture')"
|
||||||
filled
|
filled
|
||||||
dense
|
|
||||||
class="q-mb-md"
|
class="q-mb-md"
|
||||||
>
|
>
|
||||||
</q-input>
|
</q-input>
|
||||||
@@ -236,11 +276,10 @@
|
|||||||
<span v-text="$t('update_account')"></span>
|
<span v-text="$t('update_account')"></span>
|
||||||
</q-btn>
|
</q-btn>
|
||||||
<q-btn
|
<q-btn
|
||||||
@click="showChangePassword()"
|
@click="showUpdateCredentials()"
|
||||||
:label="user.has_password ? $t('change_password'): $t('set_password')"
|
:label="$t('update_credentials')"
|
||||||
outline
|
filled
|
||||||
unelevated
|
color="primary"
|
||||||
color="grey"
|
|
||||||
class="float-right"
|
class="float-right"
|
||||||
></q-btn>
|
></q-btn>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
@@ -373,6 +412,27 @@
|
|||||||
</q-btn>
|
</q-btn>
|
||||||
</div>
|
</div>
|
||||||
</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="row q-mb-md">
|
||||||
<div class="col-4">
|
<div class="col-4">
|
||||||
<span v-text="$t('toggle_darkmode')"></span>
|
<span v-text="$t('toggle_darkmode')"></span>
|
||||||
@@ -392,6 +452,23 @@
|
|||||||
</q-btn>
|
</q-btn>
|
||||||
</div>
|
</div>
|
||||||
</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="row q-mb-md">
|
||||||
<div class="col-4">Notifications</div>
|
<div class="col-4">Notifications</div>
|
||||||
<div class="col-8">
|
<div class="col-8">
|
||||||
@@ -410,7 +487,7 @@
|
|||||||
v-model="reactionChoice"
|
v-model="reactionChoice"
|
||||||
:options="reactionOptions"
|
:options="reactionOptions"
|
||||||
label="Reactions"
|
label="Reactions"
|
||||||
@input="reactionChoiceFunc"
|
@update:model-value="reactionChoiceFunc"
|
||||||
>
|
>
|
||||||
<q-tooltip
|
<q-tooltip
|
||||||
><span v-text="$t('payment_reactions')"></span
|
><span v-text="$t('payment_reactions')"></span
|
||||||
|
|||||||
@@ -23,26 +23,14 @@
|
|||||||
<div class="q-pa-xs">
|
<div class="q-pa-xs">
|
||||||
<div class="q-gutter-y-md">
|
<div class="q-gutter-y-md">
|
||||||
<q-tabs
|
<q-tabs
|
||||||
v-model="tab"
|
:model-value="tab"
|
||||||
@input="handleTabChanged"
|
@update:model-value="handleTabChanged"
|
||||||
active-color="primary"
|
active-color="primary"
|
||||||
align="left"
|
align="left"
|
||||||
>
|
>
|
||||||
<q-tab
|
<q-tab name="installed" :label="$t('installed')"></q-tab>
|
||||||
name="installed"
|
<q-tab name="all" :label="$t('all')"></q-tab>
|
||||||
:label="$t('installed')"
|
<q-tab name="featured" :label="$t('featured')"></q-tab>
|
||||||
@update="val => tab = val.name"
|
|
||||||
></q-tab>
|
|
||||||
<q-tab
|
|
||||||
name="all"
|
|
||||||
:label="$t('all')"
|
|
||||||
@update="val => tab = val.name"
|
|
||||||
></q-tab>
|
|
||||||
<q-tab
|
|
||||||
name="featured"
|
|
||||||
:label="$t('featured')"
|
|
||||||
@update="val => tab = val.name"
|
|
||||||
></q-tab>
|
|
||||||
<i
|
<i
|
||||||
v-if="!g.user.admin && tab != 'installed'"
|
v-if="!g.user.admin && tab != 'installed'"
|
||||||
v-text="$t('only_admins_can_install')"
|
v-text="$t('only_admins_can_install')"
|
||||||
@@ -117,9 +105,8 @@
|
|||||||
v-if="extension.isAvailable && extension.isInstalled && g.user.admin"
|
v-if="extension.isAvailable && extension.isInstalled && g.user.admin"
|
||||||
:label="extension.isActive ? $t('activated'): $t('deactivated') "
|
:label="extension.isActive ? $t('activated'): $t('deactivated') "
|
||||||
color="secondary"
|
color="secondary"
|
||||||
style=""
|
|
||||||
v-model="extension.isActive"
|
v-model="extension.isActive"
|
||||||
@input="toggleExtension(extension)"
|
@update:model-value="toggleExtension(extension)"
|
||||||
><q-tooltip>
|
><q-tooltip>
|
||||||
|
|
||||||
<span
|
<span
|
||||||
@@ -471,6 +458,7 @@
|
|||||||
filled
|
filled
|
||||||
dense
|
dense
|
||||||
emit-value
|
emit-value
|
||||||
|
map-options
|
||||||
v-model="release.wallet"
|
v-model="release.wallet"
|
||||||
:options="g.user.walletOptions"
|
:options="g.user.walletOptions"
|
||||||
:label="$t('wallet_required')"
|
:label="$t('wallet_required')"
|
||||||
@@ -607,6 +595,7 @@
|
|||||||
filled
|
filled
|
||||||
dense
|
dense
|
||||||
emit-value
|
emit-value
|
||||||
|
map-options
|
||||||
v-model="selectedExtension.payToEnable.wallet"
|
v-model="selectedExtension.payToEnable.wallet"
|
||||||
:options="g.user.walletOptions"
|
:options="g.user.walletOptions"
|
||||||
label="Wallet *"
|
label="Wallet *"
|
||||||
@@ -669,11 +658,9 @@
|
|||||||
<a
|
<a
|
||||||
:href="'lightning:' + selectedExtension.payToEnable.paymentRequest"
|
:href="'lightning:' + selectedExtension.payToEnable.paymentRequest"
|
||||||
>
|
>
|
||||||
<q-responsive :ratio="1" class="q-mx-xl">
|
<lnbits-qrcode
|
||||||
<lnbits-qrcode
|
:value="'lightning:' + selectedExtension.payToEnable.paymentRequest.toUpperCase()"
|
||||||
:value="'lightning:' + selectedExtension.payToEnable.paymentRequest.toUpperCase()"
|
></lnbits-qrcode>
|
||||||
></lnbits-qrcode>
|
|
||||||
</q-responsive>
|
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="col">
|
<div v-else class="col">
|
||||||
@@ -721,6 +708,7 @@
|
|||||||
dense
|
dense
|
||||||
v-model="selectedExtension.payToEnable.paymentWallet"
|
v-model="selectedExtension.payToEnable.paymentWallet"
|
||||||
emit-value
|
emit-value
|
||||||
|
map-options
|
||||||
:options="g.user.walletOptions"
|
:options="g.user.walletOptions"
|
||||||
:label="$t('wallet_required')"
|
:label="$t('wallet_required')"
|
||||||
class="q-mt-sm"
|
class="q-mt-sm"
|
||||||
@@ -796,7 +784,7 @@
|
|||||||
swipeable
|
swipeable
|
||||||
animated
|
animated
|
||||||
v-model="slide"
|
v-model="slide"
|
||||||
:fullscreen.sync="fullscreen"
|
v-model:fullscreen="fullscreen"
|
||||||
thumbnails
|
thumbnails
|
||||||
infinite
|
infinite
|
||||||
:autoplay="autoplay"
|
:autoplay="autoplay"
|
||||||
@@ -898,7 +886,7 @@
|
|||||||
</q-dialog>
|
</q-dialog>
|
||||||
{% endblock %} {% block scripts %} {{ window_vars(user) }}
|
{% endblock %} {% block scripts %} {{ window_vars(user) }}
|
||||||
<script>
|
<script>
|
||||||
new Vue({
|
window.app = Vue.createApp({
|
||||||
el: '#vue',
|
el: '#vue',
|
||||||
|
|
||||||
data: function () {
|
data: function () {
|
||||||
@@ -1029,12 +1017,16 @@
|
|||||||
this.filteredExtensions = this.extensions.concat([])
|
this.filteredExtensions = this.extensions.concat([])
|
||||||
this.handleTabChanged('installed')
|
this.handleTabChanged('installed')
|
||||||
this.tab = 'installed'
|
this.tab = 'installed'
|
||||||
this.$q.notify({
|
Quasar.Notify.create({
|
||||||
type: 'positive',
|
type: 'positive',
|
||||||
message: 'Extension uninstalled!'
|
message: 'Extension uninstalled!'
|
||||||
})
|
})
|
||||||
if (this.uninstallAndDropDb) {
|
if (this.uninstallAndDropDb) {
|
||||||
this.showDropDb()
|
this.showDropDb()
|
||||||
|
} else {
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.reload()
|
||||||
|
}, 300)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
@@ -1059,17 +1051,20 @@
|
|||||||
extension.installedRelease = null
|
extension.installedRelease = null
|
||||||
extension.inProgress = false
|
extension.inProgress = false
|
||||||
extension.hasDatabaseTables = false
|
extension.hasDatabaseTables = false
|
||||||
this.$q.notify({
|
Quasar.Notify.create({
|
||||||
type: 'positive',
|
type: 'positive',
|
||||||
message: 'Extension DB deleted!'
|
message: 'Extension DB deleted!'
|
||||||
})
|
})
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.reload()
|
||||||
|
}, 300)
|
||||||
})
|
})
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
LNbits.utils.notifyApiError(err)
|
LNbits.utils.notifyApiError(err)
|
||||||
extension.inProgress = false
|
extension.inProgress = false
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
toggleExtension: function (extension) {
|
toggleExtension(extension) {
|
||||||
const action = extension.isActive ? 'activate' : 'deactivate'
|
const action = extension.isActive ? 'activate' : 'deactivate'
|
||||||
LNbits.api
|
LNbits.api
|
||||||
.request(
|
.request(
|
||||||
@@ -1078,13 +1073,14 @@
|
|||||||
this.g.user.wallets[0].adminkey
|
this.g.user.wallets[0].adminkey
|
||||||
)
|
)
|
||||||
.then(response => {
|
.then(response => {
|
||||||
this.$q.notify({
|
Quasar.Notify.create({
|
||||||
type: 'positive',
|
type: 'positive',
|
||||||
message: `Extension '${extension.id}' ${action}d!`
|
message: `Extension '${extension.id}' ${action}d!`
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
LNbits.utils.notifyApiError(err)
|
LNbits.utils.notifyApiError(err)
|
||||||
|
extension.isActive = false
|
||||||
extension.inProgress = false
|
extension.inProgress = false
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@@ -1103,7 +1099,7 @@
|
|||||||
this.g.user.wallets[0].adminkey
|
this.g.user.wallets[0].adminkey
|
||||||
)
|
)
|
||||||
.then(response => {
|
.then(response => {
|
||||||
this.$q.notify({
|
Quasar.Notify.create({
|
||||||
type: 'positive',
|
type: 'positive',
|
||||||
message: 'Extension enabled!'
|
message: 'Extension enabled!'
|
||||||
})
|
})
|
||||||
@@ -1124,7 +1120,7 @@
|
|||||||
this.g.user.wallets[0].adminkey
|
this.g.user.wallets[0].adminkey
|
||||||
)
|
)
|
||||||
.then(response => {
|
.then(response => {
|
||||||
this.$q.notify({
|
Quasar.Notify.create({
|
||||||
type: 'positive',
|
type: 'positive',
|
||||||
message: 'Extension disabled!'
|
message: 'Extension disabled!'
|
||||||
})
|
})
|
||||||
@@ -1157,7 +1153,7 @@
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
.then(response => {
|
.then(response => {
|
||||||
this.$q.notify({
|
Quasar.Notify.create({
|
||||||
type: 'positive',
|
type: 'positive',
|
||||||
message: 'Payment info updated!'
|
message: 'Payment info updated!'
|
||||||
})
|
})
|
||||||
@@ -1339,7 +1335,7 @@
|
|||||||
ws.addEventListener('message', async ({data}) => {
|
ws.addEventListener('message', async ({data}) => {
|
||||||
const payment = JSON.parse(data)
|
const payment = JSON.parse(data)
|
||||||
if (payment.pending === false) {
|
if (payment.pending === false) {
|
||||||
this.$q.notify({
|
Quasar.Notify.create({
|
||||||
type: 'positive',
|
type: 'positive',
|
||||||
message: 'Invoice Paid!'
|
message: 'Invoice Paid!'
|
||||||
})
|
})
|
||||||
@@ -1408,13 +1404,13 @@
|
|||||||
this.paylinkWebsocket.addEventListener('message', async ({data}) => {
|
this.paylinkWebsocket.addEventListener('message', async ({data}) => {
|
||||||
const resp = JSON.parse(data)
|
const resp = JSON.parse(data)
|
||||||
if (resp.paid) {
|
if (resp.paid) {
|
||||||
this.$q.notify({
|
Quasar.Notify.create({
|
||||||
type: 'positive',
|
type: 'positive',
|
||||||
message: 'Invoice Paid!'
|
message: 'Invoice Paid!'
|
||||||
})
|
})
|
||||||
this.installExtension(this.selectedRelease)
|
this.installExtension(this.selectedRelease)
|
||||||
} else {
|
} else {
|
||||||
this.$q.notify({
|
Quasar.Notify.create({
|
||||||
type: 'warning',
|
type: 'warning',
|
||||||
message: 'Invoice tracking lost!'
|
message: 'Invoice tracking lost!'
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -97,9 +97,9 @@
|
|||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<script>
|
<script>
|
||||||
new Vue({
|
window.app = Vue.createApp({
|
||||||
el: '#vue',
|
el: '#vue',
|
||||||
mixins: [windowMixin],
|
mixins: [window.windowMixin],
|
||||||
data: function () {
|
data: function () {
|
||||||
return {
|
return {
|
||||||
loginData: {
|
loginData: {
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
color="primary"
|
color="primary"
|
||||||
@click="processing"
|
@click="processing"
|
||||||
type="a"
|
type="a"
|
||||||
href="{{ url_for('core.lnurlwallet') }}?lightning={{ lnurl }}"
|
href="/lnurlwallet?lightning={{ lnurl }}"
|
||||||
v-text="$t('press_to_claim')"
|
v-text="$t('press_to_claim')"
|
||||||
class="full-width"
|
class="full-width"
|
||||||
></q-btn>
|
></q-btn>
|
||||||
@@ -184,6 +184,47 @@
|
|||||||
</div>
|
</div>
|
||||||
</q-form>
|
</q-form>
|
||||||
</q-card-section>
|
</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 %}
|
{%endif%} {% if LNBITS_NEW_ACCOUNTS_ALLOWED %}
|
||||||
<q-card-section
|
<q-card-section
|
||||||
v-if="authAction === 'register' && authMethod === 'user-id-only'"
|
v-if="authAction === 'register' && authMethod === 'user-id-only'"
|
||||||
@@ -230,7 +271,28 @@
|
|||||||
v-if="authAction === 'login' && authMethod === 'username-password'"
|
v-if="authAction === 'login' && authMethod === 'username-password'"
|
||||||
>
|
>
|
||||||
<div class="row">
|
<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">
|
<div class="col-12 full-width q-pa-sm">
|
||||||
<q-btn
|
<q-btn
|
||||||
href="/api/v1/auth/google"
|
href="/api/v1/auth/google"
|
||||||
@@ -484,6 +546,32 @@
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</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="row">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<a
|
<a
|
||||||
@@ -519,7 +607,18 @@
|
|||||||
></q-img>
|
></q-img>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<script src="{{ static_url_for('static', 'js/wallet.js') }}"></script>
|
<script src="{{ static_url_for('static', 'js/wallet.js') }}"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
<!---->
|
<!---->
|
||||||
{% block title %} {{ wallet.name }} - {{ SITE_TITLE }} {% endblock %}
|
{% block title %}{{ wallet_name }} - {{ SITE_TITLE }} {% endblock %}
|
||||||
<!---->
|
<!---->
|
||||||
{% block page %}
|
{% block page %}
|
||||||
<div class="row q-col-gutter-md">
|
<div class="row q-col-gutter-md">
|
||||||
@@ -36,9 +36,8 @@
|
|||||||
<q-card-section>
|
<q-card-section>
|
||||||
<h3 class="q-my-none text-no-wrap">
|
<h3 class="q-my-none text-no-wrap">
|
||||||
<strong v-text="formattedBalance"></strong>
|
<strong v-text="formattedBalance"></strong>
|
||||||
<small>{{LNBITS_DENOMINATION}}</small>
|
<small> {{LNBITS_DENOMINATION}}</small>
|
||||||
<lnbits-update-balance
|
<lnbits-update-balance
|
||||||
v-if="'{{user.super_user}}' == 'True'"
|
|
||||||
:wallet_id="this.g.wallet.id"
|
:wallet_id="this.g.wallet.id"
|
||||||
flat
|
flat
|
||||||
:callback="updateBalanceCallback"
|
:callback="updateBalanceCallback"
|
||||||
@@ -102,11 +101,25 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</q-card>
|
</q-card>
|
||||||
<payment-list
|
<q-card
|
||||||
:update="updatePayments"
|
:style="
|
||||||
:wallet="this.g.wallet"
|
$q.screen.lt.md
|
||||||
:mobile-simple="mobileSimple"
|
? {
|
||||||
/>
|
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"
|
||||||
|
/>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
</div>
|
</div>
|
||||||
{% if HIDE_API %}
|
{% if HIDE_API %}
|
||||||
<div class="col-12 col-md-4 q-gutter-y-md">
|
<div class="col-12 col-md-4 q-gutter-y-md">
|
||||||
@@ -119,7 +132,7 @@
|
|||||||
<q-card-section>
|
<q-card-section>
|
||||||
<h6 class="text-subtitle1 q-mt-none q-mb-sm">
|
<h6 class="text-subtitle1 q-mt-none q-mb-sm">
|
||||||
{{ SITE_TITLE }} Wallet:
|
{{ SITE_TITLE }} Wallet:
|
||||||
<strong><em>{{wallet.name}}</em></strong>
|
<strong><em>{{wallet_name}}</em></strong>
|
||||||
</h6>
|
</h6>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
<q-card-section class="q-pa-none">
|
<q-card-section class="q-pa-none">
|
||||||
@@ -154,11 +167,17 @@
|
|||||||
<q-card>
|
<q-card>
|
||||||
<q-card-section class="text-center">
|
<q-card-section class="text-center">
|
||||||
<p v-text="$t('export_to_phone_desc')"></p>
|
<p v-text="$t('export_to_phone_desc')"></p>
|
||||||
<qrcode
|
<lnbits-qrcode :value="exportUrl"></lnbits-qrcode>
|
||||||
:value="'{{request.base_url}}wallet?usr={{user.id}}&wal={{wallet.id}}'"
|
|
||||||
:options="{width:240}"
|
|
||||||
></qrcode>
|
|
||||||
</q-card-section>
|
</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-card>
|
||||||
</q-expansion-item>
|
</q-expansion-item>
|
||||||
<q-separator></q-separator>
|
<q-separator></q-separator>
|
||||||
@@ -175,7 +194,6 @@
|
|||||||
v-model.trim="update.name"
|
v-model.trim="update.name"
|
||||||
label="Name"
|
label="Name"
|
||||||
dense
|
dense
|
||||||
@update:model-value="(e) => console.log(e)"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<q-btn
|
<q-btn
|
||||||
@@ -277,7 +295,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<q-dialog v-model="receive.show" position="top">
|
<q-dialog
|
||||||
|
v-model="receive.show"
|
||||||
|
position="top"
|
||||||
|
@hide="onReceiveDialogHide"
|
||||||
|
>
|
||||||
<q-card
|
<q-card
|
||||||
v-if="!receive.paymentReq"
|
v-if="!receive.paymentReq"
|
||||||
class="q-pa-lg q-pt-xl lnbits__dialog-card"
|
class="q-pa-lg q-pt-xl lnbits__dialog-card"
|
||||||
@@ -311,13 +333,11 @@
|
|||||||
<q-input
|
<q-input
|
||||||
ref="setAmount"
|
ref="setAmount"
|
||||||
filled
|
filled
|
||||||
|
:pattern="receive.unit === 'sat' ? '\\d*' : '\\d*\\.?\\d*'"
|
||||||
|
inputmode="numeric"
|
||||||
dense
|
dense
|
||||||
v-model.number="receive.data.amount"
|
v-model.number="receive.data.amount"
|
||||||
:label="$t('amount') + ' (' + receive.unit + ') *'"
|
: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]"
|
:min="receive.minMax[0]"
|
||||||
:max="receive.minMax[1]"
|
:max="receive.minMax[1]"
|
||||||
:readonly="receive.lnurl && receive.lnurl.fixed"
|
:readonly="receive.lnurl && receive.lnurl.fixed"
|
||||||
@@ -364,13 +384,28 @@
|
|||||||
>
|
>
|
||||||
<div class="text-center q-mb-lg">
|
<div class="text-center q-mb-lg">
|
||||||
<a :href="'lightning:' + receive.paymentReq">
|
<a :href="'lightning:' + receive.paymentReq">
|
||||||
<q-responsive :ratio="1" class="q-mx-xl">
|
<lnbits-qrcode
|
||||||
<lnbits-qrcode
|
:value="'lightning:' + receive.paymentReq.toUpperCase()"
|
||||||
:value="'lightning:' + receive.paymentReq.toUpperCase()"
|
></lnbits-qrcode>
|
||||||
></lnbits-qrcode>
|
|
||||||
</q-responsive>
|
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</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">
|
<div class="row q-mt-lg">
|
||||||
<q-btn
|
<q-btn
|
||||||
outline
|
outline
|
||||||
@@ -621,8 +656,8 @@
|
|||||||
<div v-else>
|
<div v-else>
|
||||||
<q-responsive :ratio="1">
|
<q-responsive :ratio="1">
|
||||||
<qrcode-stream
|
<qrcode-stream
|
||||||
@decode="decodeQR"
|
@detect="decodeQR"
|
||||||
@init="onInitQR"
|
@camera-on="onInitQR"
|
||||||
class="rounded-borders"
|
class="rounded-borders"
|
||||||
></qrcode-stream>
|
></qrcode-stream>
|
||||||
</q-responsive>
|
</q-responsive>
|
||||||
@@ -645,8 +680,8 @@
|
|||||||
<q-card class="q-pa-lg q-pt-xl">
|
<q-card class="q-pa-lg q-pt-xl">
|
||||||
<div class="text-center q-mb-lg">
|
<div class="text-center q-mb-lg">
|
||||||
<qrcode-stream
|
<qrcode-stream
|
||||||
@decode="decodeQR"
|
@detect="decodeQR"
|
||||||
@init="onInitQR"
|
@camera-on="onInitQR"
|
||||||
class="rounded-borders"
|
class="rounded-borders"
|
||||||
></qrcode-stream>
|
></qrcode-stream>
|
||||||
</div>
|
</div>
|
||||||
@@ -661,22 +696,28 @@
|
|||||||
</div>
|
</div>
|
||||||
</q-card>
|
</q-card>
|
||||||
</q-dialog>
|
</q-dialog>
|
||||||
|
<div
|
||||||
<q-tabs
|
|
||||||
class="lt-md fixed-bottom left-0 right-0 bg-primary text-white shadow-2 z-top"
|
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
|
<q-tabs
|
||||||
icon="file_download"
|
active-class="px-0"
|
||||||
@click="showReceiveDialog"
|
indicator-color="transparent"
|
||||||
:label="$t('receive')"
|
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
|
<q-btn
|
||||||
round
|
round
|
||||||
size="35px"
|
size="35px"
|
||||||
@@ -686,8 +727,7 @@
|
|||||||
class="text-white bg-primary z-top vertical-bottom absolute-center absolute"
|
class="text-white bg-primary z-top vertical-bottom absolute-center absolute"
|
||||||
>
|
>
|
||||||
</q-btn>
|
</q-btn>
|
||||||
</q-tabs>
|
</div>
|
||||||
|
|
||||||
<q-dialog v-model="disclaimerDialog.show" position="top">
|
<q-dialog v-model="disclaimerDialog.show" position="top">
|
||||||
<q-card class="q-pa-lg">
|
<q-card class="q-pa-lg">
|
||||||
<h6
|
<h6
|
||||||
|
|||||||
@@ -160,7 +160,7 @@
|
|||||||
<q-table
|
<q-table
|
||||||
dense
|
dense
|
||||||
flat
|
flat
|
||||||
:data="this.filteredChannels"
|
:rows="this.filteredChannels"
|
||||||
:filter="channels.filter"
|
:filter="channels.filter"
|
||||||
no-data-label="No channels opened"
|
no-data-label="No channels opened"
|
||||||
>
|
>
|
||||||
@@ -239,7 +239,7 @@
|
|||||||
<q-table
|
<q-table
|
||||||
dense
|
dense
|
||||||
flat
|
flat
|
||||||
:data="peers.data"
|
:rows="peers.data"
|
||||||
:filter="peers.filter"
|
:filter="peers.filter"
|
||||||
no-data-label="No transactions made yet"
|
no-data-label="No transactions made yet"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -51,11 +51,11 @@
|
|||||||
>
|
>
|
||||||
<a :href="'lightning:' + transactionDetailsDialog.data.bolt11">
|
<a :href="'lightning:' + transactionDetailsDialog.data.bolt11">
|
||||||
<q-responsive :ratio="1" class="q-mx-xl">
|
<q-responsive :ratio="1" class="q-mx-xl">
|
||||||
<qrcode
|
<qrcode-vue
|
||||||
:value="'lightning:' + transactionDetailsDialog.data.bolt11.toUpperCase()"
|
:value="'lightning:' + transactionDetailsDialog.data.bolt11.toUpperCase()"
|
||||||
:options="{width: 340}"
|
:options="{width: 340}"
|
||||||
class="rounded-borders"
|
class="rounded-borders"
|
||||||
></qrcode>
|
></qrcode-vue>
|
||||||
</q-responsive>
|
</q-responsive>
|
||||||
</a>
|
</a>
|
||||||
<q-btn
|
<q-btn
|
||||||
@@ -94,9 +94,9 @@
|
|||||||
<q-table
|
<q-table
|
||||||
dense
|
dense
|
||||||
flat
|
flat
|
||||||
:data="paymentsTable.data"
|
:rows="paymentsTable.data"
|
||||||
:columns="paymentsTable.columns"
|
:columns="paymentsTable.columns"
|
||||||
:pagination.sync="paymentsTable.pagination"
|
v-model:pagination="paymentsTable.pagination"
|
||||||
row-key="payment_hash"
|
row-key="payment_hash"
|
||||||
no-data-label="No transactions made yet"
|
no-data-label="No transactions made yet"
|
||||||
:filter="paymentsTable.filter"
|
:filter="paymentsTable.filter"
|
||||||
@@ -138,11 +138,11 @@
|
|||||||
>
|
>
|
||||||
<a :href="'lightning:' + props.row.bolt11">
|
<a :href="'lightning:' + props.row.bolt11">
|
||||||
<q-responsive :ratio="1" class="q-mx-xl">
|
<q-responsive :ratio="1" class="q-mx-xl">
|
||||||
<qrcode
|
<qrcode-vue
|
||||||
:value="'lightning:' + props.row.bolt11.toUpperCase()"
|
:value="'lightning:' + props.row.bolt11.toUpperCase()"
|
||||||
:options="{width: 340}"
|
:options="{width: 340}"
|
||||||
class="rounded-borders"
|
class="rounded-borders"
|
||||||
></qrcode>
|
></qrcode-vue>
|
||||||
</q-responsive>
|
</q-responsive>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -256,9 +256,9 @@
|
|||||||
<q-table
|
<q-table
|
||||||
dense
|
dense
|
||||||
flat
|
flat
|
||||||
:data="invoiceTable.data"
|
:rows="invoiceTable.data"
|
||||||
:columns="invoiceTable.columns"
|
:columns="invoiceTable.columns"
|
||||||
:pagination.sync="invoiceTable.pagination"
|
v-model:pagination="invoiceTable.pagination"
|
||||||
no-data-label="No transactions made yet"
|
no-data-label="No transactions made yet"
|
||||||
:filter="invoiceTable.filter"
|
:filter="invoiceTable.filter"
|
||||||
@request="getInvoices"
|
@request="getInvoices"
|
||||||
|
|||||||
@@ -42,12 +42,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% endblock %} {% block scripts %} {{ window_vars(user) }}
|
{% endblock %} {% block scripts %} {{ window_vars(user) }}
|
||||||
<script src="{{ static_url_for('static', 'js/node.js') }}"></script>
|
|
||||||
<script>
|
<script>
|
||||||
Vue.component(VueQrcode.name, VueQrcode)
|
window.app = Vue.createApp({
|
||||||
Vue.use(VueQrcodeReader)
|
|
||||||
|
|
||||||
new Vue({
|
|
||||||
el: '#vue',
|
el: '#vue',
|
||||||
config: {
|
config: {
|
||||||
globalProperties: {
|
globalProperties: {
|
||||||
@@ -55,7 +51,7 @@
|
|||||||
msg: 'hello'
|
msg: 'hello'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mixins: [windowMixin],
|
mixins: [window.windowMixin],
|
||||||
data: function () {
|
data: function () {
|
||||||
return {
|
return {
|
||||||
isSuperUser: false,
|
isSuperUser: false,
|
||||||
@@ -319,7 +315,7 @@
|
|||||||
.confirmDialog('Do you really wanna disconnect this peer?')
|
.confirmDialog('Do you really wanna disconnect this peer?')
|
||||||
.onOk(() => {
|
.onOk(() => {
|
||||||
this.api('DELETE', `/peers/${id}`).then(response => {
|
this.api('DELETE', `/peers/${id}`).then(response => {
|
||||||
this.$q.notify({
|
Quasar.Notify.create({
|
||||||
message: 'Disconnected',
|
message: 'Disconnected',
|
||||||
icon: null
|
icon: null
|
||||||
})
|
})
|
||||||
@@ -367,10 +363,13 @@
|
|||||||
this.transactionDetailsDialog.data = details
|
this.transactionDetailsDialog.data = details
|
||||||
console.log('details', details)
|
console.log('details', details)
|
||||||
},
|
},
|
||||||
exportCSV: function () {},
|
shortenNodeId(nodeId) {
|
||||||
shortenNodeId
|
return nodeId
|
||||||
|
? nodeId.substring(0, 5) + '...' + nodeId.substring(nodeId.length - 5)
|
||||||
|
: '...'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
<script src="{{ static_url_for('static', 'js/node.js') }}"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -53,10 +53,7 @@ context %} {% block page %}
|
|||||||
{% endblock %} {% block scripts %} {{ window_vars(user) }}
|
{% endblock %} {% block scripts %} {{ window_vars(user) }}
|
||||||
<script src="{{ static_url_for('static', 'js/node.js') }}"></script>
|
<script src="{{ static_url_for('static', 'js/node.js') }}"></script>
|
||||||
<script>
|
<script>
|
||||||
Vue.component(VueQrcode.name, VueQrcode)
|
window.app = Vue.createApp({
|
||||||
Vue.use(VueQrcodeReader)
|
|
||||||
|
|
||||||
new Vue({
|
|
||||||
el: '#vue',
|
el: '#vue',
|
||||||
config: {
|
config: {
|
||||||
globalProperties: {
|
globalProperties: {
|
||||||
@@ -64,7 +61,7 @@ context %} {% block page %}
|
|||||||
msg: 'hello'
|
msg: 'hello'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mixins: [windowMixin],
|
mixins: [window.windowMixin],
|
||||||
data: function () {
|
data: function () {
|
||||||
return {
|
return {
|
||||||
isSuperUser: false,
|
isSuperUser: false,
|
||||||
|
|||||||
@@ -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-dialog v-model="createWalletDialog.show" position="top">
|
||||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
<q-card class="q-pa-md q-pt-md lnbits__dialog-card">
|
||||||
<p>Create Wallet</p>
|
<strong>Create Wallet</strong>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<q-form @submit="createWallet">
|
<div class="row q-mt-lg">
|
||||||
<lnbits-dynamic-fields
|
<div class="col">
|
||||||
:options="createWalletDialog.fields"
|
<q-input
|
||||||
v-model="createUserDialog.data"
|
v-model="createWalletDialog.data.name"
|
||||||
></lnbits-dynamic-fields>
|
:label='$t("name_your_wallet")'
|
||||||
<div class="row q-mt-lg">
|
filled
|
||||||
<q-btn v-close-popup unelevated color="primary" type="submit"
|
dense
|
||||||
>Create</q-btn
|
class="q-mb-md"
|
||||||
>
|
|
||||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
|
|
||||||
>Cancel</q-btn
|
|
||||||
>
|
>
|
||||||
|
</q-input>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</q-card>
|
</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>
|
||||||
+87
-44
@@ -1,12 +1,38 @@
|
|||||||
<q-dialog v-model="walletDialog.show">
|
<div v-if="paymentPage.show">
|
||||||
<q-card class="q-pa-lg" style="width: 700px; max-width: 80vw">
|
<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>
|
<h2 class="text-h6 q-mb-md">Wallets</h2>
|
||||||
<q-dialog v-model="paymentDialog.show">
|
<q-table :rows="wallets" :columns="walletTable.columns">
|
||||||
<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">
|
|
||||||
<template v-slot:header="props">
|
<template v-slot:header="props">
|
||||||
<q-tr :props="props">
|
<q-tr :props="props">
|
||||||
<q-th auto-width></q-th>
|
<q-th auto-width></q-th>
|
||||||
@@ -22,6 +48,13 @@
|
|||||||
<template v-slot:body="props">
|
<template v-slot:body="props">
|
||||||
<q-tr :props="props">
|
<q-tr :props="props">
|
||||||
<q-td auto-width>
|
<q-td auto-width>
|
||||||
|
<q-btn
|
||||||
|
:label="$t('topup')"
|
||||||
|
@click="showTopupDialog(props.row.id)"
|
||||||
|
color="secondary"
|
||||||
|
size="sm"
|
||||||
|
class="q-mr-md"
|
||||||
|
></q-btn>
|
||||||
<q-btn
|
<q-btn
|
||||||
round
|
round
|
||||||
icon="menu"
|
icon="menu"
|
||||||
@@ -31,27 +64,14 @@
|
|||||||
>
|
>
|
||||||
<q-tooltip>Show Payments</q-tooltip>
|
<q-tooltip>Show Payments</q-tooltip>
|
||||||
</q-btn>
|
</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
|
<q-btn
|
||||||
round
|
round
|
||||||
v-if="!props.row.deleted"
|
v-if="!props.row.deleted"
|
||||||
icon="vpn_key"
|
icon="vpn_key"
|
||||||
size="sm"
|
size="sm"
|
||||||
color="primary"
|
color="primary"
|
||||||
|
class="q-ml-xs"
|
||||||
@click="copyText(props.row.adminkey)"
|
@click="copyText(props.row.adminkey)"
|
||||||
>
|
>
|
||||||
<q-tooltip>Copy Admin Key</q-tooltip>
|
<q-tooltip>Copy Admin Key</q-tooltip>
|
||||||
@@ -62,45 +82,68 @@
|
|||||||
icon="vpn_key"
|
icon="vpn_key"
|
||||||
size="sm"
|
size="sm"
|
||||||
color="secondary"
|
color="secondary"
|
||||||
|
class="q-ml-xs"
|
||||||
@click="copyText(props.row.inkey)"
|
@click="copyText(props.row.inkey)"
|
||||||
>
|
>
|
||||||
<q-tooltip>Copy Invoice Key</q-tooltip>
|
<q-tooltip>Copy Invoice Key</q-tooltip>
|
||||||
</q-btn>
|
</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
|
<q-btn
|
||||||
round
|
round
|
||||||
v-if="props.row.deleted"
|
v-if="props.row.deleted"
|
||||||
icon="toggle_off"
|
icon="toggle_off"
|
||||||
size="sm"
|
size="sm"
|
||||||
color="secondary"
|
color="secondary"
|
||||||
|
class="q-ml-xs"
|
||||||
@click="undeleteUserWallet(props.row.user, props.row.id)"
|
@click="undeleteUserWallet(props.row.user, props.row.id)"
|
||||||
>
|
>
|
||||||
<q-tooltip>Undelete Wallet</q-tooltip>
|
<q-tooltip>Undelete Wallet</q-tooltip>
|
||||||
</q-btn>
|
</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>
|
||||||
<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="props.row.currency"></q-td>
|
||||||
<q-td auto-width v-text="formatSat(props.row.balance_msat)"></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>
|
</q-tr>
|
||||||
</template>
|
</template>
|
||||||
</q-table>
|
</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-card>
|
||||||
</q-dialog>
|
</div>
|
||||||
@@ -1,33 +1,30 @@
|
|||||||
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
|
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
|
||||||
%} {% block page %} {% include "users/_walletDialog.html" %} {% include
|
%} {% block page %}{% include "users/_topupDialog.html" %}
|
||||||
"users/_topupDialog.html" %} {% include "users/_createUserDialog.html" %} {%
|
|
||||||
include "users/_createWalletDialog.html" %}
|
|
||||||
|
|
||||||
<h3 class="text-subtitle q-my-none" v-text="$t('users')"></h3>
|
|
||||||
|
|
||||||
<div class="row q-col-gutter-md justify-center">
|
<div class="row q-col-gutter-md justify-center">
|
||||||
<div class="col q-gutter-y-md" style="width: 300px">
|
<div class="col">
|
||||||
<div style="width: 600px">
|
{% include "users/_manageWallet.html" %}
|
||||||
<canvas ref="chart1"></canvas>
|
<div v-if="activeUser.show" class="row">
|
||||||
|
<div class="col-12 col-md-6">{%include "users/_manageUser.html" %}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div v-else-if="activeWallet.show">
|
||||||
</div>
|
{%include "users/_createWalletDialog.html" %}
|
||||||
<div class="row q-col-gutter-md justify-center">
|
</div>
|
||||||
<div class="col q-gutter-y-md">
|
<div v-else>
|
||||||
<q-card>
|
<q-btn
|
||||||
<q-card-section>
|
@click="showAccountPage()"
|
||||||
<div class="row items-center no-wrap q-mb-sm">
|
:label="$t('create_account')"
|
||||||
<q-btn :label="$t('topup')" @click="topupDialog.show = true">
|
color="primary"
|
||||||
<q-tooltip
|
class="q-mb-md"
|
||||||
>{%raw%}{{ $t('add_funds_tooltip') }}{%endraw%}</q-tooltip
|
>
|
||||||
>
|
</q-btn>
|
||||||
</q-btn>
|
|
||||||
</div>
|
<q-card class="q-pa-md">
|
||||||
<q-table
|
<q-table
|
||||||
:data="users"
|
row-key="id"
|
||||||
:row-key="usersTableRowKey"
|
:rows="users"
|
||||||
:columns="usersTable.columns"
|
:columns="usersTable.columns"
|
||||||
:pagination.sync="usersTable.pagination"
|
v-model:pagination="usersTable.pagination"
|
||||||
:no-data-label="$t('no_users')"
|
:no-data-label="$t('no_users')"
|
||||||
:filter="usersTable.search"
|
:filter="usersTable.search"
|
||||||
:loading="usersTable.loading"
|
:loading="usersTable.loading"
|
||||||
@@ -36,78 +33,118 @@ include "users/_createWalletDialog.html" %}
|
|||||||
<template v-slot:header="props">
|
<template v-slot:header="props">
|
||||||
<q-tr :props="props">
|
<q-tr :props="props">
|
||||||
<q-th auto-width></q-th>
|
<q-th auto-width></q-th>
|
||||||
<q-th
|
<q-th v-for="col in props.cols" :key="col.name" :props="props">
|
||||||
v-for="col in props.cols"
|
<q-input
|
||||||
v-text="col.label"
|
v-if="['user', 'username', 'email', 'pubkey', 'wallet_id'].includes(col.name)"
|
||||||
:key="col.name"
|
v-model="searchData[col.name]"
|
||||||
:props="props"
|
@keydown.enter="searchUserBy(col.name)"
|
||||||
></q-th>
|
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>
|
</q-tr>
|
||||||
</template>
|
</template>
|
||||||
<template v-slot:body="props">
|
<template v-slot:body="props">
|
||||||
<q-tr auto-width :props="props">
|
<q-tr auto-width :props="props">
|
||||||
<q-td>
|
<q-td>
|
||||||
<q-btn
|
<q-btn
|
||||||
|
@click="showAccountPage(props.row.id)"
|
||||||
round
|
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"
|
icon="list"
|
||||||
size="sm"
|
size="sm"
|
||||||
color="secondary"
|
color="secondary"
|
||||||
|
:label="props.row.wallet_count"
|
||||||
@click="fetchWallets(props.row.id)"
|
@click="fetchWallets(props.row.id)"
|
||||||
>
|
>
|
||||||
<q-tooltip>Show Wallets</q-tooltip>
|
<q-tooltip>Show Wallets</q-tooltip>
|
||||||
</q-btn>
|
</q-btn>
|
||||||
|
</q-td>
|
||||||
|
|
||||||
|
<q-td>
|
||||||
<q-btn
|
<q-btn
|
||||||
round
|
|
||||||
icon="content_copy"
|
icon="content_copy"
|
||||||
size="sm"
|
size="sm"
|
||||||
color="primary"
|
flat
|
||||||
|
class="cursor-pointer q-mr-xs"
|
||||||
@click="copyText(props.row.id)"
|
@click="copyText(props.row.id)"
|
||||||
>
|
>
|
||||||
<q-tooltip>Copy User ID</q-tooltip>
|
<q-tooltip>Copy User ID</q-tooltip>
|
||||||
</q-btn>
|
</q-btn>
|
||||||
<q-btn
|
<span v-text="shortify(props.row.id)"></span>
|
||||||
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>
|
|
||||||
</q-td>
|
</q-td>
|
||||||
<q-td
|
<q-td v-text="props.row.username"></q-td>
|
||||||
auto-width
|
|
||||||
v-text="formatSat(props.row.balance_msat)"
|
<q-td v-text="props.row.email"></q-td>
|
||||||
></q-td>
|
|
||||||
<q-td auto-width v-text="props.row.wallet_count"></q-td>
|
<q-td>
|
||||||
<q-td auto-width v-text="props.row.transaction_count"></q-td>
|
<q-btn
|
||||||
<q-td auto-width v-text="props.row.username"></q-td>
|
v-if="props.row.pubkey"
|
||||||
<q-td auto-width v-text="props.row.email"></q-td>
|
icon="content_copy"
|
||||||
<q-td auto-width v-text="props.row.last_payment"></q-td>
|
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>
|
</q-tr>
|
||||||
</template>
|
</template>
|
||||||
</q-table>
|
</q-table>
|
||||||
</q-card-section>
|
</q-card>
|
||||||
</q-card>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
+52
-21
@@ -2,7 +2,8 @@ import hashlib
|
|||||||
import json
|
import json
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from typing import Dict, List
|
from time import time
|
||||||
|
from typing import Any
|
||||||
from urllib.parse import ParseResult, parse_qs, urlencode, urlparse, urlunparse
|
from urllib.parse import ParseResult, parse_qs, urlencode, urlparse, urlunparse
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@@ -12,8 +13,9 @@ from fastapi import (
|
|||||||
Depends,
|
Depends,
|
||||||
)
|
)
|
||||||
from fastapi.exceptions import HTTPException
|
from fastapi.exceptions import HTTPException
|
||||||
from starlette.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
|
|
||||||
|
from lnbits.core.crud import get_user
|
||||||
from lnbits.core.models import (
|
from lnbits.core.models import (
|
||||||
BaseWallet,
|
BaseWallet,
|
||||||
ConversionData,
|
ConversionData,
|
||||||
@@ -33,41 +35,64 @@ from lnbits.settings import settings
|
|||||||
from lnbits.utils.exchange_rates import (
|
from lnbits.utils.exchange_rates import (
|
||||||
allowed_currencies,
|
allowed_currencies,
|
||||||
fiat_amount_as_satoshis,
|
fiat_amount_as_satoshis,
|
||||||
|
get_fiat_rate_satoshis,
|
||||||
satoshis_amount_as_fiat,
|
satoshis_amount_as_fiat,
|
||||||
)
|
)
|
||||||
|
from lnbits.wallets import get_funding_source
|
||||||
|
from lnbits.wallets.base import StatusResponse
|
||||||
|
|
||||||
from ..services import create_user_account, perform_lnurlauth
|
from ..services import create_user_account, perform_lnurlauth
|
||||||
|
|
||||||
# backwards compatibility for extension
|
|
||||||
# TODO: remove api_payment and pay_invoice imports from extensions
|
|
||||||
from .payment_api import api_payment, pay_invoice # noqa: F401
|
|
||||||
|
|
||||||
api_router = APIRouter(tags=["Core"])
|
api_router = APIRouter(tags=["Core"])
|
||||||
|
|
||||||
|
|
||||||
@api_router.get("/api/v1/health", status_code=HTTPStatus.OK)
|
@api_router.get("/api/v1/health", status_code=HTTPStatus.OK)
|
||||||
async def health():
|
async def health() -> dict:
|
||||||
return
|
return {
|
||||||
|
"server_time": int(time()),
|
||||||
|
"up_time": int(time() - settings.server_startup_time),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@api_router.get("/api/v1/status", status_code=HTTPStatus.OK)
|
||||||
|
async def health_check(wallet: WalletTypeInfo = Depends(require_invoice_key)) -> dict:
|
||||||
|
stat: dict[str, Any] = {
|
||||||
|
"server_time": int(time()),
|
||||||
|
"up_time": int(time() - settings.server_startup_time),
|
||||||
|
}
|
||||||
|
|
||||||
|
user = await get_user(wallet.wallet.user)
|
||||||
|
if not user:
|
||||||
|
return stat
|
||||||
|
|
||||||
|
stat["version"] = settings.version
|
||||||
|
if not user.admin:
|
||||||
|
return stat
|
||||||
|
|
||||||
|
funding_source = get_funding_source()
|
||||||
|
stat["funding_source"] = funding_source.__class__.__name__
|
||||||
|
|
||||||
|
status: StatusResponse = await funding_source.status()
|
||||||
|
stat["funding_source_error"] = status.error_message
|
||||||
|
stat["funding_source_balance_msat"] = status.balance_msat
|
||||||
|
|
||||||
|
return stat
|
||||||
|
|
||||||
|
|
||||||
@api_router.get(
|
@api_router.get(
|
||||||
"/api/v1/wallets",
|
"/api/v1/wallets",
|
||||||
name="Wallets",
|
name="Wallets",
|
||||||
description="Get basic info for all of user's wallets.",
|
description="Get basic info for all of user's wallets.",
|
||||||
|
response_model=list[BaseWallet],
|
||||||
)
|
)
|
||||||
async def api_wallets(user: User = Depends(check_user_exists)) -> List[BaseWallet]:
|
async def api_wallets(user: User = Depends(check_user_exists)) -> list[Wallet]:
|
||||||
return [BaseWallet(**w.dict()) for w in user.wallets]
|
return user.wallets
|
||||||
|
|
||||||
|
|
||||||
@api_router.post("/api/v1/account", response_model=Wallet)
|
@api_router.post("/api/v1/account")
|
||||||
async def api_create_account(data: CreateWallet) -> Wallet:
|
async def api_create_account(data: CreateWallet) -> Wallet:
|
||||||
if not settings.new_accounts_allowed:
|
user = await create_user_account(wallet_name=data.name)
|
||||||
raise HTTPException(
|
return user.wallets[0]
|
||||||
status_code=HTTPStatus.FORBIDDEN,
|
|
||||||
detail="Account creation is disabled.",
|
|
||||||
)
|
|
||||||
account = await create_user_account(wallet_name=data.name)
|
|
||||||
return account.wallets[0]
|
|
||||||
|
|
||||||
|
|
||||||
@api_router.get("/api/v1/lnurlscan/{code}")
|
@api_router.get("/api/v1/lnurlscan/{code}")
|
||||||
@@ -95,7 +120,7 @@ async def api_lnurlscan(
|
|||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
# params is what will be returned to the client
|
# params is what will be returned to the client
|
||||||
params: Dict = {"domain": domain}
|
params: dict = {"domain": domain}
|
||||||
|
|
||||||
if "tag=login" in url:
|
if "tag=login" in url:
|
||||||
params.update(kind="auth")
|
params.update(kind="auth")
|
||||||
@@ -144,7 +169,7 @@ async def api_lnurlscan(
|
|||||||
|
|
||||||
# callback with k1 already in it
|
# callback with k1 already in it
|
||||||
parsed_callback: ParseResult = urlparse(data["callback"])
|
parsed_callback: ParseResult = urlparse(data["callback"])
|
||||||
qs: Dict = parse_qs(parsed_callback.query)
|
qs: dict = parse_qs(parsed_callback.query)
|
||||||
qs["k1"] = data["k1"]
|
qs["k1"] = data["k1"]
|
||||||
|
|
||||||
# balanceCheck/balanceNotify
|
# balanceCheck/balanceNotify
|
||||||
@@ -200,8 +225,14 @@ async def api_perform_lnurlauth(
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
@api_router.get("/api/v1/rate/{currency}")
|
||||||
|
async def api_check_fiat_rate(currency: str) -> dict[str, float]:
|
||||||
|
rate = await get_fiat_rate_satoshis(currency)
|
||||||
|
return {"rate": rate}
|
||||||
|
|
||||||
|
|
||||||
@api_router.get("/api/v1/currencies")
|
@api_router.get("/api/v1/currencies")
|
||||||
async def api_list_currencies_available() -> List[str]:
|
async def api_list_currencies_available() -> list[str]:
|
||||||
return allowed_currencies()
|
return allowed_currencies()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
|
||||||
|
from lnbits.core.crud.audit import (
|
||||||
|
get_audit_entries,
|
||||||
|
get_count_stats,
|
||||||
|
get_long_duration_stats,
|
||||||
|
)
|
||||||
|
from lnbits.core.models import AuditEntry, AuditFilters
|
||||||
|
from lnbits.core.models.audit import AuditStats
|
||||||
|
from lnbits.db import Filters, Page
|
||||||
|
from lnbits.decorators import check_admin, parse_filters
|
||||||
|
from lnbits.helpers import generate_filter_params_openapi
|
||||||
|
|
||||||
|
audit_router = APIRouter(
|
||||||
|
prefix="/audit/api/v1", dependencies=[Depends(check_admin)], tags=["Audit"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@audit_router.get(
|
||||||
|
"",
|
||||||
|
name="Get audit entries",
|
||||||
|
summary="Get paginated list audit entries",
|
||||||
|
openapi_extra=generate_filter_params_openapi(AuditFilters),
|
||||||
|
)
|
||||||
|
async def api_get_audit(
|
||||||
|
filters: Filters = Depends(parse_filters(AuditFilters)),
|
||||||
|
) -> Page[AuditEntry]:
|
||||||
|
return await get_audit_entries(filters)
|
||||||
|
|
||||||
|
|
||||||
|
@audit_router.get(
|
||||||
|
"/stats",
|
||||||
|
name="Get audit entries",
|
||||||
|
summary="Get paginated list audit entries",
|
||||||
|
openapi_extra=generate_filter_params_openapi(AuditFilters),
|
||||||
|
)
|
||||||
|
async def api_get_audit_stats(
|
||||||
|
filters: Filters = Depends(parse_filters(AuditFilters)),
|
||||||
|
) -> AuditStats:
|
||||||
|
request_mothod_stats = await get_count_stats("request_method", filters)
|
||||||
|
response_code_stats = await get_count_stats("response_code", filters)
|
||||||
|
components_stats = await get_count_stats("component", filters)
|
||||||
|
long_duration_stats = await get_long_duration_stats(filters)
|
||||||
|
return AuditStats(
|
||||||
|
request_method=request_mothod_stats,
|
||||||
|
response_code=response_code_stats,
|
||||||
|
component=components_stats,
|
||||||
|
long_duration=long_duration_stats,
|
||||||
|
)
|
||||||
+261
-142
@@ -1,19 +1,18 @@
|
|||||||
|
import base64
|
||||||
import importlib
|
import importlib
|
||||||
|
import json
|
||||||
|
from http import HTTPStatus
|
||||||
|
from time import time
|
||||||
from typing import Callable, Optional
|
from typing import Callable, Optional
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
from fastapi.responses import JSONResponse, RedirectResponse
|
from fastapi.responses import JSONResponse, RedirectResponse
|
||||||
from fastapi_sso.sso.base import OpenID, SSOBase
|
from fastapi_sso.sso.base import OpenID, SSOBase
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from starlette.status import (
|
|
||||||
HTTP_400_BAD_REQUEST,
|
|
||||||
HTTP_401_UNAUTHORIZED,
|
|
||||||
HTTP_403_FORBIDDEN,
|
|
||||||
HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
)
|
|
||||||
|
|
||||||
from lnbits.core.services import create_user_account
|
from lnbits.core.services import create_user_account
|
||||||
from lnbits.decorators import check_user_exists
|
from lnbits.decorators import access_token_payload, check_user_exists
|
||||||
from lnbits.helpers import (
|
from lnbits.helpers import (
|
||||||
create_access_token,
|
create_access_token,
|
||||||
decrypt_internal_message,
|
decrypt_internal_message,
|
||||||
@@ -22,25 +21,30 @@ from lnbits.helpers import (
|
|||||||
is_valid_username,
|
is_valid_username,
|
||||||
)
|
)
|
||||||
from lnbits.settings import AuthMethods, settings
|
from lnbits.settings import AuthMethods, settings
|
||||||
|
from lnbits.utils.nostr import normalize_public_key, verify_event
|
||||||
|
|
||||||
from ..crud import (
|
from ..crud import (
|
||||||
get_account,
|
get_account,
|
||||||
get_account_by_email,
|
get_account_by_email,
|
||||||
|
get_account_by_pubkey,
|
||||||
|
get_account_by_username,
|
||||||
get_account_by_username_or_email,
|
get_account_by_username_or_email,
|
||||||
get_user,
|
get_user_from_account,
|
||||||
update_account,
|
update_account,
|
||||||
update_user_password,
|
|
||||||
verify_user_password,
|
|
||||||
)
|
)
|
||||||
from ..models import (
|
from ..models import (
|
||||||
CreateUser,
|
AccessTokenPayload,
|
||||||
|
Account,
|
||||||
LoginUsernamePassword,
|
LoginUsernamePassword,
|
||||||
LoginUsr,
|
LoginUsr,
|
||||||
|
RegisterUser,
|
||||||
|
ResetUserPassword,
|
||||||
UpdateSuperuserPassword,
|
UpdateSuperuserPassword,
|
||||||
UpdateUser,
|
UpdateUser,
|
||||||
UpdateUserPassword,
|
UpdateUserPassword,
|
||||||
|
UpdateUserPubkey,
|
||||||
User,
|
User,
|
||||||
UserConfig,
|
UserExtra,
|
||||||
)
|
)
|
||||||
|
|
||||||
auth_router = APIRouter(prefix="/api/v1/auth", tags=["Auth"])
|
auth_router = APIRouter(prefix="/api/v1/auth", tags=["Auth"])
|
||||||
@@ -55,41 +59,43 @@ async def get_auth_user(user: User = Depends(check_user_exists)) -> User:
|
|||||||
async def login(data: LoginUsernamePassword) -> JSONResponse:
|
async def login(data: LoginUsernamePassword) -> JSONResponse:
|
||||||
if not settings.is_auth_method_allowed(AuthMethods.username_and_password):
|
if not settings.is_auth_method_allowed(AuthMethods.username_and_password):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
HTTP_401_UNAUTHORIZED, "Login by 'Username and Password' not allowed."
|
HTTPStatus.UNAUTHORIZED, "Login by 'Username and Password' not allowed."
|
||||||
)
|
)
|
||||||
|
account = await get_account_by_username_or_email(data.username)
|
||||||
|
if not account or not account.verify_password(data.password):
|
||||||
|
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid credentials.")
|
||||||
|
return _auth_success_response(account.username, account.id, account.email)
|
||||||
|
|
||||||
try:
|
|
||||||
user = await get_account_by_username_or_email(data.username)
|
|
||||||
|
|
||||||
if not user:
|
@auth_router.post("/nostr", description="Login via Nostr")
|
||||||
raise HTTPException(HTTP_401_UNAUTHORIZED, "Invalid credentials.")
|
async def nostr_login(request: Request) -> JSONResponse:
|
||||||
if not await verify_user_password(user.id, data.password):
|
if not settings.is_auth_method_allowed(AuthMethods.nostr_auth_nip98):
|
||||||
raise HTTPException(HTTP_401_UNAUTHORIZED, "Invalid credentials.")
|
raise HTTPException(
|
||||||
|
HTTPStatus.UNAUTHORIZED, "Login with Nostr Auth not allowed."
|
||||||
return _auth_success_response(user.username, user.id)
|
)
|
||||||
except HTTPException as exc:
|
event = _nostr_nip98_event(request)
|
||||||
raise exc
|
account = await get_account_by_pubkey(event["pubkey"])
|
||||||
except Exception as exc:
|
if not account:
|
||||||
logger.debug(exc)
|
account = Account(
|
||||||
raise HTTPException(HTTP_500_INTERNAL_SERVER_ERROR, "Cannot login.") from exc
|
id=uuid4().hex,
|
||||||
|
pubkey=event["pubkey"],
|
||||||
|
extra=UserExtra(provider="nostr"),
|
||||||
|
)
|
||||||
|
await create_user_account(account)
|
||||||
|
return _auth_success_response(account.username or "", account.id, account.email)
|
||||||
|
|
||||||
|
|
||||||
@auth_router.post("/usr", description="Login via the User ID")
|
@auth_router.post("/usr", description="Login via the User ID")
|
||||||
async def login_usr(data: LoginUsr) -> JSONResponse:
|
async def login_usr(data: LoginUsr) -> JSONResponse:
|
||||||
if not settings.is_auth_method_allowed(AuthMethods.user_id_only):
|
if not settings.is_auth_method_allowed(AuthMethods.user_id_only):
|
||||||
raise HTTPException(HTTP_401_UNAUTHORIZED, "Login by 'User ID' not allowed.")
|
raise HTTPException(
|
||||||
|
HTTPStatus.UNAUTHORIZED,
|
||||||
try:
|
"Login by 'User ID' not allowed.",
|
||||||
user = await get_user(data.usr)
|
)
|
||||||
if not user:
|
account = await get_account(data.usr)
|
||||||
raise HTTPException(HTTP_401_UNAUTHORIZED, "User ID does not exist.")
|
if not account:
|
||||||
|
raise HTTPException(HTTPStatus.UNAUTHORIZED, "User ID does not exist.")
|
||||||
return _auth_success_response(user.username or "", user.id)
|
return _auth_success_response(account.username, account.id, account.email)
|
||||||
except HTTPException as exc:
|
|
||||||
raise exc
|
|
||||||
except Exception as exc:
|
|
||||||
logger.debug(exc)
|
|
||||||
raise HTTPException(HTTP_500_INTERNAL_SERVER_ERROR, "Cannot login.") from exc
|
|
||||||
|
|
||||||
|
|
||||||
@auth_router.get("/{provider}", description="SSO Provider")
|
@auth_router.get("/{provider}", description="SSO Provider")
|
||||||
@@ -99,7 +105,8 @@ async def login_with_sso_provider(
|
|||||||
provider_sso = _new_sso(provider)
|
provider_sso = _new_sso(provider)
|
||||||
if not provider_sso:
|
if not provider_sso:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
HTTP_401_UNAUTHORIZED, f"Login by '{provider}' not allowed."
|
HTTPStatus.UNAUTHORIZED,
|
||||||
|
f"Login by '{provider}' not allowed.",
|
||||||
)
|
)
|
||||||
|
|
||||||
provider_sso.redirect_uri = str(request.base_url) + f"api/v1/auth/{provider}/token"
|
provider_sso.redirect_uri = str(request.base_url) + f"api/v1/auth/{provider}/token"
|
||||||
@@ -113,31 +120,22 @@ async def handle_oauth_token(request: Request, provider: str) -> RedirectRespons
|
|||||||
provider_sso = _new_sso(provider)
|
provider_sso = _new_sso(provider)
|
||||||
if not provider_sso:
|
if not provider_sso:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
HTTP_401_UNAUTHORIZED, f"Login by '{provider}' not allowed."
|
HTTPStatus.UNAUTHORIZED,
|
||||||
|
f"Login by '{provider}' not allowed.",
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
with provider_sso:
|
||||||
with provider_sso:
|
userinfo = await provider_sso.verify_and_process(request)
|
||||||
userinfo = await provider_sso.verify_and_process(request)
|
if not userinfo:
|
||||||
assert userinfo is not None
|
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid user info.")
|
||||||
user_id = decrypt_internal_message(provider_sso.state)
|
user_id = decrypt_internal_message(provider_sso.state)
|
||||||
request.session.pop("user", None)
|
request.session.pop("user", None)
|
||||||
return await _handle_sso_login(userinfo, user_id)
|
return await _handle_sso_login(userinfo, user_id)
|
||||||
except HTTPException as exc:
|
|
||||||
raise exc
|
|
||||||
except ValueError as exc:
|
|
||||||
raise HTTPException(HTTP_403_FORBIDDEN, str(exc)) from exc
|
|
||||||
except Exception as exc:
|
|
||||||
logger.debug(exc)
|
|
||||||
raise HTTPException(
|
|
||||||
HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
f"Cannot authenticate user with {provider} Auth.",
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
|
|
||||||
@auth_router.post("/logout")
|
@auth_router.post("/logout")
|
||||||
async def logout() -> JSONResponse:
|
async def logout() -> JSONResponse:
|
||||||
response = JSONResponse({"status": "success"}, status_code=status.HTTP_200_OK)
|
response = JSONResponse({"status": "success"}, HTTPStatus.OK)
|
||||||
response.delete_cookie("cookie_access_token")
|
response.delete_cookie("cookie_access_token")
|
||||||
response.delete_cookie("is_lnbits_user_authorized")
|
response.delete_cookie("is_lnbits_user_authorized")
|
||||||
response.delete_cookie("is_access_token_expired")
|
response.delete_cookie("is_access_token_expired")
|
||||||
@@ -147,58 +145,127 @@ async def logout() -> JSONResponse:
|
|||||||
|
|
||||||
|
|
||||||
@auth_router.post("/register")
|
@auth_router.post("/register")
|
||||||
async def register(data: CreateUser) -> JSONResponse:
|
async def register(data: RegisterUser) -> JSONResponse:
|
||||||
if not settings.is_auth_method_allowed(AuthMethods.username_and_password):
|
if not settings.is_auth_method_allowed(AuthMethods.username_and_password):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
HTTP_401_UNAUTHORIZED, "Register by 'Username and Password' not allowed."
|
HTTPStatus.UNAUTHORIZED,
|
||||||
|
"Register by 'Username and Password' not allowed.",
|
||||||
)
|
)
|
||||||
|
|
||||||
if data.password != data.password_repeat:
|
if data.password != data.password_repeat:
|
||||||
raise HTTPException(HTTP_400_BAD_REQUEST, "Passwords do not match.")
|
raise HTTPException(HTTPStatus.BAD_REQUEST, "Passwords do not match.")
|
||||||
|
|
||||||
if not data.username:
|
if not data.username:
|
||||||
raise HTTPException(HTTP_400_BAD_REQUEST, "Missing username.")
|
raise HTTPException(HTTPStatus.BAD_REQUEST, "Missing username.")
|
||||||
if not is_valid_username(data.username):
|
if not is_valid_username(data.username):
|
||||||
raise HTTPException(HTTP_400_BAD_REQUEST, "Invalid username.")
|
raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid username.")
|
||||||
|
|
||||||
|
if await get_account_by_username(data.username):
|
||||||
|
raise HTTPException(HTTPStatus.BAD_REQUEST, "Username already exists.")
|
||||||
|
|
||||||
if data.email and not is_valid_email_address(data.email):
|
if data.email and not is_valid_email_address(data.email):
|
||||||
raise HTTPException(HTTP_400_BAD_REQUEST, "Invalid email.")
|
raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid email.")
|
||||||
|
|
||||||
try:
|
account = Account(
|
||||||
user = await create_user_account(
|
id=uuid4().hex,
|
||||||
email=data.email, username=data.username, password=data.password
|
email=data.email,
|
||||||
)
|
username=data.username,
|
||||||
return _auth_success_response(user.username)
|
)
|
||||||
|
account.hash_password(data.password)
|
||||||
|
await create_user_account(account)
|
||||||
|
return _auth_success_response(account.username, account.id, account.email)
|
||||||
|
|
||||||
except ValueError as exc:
|
|
||||||
raise HTTPException(HTTP_403_FORBIDDEN, str(exc)) from exc
|
@auth_router.put("/pubkey")
|
||||||
except Exception as exc:
|
async def update_pubkey(
|
||||||
logger.debug(exc)
|
data: UpdateUserPubkey,
|
||||||
raise HTTPException(
|
user: User = Depends(check_user_exists),
|
||||||
HTTP_500_INTERNAL_SERVER_ERROR, "Cannot create user."
|
payload: AccessTokenPayload = Depends(access_token_payload),
|
||||||
) from exc
|
) -> Optional[User]:
|
||||||
|
if data.user_id != user.id:
|
||||||
|
raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid user ID.")
|
||||||
|
|
||||||
|
_validate_auth_timeout(payload.auth_time)
|
||||||
|
if (
|
||||||
|
data.pubkey
|
||||||
|
and data.pubkey != user.pubkey
|
||||||
|
and await get_account_by_pubkey(data.pubkey)
|
||||||
|
):
|
||||||
|
raise HTTPException(HTTPStatus.BAD_REQUEST, "Public key already in use.")
|
||||||
|
|
||||||
|
account = await get_account(user.id)
|
||||||
|
if not account:
|
||||||
|
raise HTTPException(HTTPStatus.NOT_FOUND, "Account not found.")
|
||||||
|
|
||||||
|
account.pubkey = normalize_public_key(data.pubkey)
|
||||||
|
await update_account(account)
|
||||||
|
return await get_user_from_account(account)
|
||||||
|
|
||||||
|
|
||||||
@auth_router.put("/password")
|
@auth_router.put("/password")
|
||||||
async def update_password(
|
async def update_password(
|
||||||
data: UpdateUserPassword, user: User = Depends(check_user_exists)
|
data: UpdateUserPassword,
|
||||||
|
user: User = Depends(check_user_exists),
|
||||||
|
payload: AccessTokenPayload = Depends(access_token_payload),
|
||||||
) -> Optional[User]:
|
) -> Optional[User]:
|
||||||
|
_validate_auth_timeout(payload.auth_time)
|
||||||
|
assert data.user_id == user.id, "Invalid user ID."
|
||||||
|
if (
|
||||||
|
data.username
|
||||||
|
and user.username != data.username
|
||||||
|
and await get_account_by_username(data.username)
|
||||||
|
):
|
||||||
|
raise HTTPException(HTTPStatus.BAD_REQUEST, "Username already exists.")
|
||||||
|
|
||||||
|
account = await get_account(user.id)
|
||||||
|
assert account, "Account not found."
|
||||||
|
|
||||||
|
# old accounts do not have a password
|
||||||
|
if account.password_hash:
|
||||||
|
assert data.password_old, "Missing old password."
|
||||||
|
assert account.verify_password(data.password_old), "Invalid old password."
|
||||||
|
|
||||||
|
account.username = data.username
|
||||||
|
account.hash_password(data.password)
|
||||||
|
await update_account(account)
|
||||||
|
_user = await get_user_from_account(account)
|
||||||
|
if not _user:
|
||||||
|
raise HTTPException(HTTPStatus.NOT_FOUND, "User not found.")
|
||||||
|
return _user
|
||||||
|
|
||||||
|
|
||||||
|
@auth_router.put("/reset")
|
||||||
|
async def reset_password(data: ResetUserPassword) -> JSONResponse:
|
||||||
if not settings.is_auth_method_allowed(AuthMethods.username_and_password):
|
if not settings.is_auth_method_allowed(AuthMethods.username_and_password):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
HTTP_401_UNAUTHORIZED, "Auth by 'Username and Password' not allowed."
|
HTTPStatus.UNAUTHORIZED, "Auth by 'Username and Password' not allowed."
|
||||||
)
|
)
|
||||||
if data.user_id != user.id:
|
|
||||||
raise HTTPException(HTTP_400_BAD_REQUEST, "Invalid user ID.")
|
assert data.password == data.password_repeat, "Passwords do not match."
|
||||||
|
assert data.reset_key[:10].startswith("reset_key_"), "This is not a reset key."
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return await update_user_password(data)
|
reset_key = base64.b64decode(data.reset_key[10:]).decode()
|
||||||
except AssertionError as exc:
|
reset_data_json = decrypt_internal_message(reset_key)
|
||||||
raise HTTPException(HTTP_403_FORBIDDEN, str(exc)) from exc
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.debug(exc)
|
raise ValueError("Invalid reset key.") from exc
|
||||||
raise HTTPException(
|
|
||||||
HTTP_500_INTERNAL_SERVER_ERROR, "Cannot update user password."
|
assert reset_data_json, "Cannot process reset key."
|
||||||
) from exc
|
|
||||||
|
action, user_id, request_time = json.loads(reset_data_json)
|
||||||
|
assert action, "Missing action."
|
||||||
|
assert user_id, "Missing user ID."
|
||||||
|
assert request_time, "Missing reset time."
|
||||||
|
|
||||||
|
_validate_auth_timeout(request_time)
|
||||||
|
|
||||||
|
account = await get_account(user_id)
|
||||||
|
if not account:
|
||||||
|
raise HTTPException(HTTPStatus.NOT_FOUND, "User not found.")
|
||||||
|
|
||||||
|
account.hash_password(data.password)
|
||||||
|
await update_account(account)
|
||||||
|
return _auth_success_response(account.username, user_id, account.email)
|
||||||
|
|
||||||
|
|
||||||
@auth_router.put("/update")
|
@auth_router.put("/update")
|
||||||
@@ -206,80 +273,83 @@ async def update(
|
|||||||
data: UpdateUser, user: User = Depends(check_user_exists)
|
data: UpdateUser, user: User = Depends(check_user_exists)
|
||||||
) -> Optional[User]:
|
) -> Optional[User]:
|
||||||
if data.user_id != user.id:
|
if data.user_id != user.id:
|
||||||
raise HTTPException(HTTP_400_BAD_REQUEST, "Invalid user ID.")
|
raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid user ID.")
|
||||||
if data.username and not is_valid_username(data.username):
|
if data.username and not is_valid_username(data.username):
|
||||||
raise HTTPException(HTTP_400_BAD_REQUEST, "Invalid username.")
|
raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid username.")
|
||||||
if data.email != user.email:
|
if data.email != user.email:
|
||||||
raise HTTPException(HTTP_400_BAD_REQUEST, "Email mismatch.")
|
|
||||||
|
|
||||||
try:
|
|
||||||
return await update_account(user.id, data.username, None, data.config)
|
|
||||||
except AssertionError as exc:
|
|
||||||
raise HTTPException(HTTP_403_FORBIDDEN, str(exc)) from exc
|
|
||||||
except Exception as exc:
|
|
||||||
logger.debug(exc)
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
HTTP_500_INTERNAL_SERVER_ERROR, "Cannot update user."
|
HTTPStatus.BAD_REQUEST,
|
||||||
) from exc
|
"Email mismatch.",
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
data.username
|
||||||
|
and user.username != data.username
|
||||||
|
and await get_account_by_username(data.username)
|
||||||
|
):
|
||||||
|
raise HTTPException(HTTPStatus.BAD_REQUEST, "Username already exists.")
|
||||||
|
if (
|
||||||
|
data.email
|
||||||
|
and data.email != user.email
|
||||||
|
and await get_account_by_email(data.email)
|
||||||
|
):
|
||||||
|
raise HTTPException(HTTPStatus.BAD_REQUEST, "Email already exists.")
|
||||||
|
|
||||||
|
account = await get_account(user.id)
|
||||||
|
if not account:
|
||||||
|
raise HTTPException(HTTPStatus.NOT_FOUND, "Account not found.")
|
||||||
|
|
||||||
|
if data.username:
|
||||||
|
account.username = data.username
|
||||||
|
if data.email:
|
||||||
|
account.email = data.email
|
||||||
|
if data.extra:
|
||||||
|
account.extra = data.extra
|
||||||
|
|
||||||
|
await update_account(account)
|
||||||
|
return await get_user_from_account(account)
|
||||||
|
|
||||||
|
|
||||||
@auth_router.put("/first_install")
|
@auth_router.put("/first_install")
|
||||||
async def first_install(data: UpdateSuperuserPassword) -> JSONResponse:
|
async def first_install(data: UpdateSuperuserPassword) -> JSONResponse:
|
||||||
if not settings.first_install:
|
if not settings.first_install:
|
||||||
raise HTTPException(HTTP_401_UNAUTHORIZED, "This is not your first install")
|
raise HTTPException(HTTPStatus.UNAUTHORIZED, "This is not your first install")
|
||||||
try:
|
account = await get_account(settings.super_user)
|
||||||
await update_account(
|
if not account:
|
||||||
user_id=settings.super_user,
|
raise HTTPException(HTTPStatus.INTERNAL_SERVER_ERROR, "Superuser not found.")
|
||||||
username=data.username,
|
account.username = data.username
|
||||||
user_config=UserConfig(provider="lnbits"),
|
account.extra = account.extra or UserExtra()
|
||||||
)
|
account.extra.provider = "lnbits"
|
||||||
super_user = UpdateUserPassword(
|
account.hash_password(data.password)
|
||||||
user_id=settings.super_user,
|
await update_account(account)
|
||||||
password=data.password,
|
settings.first_install = False
|
||||||
password_repeat=data.password_repeat,
|
return _auth_success_response(account.username, account.id, account.email)
|
||||||
username=data.username,
|
|
||||||
)
|
|
||||||
await update_user_password(super_user)
|
|
||||||
settings.first_install = False
|
|
||||||
return _auth_success_response(username=super_user.username)
|
|
||||||
except AssertionError as exc:
|
|
||||||
raise HTTPException(HTTP_403_FORBIDDEN, str(exc)) from exc
|
|
||||||
except Exception as exc:
|
|
||||||
logger.debug(exc)
|
|
||||||
raise HTTPException(
|
|
||||||
HTTP_500_INTERNAL_SERVER_ERROR, "Cannot update user password."
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
|
|
||||||
async def _handle_sso_login(userinfo: OpenID, verified_user_id: Optional[str] = None):
|
async def _handle_sso_login(userinfo: OpenID, verified_user_id: Optional[str] = None):
|
||||||
email = userinfo.email
|
email = userinfo.email
|
||||||
if not email or not is_valid_email_address(email):
|
if not email or not is_valid_email_address(email):
|
||||||
raise HTTPException(HTTP_400_BAD_REQUEST, "Invalid email.")
|
raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid email.")
|
||||||
|
|
||||||
redirect_path = "/wallet"
|
redirect_path = "/wallet"
|
||||||
user_config = UserConfig(**dict(userinfo))
|
|
||||||
user_config.email_verified = True
|
|
||||||
|
|
||||||
account = await get_account_by_email(email)
|
account = await get_account_by_email(email)
|
||||||
|
|
||||||
if verified_user_id:
|
if verified_user_id:
|
||||||
if account:
|
if account:
|
||||||
raise HTTPException(HTTP_401_UNAUTHORIZED, "Email already used.")
|
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Email already used.")
|
||||||
account = await get_account(verified_user_id)
|
account = await get_account(verified_user_id)
|
||||||
if not account:
|
if not account:
|
||||||
raise HTTPException(HTTP_401_UNAUTHORIZED, "Cannot verify user email.")
|
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Cannot verify user email.")
|
||||||
redirect_path = "/account"
|
redirect_path = "/account"
|
||||||
|
|
||||||
if account:
|
if account:
|
||||||
user = await update_account(account.id, email=email, user_config=user_config)
|
account.extra = account.extra or UserExtra()
|
||||||
|
account.extra.email_verified = True
|
||||||
|
await update_account(account)
|
||||||
else:
|
else:
|
||||||
if not settings.new_accounts_allowed:
|
account = Account(
|
||||||
raise HTTPException(HTTP_400_BAD_REQUEST, "Account creation is disabled.")
|
id=uuid4().hex, email=email, extra=UserExtra(email_verified=True)
|
||||||
user = await create_user_account(email=email, user_config=user_config)
|
)
|
||||||
|
await create_user_account(account)
|
||||||
if not user:
|
|
||||||
raise HTTPException(HTTP_401_UNAUTHORIZED, "User not found.")
|
|
||||||
|
|
||||||
return _auth_redirect_response(redirect_path, email)
|
return _auth_redirect_response(redirect_path, email)
|
||||||
|
|
||||||
|
|
||||||
@@ -288,9 +358,10 @@ def _auth_success_response(
|
|||||||
user_id: Optional[str] = None,
|
user_id: Optional[str] = None,
|
||||||
email: Optional[str] = None,
|
email: Optional[str] = None,
|
||||||
) -> JSONResponse:
|
) -> JSONResponse:
|
||||||
access_token = create_access_token(
|
payload = AccessTokenPayload(
|
||||||
data={"sub": username or "", "usr": user_id, "email": email}
|
sub=username or "", usr=user_id, email=email, auth_time=int(time())
|
||||||
)
|
)
|
||||||
|
access_token = create_access_token(data=payload.dict())
|
||||||
response = JSONResponse({"access_token": access_token, "token_type": "bearer"})
|
response = JSONResponse({"access_token": access_token, "token_type": "bearer"})
|
||||||
response.set_cookie("cookie_access_token", access_token, httponly=True)
|
response.set_cookie("cookie_access_token", access_token, httponly=True)
|
||||||
response.set_cookie("is_lnbits_user_authorized", "true")
|
response.set_cookie("is_lnbits_user_authorized", "true")
|
||||||
@@ -300,7 +371,8 @@ def _auth_success_response(
|
|||||||
|
|
||||||
|
|
||||||
def _auth_redirect_response(path: str, email: str) -> RedirectResponse:
|
def _auth_redirect_response(path: str, email: str) -> RedirectResponse:
|
||||||
access_token = create_access_token(data={"sub": "" or "", "email": email})
|
payload = AccessTokenPayload(sub="" or "", email=email, auth_time=int(time()))
|
||||||
|
access_token = create_access_token(data=payload.dict())
|
||||||
response = RedirectResponse(path)
|
response = RedirectResponse(path)
|
||||||
response.set_cookie("cookie_access_token", access_token, httponly=True)
|
response.set_cookie("cookie_access_token", access_token, httponly=True)
|
||||||
response.set_cookie("is_lnbits_user_authorized", "true")
|
response.set_cookie("is_lnbits_user_authorized", "true")
|
||||||
@@ -338,7 +410,7 @@ def _new_sso(provider: str) -> Optional[SSOBase]:
|
|||||||
|
|
||||||
|
|
||||||
def _find_auth_provider_class(provider: str) -> Callable:
|
def _find_auth_provider_class(provider: str) -> Callable:
|
||||||
sso_modules = ["lnbits.core.sso", "fastapi_sso.sso"]
|
sso_modules = ["lnbits.core.models.sso", "fastapi_sso.sso"]
|
||||||
for module in sso_modules:
|
for module in sso_modules:
|
||||||
try:
|
try:
|
||||||
provider_module = importlib.import_module(f"{module}.{provider}")
|
provider_module = importlib.import_module(f"{module}.{provider}")
|
||||||
@@ -349,3 +421,50 @@ def _find_auth_provider_class(provider: str) -> Callable:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
raise ValueError(f"No SSO provider found for '{provider}'.")
|
raise ValueError(f"No SSO provider found for '{provider}'.")
|
||||||
|
|
||||||
|
|
||||||
|
def _nostr_nip98_event(request: Request) -> dict:
|
||||||
|
auth_header = request.headers.get("Authorization")
|
||||||
|
if not auth_header:
|
||||||
|
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Nostr Auth header missing.")
|
||||||
|
scheme, token = auth_header.split()
|
||||||
|
if scheme.lower() != "nostr":
|
||||||
|
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid Authorization scheme.")
|
||||||
|
event = None
|
||||||
|
try:
|
||||||
|
event_json = base64.b64decode(token.encode("ascii"))
|
||||||
|
event = json.loads(event_json)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(exc)
|
||||||
|
assert event, "Nostr login event cannot be parsed."
|
||||||
|
|
||||||
|
if not verify_event(event):
|
||||||
|
raise HTTPException(HTTPStatus.BAD_REQUEST, "Nostr login event is not valid.")
|
||||||
|
assert event["kind"] == 27_235, "Invalid event kind."
|
||||||
|
|
||||||
|
auth_threshold = settings.auth_credetials_update_threshold
|
||||||
|
assert (
|
||||||
|
abs(time() - event["created_at"]) < auth_threshold
|
||||||
|
), f"More than {auth_threshold} seconds have passed since the event was signed."
|
||||||
|
|
||||||
|
method: Optional[str] = next((v for k, v in event["tags"] if k == "method"), None)
|
||||||
|
assert method, "Tag 'method' is missing."
|
||||||
|
assert method.upper() == "POST", "Invalid value for tag 'method'."
|
||||||
|
|
||||||
|
url = next((v for k, v in event["tags"] if k == "u"), None)
|
||||||
|
|
||||||
|
assert url, "Tag 'u' for URL is missing."
|
||||||
|
accepted_urls = [f"{u}/nostr" for u in settings.nostr_absolute_request_urls]
|
||||||
|
assert url in accepted_urls, f"Invalid value for tag 'u': '{url}'."
|
||||||
|
|
||||||
|
return event
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_auth_timeout(auth_time: Optional[int] = 0):
|
||||||
|
if abs(time() - (auth_time or 0)) > settings.auth_credetials_update_threshold:
|
||||||
|
raise HTTPException(
|
||||||
|
HTTPStatus.BAD_REQUEST,
|
||||||
|
"You can only update your credentials in the first"
|
||||||
|
f" {settings.auth_credetials_update_threshold} seconds."
|
||||||
|
" Please login again or ask a new reset key!",
|
||||||
|
)
|
||||||
|
|||||||
+200
-201
@@ -1,9 +1,6 @@
|
|||||||
import sys
|
import sys
|
||||||
|
import traceback
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
from typing import (
|
|
||||||
List,
|
|
||||||
Optional,
|
|
||||||
)
|
|
||||||
|
|
||||||
from bolt11 import decode as bolt11_decode
|
from bolt11 import decode as bolt11_decode
|
||||||
from fastapi import (
|
from fastapi import (
|
||||||
@@ -13,49 +10,47 @@ from fastapi import (
|
|||||||
)
|
)
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from lnbits.core.db import core_app_extra
|
from lnbits.core.crud.extensions import get_user_extensions
|
||||||
from lnbits.core.helpers import (
|
|
||||||
migrate_extension_database,
|
|
||||||
stop_extension_background_work,
|
|
||||||
)
|
|
||||||
from lnbits.core.models import (
|
from lnbits.core.models import (
|
||||||
SimpleStatus,
|
SimpleStatus,
|
||||||
User,
|
User,
|
||||||
)
|
)
|
||||||
from lnbits.core.services import check_transaction_status, create_invoice
|
from lnbits.core.models.extensions import (
|
||||||
from lnbits.decorators import (
|
|
||||||
check_access_token,
|
|
||||||
check_admin,
|
|
||||||
check_user_exists,
|
|
||||||
)
|
|
||||||
from lnbits.extension_manager import (
|
|
||||||
CreateExtension,
|
CreateExtension,
|
||||||
Extension,
|
Extension,
|
||||||
|
ExtensionConfig,
|
||||||
|
ExtensionMeta,
|
||||||
ExtensionRelease,
|
ExtensionRelease,
|
||||||
InstallableExtension,
|
InstallableExtension,
|
||||||
PayToEnableInfo,
|
PayToEnableInfo,
|
||||||
ReleasePaymentInfo,
|
ReleasePaymentInfo,
|
||||||
|
UserExtension,
|
||||||
UserExtensionInfo,
|
UserExtensionInfo,
|
||||||
fetch_github_release_config,
|
|
||||||
fetch_release_details,
|
|
||||||
fetch_release_payment_info,
|
|
||||||
get_valid_extensions,
|
|
||||||
)
|
)
|
||||||
from lnbits.settings import settings
|
from lnbits.core.services import check_transaction_status, create_invoice
|
||||||
|
from lnbits.core.services.extensions import (
|
||||||
|
activate_extension,
|
||||||
|
deactivate_extension,
|
||||||
|
get_valid_extension,
|
||||||
|
get_valid_extensions,
|
||||||
|
install_extension,
|
||||||
|
uninstall_extension,
|
||||||
|
)
|
||||||
|
from lnbits.decorators import (
|
||||||
|
check_admin,
|
||||||
|
check_user_exists,
|
||||||
|
)
|
||||||
|
|
||||||
from ..crud import (
|
from ..crud import (
|
||||||
add_installed_extension,
|
create_user_extension,
|
||||||
delete_dbversion,
|
delete_dbversion,
|
||||||
delete_installed_extension,
|
|
||||||
drop_extension_db,
|
drop_extension_db,
|
||||||
get_dbversions,
|
get_db_version,
|
||||||
get_installed_extension,
|
get_installed_extension,
|
||||||
get_installed_extensions,
|
get_installed_extensions,
|
||||||
get_user_extension,
|
get_user_extension,
|
||||||
update_extension_pay_to_enable,
|
update_installed_extension,
|
||||||
update_installed_extension_state,
|
|
||||||
update_user_extension,
|
update_user_extension,
|
||||||
update_user_extension_extra,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
extension_router = APIRouter(
|
extension_router = APIRouter(
|
||||||
@@ -64,12 +59,8 @@ extension_router = APIRouter(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@extension_router.post("")
|
@extension_router.post("", dependencies=[Depends(check_admin)])
|
||||||
async def api_install_extension(
|
async def api_install_extension(data: CreateExtension):
|
||||||
data: CreateExtension,
|
|
||||||
user: User = Depends(check_admin),
|
|
||||||
access_token: Optional[str] = Depends(check_access_token),
|
|
||||||
):
|
|
||||||
release = await InstallableExtension.get_extension_release(
|
release = await InstallableExtension.get_extension_release(
|
||||||
data.ext_id, data.source_repo, data.archive, data.version
|
data.ext_id, data.source_repo, data.archive, data.version
|
||||||
)
|
)
|
||||||
@@ -84,78 +75,73 @@ async def api_install_extension(
|
|||||||
)
|
)
|
||||||
|
|
||||||
release.payment_hash = data.payment_hash
|
release.payment_hash = data.payment_hash
|
||||||
|
ext_meta = ExtensionMeta(installed_release=release)
|
||||||
ext_info = InstallableExtension(
|
ext_info = InstallableExtension(
|
||||||
id=data.ext_id, name=data.ext_id, installed_release=release, icon=release.icon
|
id=data.ext_id,
|
||||||
|
name=data.ext_id,
|
||||||
|
version=data.version,
|
||||||
|
meta=ext_meta,
|
||||||
|
icon=release.icon,
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
installed_ext = await get_installed_extension(data.ext_id)
|
extension = await install_extension(ext_info)
|
||||||
ext_info.payments = installed_ext.payments if installed_ext else []
|
|
||||||
|
|
||||||
await ext_info.download_archive()
|
|
||||||
|
|
||||||
ext_info.extract_archive()
|
|
||||||
|
|
||||||
extension = Extension.from_installable_ext(ext_info)
|
|
||||||
|
|
||||||
db_version = (await get_dbversions()).get(data.ext_id, 0)
|
|
||||||
await migrate_extension_database(extension, db_version)
|
|
||||||
|
|
||||||
ext_info.active = True
|
|
||||||
await add_installed_extension(ext_info)
|
|
||||||
|
|
||||||
if extension.is_upgrade_extension:
|
|
||||||
# call stop while the old routes are still active
|
|
||||||
await stop_extension_background_work(data.ext_id, user.id, access_token)
|
|
||||||
|
|
||||||
# mount routes for the new version
|
|
||||||
core_app_extra.register_new_ext_routes(extension)
|
|
||||||
|
|
||||||
ext_info.notify_upgrade(extension.upgrade_hash)
|
|
||||||
settings.lnbits_deactivated_extensions.discard(data.ext_id)
|
|
||||||
|
|
||||||
return extension
|
|
||||||
except AssertionError as exc:
|
|
||||||
raise HTTPException(HTTPStatus.BAD_REQUEST, str(exc)) from exc
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning(exc)
|
logger.warning(exc)
|
||||||
|
etype, _, tb = sys.exc_info()
|
||||||
|
traceback.print_exception(etype, exc, tb)
|
||||||
ext_info.clean_extension_files()
|
ext_info.clean_extension_files()
|
||||||
|
detail = (
|
||||||
|
str(exc)
|
||||||
|
if isinstance(exc, AssertionError)
|
||||||
|
else f"Failed to install extension '{ext_info.id}'."
|
||||||
|
f"({ext_info.installed_version})."
|
||||||
|
)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||||
detail=(
|
detail=detail,
|
||||||
f"Failed to install extension {ext_info.id} "
|
) from exc
|
||||||
f"({ext_info.installed_version})."
|
|
||||||
),
|
try:
|
||||||
|
await activate_extension(extension)
|
||||||
|
return extension
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(exc)
|
||||||
|
await deactivate_extension(extension.code)
|
||||||
|
detail = (
|
||||||
|
str(exc)
|
||||||
|
if isinstance(exc, AssertionError)
|
||||||
|
else f"Extension `{extension.code}` installed, but activation failed."
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||||
|
detail=detail,
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
@extension_router.get("/{ext_id}/details", dependencies=[Depends(check_user_exists)])
|
@extension_router.get("/{ext_id}/details")
|
||||||
async def api_extension_details(
|
async def api_extension_details(
|
||||||
ext_id: str,
|
ext_id: str,
|
||||||
details_link: str,
|
details_link: str,
|
||||||
):
|
):
|
||||||
|
all_releases = await InstallableExtension.get_extension_releases(ext_id)
|
||||||
|
|
||||||
try:
|
release = next((r for r in all_releases if r.details_link == details_link), None)
|
||||||
all_releases = await InstallableExtension.get_extension_releases(ext_id)
|
if not release:
|
||||||
|
|
||||||
release = next(
|
|
||||||
(r for r in all_releases if r.details_link == details_link), None
|
|
||||||
)
|
|
||||||
assert release, "Details not found for release"
|
|
||||||
|
|
||||||
release_details = await fetch_release_details(details_link)
|
|
||||||
assert release_details, "Cannot fetch details for release"
|
|
||||||
release_details["icon"] = release.icon
|
|
||||||
release_details["repo"] = release.repo
|
|
||||||
return release_details
|
|
||||||
except AssertionError as exc:
|
|
||||||
raise HTTPException(HTTPStatus.BAD_REQUEST, str(exc)) from exc
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning(exc)
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
HTTPStatus.INTERNAL_SERVER_ERROR,
|
status_code=HTTPStatus.NOT_FOUND, detail="Release not found"
|
||||||
f"Failed to get details for extension {ext_id}.",
|
)
|
||||||
) from exc
|
|
||||||
|
release_details = await ExtensionRelease.fetch_release_details(details_link)
|
||||||
|
if not release_details:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Cannot fetch details for release",
|
||||||
|
)
|
||||||
|
release_details["icon"] = release.icon
|
||||||
|
release_details["repo"] = release.repo
|
||||||
|
return release_details
|
||||||
|
|
||||||
|
|
||||||
@extension_router.put("/{ext_id}/sell")
|
@extension_router.put("/{ext_id}/sell")
|
||||||
@@ -164,29 +150,28 @@ async def api_update_pay_to_enable(
|
|||||||
data: PayToEnableInfo,
|
data: PayToEnableInfo,
|
||||||
user: User = Depends(check_admin),
|
user: User = Depends(check_admin),
|
||||||
) -> SimpleStatus:
|
) -> SimpleStatus:
|
||||||
try:
|
if data.wallet not in user.wallet_ids:
|
||||||
assert (
|
|
||||||
data.wallet in user.wallet_ids
|
|
||||||
), "Wallet does not belong to this admin user."
|
|
||||||
await update_extension_pay_to_enable(ext_id, data)
|
|
||||||
return SimpleStatus(
|
|
||||||
success=True, message=f"Payment info updated for '{ext_id}' extension."
|
|
||||||
)
|
|
||||||
except AssertionError as exc:
|
|
||||||
raise HTTPException(HTTPStatus.BAD_REQUEST, str(exc)) from exc
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning(exc)
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
HTTPStatus.BAD_REQUEST, "Wallet does not belong to this admin user."
|
||||||
detail=(f"Failed to update pay to install data for extension '{ext_id}' "),
|
)
|
||||||
) from exc
|
extension = await get_installed_extension(ext_id)
|
||||||
|
if not extension:
|
||||||
|
raise HTTPException(HTTPStatus.NOT_FOUND, f"Extension '{ext_id}' not found.")
|
||||||
|
if extension.meta:
|
||||||
|
extension.meta.pay_to_enable = data
|
||||||
|
else:
|
||||||
|
extension.meta = ExtensionMeta(pay_to_enable=data)
|
||||||
|
await update_installed_extension(extension)
|
||||||
|
return SimpleStatus(
|
||||||
|
success=True, message=f"Payment info updated for '{ext_id}' extension."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@extension_router.put("/{ext_id}/enable")
|
@extension_router.put("/{ext_id}/enable")
|
||||||
async def api_enable_extension(
|
async def api_enable_extension(
|
||||||
ext_id: str, user: User = Depends(check_user_exists)
|
ext_id: str, user: User = Depends(check_user_exists)
|
||||||
) -> SimpleStatus:
|
) -> SimpleStatus:
|
||||||
if ext_id not in [e.code for e in get_valid_extensions()]:
|
if ext_id not in [e.code for e in await get_valid_extensions()]:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
HTTPStatus.NOT_FOUND, f"Extension '{ext_id}' doesn't exist."
|
HTTPStatus.NOT_FOUND, f"Extension '{ext_id}' doesn't exist."
|
||||||
)
|
)
|
||||||
@@ -196,28 +181,34 @@ async def api_enable_extension(
|
|||||||
assert ext, f"Extension '{ext_id}' is not installed."
|
assert ext, f"Extension '{ext_id}' is not installed."
|
||||||
assert ext.active, f"Extension '{ext_id}' is not activated."
|
assert ext.active, f"Extension '{ext_id}' is not activated."
|
||||||
|
|
||||||
|
user_ext = await get_user_extension(user.id, ext_id)
|
||||||
|
if not user_ext:
|
||||||
|
user_ext = UserExtension(user=user.id, extension=ext_id, active=False)
|
||||||
|
await create_user_extension(user_ext)
|
||||||
|
|
||||||
if user.admin or not ext.requires_payment:
|
if user.admin or not ext.requires_payment:
|
||||||
await update_user_extension(user_id=user.id, extension=ext_id, active=True)
|
user_ext.active = True
|
||||||
|
await update_user_extension(user_ext)
|
||||||
return SimpleStatus(success=True, message=f"Extension '{ext_id}' enabled.")
|
return SimpleStatus(success=True, message=f"Extension '{ext_id}' enabled.")
|
||||||
|
|
||||||
user_ext = await get_user_extension(user.id, ext_id)
|
if not (user_ext.extra and user_ext.extra.payment_hash_to_enable):
|
||||||
if not (user_ext and user_ext.extra and user_ext.extra.payment_hash_to_enable):
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
HTTPStatus.PAYMENT_REQUIRED, f"Extension '{ext_id}' requires payment."
|
HTTPStatus.PAYMENT_REQUIRED, f"Extension '{ext_id}' requires payment."
|
||||||
)
|
)
|
||||||
|
|
||||||
if user_ext.is_paid:
|
if user_ext.is_paid:
|
||||||
await update_user_extension(user_id=user.id, extension=ext_id, active=True)
|
user_ext.active = True
|
||||||
|
await update_user_extension(user_ext)
|
||||||
return SimpleStatus(
|
return SimpleStatus(
|
||||||
success=True, message=f"Paid extension '{ext_id}' enabled."
|
success=True, message=f"Paid extension '{ext_id}' enabled."
|
||||||
)
|
)
|
||||||
|
|
||||||
assert (
|
assert (
|
||||||
ext.pay_to_enable and ext.pay_to_enable.wallet
|
ext.meta and ext.meta.pay_to_enable and ext.meta.pay_to_enable.wallet
|
||||||
), f"Extension '{ext_id}' is missing payment wallet."
|
), f"Extension '{ext_id}' is missing payment wallet."
|
||||||
|
|
||||||
payment_status = await check_transaction_status(
|
payment_status = await check_transaction_status(
|
||||||
wallet_id=ext.pay_to_enable.wallet,
|
wallet_id=ext.meta.pay_to_enable.wallet,
|
||||||
payment_hash=user_ext.extra.payment_hash_to_enable,
|
payment_hash=user_ext.extra.payment_hash_to_enable,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -227,10 +218,9 @@ async def api_enable_extension(
|
|||||||
f"Invoice generated but not paid for enabeling extension '{ext_id}'.",
|
f"Invoice generated but not paid for enabeling extension '{ext_id}'.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
user_ext.active = True
|
||||||
user_ext.extra.paid_to_enable = True
|
user_ext.extra.paid_to_enable = True
|
||||||
await update_user_extension_extra(user.id, ext_id, user_ext.extra)
|
await update_user_extension(user_ext)
|
||||||
|
|
||||||
await update_user_extension(user_id=user.id, extension=ext_id, active=True)
|
|
||||||
return SimpleStatus(success=True, message=f"Paid extension '{ext_id}' enabled.")
|
return SimpleStatus(success=True, message=f"Paid extension '{ext_id}' enabled.")
|
||||||
|
|
||||||
except AssertionError as exc:
|
except AssertionError as exc:
|
||||||
@@ -249,20 +239,19 @@ async def api_enable_extension(
|
|||||||
async def api_disable_extension(
|
async def api_disable_extension(
|
||||||
ext_id: str, user: User = Depends(check_user_exists)
|
ext_id: str, user: User = Depends(check_user_exists)
|
||||||
) -> SimpleStatus:
|
) -> SimpleStatus:
|
||||||
if ext_id not in [e.code for e in get_valid_extensions()]:
|
if ext_id not in [e.code for e in await get_valid_extensions()]:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
HTTPStatus.BAD_REQUEST, f"Extension '{ext_id}' doesn't exist."
|
HTTPStatus.BAD_REQUEST, f"Extension '{ext_id}' doesn't exist."
|
||||||
)
|
)
|
||||||
try:
|
user_ext = await get_user_extension(user.id, ext_id)
|
||||||
logger.info(f"Disabeling extension: {ext_id}.")
|
if not user_ext or not user_ext.active:
|
||||||
await update_user_extension(user_id=user.id, extension=ext_id, active=False)
|
return SimpleStatus(
|
||||||
return SimpleStatus(success=True, message=f"Extension '{ext_id}' disabled.")
|
success=True, message=f"Extension '{ext_id}' already disabled."
|
||||||
except Exception as exc:
|
)
|
||||||
logger.warning(exc)
|
logger.info(f"Disabeling extension: {ext_id}.")
|
||||||
raise HTTPException(
|
user_ext.active = False
|
||||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
await update_user_extension(user_ext)
|
||||||
detail=(f"Failed to disable '{ext_id}'."),
|
return SimpleStatus(success=True, message=f"Extension '{ext_id}' disabled.")
|
||||||
) from exc
|
|
||||||
|
|
||||||
|
|
||||||
@extension_router.put("/{ext_id}/activate", dependencies=[Depends(check_admin)])
|
@extension_router.put("/{ext_id}/activate", dependencies=[Depends(check_admin)])
|
||||||
@@ -270,20 +259,14 @@ async def api_activate_extension(ext_id: str) -> SimpleStatus:
|
|||||||
try:
|
try:
|
||||||
logger.info(f"Activating extension: '{ext_id}'.")
|
logger.info(f"Activating extension: '{ext_id}'.")
|
||||||
|
|
||||||
all_extensions = get_valid_extensions()
|
ext = await get_valid_extension(ext_id)
|
||||||
ext = next((e for e in all_extensions if e.code == ext_id), None)
|
|
||||||
assert ext, f"Extension '{ext_id}' doesn't exist."
|
assert ext, f"Extension '{ext_id}' doesn't exist."
|
||||||
# if extension never loaded (was deactivated on server startup)
|
|
||||||
if ext_id not in sys.modules.keys():
|
|
||||||
# run extension start-up routine
|
|
||||||
core_app_extra.register_new_ext_routes(ext)
|
|
||||||
|
|
||||||
settings.lnbits_deactivated_extensions.discard(ext_id)
|
await activate_extension(ext)
|
||||||
|
|
||||||
await update_installed_extension_state(ext_id=ext_id, active=True)
|
|
||||||
return SimpleStatus(success=True, message=f"Extension '{ext_id}' activated.")
|
return SimpleStatus(success=True, message=f"Extension '{ext_id}' activated.")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning(exc)
|
logger.warning(exc)
|
||||||
|
await deactivate_extension(ext_id)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||||
detail=(f"Failed to activate '{ext_id}'."),
|
detail=(f"Failed to activate '{ext_id}'."),
|
||||||
@@ -295,13 +278,10 @@ async def api_deactivate_extension(ext_id: str) -> SimpleStatus:
|
|||||||
try:
|
try:
|
||||||
logger.info(f"Deactivating extension: '{ext_id}'.")
|
logger.info(f"Deactivating extension: '{ext_id}'.")
|
||||||
|
|
||||||
all_extensions = get_valid_extensions()
|
ext = await get_valid_extension(ext_id)
|
||||||
ext = next((e for e in all_extensions if e.code == ext_id), None)
|
|
||||||
assert ext, f"Extension '{ext_id}' doesn't exist."
|
assert ext, f"Extension '{ext_id}' doesn't exist."
|
||||||
|
|
||||||
settings.lnbits_deactivated_extensions.add(ext_id)
|
await deactivate_extension(ext_id)
|
||||||
|
|
||||||
await update_installed_extension_state(ext_id=ext_id, active=False)
|
|
||||||
return SimpleStatus(success=True, message=f"Extension '{ext_id}' deactivated.")
|
return SimpleStatus(success=True, message=f"Extension '{ext_id}' deactivated.")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning(exc)
|
logger.warning(exc)
|
||||||
@@ -311,27 +291,27 @@ async def api_deactivate_extension(ext_id: str) -> SimpleStatus:
|
|||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
@extension_router.delete("/{ext_id}")
|
@extension_router.delete("/{ext_id}", dependencies=[Depends(check_admin)])
|
||||||
async def api_uninstall_extension(
|
async def api_uninstall_extension(ext_id: str) -> SimpleStatus:
|
||||||
ext_id: str,
|
|
||||||
user: User = Depends(check_admin),
|
|
||||||
access_token: Optional[str] = Depends(check_access_token),
|
|
||||||
) -> SimpleStatus:
|
|
||||||
installed_extensions = await get_installed_extensions()
|
|
||||||
|
|
||||||
extensions = [e for e in installed_extensions if e.id == ext_id]
|
extension = await get_installed_extension(ext_id)
|
||||||
if len(extensions) == 0:
|
if not extension:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.NOT_FOUND,
|
status_code=HTTPStatus.NOT_FOUND,
|
||||||
detail=f"Unknown extension id: {ext_id}",
|
detail=f"Unknown extension id: {ext_id}",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
installed_extensions = await get_installed_extensions()
|
||||||
# check that other extensions do not depend on this one
|
# check that other extensions do not depend on this one
|
||||||
for valid_ext_id in [ext.code for ext in get_valid_extensions()]:
|
for valid_ext_id in [ext.code for ext in await get_valid_extensions()]:
|
||||||
installed_ext = next(
|
installed_ext = next(
|
||||||
(ext for ext in installed_extensions if ext.id == valid_ext_id), None
|
(ext for ext in installed_extensions if ext.id == valid_ext_id), None
|
||||||
)
|
)
|
||||||
if installed_ext and ext_id in installed_ext.dependencies:
|
if (
|
||||||
|
installed_ext
|
||||||
|
and installed_ext.meta
|
||||||
|
and ext_id in installed_ext.meta.dependencies
|
||||||
|
):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.BAD_REQUEST,
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
detail=(
|
detail=(
|
||||||
@@ -341,14 +321,7 @@ async def api_uninstall_extension(
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# call stop while the old routes are still active
|
await uninstall_extension(ext_id)
|
||||||
await stop_extension_background_work(ext_id, user.id, access_token)
|
|
||||||
|
|
||||||
settings.lnbits_deactivated_extensions.add(ext_id)
|
|
||||||
|
|
||||||
for ext_info in extensions:
|
|
||||||
ext_info.clean_extension_files()
|
|
||||||
await delete_installed_extension(ext_id=ext_info.id)
|
|
||||||
|
|
||||||
logger.success(f"Extension '{ext_id}' uninstalled.")
|
logger.success(f"Extension '{ext_id}' uninstalled.")
|
||||||
return SimpleStatus(success=True, message=f"Extension '{ext_id}' uninstalled.")
|
return SimpleStatus(success=True, message=f"Extension '{ext_id}' uninstalled.")
|
||||||
@@ -359,9 +332,9 @@ async def api_uninstall_extension(
|
|||||||
|
|
||||||
|
|
||||||
@extension_router.get("/{ext_id}/releases", dependencies=[Depends(check_admin)])
|
@extension_router.get("/{ext_id}/releases", dependencies=[Depends(check_admin)])
|
||||||
async def get_extension_releases(ext_id: str) -> List[ExtensionRelease]:
|
async def get_extension_releases(ext_id: str) -> list[ExtensionRelease]:
|
||||||
try:
|
try:
|
||||||
extension_releases: List[ExtensionRelease] = (
|
extension_releases: list[ExtensionRelease] = (
|
||||||
await InstallableExtension.get_extension_releases(ext_id)
|
await InstallableExtension.get_extension_releases(ext_id)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -397,9 +370,8 @@ async def get_pay_to_install_invoice(
|
|||||||
assert release, "Release not found."
|
assert release, "Release not found."
|
||||||
assert release.pay_link, "Pay link not found for release."
|
assert release.pay_link, "Pay link not found for release."
|
||||||
|
|
||||||
payment_info = await fetch_release_payment_info(
|
payment_info = await release.fetch_release_payment_info(data.cost_sats)
|
||||||
release.pay_link, data.cost_sats
|
|
||||||
)
|
|
||||||
assert payment_info and payment_info.payment_request, "Cannot request invoice."
|
assert payment_info and payment_info.payment_request, "Cannot request invoice."
|
||||||
invoice = bolt11_decode(payment_info.payment_request)
|
invoice = bolt11_decode(payment_info.payment_request)
|
||||||
|
|
||||||
@@ -427,54 +399,68 @@ async def get_pay_to_install_invoice(
|
|||||||
async def get_pay_to_enable_invoice(
|
async def get_pay_to_enable_invoice(
|
||||||
ext_id: str, data: PayToEnableInfo, user: User = Depends(check_user_exists)
|
ext_id: str, data: PayToEnableInfo, user: User = Depends(check_user_exists)
|
||||||
):
|
):
|
||||||
try:
|
if not data.amount or data.amount <= 0:
|
||||||
assert data.amount and data.amount > 0, "A non-zero amount must be specified."
|
|
||||||
|
|
||||||
ext = await get_installed_extension(ext_id)
|
|
||||||
assert ext, f"Extension '{ext_id}' not found."
|
|
||||||
assert ext.pay_to_enable, f"Payment Info not found for extension '{ext_id}'."
|
|
||||||
assert (
|
|
||||||
ext.pay_to_enable.required
|
|
||||||
), f"Payment not required for extension '{ext_id}'."
|
|
||||||
assert ext.pay_to_enable.wallet and ext.pay_to_enable.amount, (
|
|
||||||
f"Payment wallet or amount missing for extension '{ext_id}'."
|
|
||||||
"Please contact the administrator."
|
|
||||||
)
|
|
||||||
assert (
|
|
||||||
data.amount >= ext.pay_to_enable.amount
|
|
||||||
), f"Minimum amount is {ext.pay_to_enable.amount} sats."
|
|
||||||
|
|
||||||
payment_hash, payment_request = await create_invoice(
|
|
||||||
wallet_id=ext.pay_to_enable.wallet,
|
|
||||||
amount=data.amount,
|
|
||||||
memo=f"Enable '{ext.name}' extension.",
|
|
||||||
)
|
|
||||||
|
|
||||||
user_ext = await get_user_extension(user.id, ext_id)
|
|
||||||
user_ext_info = (
|
|
||||||
user_ext.extra if user_ext and user_ext.extra else UserExtensionInfo()
|
|
||||||
)
|
|
||||||
user_ext_info.payment_hash_to_enable = payment_hash
|
|
||||||
await update_user_extension_extra(user.id, ext_id, user_ext_info)
|
|
||||||
|
|
||||||
return {"payment_hash": payment_hash, "payment_request": payment_request}
|
|
||||||
|
|
||||||
except AssertionError as exc:
|
|
||||||
raise HTTPException(HTTPStatus.BAD_REQUEST, str(exc)) from exc
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning(exc)
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
HTTPStatus.INTERNAL_SERVER_ERROR, "Cannot request invoice."
|
status_code=HTTPStatus.BAD_REQUEST, detail="Amount must be greater than 0."
|
||||||
) from exc
|
)
|
||||||
|
|
||||||
|
ext = await get_installed_extension(ext_id)
|
||||||
|
if not ext:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.NOT_FOUND, detail=f"Extension '{ext_id}' not found."
|
||||||
|
)
|
||||||
|
|
||||||
|
if not ext.meta or not ext.meta.pay_to_enable:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
|
detail=f"Payment info not found for extension '{ext_id}'.",
|
||||||
|
)
|
||||||
|
|
||||||
|
if not ext.meta.pay_to_enable.required:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
|
detail=f"Payment not required for extension '{ext_id}'.",
|
||||||
|
)
|
||||||
|
|
||||||
|
if not ext.meta.pay_to_enable.wallet or not ext.meta.pay_to_enable.amount:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
|
detail=f"Payment wallet or amount missing for extension '{ext_id}'.",
|
||||||
|
)
|
||||||
|
|
||||||
|
if data.amount < ext.meta.pay_to_enable.amount:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
|
detail=(
|
||||||
|
f"Amount {data.amount} sats is less than required "
|
||||||
|
f"{ext.meta.pay_to_enable.amount} sats."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
payment = await create_invoice(
|
||||||
|
wallet_id=ext.meta.pay_to_enable.wallet,
|
||||||
|
amount=data.amount,
|
||||||
|
memo=f"Enable '{ext.name}' extension.",
|
||||||
|
)
|
||||||
|
|
||||||
|
user_ext = await get_user_extension(user.id, ext_id)
|
||||||
|
if not user_ext:
|
||||||
|
user_ext = UserExtension(user=user.id, extension=ext_id, active=False)
|
||||||
|
await create_user_extension(user_ext)
|
||||||
|
user_ext_info = user_ext.extra if user_ext.extra else UserExtensionInfo()
|
||||||
|
user_ext_info.payment_hash_to_enable = payment.payment_hash
|
||||||
|
user_ext.extra = user_ext_info
|
||||||
|
await update_user_extension(user_ext)
|
||||||
|
return {"payment_hash": payment.payment_hash, "payment_request": payment.bolt11}
|
||||||
|
|
||||||
|
|
||||||
@extension_router.get(
|
@extension_router.get(
|
||||||
"/release/{org}/{repo}/{tag_name}",
|
"/release/{org}/{repo}/{tag_name}",
|
||||||
dependencies=[Depends(check_admin)],
|
dependencies=[Depends(check_user_exists)],
|
||||||
)
|
)
|
||||||
async def get_extension_release(org: str, repo: str, tag_name: str):
|
async def get_extension_release(org: str, repo: str, tag_name: str):
|
||||||
try:
|
try:
|
||||||
config = await fetch_github_release_config(org, repo, tag_name)
|
config = await ExtensionConfig.fetch_github_release_config(org, repo, tag_name)
|
||||||
if not config:
|
if not config:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
@@ -489,13 +475,26 @@ async def get_extension_release(org: str, repo: str, tag_name: str):
|
|||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@extension_router.get("")
|
||||||
|
async def api_get_user_extensions(
|
||||||
|
user: User = Depends(check_user_exists),
|
||||||
|
) -> list[Extension]:
|
||||||
|
|
||||||
|
user_extensions_ids = [ue.extension for ue in await get_user_extensions(user.id)]
|
||||||
|
return [
|
||||||
|
ext
|
||||||
|
for ext in await get_valid_extensions(False)
|
||||||
|
if ext.code in user_extensions_ids
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@extension_router.delete(
|
@extension_router.delete(
|
||||||
"/{ext_id}/db",
|
"/{ext_id}/db",
|
||||||
dependencies=[Depends(check_admin)],
|
dependencies=[Depends(check_admin)],
|
||||||
)
|
)
|
||||||
async def delete_extension_db(ext_id: str):
|
async def delete_extension_db(ext_id: str):
|
||||||
try:
|
try:
|
||||||
db_version = (await get_dbversions()).get(ext_id, None)
|
db_version = await get_db_version(ext_id)
|
||||||
if not db_version:
|
if not db_version:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.BAD_REQUEST,
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
|
|||||||
+160
-91
@@ -1,29 +1,33 @@
|
|||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Annotated, List, Optional, Union
|
from typing import Annotated, List, Optional, Union
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlencode, urlparse
|
||||||
|
|
||||||
|
import httpx
|
||||||
from fastapi import Cookie, Depends, Query, Request
|
from fastapi import Cookie, Depends, Query, Request
|
||||||
from fastapi.exceptions import HTTPException
|
from fastapi.exceptions import HTTPException
|
||||||
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
|
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
|
||||||
from fastapi.routing import APIRouter
|
from fastapi.routing import APIRouter
|
||||||
from loguru import logger
|
from lnurl import decode as lnurl_decode
|
||||||
from pydantic.types import UUID4
|
from pydantic.types import UUID4
|
||||||
|
|
||||||
from lnbits.core.helpers import to_valid_user_id
|
from lnbits.core.helpers import to_valid_user_id
|
||||||
from lnbits.core.models import User
|
from lnbits.core.models import User
|
||||||
|
from lnbits.core.models.extensions import ExtensionMeta, InstallableExtension
|
||||||
|
from lnbits.core.services import create_invoice, create_user_account
|
||||||
|
from lnbits.core.services.extensions import get_valid_extensions
|
||||||
from lnbits.decorators import check_admin, check_user_exists
|
from lnbits.decorators import check_admin, check_user_exists
|
||||||
from lnbits.helpers import template_renderer
|
from lnbits.helpers import template_renderer
|
||||||
from lnbits.settings import settings
|
from lnbits.settings import settings
|
||||||
from lnbits.wallets import get_funding_source
|
from lnbits.wallets import get_funding_source
|
||||||
|
|
||||||
from ...extension_manager import InstallableExtension, get_valid_extensions
|
|
||||||
from ...utils.exchange_rates import allowed_currencies, currencies
|
from ...utils.exchange_rates import allowed_currencies, currencies
|
||||||
from ..crud import (
|
from ..crud import (
|
||||||
create_wallet,
|
create_wallet,
|
||||||
get_dbversions,
|
get_db_versions,
|
||||||
get_installed_extensions,
|
get_installed_extensions,
|
||||||
get_user,
|
get_user,
|
||||||
|
get_wallet,
|
||||||
)
|
)
|
||||||
|
|
||||||
generic_router = APIRouter(
|
generic_router = APIRouter(
|
||||||
@@ -70,83 +74,87 @@ async def robots():
|
|||||||
|
|
||||||
@generic_router.get("/extensions", name="extensions", response_class=HTMLResponse)
|
@generic_router.get("/extensions", name="extensions", response_class=HTMLResponse)
|
||||||
async def extensions(request: Request, user: User = Depends(check_user_exists)):
|
async def extensions(request: Request, user: User = Depends(check_user_exists)):
|
||||||
try:
|
installed_exts: List[InstallableExtension] = await get_installed_extensions()
|
||||||
installed_exts: List["InstallableExtension"] = await get_installed_extensions()
|
installed_exts_ids = [e.id for e in installed_exts]
|
||||||
installed_exts_ids = [e.id for e in installed_exts]
|
|
||||||
|
|
||||||
installable_exts = await InstallableExtension.get_installable_extensions()
|
installable_exts = await InstallableExtension.get_installable_extensions()
|
||||||
installable_exts_ids = [e.id for e in installable_exts]
|
installable_exts_ids = [e.id for e in installable_exts]
|
||||||
installable_exts += [
|
installable_exts += [e for e in installed_exts if e.id not in installable_exts_ids]
|
||||||
e for e in installed_exts if e.id not in installable_exts_ids
|
|
||||||
]
|
|
||||||
|
|
||||||
for e in installable_exts:
|
for e in installable_exts:
|
||||||
installed_ext = next((ie for ie in installed_exts if e.id == ie.id), None)
|
installed_ext = next((ie for ie in installed_exts if e.id == ie.id), None)
|
||||||
if installed_ext:
|
if installed_ext and installed_ext.meta:
|
||||||
e.installed_release = installed_ext.installed_release
|
installed_release = installed_ext.meta.installed_release
|
||||||
if installed_ext.pay_to_enable and not user.admin:
|
if installed_ext.meta.pay_to_enable and not user.admin:
|
||||||
# not a security leak, but better not to share the wallet id
|
# not a security leak, but better not to share the wallet id
|
||||||
installed_ext.pay_to_enable.wallet = None
|
installed_ext.meta.pay_to_enable.wallet = None
|
||||||
e.pay_to_enable = installed_ext.pay_to_enable
|
pay_to_enable = installed_ext.meta.pay_to_enable
|
||||||
|
|
||||||
# use the installed extension values
|
if e.meta:
|
||||||
e.name = installed_ext.name
|
e.meta.installed_release = installed_release
|
||||||
e.short_description = installed_ext.short_description
|
e.meta.pay_to_enable = pay_to_enable
|
||||||
e.icon = installed_ext.icon
|
else:
|
||||||
|
e.meta = ExtensionMeta(
|
||||||
|
installed_release=installed_release,
|
||||||
|
pay_to_enable=pay_to_enable,
|
||||||
|
)
|
||||||
|
# use the installed extension values
|
||||||
|
e.name = installed_ext.name
|
||||||
|
e.short_description = installed_ext.short_description
|
||||||
|
e.icon = installed_ext.icon
|
||||||
|
|
||||||
except Exception as ex:
|
all_ext_ids = [ext.code for ext in await get_valid_extensions()]
|
||||||
logger.warning(ex)
|
inactive_extensions = [e.id for e in await get_installed_extensions(active=False)]
|
||||||
installable_exts = []
|
db_versions = await get_db_versions()
|
||||||
installed_exts_ids = []
|
|
||||||
|
|
||||||
try:
|
extensions = [
|
||||||
all_ext_ids = [ext.code for ext in get_valid_extensions()]
|
{
|
||||||
inactive_extensions = [
|
"id": ext.id,
|
||||||
e.id for e in await get_installed_extensions(active=False)
|
"name": ext.name,
|
||||||
]
|
"icon": ext.icon,
|
||||||
db_version = await get_dbversions()
|
"shortDescription": ext.short_description,
|
||||||
extensions = [
|
"stars": ext.stars,
|
||||||
{
|
"isFeatured": ext.meta.featured if ext.meta else False,
|
||||||
"id": ext.id,
|
"dependencies": ext.meta.dependencies if ext.meta else "",
|
||||||
"name": ext.name,
|
"isInstalled": ext.id in installed_exts_ids,
|
||||||
"icon": ext.icon,
|
"hasDatabaseTables": next(
|
||||||
"shortDescription": ext.short_description,
|
(True for version in db_versions if version.db == ext.id), False
|
||||||
"stars": ext.stars,
|
),
|
||||||
"isFeatured": ext.featured,
|
"isAvailable": ext.id in all_ext_ids,
|
||||||
"dependencies": ext.dependencies,
|
"isAdminOnly": ext.id in settings.lnbits_admin_extensions,
|
||||||
"isInstalled": ext.id in installed_exts_ids,
|
"isActive": ext.id not in inactive_extensions,
|
||||||
"hasDatabaseTables": ext.id in db_version,
|
"latestRelease": (
|
||||||
"isAvailable": ext.id in all_ext_ids,
|
dict(ext.meta.latest_release)
|
||||||
"isAdminOnly": ext.id in settings.lnbits_admin_extensions,
|
if ext.meta and ext.meta.latest_release
|
||||||
"isActive": ext.id not in inactive_extensions,
|
else None
|
||||||
"latestRelease": (
|
),
|
||||||
dict(ext.latest_release) if ext.latest_release else None
|
"installedRelease": (
|
||||||
),
|
dict(ext.meta.installed_release)
|
||||||
"installedRelease": (
|
if ext.meta and ext.meta.installed_release
|
||||||
dict(ext.installed_release) if ext.installed_release else None
|
else None
|
||||||
),
|
),
|
||||||
"payToEnable": (dict(ext.pay_to_enable) if ext.pay_to_enable else {}),
|
"payToEnable": (
|
||||||
"isPaymentRequired": ext.requires_payment,
|
dict(ext.meta.pay_to_enable)
|
||||||
}
|
if ext.meta and ext.meta.pay_to_enable
|
||||||
for ext in installable_exts
|
else {}
|
||||||
]
|
),
|
||||||
|
"isPaymentRequired": ext.requires_payment,
|
||||||
|
}
|
||||||
|
for ext in installable_exts
|
||||||
|
]
|
||||||
|
|
||||||
# refresh user state. Eg: enabled extensions.
|
# refresh user state. Eg: enabled extensions.
|
||||||
user = await get_user(user.id) or user
|
# TODO: refactor
|
||||||
|
# user = await get_user(user.id) or user
|
||||||
|
|
||||||
return template_renderer().TemplateResponse(
|
return template_renderer().TemplateResponse(
|
||||||
request,
|
request,
|
||||||
"core/extensions.html",
|
"core/extensions.html",
|
||||||
{
|
{
|
||||||
"user": user.dict(),
|
"user": user.json(),
|
||||||
"extensions": extensions,
|
"extensions": extensions,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
|
||||||
logger.warning(exc)
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(exc)
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
|
|
||||||
@generic_router.get(
|
@generic_router.get(
|
||||||
@@ -161,18 +169,16 @@ async def wallet(
|
|||||||
wal: Optional[UUID4] = Query(None),
|
wal: Optional[UUID4] = Query(None),
|
||||||
):
|
):
|
||||||
if wal:
|
if wal:
|
||||||
wallet_id = wal.hex
|
wallet = await get_wallet(wal.hex)
|
||||||
elif len(user.wallets) == 0:
|
elif len(user.wallets) == 0:
|
||||||
wallet = await create_wallet(user_id=user.id)
|
wallet = await create_wallet(user_id=user.id)
|
||||||
user = await get_user(user_id=user.id) or user
|
user.wallets.append(wallet)
|
||||||
wallet_id = wallet.id
|
|
||||||
elif lnbits_last_active_wallet and user.get_wallet(lnbits_last_active_wallet):
|
elif lnbits_last_active_wallet and user.get_wallet(lnbits_last_active_wallet):
|
||||||
wallet_id = lnbits_last_active_wallet
|
wallet = await get_wallet(lnbits_last_active_wallet)
|
||||||
else:
|
else:
|
||||||
wallet_id = user.wallets[0].id
|
wallet = user.wallets[0]
|
||||||
|
|
||||||
user_wallet = user.get_wallet(wallet_id)
|
if not wallet or wallet.deleted:
|
||||||
if not user_wallet or user_wallet.deleted:
|
|
||||||
return template_renderer().TemplateResponse(
|
return template_renderer().TemplateResponse(
|
||||||
request, "error.html", {"err": "Wallet not found"}, HTTPStatus.NOT_FOUND
|
request, "error.html", {"err": "Wallet not found"}, HTTPStatus.NOT_FOUND
|
||||||
)
|
)
|
||||||
@@ -181,15 +187,16 @@ async def wallet(
|
|||||||
request,
|
request,
|
||||||
"core/wallet.html",
|
"core/wallet.html",
|
||||||
{
|
{
|
||||||
"user": user.dict(),
|
"user": user.json(),
|
||||||
"wallet": user_wallet.dict(),
|
"wallet": wallet.json(),
|
||||||
|
"wallet_name": wallet.name,
|
||||||
"currencies": allowed_currencies(),
|
"currencies": allowed_currencies(),
|
||||||
"service_fee": settings.lnbits_service_fee,
|
"service_fee": settings.lnbits_service_fee,
|
||||||
"service_fee_max": settings.lnbits_service_fee_max,
|
"service_fee_max": settings.lnbits_service_fee_max,
|
||||||
"web_manifest": f"/manifest/{user.id}.webmanifest",
|
"web_manifest": f"/manifest/{user.id}.webmanifest",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
resp.set_cookie("lnbits_last_active_wallet", wallet_id)
|
resp.set_cookie("lnbits_last_active_wallet", wallet.id)
|
||||||
return resp
|
return resp
|
||||||
|
|
||||||
|
|
||||||
@@ -206,7 +213,7 @@ async def account(
|
|||||||
request,
|
request,
|
||||||
"core/account.html",
|
"core/account.html",
|
||||||
{
|
{
|
||||||
"user": user.dict(),
|
"user": user.json(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -226,11 +233,9 @@ async def service_worker(request: Request):
|
|||||||
@generic_router.get("/manifest/{usr}.webmanifest")
|
@generic_router.get("/manifest/{usr}.webmanifest")
|
||||||
async def manifest(request: Request, usr: str):
|
async def manifest(request: Request, usr: str):
|
||||||
host = urlparse(str(request.url)).netloc
|
host = urlparse(str(request.url)).netloc
|
||||||
|
|
||||||
user = await get_user(usr)
|
user = await get_user(usr)
|
||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(status_code=HTTPStatus.NOT_FOUND)
|
raise HTTPException(status_code=HTTPStatus.NOT_FOUND)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"short_name": settings.lnbits_site_title,
|
"short_name": settings.lnbits_site_title,
|
||||||
"name": settings.lnbits_site_title + " Wallet",
|
"name": settings.lnbits_site_title + " Wallet",
|
||||||
@@ -318,10 +323,10 @@ async def node(request: Request, user: User = Depends(check_admin)):
|
|||||||
request,
|
request,
|
||||||
"node/index.html",
|
"node/index.html",
|
||||||
{
|
{
|
||||||
"user": user.dict(),
|
"user": user.json(),
|
||||||
"settings": settings.dict(),
|
"settings": settings.dict(),
|
||||||
"balance": balance,
|
"balance": balance,
|
||||||
"wallets": user.wallets[0].dict(),
|
"wallets": user.wallets[0].json(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -356,7 +361,7 @@ async def admin_index(request: Request, user: User = Depends(check_admin)):
|
|||||||
request,
|
request,
|
||||||
"admin/index.html",
|
"admin/index.html",
|
||||||
{
|
{
|
||||||
"user": user.dict(),
|
"user": user.json(),
|
||||||
"settings": settings.dict(),
|
"settings": settings.dict(),
|
||||||
"balance": balance,
|
"balance": balance,
|
||||||
"currencies": list(currencies.keys()),
|
"currencies": list(currencies.keys()),
|
||||||
@@ -373,13 +378,27 @@ async def users_index(request: Request, user: User = Depends(check_admin)):
|
|||||||
"users/index.html",
|
"users/index.html",
|
||||||
{
|
{
|
||||||
"request": request,
|
"request": request,
|
||||||
"user": user.dict(),
|
"user": user.json(),
|
||||||
"settings": settings.dict(),
|
"settings": settings.dict(),
|
||||||
"currencies": list(currencies.keys()),
|
"currencies": list(currencies.keys()),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@generic_router.get("/audit", response_class=HTMLResponse)
|
||||||
|
async def audit_index(request: Request, user: User = Depends(check_admin)):
|
||||||
|
if not settings.lnbits_audit_enabled:
|
||||||
|
raise HTTPException(HTTPStatus.NOT_FOUND, "Audit not enabled")
|
||||||
|
|
||||||
|
return template_renderer().TemplateResponse(
|
||||||
|
"audit/index.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"user": user.json(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@generic_router.get("/uuidv4/{hex_value}")
|
@generic_router.get("/uuidv4/{hex_value}")
|
||||||
async def hex_to_uuid4(hex_value: str):
|
async def hex_to_uuid4(hex_value: str):
|
||||||
try:
|
try:
|
||||||
@@ -389,3 +408,53 @@ async def hex_to_uuid4(hex_value: str):
|
|||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.BAD_REQUEST, detail=str(exc)
|
status_code=HTTPStatus.BAD_REQUEST, detail=str(exc)
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@generic_router.get("/lnurlwallet", response_class=RedirectResponse)
|
||||||
|
async def lnurlwallet(request: Request):
|
||||||
|
"""
|
||||||
|
If a user doesn't have a Lightning Network wallet and scans the LNURLw QR code with
|
||||||
|
their smartphone camera, or a QR scanner app, they can follow the link provided to
|
||||||
|
claim their satoshis and get an instant LNbits wallet! lnbits/withdraw docs
|
||||||
|
"""
|
||||||
|
|
||||||
|
lightning_param = request.query_params.get("lightning")
|
||||||
|
if not lightning_param:
|
||||||
|
return {"status": "ERROR", "reason": "lightning parameter not provided."}
|
||||||
|
if not settings.lnbits_allow_new_accounts:
|
||||||
|
return {"status": "ERROR", "reason": "New accounts are not allowed."}
|
||||||
|
|
||||||
|
lnurl = lnurl_decode(lightning_param)
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
|
||||||
|
res1 = await client.get(lnurl, timeout=2)
|
||||||
|
res1.raise_for_status()
|
||||||
|
data1 = res1.json()
|
||||||
|
if data1.get("tag") != "withdrawRequest":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
|
detail="Invalid lnurl. Expected tag=withdrawRequest",
|
||||||
|
)
|
||||||
|
if not data1.get("maxWithdrawable"):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
|
detail="Invalid lnurl. Expected maxWithdrawable",
|
||||||
|
)
|
||||||
|
account = await create_user_account()
|
||||||
|
wallet = await create_wallet(user_id=account.id)
|
||||||
|
_, payment_request = await create_invoice(
|
||||||
|
wallet_id=wallet.id,
|
||||||
|
amount=data1.get("maxWithdrawable") / 1000,
|
||||||
|
memo=data1.get("defaultDescription", "lnurl wallet withdraw"),
|
||||||
|
)
|
||||||
|
url = data1.get("callback")
|
||||||
|
params = {"k1": data1.get("k1"), "pr": payment_request}
|
||||||
|
callback = url + ("&" if urlparse(url).query else "?") + urlencode(params)
|
||||||
|
|
||||||
|
res2 = await client.get(callback, timeout=2)
|
||||||
|
res2.raise_for_status()
|
||||||
|
|
||||||
|
return RedirectResponse(
|
||||||
|
f"/wallet?usr={account.id}&wal={wallet.id}",
|
||||||
|
)
|
||||||
|
|||||||
@@ -3,13 +3,12 @@ import json
|
|||||||
import uuid
|
import uuid
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
from math import ceil
|
from math import ceil
|
||||||
from typing import List, Optional, Union
|
from typing import List, Optional
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import (
|
from fastapi import (
|
||||||
APIRouter,
|
APIRouter,
|
||||||
Body,
|
|
||||||
Depends,
|
Depends,
|
||||||
Header,
|
Header,
|
||||||
HTTPException,
|
HTTPException,
|
||||||
@@ -21,12 +20,12 @@ from loguru import logger
|
|||||||
from sse_starlette.sse import EventSourceResponse
|
from sse_starlette.sse import EventSourceResponse
|
||||||
|
|
||||||
from lnbits import bolt11
|
from lnbits import bolt11
|
||||||
from lnbits.core.db import db
|
|
||||||
from lnbits.core.models import (
|
from lnbits.core.models import (
|
||||||
CreateInvoice,
|
CreateInvoice,
|
||||||
CreateLnurl,
|
CreateLnurl,
|
||||||
DecodePayment,
|
DecodePayment,
|
||||||
KeyType,
|
KeyType,
|
||||||
|
PayLnurlWData,
|
||||||
Payment,
|
Payment,
|
||||||
PaymentFilters,
|
PaymentFilters,
|
||||||
PaymentHistoryPoint,
|
PaymentHistoryPoint,
|
||||||
@@ -35,12 +34,11 @@ from lnbits.core.models import (
|
|||||||
from lnbits.db import Filters, Page
|
from lnbits.db import Filters, Page
|
||||||
from lnbits.decorators import (
|
from lnbits.decorators import (
|
||||||
WalletTypeInfo,
|
WalletTypeInfo,
|
||||||
get_key_type,
|
|
||||||
parse_filters,
|
parse_filters,
|
||||||
require_admin_key,
|
require_admin_key,
|
||||||
require_invoice_key,
|
require_invoice_key,
|
||||||
)
|
)
|
||||||
from lnbits.helpers import generate_filter_params_openapi
|
from lnbits.helpers import filter_dict_keys, generate_filter_params_openapi
|
||||||
from lnbits.lnurl import decode as lnurl_decode
|
from lnbits.lnurl import decode as lnurl_decode
|
||||||
from lnbits.settings import settings
|
from lnbits.settings import settings
|
||||||
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis
|
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis
|
||||||
@@ -52,13 +50,12 @@ from ..crud import (
|
|||||||
get_payments_paginated,
|
get_payments_paginated,
|
||||||
get_standalone_payment,
|
get_standalone_payment,
|
||||||
get_wallet_for_key,
|
get_wallet_for_key,
|
||||||
update_pending_payments,
|
|
||||||
)
|
)
|
||||||
from ..services import (
|
from ..services import (
|
||||||
check_transaction_status,
|
|
||||||
create_invoice,
|
create_invoice,
|
||||||
fee_reserve_total,
|
fee_reserve_total,
|
||||||
pay_invoice,
|
pay_invoice,
|
||||||
|
update_pending_payments,
|
||||||
)
|
)
|
||||||
from ..tasks import api_invoice_listeners
|
from ..tasks import api_invoice_listeners
|
||||||
|
|
||||||
@@ -74,12 +71,12 @@ payment_router = APIRouter(prefix="/api/v1/payments", tags=["Payments"])
|
|||||||
openapi_extra=generate_filter_params_openapi(PaymentFilters),
|
openapi_extra=generate_filter_params_openapi(PaymentFilters),
|
||||||
)
|
)
|
||||||
async def api_payments(
|
async def api_payments(
|
||||||
wallet: WalletTypeInfo = Depends(get_key_type),
|
key_info: WalletTypeInfo = Depends(require_invoice_key),
|
||||||
filters: Filters = Depends(parse_filters(PaymentFilters)),
|
filters: Filters = Depends(parse_filters(PaymentFilters)),
|
||||||
):
|
):
|
||||||
await update_pending_payments(wallet.wallet.id)
|
await update_pending_payments(key_info.wallet.id)
|
||||||
return await get_payments(
|
return await get_payments(
|
||||||
wallet_id=wallet.wallet.id,
|
wallet_id=key_info.wallet.id,
|
||||||
pending=True,
|
pending=True,
|
||||||
complete=True,
|
complete=True,
|
||||||
filters=filters,
|
filters=filters,
|
||||||
@@ -93,12 +90,12 @@ async def api_payments(
|
|||||||
openapi_extra=generate_filter_params_openapi(PaymentFilters),
|
openapi_extra=generate_filter_params_openapi(PaymentFilters),
|
||||||
)
|
)
|
||||||
async def api_payments_history(
|
async def api_payments_history(
|
||||||
wallet: WalletTypeInfo = Depends(get_key_type),
|
key_info: WalletTypeInfo = Depends(require_invoice_key),
|
||||||
group: DateTrunc = Query("day"),
|
group: DateTrunc = Query("day"),
|
||||||
filters: Filters[PaymentFilters] = Depends(parse_filters(PaymentFilters)),
|
filters: Filters[PaymentFilters] = Depends(parse_filters(PaymentFilters)),
|
||||||
):
|
):
|
||||||
await update_pending_payments(wallet.wallet.id)
|
await update_pending_payments(key_info.wallet.id)
|
||||||
return await get_payments_history(wallet.wallet.id, group, filters)
|
return await get_payments_history(key_info.wallet.id, group, filters)
|
||||||
|
|
||||||
|
|
||||||
@payment_router.get(
|
@payment_router.get(
|
||||||
@@ -110,12 +107,12 @@ async def api_payments_history(
|
|||||||
openapi_extra=generate_filter_params_openapi(PaymentFilters),
|
openapi_extra=generate_filter_params_openapi(PaymentFilters),
|
||||||
)
|
)
|
||||||
async def api_payments_paginated(
|
async def api_payments_paginated(
|
||||||
wallet: WalletTypeInfo = Depends(get_key_type),
|
key_info: WalletTypeInfo = Depends(require_invoice_key),
|
||||||
filters: Filters = Depends(parse_filters(PaymentFilters)),
|
filters: Filters = Depends(parse_filters(PaymentFilters)),
|
||||||
):
|
):
|
||||||
await update_pending_payments(wallet.wallet.id)
|
await update_pending_payments(key_info.wallet.id)
|
||||||
page = await get_payments_paginated(
|
page = await get_payments_paginated(
|
||||||
wallet_id=wallet.wallet.id,
|
wallet_id=key_info.wallet.id,
|
||||||
pending=True,
|
pending=True,
|
||||||
complete=True,
|
complete=True,
|
||||||
filters=filters,
|
filters=filters,
|
||||||
@@ -123,7 +120,7 @@ async def api_payments_paginated(
|
|||||||
return page
|
return page
|
||||||
|
|
||||||
|
|
||||||
async def api_payments_create_invoice(data: CreateInvoice, wallet: Wallet):
|
async def _api_payments_create_invoice(data: CreateInvoice, wallet: Wallet):
|
||||||
description_hash = b""
|
description_hash = b""
|
||||||
unhashed_description = b""
|
unhashed_description = b""
|
||||||
memo = data.memo or settings.lnbits_site_title
|
memo = data.memo or settings.lnbits_site_title
|
||||||
@@ -147,60 +144,42 @@ async def api_payments_create_invoice(data: CreateInvoice, wallet: Wallet):
|
|||||||
# do not save memo if description_hash or unhashed_description is set
|
# do not save memo if description_hash or unhashed_description is set
|
||||||
memo = ""
|
memo = ""
|
||||||
|
|
||||||
async with db.connect() as conn:
|
payment = await create_invoice(
|
||||||
payment_hash, payment_request = await create_invoice(
|
wallet_id=wallet.id,
|
||||||
wallet_id=wallet.id,
|
amount=data.amount,
|
||||||
amount=data.amount,
|
memo=memo,
|
||||||
memo=memo,
|
currency=data.unit,
|
||||||
currency=data.unit,
|
description_hash=description_hash,
|
||||||
description_hash=description_hash,
|
unhashed_description=unhashed_description,
|
||||||
unhashed_description=unhashed_description,
|
expiry=data.expiry,
|
||||||
expiry=data.expiry,
|
extra=data.extra,
|
||||||
extra=data.extra,
|
webhook=data.webhook,
|
||||||
webhook=data.webhook,
|
internal=data.internal,
|
||||||
internal=data.internal,
|
)
|
||||||
conn=conn,
|
|
||||||
)
|
|
||||||
# NOTE: we get the checking_id with a seperate query because create_invoice
|
|
||||||
# does not return it and it would be a big hustle to change its return type
|
|
||||||
# (used across extensions)
|
|
||||||
payment_db = await get_standalone_payment(payment_hash, conn=conn)
|
|
||||||
assert payment_db is not None, "payment not found"
|
|
||||||
checking_id = payment_db.checking_id
|
|
||||||
|
|
||||||
invoice = bolt11.decode(payment_request)
|
# lnurl_response is not saved in the database
|
||||||
|
|
||||||
lnurl_response: Union[None, bool, str] = None
|
|
||||||
if data.lnurl_callback:
|
if data.lnurl_callback:
|
||||||
headers = {"User-Agent": settings.user_agent}
|
headers = {"User-Agent": settings.user_agent}
|
||||||
async with httpx.AsyncClient(headers=headers) as client:
|
async with httpx.AsyncClient(headers=headers) as client:
|
||||||
try:
|
try:
|
||||||
r = await client.get(
|
r = await client.get(
|
||||||
data.lnurl_callback,
|
data.lnurl_callback,
|
||||||
params={
|
params={"pr": payment.bolt11},
|
||||||
"pr": payment_request,
|
|
||||||
},
|
|
||||||
timeout=10,
|
timeout=10,
|
||||||
)
|
)
|
||||||
if r.is_error:
|
if r.is_error:
|
||||||
lnurl_response = r.text
|
payment.extra["lnurl_response"] = r.text
|
||||||
else:
|
else:
|
||||||
resp = json.loads(r.text)
|
resp = json.loads(r.text)
|
||||||
if resp["status"] != "OK":
|
if resp["status"] != "OK":
|
||||||
lnurl_response = resp["reason"]
|
payment.extra["lnurl_response"] = resp["reason"]
|
||||||
else:
|
else:
|
||||||
lnurl_response = True
|
payment.extra["lnurl_response"] = True
|
||||||
except (httpx.ConnectError, httpx.RequestError) as ex:
|
except (httpx.ConnectError, httpx.RequestError) as ex:
|
||||||
logger.error(ex)
|
logger.error(ex)
|
||||||
lnurl_response = False
|
payment.extra["lnurl_response"] = False
|
||||||
|
|
||||||
return {
|
return payment
|
||||||
"payment_hash": invoice.payment_hash,
|
|
||||||
"payment_request": payment_request,
|
|
||||||
"lnurl_response": lnurl_response,
|
|
||||||
# maintain backwards compatibility with API clients:
|
|
||||||
"checking_id": checking_id,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@payment_router.post(
|
@payment_router.post(
|
||||||
@@ -222,30 +201,25 @@ async def api_payments_create_invoice(data: CreateInvoice, wallet: Wallet):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
async def api_payments_create(
|
async def api_payments_create(
|
||||||
|
invoice_data: CreateInvoice,
|
||||||
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
||||||
invoice_data: CreateInvoice = Body(...),
|
) -> Payment:
|
||||||
):
|
|
||||||
if invoice_data.out is True and wallet.key_type == KeyType.admin:
|
if invoice_data.out is True and wallet.key_type == KeyType.admin:
|
||||||
if not invoice_data.bolt11:
|
if not invoice_data.bolt11:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.BAD_REQUEST,
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
detail="BOLT11 string is invalid or not given",
|
detail="Missing BOLT11 invoice",
|
||||||
)
|
)
|
||||||
|
payment = await pay_invoice(
|
||||||
payment_hash = await pay_invoice(
|
|
||||||
wallet_id=wallet.wallet.id,
|
wallet_id=wallet.wallet.id,
|
||||||
payment_request=invoice_data.bolt11,
|
payment_request=invoice_data.bolt11,
|
||||||
extra=invoice_data.extra,
|
extra=invoice_data.extra,
|
||||||
)
|
)
|
||||||
return {
|
return payment
|
||||||
"payment_hash": payment_hash,
|
|
||||||
# maintain backwards compatibility with API clients:
|
|
||||||
"checking_id": payment_hash,
|
|
||||||
}
|
|
||||||
|
|
||||||
elif not invoice_data.out:
|
elif not invoice_data.out:
|
||||||
# invoice key
|
# invoice key
|
||||||
return await api_payments_create_invoice(invoice_data, wallet.wallet)
|
return await _api_payments_create_invoice(invoice_data, wallet.wallet)
|
||||||
else:
|
else:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.UNAUTHORIZED,
|
status_code=HTTPStatus.UNAUTHORIZED,
|
||||||
@@ -271,7 +245,7 @@ async def api_payments_fee_reserve(invoice: str = Query("invoice")) -> JSONRespo
|
|||||||
@payment_router.post("/lnurl")
|
@payment_router.post("/lnurl")
|
||||||
async def api_payments_pay_lnurl(
|
async def api_payments_pay_lnurl(
|
||||||
data: CreateLnurl, wallet: WalletTypeInfo = Depends(require_admin_key)
|
data: CreateLnurl, wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||||
):
|
) -> Payment:
|
||||||
domain = urlparse(data.callback).netloc
|
domain = urlparse(data.callback).netloc
|
||||||
|
|
||||||
headers = {"User-Agent": settings.user_agent}
|
headers = {"User-Agent": settings.user_agent}
|
||||||
@@ -315,15 +289,12 @@ async def api_payments_pay_lnurl(
|
|||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.BAD_REQUEST,
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
detail=(
|
detail=(
|
||||||
(
|
f"{domain} returned an invalid invoice. Expected"
|
||||||
f"{domain} returned an invalid invoice. Expected"
|
f" {amount_msat} msat, got {invoice.amount_msat}."
|
||||||
f" {amount_msat} msat, got {invoice.amount_msat}."
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
extra = {}
|
extra = {}
|
||||||
|
|
||||||
if params.get("successAction"):
|
if params.get("successAction"):
|
||||||
extra["success_action"] = params["successAction"]
|
extra["success_action"] = params["successAction"]
|
||||||
if data.comment:
|
if data.comment:
|
||||||
@@ -332,19 +303,14 @@ async def api_payments_pay_lnurl(
|
|||||||
extra["fiat_currency"] = data.unit
|
extra["fiat_currency"] = data.unit
|
||||||
extra["fiat_amount"] = data.amount / 1000
|
extra["fiat_amount"] = data.amount / 1000
|
||||||
assert data.description is not None, "description is required"
|
assert data.description is not None, "description is required"
|
||||||
payment_hash = await pay_invoice(
|
|
||||||
|
payment = await pay_invoice(
|
||||||
wallet_id=wallet.wallet.id,
|
wallet_id=wallet.wallet.id,
|
||||||
payment_request=params["pr"],
|
payment_request=params["pr"],
|
||||||
description=data.description,
|
description=data.description,
|
||||||
extra=extra,
|
extra=extra,
|
||||||
)
|
)
|
||||||
|
return payment
|
||||||
return {
|
|
||||||
"success_action": params.get("successAction"),
|
|
||||||
"payment_hash": payment_hash,
|
|
||||||
# maintain backwards compatibility with API clients:
|
|
||||||
"checking_id": payment_hash,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def subscribe_wallet_invoices(request: Request, wallet: Wallet):
|
async def subscribe_wallet_invoices(request: Request, wallet: Wallet):
|
||||||
@@ -379,10 +345,10 @@ async def subscribe_wallet_invoices(request: Request, wallet: Wallet):
|
|||||||
|
|
||||||
@payment_router.get("/sse")
|
@payment_router.get("/sse")
|
||||||
async def api_payments_sse(
|
async def api_payments_sse(
|
||||||
request: Request, wallet: WalletTypeInfo = Depends(get_key_type)
|
request: Request, key_info: WalletTypeInfo = Depends(require_invoice_key)
|
||||||
):
|
):
|
||||||
return EventSourceResponse(
|
return EventSourceResponse(
|
||||||
subscribe_wallet_invoices(request, wallet.wallet),
|
subscribe_wallet_invoices(request, key_info.wallet),
|
||||||
ping=20,
|
ping=20,
|
||||||
media_type="text/event-stream",
|
media_type="text/event-stream",
|
||||||
)
|
)
|
||||||
@@ -402,15 +368,8 @@ async def api_payment(payment_hash, x_api_key: Optional[str] = Header(None)):
|
|||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.NOT_FOUND, detail="Payment does not exist."
|
status_code=HTTPStatus.NOT_FOUND, detail="Payment does not exist."
|
||||||
)
|
)
|
||||||
await check_transaction_status(payment.wallet_id, payment_hash)
|
|
||||||
payment = await get_standalone_payment(
|
if payment.success:
|
||||||
payment_hash, wallet_id=wallet.id if wallet else None
|
|
||||||
)
|
|
||||||
if not payment:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=HTTPStatus.NOT_FOUND, detail="Payment does not exist."
|
|
||||||
)
|
|
||||||
elif not payment.pending:
|
|
||||||
if wallet and wallet.id == payment.wallet_id:
|
if wallet and wallet.id == payment.wallet_id:
|
||||||
return {"paid": True, "preimage": payment.preimage, "details": payment}
|
return {"paid": True, "preimage": payment.preimage, "details": payment}
|
||||||
return {"paid": True, "preimage": payment.preimage}
|
return {"paid": True, "preimage": payment.preimage}
|
||||||
@@ -424,12 +383,12 @@ async def api_payment(payment_hash, x_api_key: Optional[str] = Header(None)):
|
|||||||
|
|
||||||
if wallet and wallet.id == payment.wallet_id:
|
if wallet and wallet.id == payment.wallet_id:
|
||||||
return {
|
return {
|
||||||
"paid": not payment.pending,
|
"paid": payment.success,
|
||||||
"status": f"{status!s}",
|
"status": f"{status!s}",
|
||||||
"preimage": payment.preimage,
|
"preimage": payment.preimage,
|
||||||
"details": payment,
|
"details": payment,
|
||||||
}
|
}
|
||||||
return {"paid": not payment.pending, "preimage": payment.preimage}
|
return {"paid": payment.success, "preimage": payment.preimage}
|
||||||
|
|
||||||
|
|
||||||
@payment_router.post("/decode", status_code=HTTPStatus.OK)
|
@payment_router.post("/decode", status_code=HTTPStatus.OK)
|
||||||
@@ -441,9 +400,66 @@ async def api_payments_decode(data: DecodePayment) -> JSONResponse:
|
|||||||
return JSONResponse({"domain": url})
|
return JSONResponse({"domain": url})
|
||||||
else:
|
else:
|
||||||
invoice = bolt11.decode(payment_str)
|
invoice = bolt11.decode(payment_str)
|
||||||
return JSONResponse(invoice.data)
|
filtered_data = filter_dict_keys(invoice.data, data.filter_fields)
|
||||||
|
return JSONResponse(filtered_data)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
{"message": f"Failed to decode: {exc!s}"},
|
{"message": f"Failed to decode: {exc!s}"},
|
||||||
status_code=HTTPStatus.BAD_REQUEST,
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@payment_router.post("/{payment_request}/pay-with-nfc", status_code=HTTPStatus.OK)
|
||||||
|
async def api_payment_pay_with_nfc(
|
||||||
|
payment_request: str,
|
||||||
|
lnurl_data: PayLnurlWData,
|
||||||
|
) -> JSONResponse:
|
||||||
|
|
||||||
|
lnurl = lnurl_data.lnurl_w.lower()
|
||||||
|
|
||||||
|
# Follow LUD-17 -> https://github.com/lnurl/luds/blob/luds/17.md
|
||||||
|
url = lnurl.replace("lnurlw://", "https://")
|
||||||
|
|
||||||
|
headers = {"User-Agent": settings.user_agent}
|
||||||
|
async with httpx.AsyncClient(headers=headers, follow_redirects=True) as client:
|
||||||
|
try:
|
||||||
|
lnurl_req = await client.get(url, timeout=10)
|
||||||
|
if lnurl_req.is_error:
|
||||||
|
return JSONResponse(
|
||||||
|
{"success": False, "detail": "Error loading LNURL request"}
|
||||||
|
)
|
||||||
|
|
||||||
|
lnurl_res = lnurl_req.json()
|
||||||
|
|
||||||
|
if lnurl_res.get("status") == "ERROR":
|
||||||
|
return JSONResponse({"success": False, "detail": lnurl_res["reason"]})
|
||||||
|
|
||||||
|
if lnurl_res.get("tag") != "withdrawRequest":
|
||||||
|
return JSONResponse(
|
||||||
|
{"success": False, "detail": "Invalid LNURL-withdraw"}
|
||||||
|
)
|
||||||
|
|
||||||
|
callback_url = lnurl_res["callback"]
|
||||||
|
k1 = lnurl_res["k1"]
|
||||||
|
|
||||||
|
callback_req = await client.get(
|
||||||
|
callback_url,
|
||||||
|
params={"k1": k1, "pr": payment_request},
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
if callback_req.is_error:
|
||||||
|
return JSONResponse(
|
||||||
|
{"success": False, "detail": "Error loading callback request"}
|
||||||
|
)
|
||||||
|
|
||||||
|
callback_res = callback_req.json()
|
||||||
|
|
||||||
|
if callback_res.get("status") == "ERROR":
|
||||||
|
return JSONResponse(
|
||||||
|
{"success": False, "detail": callback_res["reason"]}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return JSONResponse({"success": True, "detail": callback_res})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return JSONResponse({"success": False, "detail": f"Unexpected error: {e}"})
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ async def api_public_payment_longpolling(payment_hash):
|
|||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.NOT_FOUND, detail="Payment does not exist."
|
status_code=HTTPStatus.NOT_FOUND, detail="Payment does not exist."
|
||||||
)
|
)
|
||||||
elif not payment.pending:
|
# TODO: refactor to use PaymentState
|
||||||
|
if payment.success:
|
||||||
return {"status": "paid"}
|
return {"status": "paid"}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
+225
-102
@@ -1,159 +1,282 @@
|
|||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import time
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
from typing import List
|
from typing import List, Optional
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
import shortuuid
|
||||||
from starlette.exceptions import HTTPException
|
from fastapi import APIRouter, Body, Depends
|
||||||
|
from fastapi.exceptions import HTTPException
|
||||||
|
|
||||||
from lnbits.core.crud import (
|
from lnbits.core.crud import (
|
||||||
|
create_wallet,
|
||||||
delete_account,
|
delete_account,
|
||||||
delete_wallet,
|
delete_wallet,
|
||||||
force_delete_wallet,
|
force_delete_wallet,
|
||||||
get_accounts,
|
get_accounts,
|
||||||
|
get_user,
|
||||||
get_wallet,
|
get_wallet,
|
||||||
get_wallets,
|
get_wallets,
|
||||||
update_admin_settings,
|
update_admin_settings,
|
||||||
|
update_wallet,
|
||||||
)
|
)
|
||||||
from lnbits.core.models import (
|
from lnbits.core.models import (
|
||||||
Account,
|
|
||||||
AccountFilters,
|
AccountFilters,
|
||||||
|
AccountOverview,
|
||||||
CreateTopup,
|
CreateTopup,
|
||||||
|
CreateUser,
|
||||||
|
SimpleStatus,
|
||||||
User,
|
User,
|
||||||
|
UserExtra,
|
||||||
Wallet,
|
Wallet,
|
||||||
)
|
)
|
||||||
from lnbits.core.services import update_wallet_balance
|
from lnbits.core.models.users import Account
|
||||||
|
from lnbits.core.services import (
|
||||||
|
create_user_account_no_ckeck,
|
||||||
|
update_user_account,
|
||||||
|
update_user_extensions,
|
||||||
|
update_wallet_balance,
|
||||||
|
)
|
||||||
from lnbits.db import Filters, Page
|
from lnbits.db import Filters, Page
|
||||||
from lnbits.decorators import check_admin, check_super_user, parse_filters
|
from lnbits.decorators import check_admin, check_super_user, parse_filters
|
||||||
from lnbits.helpers import generate_filter_params_openapi
|
from lnbits.helpers import (
|
||||||
|
encrypt_internal_message,
|
||||||
|
generate_filter_params_openapi,
|
||||||
|
)
|
||||||
from lnbits.settings import EditableSettings, settings
|
from lnbits.settings import EditableSettings, settings
|
||||||
|
from lnbits.utils.exchange_rates import allowed_currencies
|
||||||
|
|
||||||
users_router = APIRouter(prefix="/users/api/v1", dependencies=[Depends(check_admin)])
|
users_router = APIRouter(
|
||||||
|
prefix="/users/api/v1", dependencies=[Depends(check_admin)], tags=["Users"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@users_router.get(
|
@users_router.get(
|
||||||
"/user",
|
"/user",
|
||||||
name="get accounts",
|
name="Get accounts",
|
||||||
summary="Get paginated list of accounts",
|
summary="Get paginated list of accounts",
|
||||||
openapi_extra=generate_filter_params_openapi(AccountFilters),
|
openapi_extra=generate_filter_params_openapi(AccountFilters),
|
||||||
)
|
)
|
||||||
async def api_get_users(
|
async def api_get_users(
|
||||||
filters: Filters = Depends(parse_filters(AccountFilters)),
|
filters: Filters = Depends(parse_filters(AccountFilters)),
|
||||||
) -> Page[Account]:
|
) -> Page[AccountOverview]:
|
||||||
try:
|
return await get_accounts(filters=filters)
|
||||||
filtered = await get_accounts(filters=filters)
|
|
||||||
for user in filtered.data:
|
|
||||||
user.is_super_user = user.id == settings.super_user
|
@users_router.get(
|
||||||
user.is_admin = user.id in settings.lnbits_admin_users or user.is_super_user
|
"/user/{user_id}",
|
||||||
return filtered
|
name="Get user",
|
||||||
except Exception as exc:
|
summary="Get user by Id",
|
||||||
|
)
|
||||||
|
async def api_get_user(user_id: str) -> User:
|
||||||
|
user = await get_user(user_id)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(HTTPStatus.NOT_FOUND, "User not found.")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@users_router.post("/user", name="Create user")
|
||||||
|
async def api_create_user(data: CreateUser) -> CreateUser:
|
||||||
|
if not data.username and data.password:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
HTTPStatus.BAD_REQUEST, "Username required when password provided."
|
||||||
detail=f"Could not fetch users. {exc!s}",
|
)
|
||||||
) from exc
|
|
||||||
|
if data.password != data.password_repeat:
|
||||||
|
raise HTTPException(HTTPStatus.BAD_REQUEST, "Passwords do not match.")
|
||||||
|
|
||||||
|
if not data.password:
|
||||||
|
random_password = shortuuid.uuid()
|
||||||
|
data.password = random_password
|
||||||
|
data.password_repeat = random_password
|
||||||
|
data.extra = data.extra or UserExtra()
|
||||||
|
data.extra.provider = data.extra.provider or "lnbits"
|
||||||
|
|
||||||
|
account = Account(
|
||||||
|
id=uuid4().hex,
|
||||||
|
username=data.username,
|
||||||
|
email=data.email,
|
||||||
|
pubkey=data.pubkey,
|
||||||
|
extra=data.extra,
|
||||||
|
)
|
||||||
|
account.validate_fields()
|
||||||
|
account.hash_password(data.password)
|
||||||
|
user = await create_user_account_no_ckeck(account)
|
||||||
|
data.id = user.id
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
@users_router.delete("/user/{user_id}", status_code=HTTPStatus.OK)
|
@users_router.put("/user/{user_id}", name="Update user")
|
||||||
|
async def api_update_user(user_id: str, data: CreateUser) -> CreateUser:
|
||||||
|
if user_id != data.id:
|
||||||
|
raise HTTPException(HTTPStatus.BAD_REQUEST, "User Id missmatch.")
|
||||||
|
|
||||||
|
if data.password or data.password_repeat:
|
||||||
|
raise HTTPException(
|
||||||
|
HTTPStatus.BAD_REQUEST, "Use 'reset password' functionality."
|
||||||
|
)
|
||||||
|
|
||||||
|
account = Account(
|
||||||
|
id=user_id,
|
||||||
|
username=data.username,
|
||||||
|
email=data.email,
|
||||||
|
pubkey=data.pubkey,
|
||||||
|
extra=data.extra or UserExtra(),
|
||||||
|
)
|
||||||
|
await update_user_account(account)
|
||||||
|
|
||||||
|
await update_user_extensions(user_id, data.extensions or [])
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
@users_router.delete(
|
||||||
|
"/user/{user_id}",
|
||||||
|
status_code=HTTPStatus.OK,
|
||||||
|
name="Delete user by Id",
|
||||||
|
)
|
||||||
async def api_users_delete_user(
|
async def api_users_delete_user(
|
||||||
user_id: str, user: User = Depends(check_admin)
|
user_id: str, user: User = Depends(check_admin)
|
||||||
) -> None:
|
) -> SimpleStatus:
|
||||||
|
wallets = await get_wallets(user_id)
|
||||||
try:
|
if len(wallets) > 0:
|
||||||
wallets = await get_wallets(user_id)
|
|
||||||
if len(wallets) > 0:
|
|
||||||
raise Exception("Cannot delete user with wallets.")
|
|
||||||
if user_id == settings.super_user:
|
|
||||||
raise Exception("Cannot delete super user.")
|
|
||||||
|
|
||||||
if user_id in settings.lnbits_admin_users and not user.super_user:
|
|
||||||
raise Exception("Only super_user can delete admin user.")
|
|
||||||
|
|
||||||
await delete_account(user_id)
|
|
||||||
|
|
||||||
except Exception as exc:
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
detail=f"{exc!s}",
|
detail="Cannot delete user with wallets.",
|
||||||
) from exc
|
)
|
||||||
|
|
||||||
|
if user_id == settings.super_user:
|
||||||
@users_router.get("/user/{user_id}/admin", dependencies=[Depends(check_super_user)])
|
|
||||||
async def api_users_toggle_admin(user_id: str) -> None:
|
|
||||||
try:
|
|
||||||
if user_id == settings.super_user:
|
|
||||||
raise Exception("Cannot change super user.")
|
|
||||||
if user_id in settings.lnbits_admin_users:
|
|
||||||
settings.lnbits_admin_users.remove(user_id)
|
|
||||||
else:
|
|
||||||
settings.lnbits_admin_users.append(user_id)
|
|
||||||
update_settings = EditableSettings(
|
|
||||||
lnbits_admin_users=settings.lnbits_admin_users
|
|
||||||
)
|
|
||||||
await update_admin_settings(update_settings)
|
|
||||||
except Exception as exc:
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
detail=f"Could not update admin settings. {exc}",
|
detail="Cannot delete super user.",
|
||||||
) from exc
|
)
|
||||||
|
|
||||||
|
if user_id in settings.lnbits_admin_users and not user.super_user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
|
detail="Only super_user can delete admin user.",
|
||||||
|
)
|
||||||
|
await delete_account(user_id)
|
||||||
|
return SimpleStatus(success=True, message="User deleted.")
|
||||||
|
|
||||||
|
|
||||||
@users_router.get("/user/{user_id}/wallet")
|
@users_router.put(
|
||||||
|
"/user/{user_id}/reset_password",
|
||||||
|
dependencies=[Depends(check_super_user)],
|
||||||
|
name="Reset user password",
|
||||||
|
)
|
||||||
|
async def api_users_reset_password(user_id: str) -> str:
|
||||||
|
if user_id == settings.super_user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.FORBIDDEN,
|
||||||
|
detail="Cannot change superuser password.",
|
||||||
|
)
|
||||||
|
|
||||||
|
reset_data = ["reset", user_id, int(time.time())]
|
||||||
|
reset_data_json = json.dumps(reset_data, separators=(",", ":"), ensure_ascii=False)
|
||||||
|
reset_key = encrypt_internal_message(reset_data_json)
|
||||||
|
assert reset_key, "Cannot generate reset key."
|
||||||
|
reset_key_b64 = base64.b64encode(reset_key.encode()).decode()
|
||||||
|
return f"reset_key_{reset_key_b64}"
|
||||||
|
|
||||||
|
|
||||||
|
@users_router.get(
|
||||||
|
"/user/{user_id}/admin",
|
||||||
|
dependencies=[Depends(check_super_user)],
|
||||||
|
name="Give or revoke admin permsisions to a user",
|
||||||
|
)
|
||||||
|
async def api_users_toggle_admin(user_id: str) -> SimpleStatus:
|
||||||
|
if user_id == settings.super_user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
|
detail="Cannot change super user.",
|
||||||
|
)
|
||||||
|
|
||||||
|
if settings.is_admin_user(user_id):
|
||||||
|
settings.lnbits_admin_users.remove(user_id)
|
||||||
|
else:
|
||||||
|
settings.lnbits_admin_users.append(user_id)
|
||||||
|
update_settings = EditableSettings(lnbits_admin_users=settings.lnbits_admin_users)
|
||||||
|
await update_admin_settings(update_settings)
|
||||||
|
return SimpleStatus(
|
||||||
|
success=True, message=f"User admin: '{settings.is_admin_user(user_id)}'."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@users_router.get("/user/{user_id}/wallet", name="Get wallets for user")
|
||||||
async def api_users_get_user_wallet(user_id: str) -> List[Wallet]:
|
async def api_users_get_user_wallet(user_id: str) -> List[Wallet]:
|
||||||
try:
|
return await get_wallets(user_id)
|
||||||
return await get_wallets(user_id)
|
|
||||||
except Exception as exc:
|
|
||||||
|
@users_router.post("/user/{user_id}/wallet", name="Create a new wallet for user")
|
||||||
|
async def api_users_create_user_wallet(
|
||||||
|
user_id: str, name: Optional[str] = Body(None), currency: Optional[str] = Body(None)
|
||||||
|
):
|
||||||
|
if currency and currency not in allowed_currencies():
|
||||||
|
raise ValueError(f"Currency '{currency}' not allowed.")
|
||||||
|
|
||||||
|
wallet = await create_wallet(user_id=user_id, wallet_name=name)
|
||||||
|
|
||||||
|
if currency:
|
||||||
|
wallet.currency = currency
|
||||||
|
await update_wallet(wallet)
|
||||||
|
|
||||||
|
return wallet
|
||||||
|
|
||||||
|
|
||||||
|
@users_router.put(
|
||||||
|
"/user/{user_id}/wallet/{wallet}/undelete", name="Reactivate deleted wallet"
|
||||||
|
)
|
||||||
|
async def api_users_undelete_user_wallet(user_id: str, wallet: str) -> SimpleStatus:
|
||||||
|
wal = await get_wallet(wallet)
|
||||||
|
if not wal:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
status_code=HTTPStatus.NOT_FOUND,
|
||||||
detail=f"Could not fetch user wallets. {exc}",
|
detail="Wallet does not exist.",
|
||||||
) from exc
|
)
|
||||||
|
|
||||||
|
if user_id != wal.user:
|
||||||
@users_router.get("/user/{user_id}/wallet/{wallet}/undelete")
|
|
||||||
async def api_users_undelete_user_wallet(user_id: str, wallet: str) -> None:
|
|
||||||
try:
|
|
||||||
wal = await get_wallet(wallet)
|
|
||||||
if not wal:
|
|
||||||
raise Exception("Wallet does not exist.")
|
|
||||||
if user_id != wal.user:
|
|
||||||
raise Exception("Wallet does not belong to user.")
|
|
||||||
if wal.deleted:
|
|
||||||
await delete_wallet(user_id=user_id, wallet_id=wallet, deleted=False)
|
|
||||||
except Exception as exc:
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
status_code=HTTPStatus.FORBIDDEN,
|
||||||
detail=f"{exc!s}",
|
detail="Wallet does not belong to user.",
|
||||||
) from exc
|
)
|
||||||
|
if wal.deleted:
|
||||||
|
await delete_wallet(user_id=user_id, wallet_id=wallet, deleted=False)
|
||||||
|
return SimpleStatus(success=True, message="Wallet undeleted.")
|
||||||
|
|
||||||
|
return SimpleStatus(success=True, message="Wallet is already active.")
|
||||||
|
|
||||||
|
|
||||||
@users_router.delete("/user/{user_id}/wallet/{wallet}")
|
@users_router.delete(
|
||||||
async def api_users_delete_user_wallet(user_id: str, wallet: str) -> None:
|
"/user/{user_id}/wallet/{wallet}",
|
||||||
try:
|
name="Delete wallet by id",
|
||||||
wal = await get_wallet(wallet)
|
summary="First time it is called it does a soft delete (only sets a flag)."
|
||||||
if not wal:
|
"The second time it is called will delete the entry from the DB",
|
||||||
raise Exception("Wallet does not exist.")
|
)
|
||||||
if wal.deleted:
|
async def api_users_delete_user_wallet(user_id: str, wallet: str) -> SimpleStatus:
|
||||||
await force_delete_wallet(wallet)
|
wal = await get_wallet(wallet)
|
||||||
await delete_wallet(user_id=user_id, wallet_id=wallet)
|
if not wal:
|
||||||
except Exception as exc:
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
status_code=HTTPStatus.NOT_FOUND,
|
||||||
detail=f"{exc!s}",
|
detail="Wallet does not exist.",
|
||||||
) from exc
|
)
|
||||||
|
if wal.deleted:
|
||||||
|
await force_delete_wallet(wallet)
|
||||||
|
await delete_wallet(user_id=user_id, wallet_id=wallet)
|
||||||
|
return SimpleStatus(success=True, message="Wallet deleted.")
|
||||||
|
|
||||||
|
|
||||||
@users_router.put(
|
@users_router.put(
|
||||||
"/topup",
|
"/topup",
|
||||||
name="Topup",
|
name="Topup",
|
||||||
|
summary="Update balance for a particular wallet.",
|
||||||
status_code=HTTPStatus.OK,
|
status_code=HTTPStatus.OK,
|
||||||
dependencies=[Depends(check_super_user)],
|
dependencies=[Depends(check_super_user)],
|
||||||
)
|
)
|
||||||
async def api_topup_balance(data: CreateTopup) -> dict[str, str]:
|
async def api_topup_balance(data: CreateTopup) -> SimpleStatus:
|
||||||
try:
|
await get_wallet(data.id)
|
||||||
await get_wallet(data.id)
|
if settings.lnbits_backend_wallet_class == "VoidWallet":
|
||||||
if settings.lnbits_backend_wallet_class == "VoidWallet":
|
raise Exception("VoidWallet active")
|
||||||
raise Exception("VoidWallet active")
|
|
||||||
|
|
||||||
await update_wallet_balance(wallet_id=data.id, amount=int(data.amount))
|
await update_wallet_balance(wallet_id=data.id, amount=int(data.amount))
|
||||||
return {"status": "Success"}
|
return SimpleStatus(success=True, message="Balance updated.")
|
||||||
except Exception as exc:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=f"{exc!s}"
|
|
||||||
) from exc
|
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
|
from http import HTTPStatus
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import (
|
from fastapi import (
|
||||||
APIRouter,
|
APIRouter,
|
||||||
Body,
|
Body,
|
||||||
Depends,
|
Depends,
|
||||||
|
HTTPException,
|
||||||
)
|
)
|
||||||
|
|
||||||
from lnbits.core.models import (
|
from lnbits.core.models import (
|
||||||
@@ -13,13 +15,14 @@ from lnbits.core.models import (
|
|||||||
)
|
)
|
||||||
from lnbits.decorators import (
|
from lnbits.decorators import (
|
||||||
WalletTypeInfo,
|
WalletTypeInfo,
|
||||||
get_key_type,
|
|
||||||
require_admin_key,
|
require_admin_key,
|
||||||
|
require_invoice_key,
|
||||||
)
|
)
|
||||||
|
|
||||||
from ..crud import (
|
from ..crud import (
|
||||||
create_wallet,
|
create_wallet,
|
||||||
delete_wallet,
|
delete_wallet,
|
||||||
|
get_wallet,
|
||||||
update_wallet,
|
update_wallet,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -27,36 +30,45 @@ wallet_router = APIRouter(prefix="/api/v1/wallet", tags=["Wallet"])
|
|||||||
|
|
||||||
|
|
||||||
@wallet_router.get("")
|
@wallet_router.get("")
|
||||||
async def api_wallet(wallet: WalletTypeInfo = Depends(get_key_type)):
|
async def api_wallet(key_info: WalletTypeInfo = Depends(require_invoice_key)):
|
||||||
if wallet.key_type == KeyType.admin:
|
res = {
|
||||||
return {
|
"name": key_info.wallet.name,
|
||||||
"id": wallet.wallet.id,
|
"balance": key_info.wallet.balance_msat,
|
||||||
"name": wallet.wallet.name,
|
}
|
||||||
"balance": wallet.wallet.balance_msat,
|
if key_info.key_type == KeyType.admin:
|
||||||
}
|
res["id"] = key_info.wallet.id
|
||||||
else:
|
return res
|
||||||
return {"name": wallet.wallet.name, "balance": wallet.wallet.balance_msat}
|
|
||||||
|
|
||||||
|
|
||||||
@wallet_router.put("/{new_name}")
|
@wallet_router.put("/{new_name}")
|
||||||
async def api_update_wallet_name(
|
async def api_update_wallet_name(
|
||||||
new_name: str, wallet: WalletTypeInfo = Depends(require_admin_key)
|
new_name: str, key_info: WalletTypeInfo = Depends(require_admin_key)
|
||||||
):
|
):
|
||||||
await update_wallet(wallet.wallet.id, new_name)
|
wallet = await get_wallet(key_info.wallet.id)
|
||||||
|
if not wallet:
|
||||||
|
raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Wallet not found")
|
||||||
|
wallet.name = new_name
|
||||||
|
await update_wallet(wallet)
|
||||||
return {
|
return {
|
||||||
"id": wallet.wallet.id,
|
"id": wallet.id,
|
||||||
"name": wallet.wallet.name,
|
"name": wallet.name,
|
||||||
"balance": wallet.wallet.balance_msat,
|
"balance": wallet.balance_msat,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@wallet_router.patch("", response_model=Wallet)
|
@wallet_router.patch("")
|
||||||
async def api_update_wallet(
|
async def api_update_wallet(
|
||||||
name: Optional[str] = Body(None),
|
name: Optional[str] = Body(None),
|
||||||
currency: Optional[str] = Body(None),
|
currency: Optional[str] = Body(None),
|
||||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
key_info: WalletTypeInfo = Depends(require_admin_key),
|
||||||
):
|
) -> Wallet:
|
||||||
return await update_wallet(wallet.wallet.id, name, currency)
|
wallet = await get_wallet(key_info.wallet.id)
|
||||||
|
if not wallet:
|
||||||
|
raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Wallet not found")
|
||||||
|
wallet.name = name or wallet.name
|
||||||
|
wallet.currency = currency if currency is not None else wallet.currency
|
||||||
|
await update_wallet(wallet)
|
||||||
|
return wallet
|
||||||
|
|
||||||
|
|
||||||
@wallet_router.delete("")
|
@wallet_router.delete("")
|
||||||
@@ -69,9 +81,9 @@ async def api_delete_wallet(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@wallet_router.post("", response_model=Wallet)
|
@wallet_router.post("")
|
||||||
async def api_create_wallet(
|
async def api_create_wallet(
|
||||||
data: CreateWallet,
|
data: CreateWallet,
|
||||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
key_info: WalletTypeInfo = Depends(require_admin_key),
|
||||||
) -> Wallet:
|
) -> Wallet:
|
||||||
return await create_wallet(user_id=wallet.wallet.user, wallet_name=data.name)
|
return await create_wallet(user_id=key_info.wallet.user, wallet_name=data.name)
|
||||||
|
|||||||
+307
-133
@@ -1,20 +1,20 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import datetime
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
from datetime import datetime, timezone
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from sqlite3 import Row
|
from typing import Any, Generic, Literal, Optional, TypeVar, Union, get_origin
|
||||||
from typing import Any, Generic, Literal, Optional, TypeVar
|
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from pydantic import BaseModel, ValidationError, root_validator
|
from pydantic import BaseModel, ValidationError, root_validator
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import event
|
||||||
from sqlalchemy_aio.base import AsyncConnection
|
from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine, create_async_engine
|
||||||
from sqlalchemy_aio.strategy import ASYNCIO_STRATEGY
|
from sqlalchemy.sql import text
|
||||||
|
|
||||||
from lnbits.settings import settings
|
from lnbits.settings import settings
|
||||||
|
|
||||||
@@ -24,31 +24,15 @@ SQLITE = "SQLITE"
|
|||||||
|
|
||||||
if settings.lnbits_database_url:
|
if settings.lnbits_database_url:
|
||||||
database_uri = settings.lnbits_database_url
|
database_uri = settings.lnbits_database_url
|
||||||
|
|
||||||
if database_uri.startswith("cockroachdb://"):
|
if database_uri.startswith("cockroachdb://"):
|
||||||
DB_TYPE = COCKROACH
|
DB_TYPE = COCKROACH
|
||||||
else:
|
else:
|
||||||
|
if not database_uri.startswith("postgres://"):
|
||||||
|
raise ValueError(
|
||||||
|
"Please use the 'postgres://...' " "format for the database URL."
|
||||||
|
)
|
||||||
DB_TYPE = POSTGRES
|
DB_TYPE = POSTGRES
|
||||||
|
|
||||||
from psycopg2.extensions import DECIMAL, new_type, register_type
|
|
||||||
|
|
||||||
def _parse_timestamp(value, _):
|
|
||||||
if value is None:
|
|
||||||
return None
|
|
||||||
f = "%Y-%m-%d %H:%M:%S.%f"
|
|
||||||
if "." not in value:
|
|
||||||
f = "%Y-%m-%d %H:%M:%S"
|
|
||||||
return time.mktime(datetime.datetime.strptime(value, f).timetuple())
|
|
||||||
|
|
||||||
register_type(
|
|
||||||
new_type(
|
|
||||||
DECIMAL.values,
|
|
||||||
"DEC2FLOAT",
|
|
||||||
lambda value, curs: float(value) if value is not None else None,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
register_type(new_type((1184, 1114), "TIMESTAMP2INT", _parse_timestamp))
|
|
||||||
else:
|
else:
|
||||||
if not os.path.isdir(settings.lnbits_data_folder):
|
if not os.path.isdir(settings.lnbits_data_folder):
|
||||||
os.mkdir(settings.lnbits_data_folder)
|
os.mkdir(settings.lnbits_data_folder)
|
||||||
@@ -56,13 +40,21 @@ else:
|
|||||||
DB_TYPE = SQLITE
|
DB_TYPE = SQLITE
|
||||||
|
|
||||||
|
|
||||||
def compat_timestamp_placeholder():
|
def compat_timestamp_placeholder(key: str):
|
||||||
if DB_TYPE == POSTGRES:
|
if DB_TYPE == POSTGRES:
|
||||||
return "to_timestamp(?)"
|
return f"to_timestamp(:{key})"
|
||||||
elif DB_TYPE == COCKROACH:
|
elif DB_TYPE == COCKROACH:
|
||||||
return "cast(? AS timestamp)"
|
return f"cast(:{key} AS timestamp)"
|
||||||
else:
|
else:
|
||||||
return "?"
|
return f":{key}"
|
||||||
|
|
||||||
|
|
||||||
|
def get_placeholder(model: Any, field: str) -> str:
|
||||||
|
type_ = model.__fields__[field].type_
|
||||||
|
if type_ == datetime:
|
||||||
|
return compat_timestamp_placeholder(field)
|
||||||
|
else:
|
||||||
|
return f":{field}"
|
||||||
|
|
||||||
|
|
||||||
class Compat:
|
class Compat:
|
||||||
@@ -76,7 +68,7 @@ class Compat:
|
|||||||
return f"{seconds}"
|
return f"{seconds}"
|
||||||
return "<nothing>"
|
return "<nothing>"
|
||||||
|
|
||||||
def datetime_to_timestamp(self, date: datetime.datetime):
|
def datetime_to_timestamp(self, date: datetime):
|
||||||
if self.type in {POSTGRES, COCKROACH}:
|
if self.type in {POSTGRES, COCKROACH}:
|
||||||
return date.strftime("%Y-%m-%d %H:%M:%S")
|
return date.strftime("%Y-%m-%d %H:%M:%S")
|
||||||
elif self.type == SQLITE:
|
elif self.type == SQLITE:
|
||||||
@@ -119,15 +111,13 @@ class Compat:
|
|||||||
return "BIGINT"
|
return "BIGINT"
|
||||||
return "INT"
|
return "INT"
|
||||||
|
|
||||||
@property
|
def timestamp_placeholder(self, key: str) -> str:
|
||||||
def timestamp_placeholder(self) -> str:
|
return compat_timestamp_placeholder(key)
|
||||||
return compat_timestamp_placeholder()
|
|
||||||
|
|
||||||
|
|
||||||
class Connection(Compat):
|
class Connection(Compat):
|
||||||
def __init__(self, conn: AsyncConnection, txn, typ, name, schema):
|
def __init__(self, conn: AsyncConnection, typ, name, schema):
|
||||||
self.conn = conn
|
self.conn = conn
|
||||||
self.txn = txn
|
|
||||||
self.type = typ
|
self.type = typ
|
||||||
self.name = name
|
self.name = name
|
||||||
self.schema = schema
|
self.schema = schema
|
||||||
@@ -138,49 +128,76 @@ class Connection(Compat):
|
|||||||
query = query.replace("?", "%s")
|
query = query.replace("?", "%s")
|
||||||
return query
|
return query
|
||||||
|
|
||||||
def rewrite_values(self, values):
|
def rewrite_values(self, values: dict) -> dict:
|
||||||
# strip html
|
# strip html
|
||||||
clean_regex = re.compile("<.*?>|&([a-z0-9]+|#[0-9]{1,6}|#x[0-9a-f]{1,6});")
|
clean_regex = re.compile("<.*?>|&([a-z0-9]+|#[0-9]{1,6}|#x[0-9a-f]{1,6});")
|
||||||
|
clean_values: dict = {}
|
||||||
# tuple to list and back to tuple
|
for key, raw_value in values.items():
|
||||||
raw_values = [values] if isinstance(values, str) else list(values)
|
|
||||||
values = []
|
|
||||||
for raw_value in raw_values:
|
|
||||||
if isinstance(raw_value, str):
|
if isinstance(raw_value, str):
|
||||||
values.append(re.sub(clean_regex, "", raw_value))
|
clean_values[key] = re.sub(clean_regex, "", raw_value)
|
||||||
elif isinstance(raw_value, datetime.datetime):
|
elif isinstance(raw_value, datetime):
|
||||||
ts = raw_value.timestamp()
|
ts = raw_value.timestamp()
|
||||||
if self.type == SQLITE:
|
if self.type == SQLITE:
|
||||||
values.append(int(ts))
|
clean_values[key] = int(ts)
|
||||||
else:
|
else:
|
||||||
values.append(ts)
|
clean_values[key] = ts
|
||||||
else:
|
else:
|
||||||
values.append(raw_value)
|
clean_values[key] = raw_value
|
||||||
return tuple(values)
|
return clean_values
|
||||||
|
|
||||||
async def fetchall(self, query: str, values: tuple = ()) -> list:
|
async def fetchall(
|
||||||
result = await self.conn.execute(
|
self,
|
||||||
self.rewrite_query(query), self.rewrite_values(values)
|
query: str,
|
||||||
)
|
values: Optional[dict] = None,
|
||||||
return await result.fetchall()
|
model: Optional[type[TModel]] = None,
|
||||||
|
) -> list[TModel]:
|
||||||
async def fetchone(self, query: str, values: tuple = ()):
|
params = self.rewrite_values(values) if values else {}
|
||||||
result = await self.conn.execute(
|
result = await self.conn.execute(text(self.rewrite_query(query)), params)
|
||||||
self.rewrite_query(query), self.rewrite_values(values)
|
row = result.mappings().all()
|
||||||
)
|
result.close()
|
||||||
row = await result.fetchone()
|
if not row:
|
||||||
await result.close()
|
return []
|
||||||
|
if model:
|
||||||
|
return [dict_to_model(r, model) for r in row]
|
||||||
return row
|
return row
|
||||||
|
|
||||||
|
async def fetchone(
|
||||||
|
self,
|
||||||
|
query: str,
|
||||||
|
values: Optional[dict] = None,
|
||||||
|
model: Optional[type[TModel]] = None,
|
||||||
|
) -> TModel:
|
||||||
|
params = self.rewrite_values(values) if values else {}
|
||||||
|
result = await self.conn.execute(text(self.rewrite_query(query)), params)
|
||||||
|
row = result.mappings().first()
|
||||||
|
result.close()
|
||||||
|
if model and row:
|
||||||
|
return dict_to_model(row, model)
|
||||||
|
return row
|
||||||
|
|
||||||
|
async def update(
|
||||||
|
self, table_name: str, model: BaseModel, where: str = "WHERE id = :id"
|
||||||
|
):
|
||||||
|
await self.conn.execute(
|
||||||
|
text(update_query(table_name, model, where)), model_to_dict(model)
|
||||||
|
)
|
||||||
|
await self.conn.commit()
|
||||||
|
|
||||||
|
async def insert(self, table_name: str, model: BaseModel):
|
||||||
|
await self.conn.execute(
|
||||||
|
text(insert_query(table_name, model)), model_to_dict(model)
|
||||||
|
)
|
||||||
|
await self.conn.commit()
|
||||||
|
|
||||||
async def fetch_page(
|
async def fetch_page(
|
||||||
self,
|
self,
|
||||||
query: str,
|
query: str,
|
||||||
where: Optional[list[str]] = None,
|
where: Optional[list[str]] = None,
|
||||||
values: Optional[list[str]] = None,
|
values: Optional[dict] = None,
|
||||||
filters: Optional[Filters] = None,
|
filters: Optional[Filters] = None,
|
||||||
model: Optional[type[TRowModel]] = None,
|
model: Optional[type[TModel]] = None,
|
||||||
group_by: Optional[list[str]] = None,
|
group_by: Optional[list[str]] = None,
|
||||||
) -> Page[TRowModel]:
|
) -> Page[TModel]:
|
||||||
if not filters:
|
if not filters:
|
||||||
filters = Filters()
|
filters = Filters()
|
||||||
clause = filters.where(where)
|
clause = filters.where(where)
|
||||||
@@ -203,14 +220,15 @@ class Connection(Compat):
|
|||||||
{filters.order_by()}
|
{filters.order_by()}
|
||||||
{filters.pagination()}
|
{filters.pagination()}
|
||||||
""",
|
""",
|
||||||
parsed_values,
|
self.rewrite_values(parsed_values),
|
||||||
|
model,
|
||||||
)
|
)
|
||||||
if rows:
|
if rows:
|
||||||
# no need for extra query if no pagination is specified
|
# no need for extra query if no pagination is specified
|
||||||
if filters.offset or filters.limit:
|
if filters.offset or filters.limit:
|
||||||
count = await self.fetchone(
|
result = await self.execute(
|
||||||
f"""
|
f"""
|
||||||
SELECT COUNT(*) FROM (
|
SELECT COUNT(*) as count FROM (
|
||||||
{query}
|
{query}
|
||||||
{clause}
|
{clause}
|
||||||
{group_by_string}
|
{group_by_string}
|
||||||
@@ -218,21 +236,24 @@ class Connection(Compat):
|
|||||||
""",
|
""",
|
||||||
parsed_values,
|
parsed_values,
|
||||||
)
|
)
|
||||||
count = int(count[0])
|
row = result.mappings().first()
|
||||||
|
result.close()
|
||||||
|
count = int(row.get("count", 0))
|
||||||
else:
|
else:
|
||||||
count = len(rows)
|
count = len(rows)
|
||||||
else:
|
else:
|
||||||
count = 0
|
count = 0
|
||||||
|
|
||||||
return Page(
|
return Page(
|
||||||
data=[model.from_row(row) for row in rows] if model else rows,
|
data=rows,
|
||||||
total=count,
|
total=count,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def execute(self, query: str, values: tuple = ()):
|
async def execute(self, query: str, values: Optional[dict] = None):
|
||||||
return await self.conn.execute(
|
params = self.rewrite_values(values) if values else {}
|
||||||
self.rewrite_query(query), self.rewrite_values(values)
|
result = await self.conn.execute(text(self.rewrite_query(query)), params)
|
||||||
)
|
await self.conn.commit()
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
class Database(Compat):
|
class Database(Compat):
|
||||||
@@ -245,18 +266,42 @@ class Database(Compat):
|
|||||||
self.path = os.path.join(
|
self.path = os.path.join(
|
||||||
settings.lnbits_data_folder, f"{self.name}.sqlite3"
|
settings.lnbits_data_folder, f"{self.name}.sqlite3"
|
||||||
)
|
)
|
||||||
database_uri = f"sqlite:///{self.path}"
|
database_uri = f"sqlite+aiosqlite:///{self.path}"
|
||||||
else:
|
else:
|
||||||
database_uri = settings.lnbits_database_url
|
database_uri = settings.lnbits_database_url.replace(
|
||||||
|
"postgres://", "postgresql+asyncpg://"
|
||||||
|
)
|
||||||
|
|
||||||
if self.name.startswith("ext_"):
|
if self.name.startswith("ext_"):
|
||||||
self.schema = self.name[4:]
|
self.schema = self.name[4:]
|
||||||
else:
|
else:
|
||||||
self.schema = None
|
self.schema = None
|
||||||
|
|
||||||
self.engine = create_engine(
|
self.engine: AsyncEngine = create_async_engine(
|
||||||
database_uri, strategy=ASYNCIO_STRATEGY, echo=settings.debug_database
|
database_uri, echo=settings.debug_database
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if self.type in {POSTGRES, COCKROACH}:
|
||||||
|
|
||||||
|
@event.listens_for(self.engine.sync_engine, "connect")
|
||||||
|
def register_custom_types(dbapi_connection, *_):
|
||||||
|
def _parse_date(value) -> datetime:
|
||||||
|
if value is None:
|
||||||
|
value = "1970-01-01 00:00:00"
|
||||||
|
f = "%Y-%m-%d %H:%M:%S.%f"
|
||||||
|
if "." not in value:
|
||||||
|
f = "%Y-%m-%d %H:%M:%S"
|
||||||
|
return datetime.strptime(value, f)
|
||||||
|
|
||||||
|
dbapi_connection.run_async(
|
||||||
|
lambda connection: connection.set_type_codec(
|
||||||
|
"TIMESTAMP",
|
||||||
|
encoder=datetime,
|
||||||
|
decoder=_parse_date,
|
||||||
|
schema="pg_catalog",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
self.lock = asyncio.Lock()
|
self.lock = asyncio.Lock()
|
||||||
|
|
||||||
logger.trace(f"database {self.type} added for {self.name}")
|
logger.trace(f"database {self.type} added for {self.name}")
|
||||||
@@ -265,49 +310,65 @@ class Database(Compat):
|
|||||||
async def connect(self):
|
async def connect(self):
|
||||||
await self.lock.acquire()
|
await self.lock.acquire()
|
||||||
try:
|
try:
|
||||||
async with self.engine.connect() as conn: # type: ignore
|
async with self.engine.connect() as conn:
|
||||||
async with conn.begin() as txn:
|
if not conn:
|
||||||
wconn = Connection(conn, txn, self.type, self.name, self.schema)
|
raise Exception("Could not connect to the database")
|
||||||
|
|
||||||
if self.schema:
|
wconn = Connection(conn, self.type, self.name, self.schema)
|
||||||
if self.type in {POSTGRES, COCKROACH}:
|
|
||||||
await wconn.execute(
|
|
||||||
f"CREATE SCHEMA IF NOT EXISTS {self.schema}"
|
|
||||||
)
|
|
||||||
elif self.type == SQLITE:
|
|
||||||
await wconn.execute(
|
|
||||||
f"ATTACH '{self.path}' AS {self.schema}"
|
|
||||||
)
|
|
||||||
|
|
||||||
yield wconn
|
if self.schema:
|
||||||
|
if self.type in {POSTGRES, COCKROACH}:
|
||||||
|
await wconn.execute(
|
||||||
|
f"CREATE SCHEMA IF NOT EXISTS {self.schema}"
|
||||||
|
)
|
||||||
|
elif self.type == SQLITE:
|
||||||
|
await wconn.execute(f"ATTACH '{self.path}' AS {self.schema}")
|
||||||
|
|
||||||
|
yield wconn
|
||||||
finally:
|
finally:
|
||||||
self.lock.release()
|
self.lock.release()
|
||||||
|
|
||||||
async def fetchall(self, query: str, values: tuple = ()) -> list:
|
async def fetchall(
|
||||||
|
self,
|
||||||
|
query: str,
|
||||||
|
values: Optional[dict] = None,
|
||||||
|
model: Optional[type[TModel]] = None,
|
||||||
|
) -> list[TModel]:
|
||||||
async with self.connect() as conn:
|
async with self.connect() as conn:
|
||||||
result = await conn.execute(query, values)
|
return await conn.fetchall(query, values, model)
|
||||||
return await result.fetchall()
|
|
||||||
|
|
||||||
async def fetchone(self, query: str, values: tuple = ()):
|
async def fetchone(
|
||||||
|
self,
|
||||||
|
query: str,
|
||||||
|
values: Optional[dict] = None,
|
||||||
|
model: Optional[type[TModel]] = None,
|
||||||
|
) -> TModel:
|
||||||
async with self.connect() as conn:
|
async with self.connect() as conn:
|
||||||
result = await conn.execute(query, values)
|
return await conn.fetchone(query, values, model)
|
||||||
row = await result.fetchone()
|
|
||||||
await result.close()
|
async def insert(self, table_name: str, model: BaseModel) -> None:
|
||||||
return row
|
async with self.connect() as conn:
|
||||||
|
await conn.insert(table_name, model)
|
||||||
|
|
||||||
|
async def update(
|
||||||
|
self, table_name: str, model: BaseModel, where: str = "WHERE id = :id"
|
||||||
|
) -> None:
|
||||||
|
async with self.connect() as conn:
|
||||||
|
await conn.update(table_name, model, where)
|
||||||
|
|
||||||
async def fetch_page(
|
async def fetch_page(
|
||||||
self,
|
self,
|
||||||
query: str,
|
query: str,
|
||||||
where: Optional[list[str]] = None,
|
where: Optional[list[str]] = None,
|
||||||
values: Optional[list[str]] = None,
|
values: Optional[dict] = None,
|
||||||
filters: Optional[Filters] = None,
|
filters: Optional[Filters] = None,
|
||||||
model: Optional[type[TRowModel]] = None,
|
model: Optional[type[TModel]] = None,
|
||||||
group_by: Optional[list[str]] = None,
|
group_by: Optional[list[str]] = None,
|
||||||
) -> Page[TRowModel]:
|
) -> Page[TModel]:
|
||||||
async with self.connect() as conn:
|
async with self.connect() as conn:
|
||||||
return await conn.fetch_page(query, where, values, filters, model, group_by)
|
return await conn.fetch_page(query, where, values, filters, model, group_by)
|
||||||
|
|
||||||
async def execute(self, query: str, values: tuple = ()):
|
async def execute(self, query: str, values: Optional[dict] = None):
|
||||||
async with self.connect() as conn:
|
async with self.connect() as conn:
|
||||||
return await conn.execute(query, values)
|
return await conn.execute(query, values)
|
||||||
|
|
||||||
@@ -363,12 +424,6 @@ class Operator(Enum):
|
|||||||
raise ValueError("Unknown SQL Operator")
|
raise ValueError("Unknown SQL Operator")
|
||||||
|
|
||||||
|
|
||||||
class FromRowModel(BaseModel):
|
|
||||||
@classmethod
|
|
||||||
def from_row(cls, row: Row):
|
|
||||||
return cls(**dict(row))
|
|
||||||
|
|
||||||
|
|
||||||
class FilterModel(BaseModel):
|
class FilterModel(BaseModel):
|
||||||
__search_fields__: list[str] = []
|
__search_fields__: list[str] = []
|
||||||
__sort_fields__: Optional[list[str]] = None
|
__sort_fields__: Optional[list[str]] = None
|
||||||
@@ -376,7 +431,6 @@ class FilterModel(BaseModel):
|
|||||||
|
|
||||||
T = TypeVar("T")
|
T = TypeVar("T")
|
||||||
TModel = TypeVar("TModel", bound=BaseModel)
|
TModel = TypeVar("TModel", bound=BaseModel)
|
||||||
TRowModel = TypeVar("TRowModel", bound=FromRowModel)
|
|
||||||
TFilterModel = TypeVar("TFilterModel", bound=FilterModel)
|
TFilterModel = TypeVar("TFilterModel", bound=FilterModel)
|
||||||
|
|
||||||
|
|
||||||
@@ -388,12 +442,13 @@ class Page(BaseModel, Generic[T]):
|
|||||||
class Filter(BaseModel, Generic[TFilterModel]):
|
class Filter(BaseModel, Generic[TFilterModel]):
|
||||||
field: str
|
field: str
|
||||||
op: Operator = Operator.EQ
|
op: Operator = Operator.EQ
|
||||||
values: list[Any]
|
|
||||||
|
|
||||||
model: Optional[type[TFilterModel]]
|
model: Optional[type[TFilterModel]]
|
||||||
|
values: Optional[dict] = None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def parse_query(cls, key: str, raw_values: list[Any], model: type[TFilterModel]):
|
def parse_query(
|
||||||
|
cls, key: str, raw_values: list[Any], model: type[TFilterModel], i: int = 0
|
||||||
|
):
|
||||||
# Key format:
|
# Key format:
|
||||||
# key[operator]
|
# key[operator]
|
||||||
# e.g. name[eq]
|
# e.g. name[eq]
|
||||||
@@ -409,12 +464,12 @@ class Filter(BaseModel, Generic[TFilterModel]):
|
|||||||
|
|
||||||
if field in model.__fields__:
|
if field in model.__fields__:
|
||||||
compare_field = model.__fields__[field]
|
compare_field = model.__fields__[field]
|
||||||
values = []
|
values: dict = {}
|
||||||
for raw_value in raw_values:
|
for raw_value in raw_values:
|
||||||
validated, errors = compare_field.validate(raw_value, {}, loc="none")
|
validated, errors = compare_field.validate(raw_value, {}, loc="none")
|
||||||
if errors:
|
if errors:
|
||||||
raise ValidationError(errors=[errors], model=model)
|
raise ValidationError(errors=[errors], model=model)
|
||||||
values.append(validated)
|
values[f"{field}__{i}"] = validated
|
||||||
else:
|
else:
|
||||||
raise ValueError("Unknown filter field")
|
raise ValueError("Unknown filter field")
|
||||||
|
|
||||||
@@ -422,15 +477,14 @@ class Filter(BaseModel, Generic[TFilterModel]):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def statement(self):
|
def statement(self):
|
||||||
if self.model and self.model.__fields__[self.field].type_ == datetime.datetime:
|
stmt = []
|
||||||
placeholder = compat_timestamp_placeholder()
|
for key in self.values.keys() if self.values else []:
|
||||||
else:
|
clean_key = key.split("__")[0]
|
||||||
placeholder = "?"
|
if self.model and self.model.__fields__[clean_key].type_ == datetime:
|
||||||
if self.op in (Operator.INCLUDE, Operator.EXCLUDE):
|
placeholder = compat_timestamp_placeholder(key)
|
||||||
placeholders = ", ".join([placeholder] * len(self.values))
|
else:
|
||||||
stmt = [f"{self.field} {self.op.as_sql} ({placeholders})"]
|
placeholder = f":{key}"
|
||||||
else:
|
stmt.append(f"{clean_key} {self.op.as_sql} {placeholder}")
|
||||||
stmt = [f"{self.field} {self.op.as_sql} {placeholder}"] * len(self.values)
|
|
||||||
return " OR ".join(stmt)
|
return " OR ".join(stmt)
|
||||||
|
|
||||||
|
|
||||||
@@ -481,14 +535,11 @@ class Filters(BaseModel, Generic[TFilterModel]):
|
|||||||
for page_filter in self.filters:
|
for page_filter in self.filters:
|
||||||
where_stmts.append(page_filter.statement)
|
where_stmts.append(page_filter.statement)
|
||||||
if self.search and self.model:
|
if self.search and self.model:
|
||||||
|
fields = self.model.__search_fields__
|
||||||
if DB_TYPE == POSTGRES:
|
if DB_TYPE == POSTGRES:
|
||||||
where_stmts.append(
|
where_stmts.append(f"lower(concat({', '.join(fields)})) LIKE :search")
|
||||||
f"lower(concat({', '.join(self.model.__search_fields__)})) LIKE ?"
|
|
||||||
)
|
|
||||||
elif DB_TYPE == SQLITE:
|
elif DB_TYPE == SQLITE:
|
||||||
where_stmts.append(
|
where_stmts.append(f"lower({'||'.join(fields)}) LIKE :search")
|
||||||
f"lower({'||'.join(self.model.__search_fields__)}) LIKE ?"
|
|
||||||
)
|
|
||||||
if where_stmts:
|
if where_stmts:
|
||||||
return "WHERE " + " AND ".join(where_stmts)
|
return "WHERE " + " AND ".join(where_stmts)
|
||||||
return ""
|
return ""
|
||||||
@@ -498,12 +549,135 @@ class Filters(BaseModel, Generic[TFilterModel]):
|
|||||||
return f"ORDER BY {self.sortby} {self.direction or 'asc'}"
|
return f"ORDER BY {self.sortby} {self.direction or 'asc'}"
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
def values(self, values: Optional[list[str]] = None) -> tuple:
|
def values(self, values: Optional[dict] = None) -> dict:
|
||||||
if not values:
|
if not values:
|
||||||
values = []
|
values = {}
|
||||||
if self.filters:
|
if self.filters:
|
||||||
for page_filter in self.filters:
|
for page_filter in self.filters:
|
||||||
values.extend(page_filter.values)
|
if page_filter.values:
|
||||||
|
for key, value in page_filter.values.items():
|
||||||
|
values[key] = value
|
||||||
if self.search and self.model:
|
if self.search and self.model:
|
||||||
values.append(f"%{self.search}%")
|
values["search"] = f"%{self.search}%"
|
||||||
return tuple(values)
|
return values
|
||||||
|
|
||||||
|
|
||||||
|
def insert_query(table_name: str, model: BaseModel) -> str:
|
||||||
|
"""
|
||||||
|
Generate an insert query with placeholders for a given table and model
|
||||||
|
:param table_name: Name of the table
|
||||||
|
:param model: Pydantic model
|
||||||
|
"""
|
||||||
|
placeholders = []
|
||||||
|
keys = model_to_dict(model).keys()
|
||||||
|
for field in keys:
|
||||||
|
placeholders.append(get_placeholder(model, field))
|
||||||
|
# add quotes to keys to avoid SQL conflicts (e.g. `user` is a reserved keyword)
|
||||||
|
fields = ", ".join([f'"{key}"' for key in keys])
|
||||||
|
values = ", ".join(placeholders)
|
||||||
|
return f"INSERT INTO {table_name} ({fields}) VALUES ({values})"
|
||||||
|
|
||||||
|
|
||||||
|
def update_query(
|
||||||
|
table_name: str, model: BaseModel, where: str = "WHERE id = :id"
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Generate an update query with placeholders for a given table and model
|
||||||
|
:param table_name: Name of the table
|
||||||
|
:param model: Pydantic model
|
||||||
|
:param where: Where string, default to `WHERE id = :id`
|
||||||
|
"""
|
||||||
|
fields = []
|
||||||
|
for field in model_to_dict(model).keys():
|
||||||
|
placeholder = get_placeholder(model, field)
|
||||||
|
# add quotes to keys to avoid SQL conflicts (e.g. `user` is a reserved keyword)
|
||||||
|
fields.append(f'"{field}" = {placeholder}')
|
||||||
|
query = ", ".join(fields)
|
||||||
|
return f"UPDATE {table_name} SET {query} {where}"
|
||||||
|
|
||||||
|
|
||||||
|
def model_to_dict(model: BaseModel) -> dict:
|
||||||
|
"""
|
||||||
|
Convert a Pydantic model to a dictionary with JSON-encoded nested models
|
||||||
|
private fields starting with _ are ignored
|
||||||
|
:param model: Pydantic model
|
||||||
|
"""
|
||||||
|
_dict: dict = {}
|
||||||
|
for key, value in model.dict().items():
|
||||||
|
type_ = model.__fields__[key].type_
|
||||||
|
outertype_ = model.__fields__[key].outer_type_
|
||||||
|
if model.__fields__[key].field_info.extra.get("no_database", False):
|
||||||
|
continue
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
_dict[key] = value.timestamp()
|
||||||
|
continue
|
||||||
|
if (
|
||||||
|
type(type_) is type(BaseModel)
|
||||||
|
or type_ is dict
|
||||||
|
or get_origin(outertype_) is list
|
||||||
|
):
|
||||||
|
_dict[key] = json.dumps(value)
|
||||||
|
continue
|
||||||
|
_dict[key] = value
|
||||||
|
|
||||||
|
return _dict
|
||||||
|
|
||||||
|
|
||||||
|
def dict_to_submodel(model: type[TModel], value: Union[dict, str]) -> Optional[TModel]:
|
||||||
|
"""convert a dictionary or JSON string to a Pydantic model"""
|
||||||
|
if isinstance(value, str):
|
||||||
|
if value == "null":
|
||||||
|
return None
|
||||||
|
_subdict = json.loads(value)
|
||||||
|
elif isinstance(value, dict):
|
||||||
|
_subdict = value
|
||||||
|
|
||||||
|
# recursively convert nested models
|
||||||
|
return dict_to_model(_subdict, model)
|
||||||
|
|
||||||
|
|
||||||
|
def dict_to_model(_row: dict, model: type[TModel]) -> TModel:
|
||||||
|
"""
|
||||||
|
Convert a dictionary with JSON-encoded nested models to a Pydantic model
|
||||||
|
:param _dict: Dictionary from database
|
||||||
|
:param model: Pydantic model
|
||||||
|
"""
|
||||||
|
_dict: dict = {}
|
||||||
|
for key, value in _row.items():
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
if key not in model.__fields__:
|
||||||
|
# Somethimes an SQL JOIN will create additional column
|
||||||
|
continue
|
||||||
|
type_ = model.__fields__[key].type_
|
||||||
|
outertype_ = model.__fields__[key].outer_type_
|
||||||
|
if get_origin(outertype_) is list:
|
||||||
|
_items = json.loads(value) if isinstance(value, str) else value
|
||||||
|
_dict[key] = [
|
||||||
|
dict_to_submodel(type_, v) if issubclass(type_, BaseModel) else v
|
||||||
|
for v in _items
|
||||||
|
]
|
||||||
|
continue
|
||||||
|
if issubclass(type_, bool):
|
||||||
|
_dict[key] = bool(value)
|
||||||
|
continue
|
||||||
|
if issubclass(type_, datetime):
|
||||||
|
if DB_TYPE == SQLITE:
|
||||||
|
_dict[key] = datetime.fromtimestamp(value, timezone.utc)
|
||||||
|
else:
|
||||||
|
_dict[key] = value
|
||||||
|
continue
|
||||||
|
if issubclass(type_, BaseModel):
|
||||||
|
_dict[key] = dict_to_submodel(type_, value)
|
||||||
|
continue
|
||||||
|
# TODO: remove this when all sub models are migrated to Pydantic
|
||||||
|
# NOTE: this is for type dict on BaseModel, (used in Payment class)
|
||||||
|
if type_ is dict and value:
|
||||||
|
_dict[key] = json.loads(value)
|
||||||
|
continue
|
||||||
|
_dict[key] = value
|
||||||
|
continue
|
||||||
|
_model = model.construct(**_dict)
|
||||||
|
if isinstance(_model, BaseModel):
|
||||||
|
_model.__init__(**_dict) # type: ignore
|
||||||
|
return _model
|
||||||
|
|||||||
+58
-34
@@ -1,12 +1,12 @@
|
|||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
from typing import Annotated, Literal, Optional, Type, Union
|
from typing import Annotated, Literal, Optional, Type, Union
|
||||||
|
|
||||||
|
import jwt
|
||||||
from fastapi import Cookie, Depends, Query, Request, Security
|
from fastapi import Cookie, Depends, Query, Request, Security
|
||||||
from fastapi.exceptions import HTTPException
|
from fastapi.exceptions import HTTPException
|
||||||
from fastapi.openapi.models import APIKey, APIKeyIn, SecuritySchemeType
|
from fastapi.openapi.models import APIKey, APIKeyIn, SecuritySchemeType
|
||||||
from fastapi.security import APIKeyHeader, APIKeyQuery, OAuth2PasswordBearer
|
from fastapi.security import APIKeyHeader, APIKeyQuery, OAuth2PasswordBearer
|
||||||
from fastapi.security.base import SecurityBase
|
from fastapi.security.base import SecurityBase
|
||||||
from jose import ExpiredSignatureError, JWTError, jwt
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from pydantic.types import UUID4
|
from pydantic.types import UUID4
|
||||||
|
|
||||||
@@ -14,12 +14,19 @@ from lnbits.core.crud import (
|
|||||||
get_account,
|
get_account,
|
||||||
get_account_by_email,
|
get_account_by_email,
|
||||||
get_account_by_username,
|
get_account_by_username,
|
||||||
get_user,
|
|
||||||
get_user_active_extensions_ids,
|
get_user_active_extensions_ids,
|
||||||
|
get_user_from_account,
|
||||||
get_wallet_for_key,
|
get_wallet_for_key,
|
||||||
)
|
)
|
||||||
from lnbits.core.models import KeyType, SimpleStatus, User, WalletTypeInfo
|
from lnbits.core.models import (
|
||||||
from lnbits.db import Filter, Filters, TFilterModel
|
AccessTokenPayload,
|
||||||
|
Account,
|
||||||
|
KeyType,
|
||||||
|
SimpleStatus,
|
||||||
|
User,
|
||||||
|
WalletTypeInfo,
|
||||||
|
)
|
||||||
|
from lnbits.db import Connection, Filter, Filters, TFilterModel
|
||||||
from lnbits.settings import AuthMethods, settings
|
from lnbits.settings import AuthMethods, settings
|
||||||
|
|
||||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="api/v1/auth", auto_error=False)
|
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="api/v1/auth", auto_error=False)
|
||||||
@@ -59,7 +66,7 @@ class KeyChecker(SecurityBase):
|
|||||||
name="X-API-KEY",
|
name="X-API-KEY",
|
||||||
description="Wallet API Key - HEADER",
|
description="Wallet API Key - HEADER",
|
||||||
)
|
)
|
||||||
self.model: APIKey = openapi_model
|
self.model: APIKey = openapi_model # type: ignore
|
||||||
|
|
||||||
async def __call__(self, request: Request) -> WalletTypeInfo:
|
async def __call__(self, request: Request) -> WalletTypeInfo:
|
||||||
|
|
||||||
@@ -83,6 +90,7 @@ class KeyChecker(SecurityBase):
|
|||||||
detail="Wallet not found.",
|
detail="Wallet not found.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
request.scope["user_id"] = wallet.user
|
||||||
if self.expected_key_type is KeyType.admin and wallet.adminkey != key_value:
|
if self.expected_key_type is KeyType.admin and wallet.adminkey != key_value:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.UNAUTHORIZED,
|
status_code=HTTPStatus.UNAUTHORIZED,
|
||||||
@@ -95,15 +103,6 @@ class KeyChecker(SecurityBase):
|
|||||||
return WalletTypeInfo(key_type, wallet)
|
return WalletTypeInfo(key_type, wallet)
|
||||||
|
|
||||||
|
|
||||||
async def get_key_type(
|
|
||||||
request: Request,
|
|
||||||
api_key_header: str = Security(api_key_header),
|
|
||||||
api_key_query: str = Security(api_key_query),
|
|
||||||
) -> WalletTypeInfo:
|
|
||||||
check: KeyChecker = KeyChecker(api_key=api_key_header or api_key_query)
|
|
||||||
return await check(request)
|
|
||||||
|
|
||||||
|
|
||||||
async def require_admin_key(
|
async def require_admin_key(
|
||||||
request: Request,
|
request: Request,
|
||||||
api_key_header: str = Security(api_key_header),
|
api_key_header: str = Security(api_key_header),
|
||||||
@@ -147,14 +146,17 @@ async def check_user_exists(
|
|||||||
else:
|
else:
|
||||||
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Missing user ID or access token.")
|
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Missing user ID or access token.")
|
||||||
|
|
||||||
if not account or not settings.is_user_allowed(account.id):
|
if not account:
|
||||||
|
raise HTTPException(HTTPStatus.UNAUTHORIZED, "User not found.")
|
||||||
|
|
||||||
|
r.scope["user_id"] = account.id
|
||||||
|
if not settings.is_user_allowed(account.id):
|
||||||
raise HTTPException(HTTPStatus.UNAUTHORIZED, "User not allowed.")
|
raise HTTPException(HTTPStatus.UNAUTHORIZED, "User not allowed.")
|
||||||
|
|
||||||
user = await get_user(account.id)
|
user = await get_user_from_account(account)
|
||||||
assert user, "User not found for account."
|
if not user:
|
||||||
|
raise HTTPException(HTTPStatus.UNAUTHORIZED, "User not found.")
|
||||||
await _check_user_extension_access(user.id, r["path"])
|
await _check_user_extension_access(user.id, r["path"])
|
||||||
|
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
@@ -171,6 +173,16 @@ async def optional_user_id(
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def access_token_payload(
|
||||||
|
access_token: Annotated[Optional[str], Depends(check_access_token)],
|
||||||
|
) -> AccessTokenPayload:
|
||||||
|
if not access_token:
|
||||||
|
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Missing access token.")
|
||||||
|
|
||||||
|
payload: dict = jwt.decode(access_token, settings.auth_secret_key, ["HS256"])
|
||||||
|
return AccessTokenPayload(**payload)
|
||||||
|
|
||||||
|
|
||||||
async def check_admin(user: Annotated[User, Depends(check_user_exists)]) -> User:
|
async def check_admin(user: Annotated[User, Depends(check_user_exists)]) -> User:
|
||||||
if user.id != settings.super_user and user.id not in settings.lnbits_admin_users:
|
if user.id != settings.super_user and user.id not in settings.lnbits_admin_users:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -204,9 +216,9 @@ def parse_filters(model: Type[TFilterModel]):
|
|||||||
):
|
):
|
||||||
params = request.query_params
|
params = request.query_params
|
||||||
filters = []
|
filters = []
|
||||||
for key in params.keys():
|
for i, key in enumerate(params.keys()):
|
||||||
try:
|
try:
|
||||||
filters.append(Filter.parse_query(key, params.getlist(key), model))
|
filters.append(Filter.parse_query(key, params.getlist(key), model, i))
|
||||||
except ValueError:
|
except ValueError:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -223,7 +235,9 @@ def parse_filters(model: Type[TFilterModel]):
|
|||||||
return dependency
|
return dependency
|
||||||
|
|
||||||
|
|
||||||
async def check_user_extension_access(user_id: str, ext_id: str) -> SimpleStatus:
|
async def check_user_extension_access(
|
||||||
|
user_id: str, ext_id: str, conn: Optional[Connection] = None
|
||||||
|
) -> SimpleStatus:
|
||||||
"""
|
"""
|
||||||
Check if the user has access to a particular extension.
|
Check if the user has access to a particular extension.
|
||||||
Raises HTTP Forbidden if the user is not allowed.
|
Raises HTTP Forbidden if the user is not allowed.
|
||||||
@@ -234,7 +248,7 @@ async def check_user_extension_access(user_id: str, ext_id: str) -> SimpleStatus
|
|||||||
)
|
)
|
||||||
|
|
||||||
if settings.is_extension_id(ext_id):
|
if settings.is_extension_id(ext_id):
|
||||||
ext_ids = await get_user_active_extensions_ids(user_id)
|
ext_ids = await get_user_active_extensions_ids(user_id, conn=conn)
|
||||||
if ext_id not in ext_ids:
|
if ext_id not in ext_ids:
|
||||||
return SimpleStatus(
|
return SimpleStatus(
|
||||||
success=False, message=f"User extension '{ext_id}' not enabled."
|
success=False, message=f"User extension '{ext_id}' not enabled."
|
||||||
@@ -254,21 +268,31 @@ async def _check_user_extension_access(user_id: str, current_path: str):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _get_account_from_token(access_token):
|
async def _get_account_from_token(access_token) -> Optional[Account]:
|
||||||
try:
|
try:
|
||||||
payload = jwt.decode(access_token, settings.auth_secret_key, "HS256")
|
payload: dict = jwt.decode(access_token, settings.auth_secret_key, ["HS256"])
|
||||||
if "sub" in payload and payload.get("sub"):
|
user = await _get_user_from_jwt_payload(payload)
|
||||||
return await get_account_by_username(str(payload.get("sub")))
|
if not user:
|
||||||
if "usr" in payload and payload.get("usr"):
|
raise HTTPException(
|
||||||
return await get_account(str(payload.get("usr")))
|
HTTPStatus.UNAUTHORIZED, "Data missing for access token."
|
||||||
if "email" in payload and payload.get("email"):
|
)
|
||||||
return await get_account_by_email(str(payload.get("email")))
|
|
||||||
|
|
||||||
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Data missing for access token.")
|
return user
|
||||||
except ExpiredSignatureError as exc:
|
|
||||||
|
except jwt.ExpiredSignatureError as exc:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
HTTPStatus.UNAUTHORIZED, "Session expired.", {"token-expired": "true"}
|
HTTPStatus.UNAUTHORIZED, "Session expired.", {"token-expired": "true"}
|
||||||
) from exc
|
) from exc
|
||||||
except JWTError as exc:
|
except jwt.PyJWTError as exc:
|
||||||
logger.debug(exc)
|
logger.debug(exc)
|
||||||
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid access token.") from exc
|
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid access token.") from exc
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_user_from_jwt_payload(payload) -> Optional[Account]:
|
||||||
|
if "sub" in payload and payload.get("sub"):
|
||||||
|
return await get_account_by_username(str(payload.get("sub")))
|
||||||
|
if "usr" in payload and payload.get("usr"):
|
||||||
|
return await get_account(str(payload.get("usr")))
|
||||||
|
if "email" in payload and payload.get("email"):
|
||||||
|
return await get_account_by_email(str(payload.get("email")))
|
||||||
|
return None
|
||||||
|
|||||||
+55
-41
@@ -8,52 +8,54 @@ from fastapi.exceptions import RequestValidationError
|
|||||||
from fastapi.responses import JSONResponse, RedirectResponse, Response
|
from fastapi.responses import JSONResponse, RedirectResponse, Response
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from lnbits.core.services import InvoiceError, PaymentError
|
|
||||||
|
|
||||||
from .helpers import template_renderer
|
from .helpers import template_renderer
|
||||||
|
|
||||||
|
|
||||||
def register_exception_handlers(app: FastAPI):
|
class PaymentError(Exception):
|
||||||
register_exception_handler(app)
|
def __init__(self, message: str, status: str = "pending"):
|
||||||
register_request_validation_exception_handler(app)
|
self.message = message
|
||||||
register_http_exception_handler(app)
|
self.status = status
|
||||||
register_payment_error_handler(app)
|
|
||||||
register_invoice_error_handler(app)
|
|
||||||
|
class InvoiceError(Exception):
|
||||||
|
def __init__(self, message: str, status: str = "pending"):
|
||||||
|
self.message = message
|
||||||
|
self.status = status
|
||||||
|
|
||||||
|
|
||||||
def render_html_error(request: Request, exc: Exception) -> Optional[Response]:
|
def render_html_error(request: Request, exc: Exception) -> Optional[Response]:
|
||||||
# Only the browser sends "text/html" request
|
# Only the browser sends "text/html" request
|
||||||
# not fail proof, but everything else get's a JSON response
|
# not fail proof, but everything else get's a JSON response
|
||||||
|
if not request.headers:
|
||||||
|
return None
|
||||||
|
if "text/html" not in request.headers.get("accept", ""):
|
||||||
|
return None
|
||||||
|
|
||||||
if (
|
if (
|
||||||
request.headers
|
isinstance(exc, HTTPException)
|
||||||
and "accept" in request.headers
|
and exc.headers
|
||||||
and "text/html" in request.headers["accept"]
|
and "token-expired" in exc.headers
|
||||||
):
|
):
|
||||||
if (
|
response = RedirectResponse("/")
|
||||||
isinstance(exc, HTTPException)
|
response.delete_cookie("cookie_access_token")
|
||||||
and exc.headers
|
response.delete_cookie("is_lnbits_user_authorized")
|
||||||
and "token-expired" in exc.headers
|
response.set_cookie("is_access_token_expired", "true")
|
||||||
):
|
return response
|
||||||
response = RedirectResponse("/")
|
|
||||||
response.delete_cookie("cookie_access_token")
|
|
||||||
response.delete_cookie("is_lnbits_user_authorized")
|
|
||||||
response.set_cookie("is_access_token_expired", "true")
|
|
||||||
return response
|
|
||||||
|
|
||||||
status_code: int = (
|
status_code: int = (
|
||||||
exc.status_code
|
exc.status_code
|
||||||
if isinstance(exc, HTTPException)
|
if isinstance(exc, HTTPException)
|
||||||
else HTTPStatus.INTERNAL_SERVER_ERROR
|
else HTTPStatus.INTERNAL_SERVER_ERROR
|
||||||
)
|
)
|
||||||
|
|
||||||
return template_renderer().TemplateResponse(
|
return template_renderer().TemplateResponse(
|
||||||
request, "error.html", {"err": f"Error: {exc!s}"}, status_code
|
request, "error.html", {"err": f"Error: {exc!s}"}, status_code
|
||||||
)
|
)
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def register_exception_handler(app: FastAPI):
|
def register_exception_handlers(app: FastAPI):
|
||||||
|
"""Register exception handlers for the FastAPI app"""
|
||||||
|
|
||||||
@app.exception_handler(Exception)
|
@app.exception_handler(Exception)
|
||||||
async def exception_handler(request: Request, exc: Exception):
|
async def exception_handler(request: Request, exc: Exception):
|
||||||
etype, _, tb = sys.exc_info()
|
etype, _, tb = sys.exc_info()
|
||||||
@@ -64,8 +66,26 @@ def register_exception_handler(app: FastAPI):
|
|||||||
content={"detail": str(exc)},
|
content={"detail": str(exc)},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@app.exception_handler(AssertionError)
|
||||||
|
async def assert_error_handler(request: Request, exc: AssertionError):
|
||||||
|
etype, _, tb = sys.exc_info()
|
||||||
|
traceback.print_exception(etype, exc, tb)
|
||||||
|
logger.warning(f"AssertionError: {exc!s}")
|
||||||
|
return render_html_error(request, exc) or JSONResponse(
|
||||||
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
|
content={"detail": str(exc)},
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.exception_handler(ValueError)
|
||||||
|
async def value_error_handler(request: Request, exc: ValueError):
|
||||||
|
etype, _, tb = sys.exc_info()
|
||||||
|
traceback.print_exception(etype, exc, tb)
|
||||||
|
logger.warning(f"ValueError: {exc!s}")
|
||||||
|
return render_html_error(request, exc) or JSONResponse(
|
||||||
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
|
content={"detail": str(exc)},
|
||||||
|
)
|
||||||
|
|
||||||
def register_request_validation_exception_handler(app: FastAPI):
|
|
||||||
@app.exception_handler(RequestValidationError)
|
@app.exception_handler(RequestValidationError)
|
||||||
async def validation_exception_handler(
|
async def validation_exception_handler(
|
||||||
request: Request, exc: RequestValidationError
|
request: Request, exc: RequestValidationError
|
||||||
@@ -76,8 +96,6 @@ def register_request_validation_exception_handler(app: FastAPI):
|
|||||||
content={"detail": str(exc)},
|
content={"detail": str(exc)},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def register_http_exception_handler(app: FastAPI):
|
|
||||||
@app.exception_handler(HTTPException)
|
@app.exception_handler(HTTPException)
|
||||||
async def http_exception_handler(request: Request, exc: HTTPException):
|
async def http_exception_handler(request: Request, exc: HTTPException):
|
||||||
logger.error(f"HTTPException {exc.status_code}: {exc.detail}")
|
logger.error(f"HTTPException {exc.status_code}: {exc.detail}")
|
||||||
@@ -86,21 +104,17 @@ def register_http_exception_handler(app: FastAPI):
|
|||||||
content={"detail": exc.detail},
|
content={"detail": exc.detail},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def register_payment_error_handler(app: FastAPI):
|
|
||||||
@app.exception_handler(PaymentError)
|
@app.exception_handler(PaymentError)
|
||||||
async def payment_error_handler(request: Request, exc: PaymentError):
|
async def payment_error_handler(request: Request, exc: PaymentError):
|
||||||
logger.error(f"PaymentError: {exc.message}, {exc.status}")
|
logger.error(f"{exc.message}, {exc.status}")
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=520,
|
status_code=520,
|
||||||
content={"detail": exc.message, "status": exc.status},
|
content={"detail": exc.message, "status": exc.status},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def register_invoice_error_handler(app: FastAPI):
|
|
||||||
@app.exception_handler(InvoiceError)
|
@app.exception_handler(InvoiceError)
|
||||||
async def invoice_error_handler(request: Request, exc: InvoiceError):
|
async def invoice_error_handler(request: Request, exc: InvoiceError):
|
||||||
logger.error(f"InvoiceError: {exc.message}, Status: {exc.status}")
|
logger.error(f"{exc.message}, Status: {exc.status}")
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=520,
|
status_code=520,
|
||||||
content={"detail": exc.message, "status": exc.status},
|
content={"detail": exc.message, "status": exc.status},
|
||||||
|
|||||||
+62
-30
@@ -1,13 +1,15 @@
|
|||||||
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, List, Optional, Type
|
from typing import Any, Optional, Type
|
||||||
|
from urllib import request
|
||||||
|
|
||||||
import jinja2
|
import jinja2
|
||||||
|
import jwt
|
||||||
import shortuuid
|
import shortuuid
|
||||||
from jose import jwt
|
from packaging import version
|
||||||
from pydantic import BaseModel
|
|
||||||
from pydantic.schema import field_schema
|
from pydantic.schema import field_schema
|
||||||
|
|
||||||
from lnbits.jinja2_templating import Jinja2Templates
|
from lnbits.jinja2_templating import Jinja2Templates
|
||||||
@@ -17,7 +19,6 @@ from lnbits.settings import settings
|
|||||||
from lnbits.utils.crypto import AESCipher
|
from lnbits.utils.crypto import AESCipher
|
||||||
|
|
||||||
from .db import FilterModel
|
from .db import FilterModel
|
||||||
from .extension_manager import get_valid_extensions
|
|
||||||
|
|
||||||
|
|
||||||
def get_db_vendor_name():
|
def get_db_vendor_name():
|
||||||
@@ -50,7 +51,7 @@ def static_url_for(static: str, path: str) -> str:
|
|||||||
return f"/{static}/{path}?v={settings.server_startup_time}"
|
return f"/{static}/{path}?v={settings.server_startup_time}"
|
||||||
|
|
||||||
|
|
||||||
def template_renderer(additional_folders: Optional[List] = None) -> Jinja2Templates:
|
def template_renderer(additional_folders: Optional[list] = None) -> Jinja2Templates:
|
||||||
folders = ["lnbits/templates", "lnbits/core/templates"]
|
folders = ["lnbits/templates", "lnbits/core/templates"]
|
||||||
if additional_folders:
|
if additional_folders:
|
||||||
additional_folders += [
|
additional_folders += [
|
||||||
@@ -85,6 +86,8 @@ def template_renderer(additional_folders: Optional[List] = None) -> Jinja2Templa
|
|||||||
t.env.globals["LNBITS_EXTENSIONS_DEACTIVATE_ALL"] = (
|
t.env.globals["LNBITS_EXTENSIONS_DEACTIVATE_ALL"] = (
|
||||||
settings.lnbits_extensions_deactivate_all
|
settings.lnbits_extensions_deactivate_all
|
||||||
)
|
)
|
||||||
|
t.env.globals["LNBITS_AUDIT_ENABLED"] = settings.lnbits_audit_enabled
|
||||||
|
|
||||||
t.env.globals["LNBITS_SERVICE_FEE"] = settings.lnbits_service_fee
|
t.env.globals["LNBITS_SERVICE_FEE"] = settings.lnbits_service_fee
|
||||||
t.env.globals["LNBITS_SERVICE_FEE_MAX"] = settings.lnbits_service_fee_max
|
t.env.globals["LNBITS_SERVICE_FEE_MAX"] = settings.lnbits_service_fee_max
|
||||||
t.env.globals["LNBITS_SERVICE_FEE_WALLET"] = settings.lnbits_service_fee_wallet
|
t.env.globals["LNBITS_SERVICE_FEE_WALLET"] = settings.lnbits_service_fee_wallet
|
||||||
@@ -92,19 +95,22 @@ def template_renderer(additional_folders: Optional[List] = None) -> Jinja2Templa
|
|||||||
settings.lnbits_node_ui and get_node_class() is not None
|
settings.lnbits_node_ui and get_node_class() is not None
|
||||||
)
|
)
|
||||||
t.env.globals["LNBITS_NODE_UI_AVAILABLE"] = get_node_class() is not None
|
t.env.globals["LNBITS_NODE_UI_AVAILABLE"] = get_node_class() is not None
|
||||||
t.env.globals["EXTENSIONS"] = get_valid_extensions(False)
|
t.env.globals["EXTENSIONS"] = list(settings.lnbits_all_extensions_ids)
|
||||||
|
|
||||||
if settings.lnbits_custom_logo:
|
if settings.lnbits_custom_logo:
|
||||||
t.env.globals["USE_CUSTOM_LOGO"] = settings.lnbits_custom_logo
|
t.env.globals["USE_CUSTOM_LOGO"] = settings.lnbits_custom_logo
|
||||||
|
|
||||||
if settings.bundle_assets:
|
if settings.bundle_assets:
|
||||||
t.env.globals["INCLUDED_JS"] = ["bundle.min.js"]
|
t.env.globals["INCLUDED_JS"] = ["bundle.min.js"]
|
||||||
t.env.globals["INCLUDED_CSS"] = ["bundle.min.css"]
|
t.env.globals["INCLUDED_CSS"] = ["bundle.min.css"]
|
||||||
|
t.env.globals["INCLUDED_COMPONENTS"] = ["bundle-components.min.js"]
|
||||||
else:
|
else:
|
||||||
vendor_filepath = Path(settings.lnbits_path, "static", "vendor.json")
|
vendor_filepath = Path(settings.lnbits_path, "static", "vendor.json")
|
||||||
with open(vendor_filepath) as vendor_file:
|
with open(vendor_filepath) as vendor_file:
|
||||||
vendor_files = json.loads(vendor_file.read())
|
vendor_files = json.loads(vendor_file.read())
|
||||||
t.env.globals["INCLUDED_JS"] = vendor_files["js"]
|
t.env.globals["INCLUDED_JS"] = vendor_files["js"]
|
||||||
t.env.globals["INCLUDED_CSS"] = vendor_files["css"]
|
t.env.globals["INCLUDED_CSS"] = vendor_files["css"]
|
||||||
|
t.env.globals["INCLUDED_COMPONENTS"] = vendor_files["components"]
|
||||||
|
|
||||||
t.env.globals["WEBPUSH_PUBKEY"] = settings.lnbits_webpush_pubkey
|
t.env.globals["WEBPUSH_PUBKEY"] = settings.lnbits_webpush_pubkey
|
||||||
|
|
||||||
@@ -172,28 +178,6 @@ def generate_filter_params_openapi(model: Type[FilterModel], keep_optional=False
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def insert_query(table_name: str, model: BaseModel) -> str:
|
|
||||||
"""
|
|
||||||
Generate an insert query with placeholders for a given table and model
|
|
||||||
:param table_name: Name of the table
|
|
||||||
:param model: Pydantic model
|
|
||||||
"""
|
|
||||||
placeholders = ", ".join(["?"] * len(model.dict().keys()))
|
|
||||||
fields = ", ".join(model.dict().keys())
|
|
||||||
return f"INSERT INTO {table_name} ({fields}) VALUES ({placeholders})"
|
|
||||||
|
|
||||||
|
|
||||||
def update_query(table_name: str, model: BaseModel, where: str = "WHERE id = ?") -> str:
|
|
||||||
"""
|
|
||||||
Generate an update query with placeholders for a given table and model
|
|
||||||
:param table_name: Name of the table
|
|
||||||
:param model: Pydantic model
|
|
||||||
:param where: Where string, default to `WHERE id = ?`
|
|
||||||
"""
|
|
||||||
query = ", ".join([f"{field} = ?" for field in model.dict().keys()])
|
|
||||||
return f"UPDATE {table_name} SET {query} {where}"
|
|
||||||
|
|
||||||
|
|
||||||
def is_valid_email_address(email: str) -> bool:
|
def is_valid_email_address(email: str) -> bool:
|
||||||
email_regex = r"[A-Za-z0-9\._%+-]+@[A-Za-z0-9\.-]+\.[A-Za-z]{2,63}"
|
email_regex = r"[A-Za-z0-9\._%+-]+@[A-Za-z0-9\.-]+\.[A-Za-z]{2,63}"
|
||||||
return re.fullmatch(email_regex, email) is not None
|
return re.fullmatch(email_regex, email) is not None
|
||||||
@@ -204,8 +188,20 @@ def is_valid_username(username: str) -> bool:
|
|||||||
return re.fullmatch(username_regex, username) is not None
|
return re.fullmatch(username_regex, username) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def is_valid_pubkey(pubkey: str) -> bool:
|
||||||
|
if len(pubkey) != 64:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
int(pubkey, 16)
|
||||||
|
return True
|
||||||
|
except Exception as _:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def create_access_token(data: dict):
|
def create_access_token(data: dict):
|
||||||
expire = datetime.utcnow() + timedelta(minutes=settings.auth_token_expire_minutes)
|
expire = datetime.now(timezone.utc) + timedelta(
|
||||||
|
minutes=settings.auth_token_expire_minutes
|
||||||
|
)
|
||||||
to_encode = data.copy()
|
to_encode = data.copy()
|
||||||
to_encode.update({"exp": expire})
|
to_encode.update({"exp": expire})
|
||||||
return jwt.encode(to_encode, settings.auth_secret_key, "HS256")
|
return jwt.encode(to_encode, settings.auth_secret_key, "HS256")
|
||||||
@@ -223,3 +219,39 @@ def decrypt_internal_message(m: Optional[str] = None) -> Optional[str]:
|
|||||||
if not m:
|
if not m:
|
||||||
return None
|
return None
|
||||||
return AESCipher(key=settings.auth_secret_key).decrypt(m)
|
return AESCipher(key=settings.auth_secret_key).decrypt(m)
|
||||||
|
|
||||||
|
|
||||||
|
def filter_dict_keys(data: dict, filter_keys: Optional[list[str]]) -> dict:
|
||||||
|
if not filter_keys:
|
||||||
|
# return shallow clone of the dict even if there are no filters
|
||||||
|
return {**data}
|
||||||
|
return {key: data[key] for key in filter_keys if key in data}
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
||||||
|
# remove release candidate suffix
|
||||||
|
v = v.split("-")[0].split("rc")[0]
|
||||||
|
return version.parse(v)
|
||||||
|
except Exception:
|
||||||
|
return version.parse("0.0.0")
|
||||||
|
|
||||||
|
|
||||||
|
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())
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
||||||
|
|||||||
+65
-1
@@ -1 +1,65 @@
|
|||||||
from lnurl import LnurlErrorResponse, decode, encode, handle # noqa: F401
|
from typing import Callable
|
||||||
|
|
||||||
|
from fastapi import HTTPException, Request, Response
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from fastapi.routing import APIRoute
|
||||||
|
from lnurl import LnurlErrorResponse, decode, encode, handle
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from lnbits.exceptions import InvoiceError, PaymentError
|
||||||
|
|
||||||
|
|
||||||
|
class LnurlErrorResponseHandler(APIRoute):
|
||||||
|
"""
|
||||||
|
Custom APIRoute class to handle LNURL errors.
|
||||||
|
LNURL errors always return with status 200 and
|
||||||
|
a JSON response with `status="ERROR"` and a `reason` key.
|
||||||
|
Helps to catch HTTPException and return a valid lnurl error response
|
||||||
|
|
||||||
|
Example:
|
||||||
|
withdraw_lnurl_router = APIRouter(prefix="/api/v1/lnurl")
|
||||||
|
withdraw_lnurl_router.route_class = LnurlErrorResponseHandler
|
||||||
|
"""
|
||||||
|
|
||||||
|
def get_route_handler(self) -> Callable:
|
||||||
|
original_route_handler = super().get_route_handler()
|
||||||
|
|
||||||
|
async def lnurl_route_handler(request: Request) -> Response:
|
||||||
|
try:
|
||||||
|
response = await original_route_handler(request)
|
||||||
|
return response
|
||||||
|
except (InvoiceError, PaymentError) as exc:
|
||||||
|
logger.debug(f"Wallet Error: {exc}")
|
||||||
|
response = JSONResponse(
|
||||||
|
status_code=200,
|
||||||
|
content={"status": "ERROR", "reason": f"{exc.message}"},
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
except HTTPException as exc:
|
||||||
|
logger.debug(f"HTTPException: {exc}")
|
||||||
|
response = JSONResponse(
|
||||||
|
status_code=200,
|
||||||
|
content={"status": "ERROR", "reason": f"{exc.detail}"},
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Unknown Error:", exc)
|
||||||
|
response = JSONResponse(
|
||||||
|
status_code=200,
|
||||||
|
content={
|
||||||
|
"status": "ERROR",
|
||||||
|
"reason": f"UNKNOWN ERROR: {exc!s}",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
|
return lnurl_route_handler
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"decode",
|
||||||
|
"encode",
|
||||||
|
"handle",
|
||||||
|
"LnurlErrorResponse",
|
||||||
|
"LnurlErrorResponseHandler",
|
||||||
|
]
|
||||||
|
|||||||
+94
-69
@@ -1,15 +1,21 @@
|
|||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timezone
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
from typing import Any, List, Tuple, Union
|
from typing import Any, List, Optional, Union
|
||||||
|
|
||||||
from fastapi import FastAPI, Request
|
from fastapi import FastAPI, Request, Response
|
||||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||||
|
from loguru import logger
|
||||||
from slowapi import _rate_limit_exceeded_handler
|
from slowapi import _rate_limit_exceeded_handler
|
||||||
from slowapi.errors import RateLimitExceeded
|
from slowapi.errors import RateLimitExceeded
|
||||||
from slowapi.middleware import SlowAPIMiddleware
|
from slowapi.middleware import SlowAPIMiddleware
|
||||||
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
from starlette.middleware.gzip import GZipMiddleware
|
from starlette.middleware.gzip import GZipMiddleware
|
||||||
from starlette.types import ASGIApp, Receive, Scope, Send
|
from starlette.types import ASGIApp, Receive, Scope, Send
|
||||||
|
|
||||||
from lnbits.core.db import core_app_extra
|
from lnbits.core.db import core_app_extra
|
||||||
|
from lnbits.core.models import AuditEntry
|
||||||
from lnbits.helpers import template_renderer
|
from lnbits.helpers import template_renderer
|
||||||
from lnbits.settings import settings
|
from lnbits.settings import settings
|
||||||
|
|
||||||
@@ -45,16 +51,11 @@ class InstalledExtensionMiddleware:
|
|||||||
await self.app(scope, receive, send)
|
await self.app(scope, receive, send)
|
||||||
return
|
return
|
||||||
|
|
||||||
upgrade_path = next(
|
|
||||||
(
|
|
||||||
e
|
|
||||||
for e in settings.lnbits_upgraded_extensions
|
|
||||||
if e.endswith(f"/{top_path}")
|
|
||||||
),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
# re-route all trafic if the extension has been upgraded
|
# re-route all trafic if the extension has been upgraded
|
||||||
if upgrade_path:
|
if top_path in settings.lnbits_upgraded_extensions:
|
||||||
|
upgrade_path = (
|
||||||
|
f"""{settings.lnbits_upgraded_extensions[top_path]}/{top_path}"""
|
||||||
|
)
|
||||||
tail = "/".join(rest)
|
tail = "/".join(rest)
|
||||||
scope["path"] = f"/upgrades/{upgrade_path}/{tail}"
|
scope["path"] = f"/upgrades/{upgrade_path}/{tail}"
|
||||||
|
|
||||||
@@ -118,71 +119,99 @@ class ExtensionsRedirectMiddleware:
|
|||||||
return
|
return
|
||||||
|
|
||||||
req_headers = scope["headers"] if "headers" in scope else []
|
req_headers = scope["headers"] if "headers" in scope else []
|
||||||
redirect = self._find_redirect(scope["path"], req_headers)
|
redirect = settings.find_extension_redirect(scope["path"], req_headers)
|
||||||
if redirect:
|
if redirect:
|
||||||
scope["path"] = self._new_path(redirect, scope["path"])
|
scope["path"] = redirect.new_path_from(scope["path"])
|
||||||
|
|
||||||
await self.app(scope, receive, send)
|
await self.app(scope, receive, send)
|
||||||
|
|
||||||
def _find_redirect(self, path: str, req_headers: List[Tuple[bytes, bytes]]):
|
|
||||||
return next(
|
|
||||||
(
|
|
||||||
r
|
|
||||||
for r in settings.lnbits_extensions_redirects
|
|
||||||
if self._redirect_matches(r, path, req_headers)
|
|
||||||
),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _redirect_matches(
|
class AuditMiddleware(BaseHTTPMiddleware):
|
||||||
self, redirect: dict, path: str, req_headers: List[Tuple[bytes, bytes]]
|
|
||||||
) -> bool:
|
|
||||||
if "from_path" not in redirect:
|
|
||||||
return False
|
|
||||||
header_filters = (
|
|
||||||
redirect["header_filters"] if "header_filters" in redirect else {}
|
|
||||||
)
|
|
||||||
return self._has_common_path(redirect["from_path"], path) and self._has_headers(
|
|
||||||
header_filters, req_headers
|
|
||||||
)
|
|
||||||
|
|
||||||
def _has_headers(
|
def __init__(self, app: ASGIApp, audit_queue: asyncio.Queue) -> None:
|
||||||
self, filter_headers: dict, req_headers: List[Tuple[bytes, bytes]]
|
super().__init__(app)
|
||||||
) -> bool:
|
self.audit_queue = audit_queue
|
||||||
for h in filter_headers:
|
# delete_time purge after X days
|
||||||
if not self._has_header(req_headers, (str(h), str(filter_headers[h]))):
|
# time, # include pats, exclude paths (regex)
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
def _has_header(
|
async def dispatch(self, request: Request, call_next) -> Response:
|
||||||
self, req_headers: List[Tuple[bytes, bytes]], header: Tuple[str, str]
|
start_time = datetime.now(timezone.utc)
|
||||||
) -> bool:
|
request_details = await self._request_details(request)
|
||||||
for h in req_headers:
|
response: Optional[Response] = None
|
||||||
if (
|
|
||||||
h[0].decode().lower() == header[0].lower()
|
|
||||||
and h[1].decode() == header[1]
|
|
||||||
):
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
def _has_common_path(self, redirect_path: str, req_path: str) -> bool:
|
try:
|
||||||
redirect_path_elements = redirect_path.split("/")
|
response = await call_next(request)
|
||||||
req_path_elements = req_path.split("/")
|
assert response
|
||||||
if len(redirect_path) > len(req_path):
|
return response
|
||||||
return False
|
finally:
|
||||||
sub_path = req_path_elements[: len(redirect_path_elements)]
|
duration = (datetime.now(timezone.utc) - start_time).total_seconds()
|
||||||
return redirect_path == "/".join(sub_path)
|
await self._log_audit(request, response, duration, request_details)
|
||||||
|
|
||||||
def _new_path(self, redirect: dict, req_path: str) -> str:
|
async def _log_audit(
|
||||||
from_path = redirect["from_path"].split("/")
|
self,
|
||||||
redirect_to = redirect["redirect_to_path"].split("/")
|
request: Request,
|
||||||
req_tail_path = req_path.split("/")[len(from_path) :]
|
response: Optional[Response],
|
||||||
|
duration: float,
|
||||||
|
request_details: Optional[str],
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
http_method = request.scope.get("method", None)
|
||||||
|
path: Optional[str] = getattr(request.scope.get("route", {}), "path", None)
|
||||||
|
response_code = str(response.status_code) if response else None
|
||||||
|
if not settings.audit_http_request(http_method, path, response_code):
|
||||||
|
return None
|
||||||
|
ip_address = (
|
||||||
|
request.client.host
|
||||||
|
if settings.lnbits_audit_log_ip_address and request.client
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
user_id = request.scope.get("user_id", None)
|
||||||
|
if settings.is_super_user(user_id):
|
||||||
|
user_id = "super_user"
|
||||||
|
component = "core"
|
||||||
|
if path and not path.startswith("/api"):
|
||||||
|
component = path.split("/")[1]
|
||||||
|
|
||||||
elements = [
|
data = AuditEntry(
|
||||||
e for e in ([redirect["ext_id"], *redirect_to, *req_tail_path]) if e != ""
|
component=component,
|
||||||
]
|
ip_address=ip_address,
|
||||||
|
user_id=user_id,
|
||||||
|
path=path,
|
||||||
|
request_type=request.scope.get("type", None),
|
||||||
|
request_method=http_method,
|
||||||
|
request_details=request_details,
|
||||||
|
response_code=response_code,
|
||||||
|
duration=duration,
|
||||||
|
)
|
||||||
|
await self.audit_queue.put(data)
|
||||||
|
except Exception as ex:
|
||||||
|
logger.warning(ex)
|
||||||
|
|
||||||
return "/" + "/".join(elements)
|
async def _request_details(self, request: Request) -> Optional[str]:
|
||||||
|
if not settings.audit_http_request_details():
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
http_method = request.scope.get("method", None)
|
||||||
|
path = request.scope.get("path", None)
|
||||||
|
|
||||||
|
if not settings.audit_http_request(http_method, path):
|
||||||
|
return None
|
||||||
|
|
||||||
|
details: dict = {}
|
||||||
|
if settings.lnbits_audit_log_path_params:
|
||||||
|
details["path_params"] = request.path_params
|
||||||
|
if settings.lnbits_audit_log_query_params:
|
||||||
|
details["query_params"] = dict(request.query_params)
|
||||||
|
if settings.lnbits_audit_log_request_body:
|
||||||
|
_body = await request.body()
|
||||||
|
details["body"] = _body.decode("utf-8")
|
||||||
|
details_str = json.dumps(details)
|
||||||
|
# Make sure the super_user id is not leaked
|
||||||
|
return details_str.replace(settings.super_user, "super_user")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def add_ratelimit_middleware(app: FastAPI):
|
def add_ratelimit_middleware(app: FastAPI):
|
||||||
@@ -214,8 +243,6 @@ def add_ip_block_middleware(app: FastAPI):
|
|||||||
)
|
)
|
||||||
return await call_next(request)
|
return await call_next(request)
|
||||||
|
|
||||||
app.middleware("http")(block_allow_ip_middleware)
|
|
||||||
|
|
||||||
|
|
||||||
def add_first_install_middleware(app: FastAPI):
|
def add_first_install_middleware(app: FastAPI):
|
||||||
@app.middleware("http")
|
@app.middleware("http")
|
||||||
@@ -228,5 +255,3 @@ def add_first_install_middleware(app: FastAPI):
|
|||||||
):
|
):
|
||||||
return RedirectResponse("/first_install")
|
return RedirectResponse("/first_install")
|
||||||
return await call_next(request)
|
return await call_next(request)
|
||||||
|
|
||||||
app.middleware("http")(first_install_middleware)
|
|
||||||
|
|||||||
+288
-18
@@ -4,10 +4,10 @@ import importlib
|
|||||||
import importlib.metadata
|
import importlib.metadata
|
||||||
import inspect
|
import inspect
|
||||||
import json
|
import json
|
||||||
|
import re
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from hashlib import sha256
|
from hashlib import sha256
|
||||||
from os import path
|
from os import path
|
||||||
from sqlite3 import Row
|
|
||||||
from time import time
|
from time import time
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
@@ -62,26 +62,135 @@ class ExtensionsInstallSettings(LNbitsSettings):
|
|||||||
lnbits_ext_github_token: str = Field(default="")
|
lnbits_ext_github_token: str = Field(default="")
|
||||||
|
|
||||||
|
|
||||||
|
class RedirectPath(BaseModel):
|
||||||
|
ext_id: str
|
||||||
|
from_path: str
|
||||||
|
redirect_to_path: str
|
||||||
|
header_filters: dict = {}
|
||||||
|
|
||||||
|
def in_conflict(self, other: RedirectPath) -> bool:
|
||||||
|
if self.ext_id == other.ext_id:
|
||||||
|
return False
|
||||||
|
return self.redirect_matches(
|
||||||
|
other.from_path, list(other.header_filters.items())
|
||||||
|
) or other.redirect_matches(self.from_path, list(self.header_filters.items()))
|
||||||
|
|
||||||
|
def find_in_conflict(self, others: list[RedirectPath]) -> Optional[RedirectPath]:
|
||||||
|
for other in others:
|
||||||
|
if self.in_conflict(other):
|
||||||
|
return other
|
||||||
|
return None
|
||||||
|
|
||||||
|
def new_path_from(self, req_path: str) -> str:
|
||||||
|
from_path = self.from_path.split("/")
|
||||||
|
redirect_to = self.redirect_to_path.split("/")
|
||||||
|
req_tail_path = req_path.split("/")[len(from_path) :]
|
||||||
|
|
||||||
|
elements = [e for e in ([self.ext_id, *redirect_to, *req_tail_path]) if e != ""]
|
||||||
|
|
||||||
|
return "/" + "/".join(elements)
|
||||||
|
|
||||||
|
def redirect_matches(self, path: str, req_headers: list[tuple[str, str]]) -> bool:
|
||||||
|
return self._has_common_path(path) and self._has_headers(req_headers)
|
||||||
|
|
||||||
|
def _has_common_path(self, req_path: str) -> bool:
|
||||||
|
if len(self.from_path) > len(req_path):
|
||||||
|
return False
|
||||||
|
|
||||||
|
redirect_path_elements = self.from_path.split("/")
|
||||||
|
req_path_elements = req_path.split("/")
|
||||||
|
|
||||||
|
sub_path = req_path_elements[: len(redirect_path_elements)]
|
||||||
|
return self.from_path == "/".join(sub_path)
|
||||||
|
|
||||||
|
def _has_headers(self, req_headers: list[tuple[str, str]]) -> bool:
|
||||||
|
for h in self.header_filters:
|
||||||
|
if not self._has_header(req_headers, (str(h), str(self.header_filters[h]))):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _has_header(
|
||||||
|
self, req_headers: list[tuple[str, str]], header: tuple[str, str]
|
||||||
|
) -> bool:
|
||||||
|
for h in req_headers:
|
||||||
|
if h[0].lower() == header[0].lower() and h[1].lower() == header[1].lower():
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
class InstalledExtensionsSettings(LNbitsSettings):
|
class InstalledExtensionsSettings(LNbitsSettings):
|
||||||
# installed extensions that have been deactivated
|
# installed extensions that have been deactivated
|
||||||
lnbits_deactivated_extensions: set[str] = Field(default=[])
|
lnbits_deactivated_extensions: set[str] = Field(default=[])
|
||||||
# upgraded extensions that require API redirects
|
# upgraded extensions that require API redirects
|
||||||
lnbits_upgraded_extensions: set[str] = Field(default=[])
|
lnbits_upgraded_extensions: dict[str, str] = Field(default={})
|
||||||
# list of redirects that extensions want to perform
|
# list of redirects that extensions want to perform
|
||||||
lnbits_extensions_redirects: list[Any] = Field(default=[])
|
lnbits_extensions_redirects: list[RedirectPath] = Field(default=[])
|
||||||
|
|
||||||
# list of all extension ids
|
# list of all extension ids
|
||||||
lnbits_all_extensions_ids: set[Any] = Field(default=[])
|
lnbits_all_extensions_ids: set[str] = Field(default=[])
|
||||||
|
|
||||||
def extension_upgrade_path(self, ext_id: str) -> Optional[str]:
|
def find_extension_redirect(
|
||||||
|
self, path: str, req_headers: list[tuple[bytes, bytes]]
|
||||||
|
) -> Optional[RedirectPath]:
|
||||||
|
headers = [(k.decode(), v.decode()) for k, v in req_headers]
|
||||||
return next(
|
return next(
|
||||||
(e for e in self.lnbits_upgraded_extensions if e.endswith(f"/{ext_id}")),
|
(
|
||||||
|
r
|
||||||
|
for r in self.lnbits_extensions_redirects
|
||||||
|
if r.redirect_matches(path, headers)
|
||||||
|
),
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
|
|
||||||
def extension_upgrade_hash(self, ext_id: str) -> Optional[str]:
|
def activate_extension_paths(
|
||||||
path = settings.extension_upgrade_path(ext_id)
|
self,
|
||||||
return path.split("/")[0] if path else None
|
ext_id: str,
|
||||||
|
upgrade_hash: Optional[str] = None,
|
||||||
|
ext_redirects: Optional[list[dict]] = None,
|
||||||
|
):
|
||||||
|
self.lnbits_deactivated_extensions.discard(ext_id)
|
||||||
|
|
||||||
|
"""
|
||||||
|
Update the list of upgraded extensions. The middleware will perform
|
||||||
|
redirects based on this
|
||||||
|
"""
|
||||||
|
if upgrade_hash:
|
||||||
|
self.lnbits_upgraded_extensions[ext_id] = upgrade_hash
|
||||||
|
|
||||||
|
if ext_redirects:
|
||||||
|
self._activate_extension_redirects(ext_id, ext_redirects)
|
||||||
|
|
||||||
|
self.lnbits_all_extensions_ids.add(ext_id)
|
||||||
|
|
||||||
|
def deactivate_extension_paths(self, ext_id: str):
|
||||||
|
self.lnbits_deactivated_extensions.add(ext_id)
|
||||||
|
self._remove_extension_redirects(ext_id)
|
||||||
|
|
||||||
|
def extension_upgrade_hash(self, ext_id: str) -> str:
|
||||||
|
return settings.lnbits_upgraded_extensions.get(ext_id, "")
|
||||||
|
|
||||||
|
def _activate_extension_redirects(self, ext_id: str, ext_redirects: list[dict]):
|
||||||
|
ext_redirect_paths = [
|
||||||
|
RedirectPath(**{"ext_id": ext_id, **er}) for er in ext_redirects
|
||||||
|
]
|
||||||
|
existing_redirects = {
|
||||||
|
r.ext_id
|
||||||
|
for r in self.lnbits_extensions_redirects
|
||||||
|
if r.find_in_conflict(ext_redirect_paths)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert len(existing_redirects) == 0, (
|
||||||
|
f"Cannot redirect for extension '{ext_id}'."
|
||||||
|
f" Already mapped by {existing_redirects}."
|
||||||
|
)
|
||||||
|
|
||||||
|
self._remove_extension_redirects(ext_id)
|
||||||
|
self.lnbits_extensions_redirects += ext_redirect_paths
|
||||||
|
|
||||||
|
def _remove_extension_redirects(self, ext_id: str):
|
||||||
|
self.lnbits_extensions_redirects = [
|
||||||
|
er for er in self.lnbits_extensions_redirects if er.ext_id != ext_id
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class ThemesSettings(LNbitsSettings):
|
class ThemesSettings(LNbitsSettings):
|
||||||
@@ -118,14 +227,27 @@ class ThemesSettings(LNbitsSettings):
|
|||||||
|
|
||||||
class OpsSettings(LNbitsSettings):
|
class OpsSettings(LNbitsSettings):
|
||||||
lnbits_baseurl: str = Field(default="http://127.0.0.1:5000/")
|
lnbits_baseurl: str = Field(default="http://127.0.0.1:5000/")
|
||||||
|
lnbits_hide_api: bool = Field(default=False)
|
||||||
|
lnbits_denomination: str = Field(default="sats")
|
||||||
|
|
||||||
|
|
||||||
|
class FeeSettings(LNbitsSettings):
|
||||||
|
|
||||||
lnbits_reserve_fee_min: int = Field(default=2000)
|
lnbits_reserve_fee_min: int = Field(default=2000)
|
||||||
lnbits_reserve_fee_percent: float = Field(default=1.0)
|
lnbits_reserve_fee_percent: float = Field(default=1.0)
|
||||||
lnbits_service_fee: float = Field(default=0)
|
lnbits_service_fee: float = Field(default=0)
|
||||||
lnbits_service_fee_ignore_internal: bool = Field(default=True)
|
lnbits_service_fee_ignore_internal: bool = Field(default=True)
|
||||||
lnbits_service_fee_max: int = Field(default=0)
|
lnbits_service_fee_max: int = Field(default=0)
|
||||||
lnbits_service_fee_wallet: Optional[str] = Field(default=None)
|
lnbits_service_fee_wallet: Optional[str] = Field(default=None)
|
||||||
lnbits_hide_api: bool = Field(default=False)
|
|
||||||
lnbits_denomination: str = Field(default="sats")
|
# 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(self, amount_msat: int, internal: bool = False) -> int:
|
||||||
|
if internal:
|
||||||
|
return 0
|
||||||
|
reserve_min = self.lnbits_reserve_fee_min
|
||||||
|
reserve_percent = self.lnbits_reserve_fee_percent
|
||||||
|
return max(int(reserve_min), int(amount_msat * reserve_percent / 100.0))
|
||||||
|
|
||||||
|
|
||||||
class SecuritySettings(LNbitsSettings):
|
class SecuritySettings(LNbitsSettings):
|
||||||
@@ -194,6 +316,7 @@ class LndRestFundingSource(LNbitsSettings):
|
|||||||
lnd_rest_macaroon: Optional[str] = Field(default=None)
|
lnd_rest_macaroon: Optional[str] = Field(default=None)
|
||||||
lnd_rest_macaroon_encrypted: Optional[str] = Field(default=None)
|
lnd_rest_macaroon_encrypted: Optional[str] = Field(default=None)
|
||||||
lnd_rest_route_hints: bool = Field(default=True)
|
lnd_rest_route_hints: bool = Field(default=True)
|
||||||
|
lnd_rest_allow_self_payment: bool = Field(default=False)
|
||||||
lnd_cert: Optional[str] = Field(default=None)
|
lnd_cert: Optional[str] = Field(default=None)
|
||||||
lnd_admin_macaroon: Optional[str] = Field(default=None)
|
lnd_admin_macaroon: Optional[str] = Field(default=None)
|
||||||
lnd_invoice_macaroon: Optional[str] = Field(default=None)
|
lnd_invoice_macaroon: Optional[str] = Field(default=None)
|
||||||
@@ -258,6 +381,25 @@ class LnTipsFundingSource(LNbitsSettings):
|
|||||||
lntips_invoice_key: Optional[str] = Field(default=None)
|
lntips_invoice_key: Optional[str] = Field(default=None)
|
||||||
|
|
||||||
|
|
||||||
|
class NWCFundingSource(LNbitsSettings):
|
||||||
|
nwc_pairing_url: Optional[str] = Field(default=None)
|
||||||
|
|
||||||
|
|
||||||
|
class BreezSdkFundingSource(LNbitsSettings):
|
||||||
|
breez_api_key: Optional[str] = Field(default=None)
|
||||||
|
breez_greenlight_seed: Optional[str] = Field(default=None)
|
||||||
|
breez_greenlight_invite_code: Optional[str] = Field(default=None)
|
||||||
|
breez_greenlight_device_key: Optional[str] = Field(default=None)
|
||||||
|
breez_greenlight_device_cert: Optional[str] = Field(default=None)
|
||||||
|
|
||||||
|
|
||||||
|
class BoltzFundingSource(LNbitsSettings):
|
||||||
|
boltz_client_endpoint: Optional[str] = Field(default="127.0.0.1:9002")
|
||||||
|
boltz_client_macaroon: Optional[str] = Field(default=None)
|
||||||
|
boltz_client_wallet: Optional[str] = Field(default="lnbits")
|
||||||
|
boltz_client_cert: Optional[str] = Field(default=None)
|
||||||
|
|
||||||
|
|
||||||
class LightningSettings(LNbitsSettings):
|
class LightningSettings(LNbitsSettings):
|
||||||
lightning_invoice_expiry: int = Field(default=3600)
|
lightning_invoice_expiry: int = Field(default=3600)
|
||||||
|
|
||||||
@@ -274,11 +416,14 @@ class FundingSourcesSettings(
|
|||||||
LnPayFundingSource,
|
LnPayFundingSource,
|
||||||
BlinkFundingSource,
|
BlinkFundingSource,
|
||||||
AlbyFundingSource,
|
AlbyFundingSource,
|
||||||
|
BoltzFundingSource,
|
||||||
ZBDFundingSource,
|
ZBDFundingSource,
|
||||||
PhoenixdFundingSource,
|
PhoenixdFundingSource,
|
||||||
OpenNodeFundingSource,
|
OpenNodeFundingSource,
|
||||||
SparkFundingSource,
|
SparkFundingSource,
|
||||||
LnTipsFundingSource,
|
LnTipsFundingSource,
|
||||||
|
NWCFundingSource,
|
||||||
|
BreezSdkFundingSource,
|
||||||
):
|
):
|
||||||
lnbits_backend_wallet_class: str = Field(default="VoidWallet")
|
lnbits_backend_wallet_class: str = Field(default="VoidWallet")
|
||||||
|
|
||||||
@@ -301,10 +446,22 @@ class NodeUISettings(LNbitsSettings):
|
|||||||
class AuthMethods(Enum):
|
class AuthMethods(Enum):
|
||||||
user_id_only = "user-id-only"
|
user_id_only = "user-id-only"
|
||||||
username_and_password = "username-password"
|
username_and_password = "username-password"
|
||||||
|
nostr_auth_nip98 = "nostr-auth-nip98"
|
||||||
google_auth = "google-auth"
|
google_auth = "google-auth"
|
||||||
github_auth = "github-auth"
|
github_auth = "github-auth"
|
||||||
keycloak_auth = "keycloak-auth"
|
keycloak_auth = "keycloak-auth"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def all(cls):
|
||||||
|
return [
|
||||||
|
AuthMethods.user_id_only.value,
|
||||||
|
AuthMethods.username_and_password.value,
|
||||||
|
AuthMethods.nostr_auth_nip98.value,
|
||||||
|
AuthMethods.google_auth.value,
|
||||||
|
AuthMethods.github_auth.value,
|
||||||
|
AuthMethods.keycloak_auth.value,
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class AuthSettings(LNbitsSettings):
|
class AuthSettings(LNbitsSettings):
|
||||||
auth_token_expire_minutes: int = Field(default=525600)
|
auth_token_expire_minutes: int = Field(default=525600)
|
||||||
@@ -315,11 +472,20 @@ class AuthSettings(LNbitsSettings):
|
|||||||
AuthMethods.username_and_password.value,
|
AuthMethods.username_and_password.value,
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
# How many seconds after login the user is allowed to update its credentials.
|
||||||
|
# A fresh login is required afterwards.
|
||||||
|
auth_credetials_update_threshold: int = Field(default=120)
|
||||||
|
|
||||||
def is_auth_method_allowed(self, method: AuthMethods):
|
def is_auth_method_allowed(self, method: AuthMethods):
|
||||||
return method.value in self.auth_allowed_methods
|
return method.value in self.auth_allowed_methods
|
||||||
|
|
||||||
|
|
||||||
|
class NostrAuthSettings(LNbitsSettings):
|
||||||
|
nostr_absolute_request_urls: list[str] = Field(
|
||||||
|
default=["http://127.0.0.1:5000", "http://localhost:5000"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class GoogleAuthSettings(LNbitsSettings):
|
class GoogleAuthSettings(LNbitsSettings):
|
||||||
google_client_id: str = Field(default="")
|
google_client_id: str = Field(default="")
|
||||||
google_client_secret: str = Field(default="")
|
google_client_secret: str = Field(default="")
|
||||||
@@ -336,17 +502,106 @@ class KeycloakAuthSettings(LNbitsSettings):
|
|||||||
keycloak_client_secret: str = Field(default="")
|
keycloak_client_secret: str = Field(default="")
|
||||||
|
|
||||||
|
|
||||||
|
class AuditSettings(LNbitsSettings):
|
||||||
|
lnbits_audit_enabled: bool = Field(default=True)
|
||||||
|
|
||||||
|
# number of days to keep the audit entry
|
||||||
|
lnbits_audit_retention_days: int = Field(default=7)
|
||||||
|
|
||||||
|
lnbits_audit_log_ip_address: bool = Field(default=False)
|
||||||
|
lnbits_audit_log_path_params: bool = Field(default=True)
|
||||||
|
lnbits_audit_log_query_params: bool = Field(default=True)
|
||||||
|
lnbits_audit_log_request_body: bool = Field(default=False)
|
||||||
|
|
||||||
|
# List of paths to be included (regex match). Empty list means all.
|
||||||
|
lnbits_audit_include_paths: list[str] = Field(default=[".*api/v1/.*"])
|
||||||
|
# List of paths to be excluded (regex match). Empty list means none.
|
||||||
|
lnbits_audit_exclude_paths: list[str] = Field(default=["/static"])
|
||||||
|
|
||||||
|
# List of HTTP methods to be included. Empty lists means all.
|
||||||
|
# Options (case-sensitive): GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS
|
||||||
|
lnbits_audit_http_methods: list[str] = Field(
|
||||||
|
default=["POST", "PUT", "PATCH", "DELETE"]
|
||||||
|
)
|
||||||
|
|
||||||
|
# List of HTTP codes to be included (regex match). Empty lists means all.
|
||||||
|
lnbits_audit_http_response_codes: list[str] = Field(default=["4.*", "5.*"])
|
||||||
|
|
||||||
|
def audit_http_request_details(self) -> bool:
|
||||||
|
return (
|
||||||
|
self.lnbits_audit_log_path_params
|
||||||
|
or self.lnbits_audit_log_query_params
|
||||||
|
or self.lnbits_audit_log_request_body
|
||||||
|
)
|
||||||
|
|
||||||
|
def audit_http_request(
|
||||||
|
self,
|
||||||
|
http_method: Optional[str] = None,
|
||||||
|
path: Optional[str] = None,
|
||||||
|
http_response_code: Optional[str] = None,
|
||||||
|
) -> bool:
|
||||||
|
if not self.lnbits_audit_enabled:
|
||||||
|
return False
|
||||||
|
if len(self.lnbits_audit_http_methods) != 0:
|
||||||
|
if not http_method:
|
||||||
|
return False
|
||||||
|
if http_method not in self.lnbits_audit_http_methods:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not self._is_http_request_path_auditable(path):
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not self._is_http_response_code_auditable(http_response_code):
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _is_http_request_path_auditable(self, path: Optional[str]):
|
||||||
|
if len(self.lnbits_audit_exclude_paths) != 0 and path:
|
||||||
|
for exclude_path in self.lnbits_audit_exclude_paths:
|
||||||
|
if _re_fullmatch_safe(exclude_path, path):
|
||||||
|
return False
|
||||||
|
|
||||||
|
if len(self.lnbits_audit_include_paths) != 0:
|
||||||
|
if not path:
|
||||||
|
return False
|
||||||
|
for include_path in self.lnbits_audit_include_paths:
|
||||||
|
if _re_fullmatch_safe(include_path, path):
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _is_http_response_code_auditable(
|
||||||
|
self, http_response_code: Optional[str]
|
||||||
|
) -> bool:
|
||||||
|
if not http_response_code:
|
||||||
|
# No response code means only request filters should apply
|
||||||
|
return True
|
||||||
|
|
||||||
|
if len(self.lnbits_audit_http_response_codes) == 0:
|
||||||
|
return True
|
||||||
|
|
||||||
|
for response_code in self.lnbits_audit_http_response_codes:
|
||||||
|
if _re_fullmatch_safe(response_code, http_response_code):
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
class EditableSettings(
|
class EditableSettings(
|
||||||
UsersSettings,
|
UsersSettings,
|
||||||
ExtensionsSettings,
|
ExtensionsSettings,
|
||||||
ThemesSettings,
|
ThemesSettings,
|
||||||
OpsSettings,
|
OpsSettings,
|
||||||
|
FeeSettings,
|
||||||
SecuritySettings,
|
SecuritySettings,
|
||||||
FundingSourcesSettings,
|
FundingSourcesSettings,
|
||||||
LightningSettings,
|
LightningSettings,
|
||||||
WebPushSettings,
|
WebPushSettings,
|
||||||
NodeUISettings,
|
NodeUISettings,
|
||||||
|
AuditSettings,
|
||||||
AuthSettings,
|
AuthSettings,
|
||||||
|
NostrAuthSettings,
|
||||||
GoogleAuthSettings,
|
GoogleAuthSettings,
|
||||||
GitHubAuthSettings,
|
GitHubAuthSettings,
|
||||||
KeycloakAuthSettings,
|
KeycloakAuthSettings,
|
||||||
@@ -422,7 +677,9 @@ class SuperUserSettings(LNbitsSettings):
|
|||||||
lnbits_allowed_funding_sources: list[str] = Field(
|
lnbits_allowed_funding_sources: list[str] = Field(
|
||||||
default=[
|
default=[
|
||||||
"AlbyWallet",
|
"AlbyWallet",
|
||||||
|
"BoltzWallet",
|
||||||
"BlinkWallet",
|
"BlinkWallet",
|
||||||
|
"BreezSdkWallet",
|
||||||
"CoreLightningRestWallet",
|
"CoreLightningRestWallet",
|
||||||
"CoreLightningWallet",
|
"CoreLightningWallet",
|
||||||
"EclairWallet",
|
"EclairWallet",
|
||||||
@@ -436,6 +693,7 @@ class SuperUserSettings(LNbitsSettings):
|
|||||||
"PhoenixdWallet",
|
"PhoenixdWallet",
|
||||||
"VoidWallet",
|
"VoidWallet",
|
||||||
"ZBDWallet",
|
"ZBDWallet",
|
||||||
|
"NWCWallet",
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -466,7 +724,7 @@ class ReadOnlySettings(
|
|||||||
PersistenceSettings,
|
PersistenceSettings,
|
||||||
SuperUserSettings,
|
SuperUserSettings,
|
||||||
):
|
):
|
||||||
lnbits_admin_ui: bool = Field(default=False)
|
lnbits_admin_ui: bool = Field(default=True)
|
||||||
|
|
||||||
@validator(
|
@validator(
|
||||||
"lnbits_allowed_funding_sources",
|
"lnbits_allowed_funding_sources",
|
||||||
@@ -482,11 +740,6 @@ class ReadOnlySettings(
|
|||||||
|
|
||||||
|
|
||||||
class Settings(EditableSettings, ReadOnlySettings, TransientSettings, BaseSettings):
|
class Settings(EditableSettings, ReadOnlySettings, TransientSettings, BaseSettings):
|
||||||
@classmethod
|
|
||||||
def from_row(cls, row: Row) -> Settings:
|
|
||||||
data = dict(row)
|
|
||||||
return cls(**data)
|
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
env_file = ".env"
|
env_file = ".env"
|
||||||
env_file_encoding = "utf-8"
|
env_file_encoding = "utf-8"
|
||||||
@@ -501,8 +754,11 @@ class Settings(EditableSettings, ReadOnlySettings, TransientSettings, BaseSettin
|
|||||||
or user_id == self.super_user
|
or user_id == self.super_user
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def is_super_user(self, user_id: Optional[str] = None) -> bool:
|
||||||
|
return user_id == self.super_user
|
||||||
|
|
||||||
def is_admin_user(self, user_id: str) -> bool:
|
def is_admin_user(self, user_id: str) -> bool:
|
||||||
return user_id in self.lnbits_admin_users or user_id == self.super_user
|
return self.is_super_user(user_id) or user_id in self.lnbits_admin_users
|
||||||
|
|
||||||
def is_admin_extension(self, ext_id: str) -> bool:
|
def is_admin_extension(self, ext_id: str) -> bool:
|
||||||
return ext_id in self.lnbits_admin_extensions
|
return ext_id in self.lnbits_admin_extensions
|
||||||
@@ -520,6 +776,20 @@ class AdminSettings(EditableSettings):
|
|||||||
lnbits_allowed_funding_sources: Optional[list[str]]
|
lnbits_allowed_funding_sources: Optional[list[str]]
|
||||||
|
|
||||||
|
|
||||||
|
class SettingsField(BaseModel):
|
||||||
|
id: str
|
||||||
|
value: Optional[Any]
|
||||||
|
tag: str = "core"
|
||||||
|
|
||||||
|
|
||||||
|
def _re_fullmatch_safe(pattern: str, string: str):
|
||||||
|
try:
|
||||||
|
return re.fullmatch(pattern, string) is not None
|
||||||
|
except Exception as _:
|
||||||
|
logger.warning(f"Regex error for pattern {pattern}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def set_cli_settings(**kwargs):
|
def set_cli_settings(**kwargs):
|
||||||
for key, value in kwargs.items():
|
for key, value in kwargs.items():
|
||||||
setattr(settings, key, value)
|
setattr(settings, key, value)
|
||||||
|
|||||||
+1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+34
-46
File diff suppressed because one or more lines are too long
@@ -530,23 +530,26 @@ video {
|
|||||||
overflow-wrap: break-word;
|
overflow-wrap: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.qrcode__wrapper canvas {
|
.qrcode__wrapper {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qrcode__wrapper canvas {
|
||||||
width: 100% !important;
|
width: 100% !important;
|
||||||
max-width: 100%;
|
height: 100% !important;
|
||||||
max-height: 100%;
|
max-width: 350px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.qrcode__image {
|
.qrcode__image {
|
||||||
|
position: absolute;
|
||||||
|
max-width: 52px;
|
||||||
width: 15%;
|
width: 15%;
|
||||||
height: 15%;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
left: 50%;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
padding: 0.2rem;
|
padding: 0.2rem;
|
||||||
border-radius: 0.2rem;
|
border-radius: 0.2rem;
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-11
@@ -35,9 +35,9 @@ window.localisation.br = {
|
|||||||
reset_defaults_tooltip:
|
reset_defaults_tooltip:
|
||||||
'Apagar todas as configurações e redefinir para os padrões.',
|
'Apagar todas as configurações e redefinir para os padrões.',
|
||||||
download_backup: 'Fazer backup do banco de dados',
|
download_backup: 'Fazer backup do banco de dados',
|
||||||
name_your_wallet: 'Nomeie sua carteira %{name}',
|
name_your_wallet: 'Nomeie sua carteira {name}',
|
||||||
wallet_topup_ok:
|
wallet_topup_ok:
|
||||||
'Sucesso ao criar fundos virtuais (%{amount} sats). Pagamentos dependem dos fundos reais na fonte de financiamento.',
|
'Sucesso ao criar fundos virtuais ({amount} sats). Pagamentos dependem dos fundos reais na fonte de financiamento.',
|
||||||
paste_invoice_label: 'Cole uma fatura, pedido de pagamento ou código lnurl *',
|
paste_invoice_label: 'Cole uma fatura, pedido de pagamento ou código lnurl *',
|
||||||
lnbits_description:
|
lnbits_description:
|
||||||
'Fácil de configurar e leve, o LNbits pode ser executado em qualquer fonte de financiamento da Lightning Network e até mesmo o LNbits em si! Você pode executar o LNbits para si mesmo ou oferecer facilmente uma solução de custódia para outros. Cada carteira tem suas próprias chaves de API e não há limite para o número de carteiras que você pode criar. Ser capaz de particionar fundos torna o LNbits uma ferramenta útil para gerenciamento de dinheiro e como uma ferramenta de desenvolvimento. As extensões adicionam funcionalidades extras ao LNbits para que você possa experimentar uma série de tecnologias de ponta na rede lightning. Nós tornamos o desenvolvimento de extensões o mais fácil possível e, como um projeto gratuito e de código aberto, incentivamos as pessoas a desenvolver e enviar as suas próprias.',
|
'Fácil de configurar e leve, o LNbits pode ser executado em qualquer fonte de financiamento da Lightning Network e até mesmo o LNbits em si! Você pode executar o LNbits para si mesmo ou oferecer facilmente uma solução de custódia para outros. Cada carteira tem suas próprias chaves de API e não há limite para o número de carteiras que você pode criar. Ser capaz de particionar fundos torna o LNbits uma ferramenta útil para gerenciamento de dinheiro e como uma ferramenta de desenvolvimento. As extensões adicionam funcionalidades extras ao LNbits para que você possa experimentar uma série de tecnologias de ponta na rede lightning. Nós tornamos o desenvolvimento de extensões o mais fácil possível e, como um projeto gratuito e de código aberto, incentivamos as pessoas a desenvolver e enviar as suas próprias.',
|
||||||
@@ -58,10 +58,9 @@ window.localisation.br = {
|
|||||||
donate: 'Doar',
|
donate: 'Doar',
|
||||||
view_github: 'Ver no GitHub',
|
view_github: 'Ver no GitHub',
|
||||||
voidwallet_active: 'VoidWallet está ativo! Pagamentos desabilitados',
|
voidwallet_active: 'VoidWallet está ativo! Pagamentos desabilitados',
|
||||||
use_with_caution: 'USE COM CAUTELA - a carteira %{name} ainda está em BETA',
|
use_with_caution: 'USE COM CAUTELA - a carteira {name} ainda está em BETA',
|
||||||
service_fee: 'Taxa de serviço: %{amount} % por transação',
|
service_fee: 'Taxa de serviço: {amount} % por transação',
|
||||||
service_fee_max:
|
service_fee_max: 'Taxa de serviço: {amount} % por transação (máx {max} sats)',
|
||||||
'Taxa de serviço: %{amount} % por transação (máx %{max} sats)',
|
|
||||||
service_fee_tooltip:
|
service_fee_tooltip:
|
||||||
'Taxa de serviço cobrada pelo administrador do servidor LNbits por transação de saída',
|
'Taxa de serviço cobrada pelo administrador do servidor LNbits por transação de saída',
|
||||||
toggle_darkmode: 'Alternar modo escuro',
|
toggle_darkmode: 'Alternar modo escuro',
|
||||||
@@ -72,7 +71,7 @@ window.localisation.br = {
|
|||||||
lnbits_version: 'Versão do LNbits',
|
lnbits_version: 'Versão do LNbits',
|
||||||
runs_on: 'Executa em',
|
runs_on: 'Executa em',
|
||||||
credit_hint: 'Pressione Enter para creditar a conta',
|
credit_hint: 'Pressione Enter para creditar a conta',
|
||||||
credit_label: '%{denomination} para creditar',
|
credit_label: '{denomination} para creditar',
|
||||||
paste: 'Colar',
|
paste: 'Colar',
|
||||||
paste_from_clipboard: 'Cole do clipboard',
|
paste_from_clipboard: 'Cole do clipboard',
|
||||||
paste_request: 'Colar Pedido',
|
paste_request: 'Colar Pedido',
|
||||||
@@ -155,8 +154,8 @@ window.localisation.br = {
|
|||||||
expiry: 'Validade',
|
expiry: 'Validade',
|
||||||
webhook: 'Webhook',
|
webhook: 'Webhook',
|
||||||
payment_proof: 'Comprovante de pagamento',
|
payment_proof: 'Comprovante de pagamento',
|
||||||
update_available: 'Atualização %{version} disponível!',
|
update_available: 'Atualização {version} disponível!',
|
||||||
latest_update: 'Você está na versão mais recente %{version}.',
|
latest_update: 'Você está na versão mais recente {version}.',
|
||||||
notifications: 'Notificações',
|
notifications: 'Notificações',
|
||||||
no_notifications: 'Sem notificações',
|
no_notifications: 'Sem notificações',
|
||||||
notifications_disabled:
|
notifications_disabled:
|
||||||
@@ -247,8 +246,8 @@ window.localisation.br = {
|
|||||||
look_and_feel: 'Aparência',
|
look_and_feel: 'Aparência',
|
||||||
language: 'Idioma',
|
language: 'Idioma',
|
||||||
color_scheme: 'Esquema de Cores',
|
color_scheme: 'Esquema de Cores',
|
||||||
extension_cost: 'Este lançamento requer um pagamento mínimo de %{cost} sats.',
|
extension_cost: 'Este lançamento requer um pagamento mínimo de {cost} sats.',
|
||||||
extension_paid_sats: 'Você já pagou %{paid_sats} sats.',
|
extension_paid_sats: 'Você já pagou {paid_sats} sats.',
|
||||||
release_details_error: 'Não é possível obter os detalhes da versão.',
|
release_details_error: 'Não é possível obter os detalhes da versão.',
|
||||||
pay_from_wallet: 'Pagar com a Carteira',
|
pay_from_wallet: 'Pagar com a Carteira',
|
||||||
show_qr: 'Exibir QR',
|
show_qr: 'Exibir QR',
|
||||||
|
|||||||
+10
-10
@@ -34,9 +34,9 @@ window.localisation.cn = {
|
|||||||
reset_defaults: '重置为默认设置',
|
reset_defaults: '重置为默认设置',
|
||||||
reset_defaults_tooltip: '删除所有设置并重置为默认设置',
|
reset_defaults_tooltip: '删除所有设置并重置为默认设置',
|
||||||
download_backup: '下载数据库备份',
|
download_backup: '下载数据库备份',
|
||||||
name_your_wallet: '给你的 %{name}钱包起个名字',
|
name_your_wallet: '给你的 {name}钱包起个名字',
|
||||||
wallet_topup_ok:
|
wallet_topup_ok:
|
||||||
'成功创建虚拟资金(%{amount} sats)。付款取决于资金来源的实际资金。',
|
'成功创建虚拟资金({amount} sats)。付款取决于资金来源的实际资金。',
|
||||||
paste_invoice_label: '粘贴发票,付款请求或lnurl*',
|
paste_invoice_label: '粘贴发票,付款请求或lnurl*',
|
||||||
lnbits_description:
|
lnbits_description:
|
||||||
'LNbits 设置简单、轻量级,可以在任何闪电网络的资金来源上运行,甚至可以在LNbits自身上运行!您可以为自己运行LNbits,或者轻松为他人提供托管解决方案。每个钱包都有自己的 API 密钥,你可以创建的钱包数量没有限制。能够把资金分开管理使 LNbits 成为一款有用的资金管理和开发工具。扩展程序增加了 LNbits 的额外功能,所以你可以在闪电网络上尝试各种尖端技术。我们已经尽可能简化了开发扩展程序的过程,作为一个免费和开源的项目,我们鼓励人们开发并提交自己的扩展程序。',
|
'LNbits 设置简单、轻量级,可以在任何闪电网络的资金来源上运行,甚至可以在LNbits自身上运行!您可以为自己运行LNbits,或者轻松为他人提供托管解决方案。每个钱包都有自己的 API 密钥,你可以创建的钱包数量没有限制。能够把资金分开管理使 LNbits 成为一款有用的资金管理和开发工具。扩展程序增加了 LNbits 的额外功能,所以你可以在闪电网络上尝试各种尖端技术。我们已经尽可能简化了开发扩展程序的过程,作为一个免费和开源的项目,我们鼓励人们开发并提交自己的扩展程序。',
|
||||||
@@ -56,9 +56,9 @@ window.localisation.cn = {
|
|||||||
donate: '捐献',
|
donate: '捐献',
|
||||||
view_github: '在GitHub上查看',
|
view_github: '在GitHub上查看',
|
||||||
voidwallet_active: 'VoidWallet 已激活!付款功能已禁用。',
|
voidwallet_active: 'VoidWallet 已激活!付款功能已禁用。',
|
||||||
use_with_caution: '请谨慎使用 - %{name}钱包还处于测试版阶段',
|
use_with_caution: '请谨慎使用 - {name}钱包还处于测试版阶段',
|
||||||
service_fee: '服务费:%{amount}% 每笔交易',
|
service_fee: '服务费:{amount}% 每笔交易',
|
||||||
service_fee_max: '服务费:%{amount}% 每笔交易(最高 %{max} sats)',
|
service_fee_max: '服务费:{amount}% 每笔交易(最高 {max} sats)',
|
||||||
service_fee_tooltip: 'LNbits服务器管理员每笔外发交易收取的服务费',
|
service_fee_tooltip: 'LNbits服务器管理员每笔外发交易收取的服务费',
|
||||||
toggle_darkmode: '切换暗黑模式',
|
toggle_darkmode: '切换暗黑模式',
|
||||||
payment_reactions: '支付反应',
|
payment_reactions: '支付反应',
|
||||||
@@ -68,7 +68,7 @@ window.localisation.cn = {
|
|||||||
lnbits_version: 'LNbits版本',
|
lnbits_version: 'LNbits版本',
|
||||||
runs_on: '可运行在',
|
runs_on: '可运行在',
|
||||||
credit_hint: '按 Enter 键充值账户',
|
credit_hint: '按 Enter 键充值账户',
|
||||||
credit_label: '%{denomination} 充值',
|
credit_label: '{denomination} 充值',
|
||||||
paste: '粘贴',
|
paste: '粘贴',
|
||||||
paste_from_clipboard: '从剪贴板粘贴',
|
paste_from_clipboard: '从剪贴板粘贴',
|
||||||
paste_request: '粘贴请求',
|
paste_request: '粘贴请求',
|
||||||
@@ -146,8 +146,8 @@ window.localisation.cn = {
|
|||||||
expiry: '过期时间',
|
expiry: '过期时间',
|
||||||
webhook: 'Webhook',
|
webhook: 'Webhook',
|
||||||
payment_proof: '付款证明',
|
payment_proof: '付款证明',
|
||||||
update_available: '更新%{version}可用!',
|
update_available: '更新{version}可用!',
|
||||||
latest_update: '您当前使用的是最新版本%{version}。',
|
latest_update: '您当前使用的是最新版本{version}。',
|
||||||
notifications: '通知',
|
notifications: '通知',
|
||||||
no_notifications: '没有通知',
|
no_notifications: '没有通知',
|
||||||
notifications_disabled: 'LNbits状态通知已禁用。',
|
notifications_disabled: 'LNbits状态通知已禁用。',
|
||||||
@@ -235,8 +235,8 @@ window.localisation.cn = {
|
|||||||
look_and_feel: '外观和感觉',
|
look_and_feel: '外观和感觉',
|
||||||
language: '语言',
|
language: '语言',
|
||||||
color_scheme: '配色方案',
|
color_scheme: '配色方案',
|
||||||
extension_cost: '此版本需要支付最低 %{cost} sats。',
|
extension_cost: '此版本需要支付最低 {cost} sats。',
|
||||||
extension_paid_sats: '您已经支付了%{paid_sats} sats。',
|
extension_paid_sats: '您已经支付了{paid_sats} sats。',
|
||||||
release_details_error: '无法获取发布详情。',
|
release_details_error: '无法获取发布详情。',
|
||||||
pay_from_wallet: '从钱包支付',
|
pay_from_wallet: '从钱包支付',
|
||||||
show_qr: '显示QR码',
|
show_qr: '显示QR码',
|
||||||
|
|||||||
+10
-10
@@ -34,9 +34,9 @@ window.localisation.cs = {
|
|||||||
reset_defaults: 'Obnovit výchozí',
|
reset_defaults: 'Obnovit výchozí',
|
||||||
reset_defaults_tooltip: 'Smazat všechna nastavení a obnovit výchozí.',
|
reset_defaults_tooltip: 'Smazat všechna nastavení a obnovit výchozí.',
|
||||||
download_backup: 'Stáhnout zálohu databáze',
|
download_backup: 'Stáhnout zálohu databáze',
|
||||||
name_your_wallet: 'Pojmenujte svou %{name} peněženku',
|
name_your_wallet: 'Pojmenujte svou {name} peněženku',
|
||||||
wallet_topup_ok:
|
wallet_topup_ok:
|
||||||
'Úspěšně vytvořeny virtuální prostředky (%{amount} sats). Platby závisí na skutečných prostředcích na zdrojovém účtu.',
|
'Úspěšně vytvořeny virtuální prostředky ({amount} sats). Platby závisí na skutečných prostředcích na zdrojovém účtu.',
|
||||||
paste_invoice_label: 'Vložte fakturu, platební požadavek nebo lnurl kód *',
|
paste_invoice_label: 'Vložte fakturu, platební požadavek nebo lnurl kód *',
|
||||||
lnbits_description:
|
lnbits_description:
|
||||||
'Snadno nastavitelný a lehkotonážní, LNbits může běžet na jakémkoliv zdroji financování Lightning Network a dokonce LNbits samotné! LNbits můžete provozovat pro sebe, nebo snadno nabízet správu peněženek pro ostatní. Každá peněženka má své vlastní API klíče a není omezen počet peněženek, které můžete vytvořit. Možnost rozdělení prostředků dělá z LNbits užitečný nástroj pro správu peněz a jako vývojový nástroj. Rozšíření přidávají extra funkčnost k LNbits, takže můžete experimentovat s řadou špičkových technologií na lightning network. Vývoj rozšíření jsme učinili co nejjednodušší a jako svobodný a open-source projekt podporujeme lidi ve vývoji a zasílání vlastních rozšíření.',
|
'Snadno nastavitelný a lehkotonážní, LNbits může běžet na jakémkoliv zdroji financování Lightning Network a dokonce LNbits samotné! LNbits můžete provozovat pro sebe, nebo snadno nabízet správu peněženek pro ostatní. Každá peněženka má své vlastní API klíče a není omezen počet peněženek, které můžete vytvořit. Možnost rozdělení prostředků dělá z LNbits užitečný nástroj pro správu peněz a jako vývojový nástroj. Rozšíření přidávají extra funkčnost k LNbits, takže můžete experimentovat s řadou špičkových technologií na lightning network. Vývoj rozšíření jsme učinili co nejjednodušší a jako svobodný a open-source projekt podporujeme lidi ve vývoji a zasílání vlastních rozšíření.',
|
||||||
@@ -58,10 +58,10 @@ window.localisation.cs = {
|
|||||||
view_github: 'Zobrazit na GitHubu',
|
view_github: 'Zobrazit na GitHubu',
|
||||||
voidwallet_active: 'VoidWallet je aktivní! Platby zakázány',
|
voidwallet_active: 'VoidWallet je aktivní! Platby zakázány',
|
||||||
use_with_caution:
|
use_with_caution:
|
||||||
'POUŽÍVEJTE S OBEZŘETNOSTÍ - %{name} peněženka je stále v BETĚ',
|
'POUŽÍVEJTE S OBEZŘETNOSTÍ - {name} peněženka je stále v BETĚ',
|
||||||
service_fee: 'Servisný poplatek: %{amount} % za transakci',
|
service_fee: 'Servisný poplatek: {amount} % za transakci',
|
||||||
service_fee_max:
|
service_fee_max:
|
||||||
'Servisný poplatek: %{amount} % za transakci (max %{max} satoshi)',
|
'Servisný poplatek: {amount} % za transakci (max {max} satoshi)',
|
||||||
service_fee_tooltip:
|
service_fee_tooltip:
|
||||||
'Servisní poplatek účtovaný správcem LNbits serveru za odchozí transakci',
|
'Servisní poplatek účtovaný správcem LNbits serveru za odchozí transakci',
|
||||||
toggle_darkmode: 'Přepnout tmavý režim',
|
toggle_darkmode: 'Přepnout tmavý režim',
|
||||||
@@ -72,7 +72,7 @@ window.localisation.cs = {
|
|||||||
lnbits_version: 'Verze LNbits',
|
lnbits_version: 'Verze LNbits',
|
||||||
runs_on: 'Běží na',
|
runs_on: 'Běží na',
|
||||||
credit_hint: 'Stiskněte Enter pro připsání na účet',
|
credit_hint: 'Stiskněte Enter pro připsání na účet',
|
||||||
credit_label: '%{denomination} k připsání',
|
credit_label: '{denomination} k připsání',
|
||||||
paste: 'Vložit',
|
paste: 'Vložit',
|
||||||
paste_from_clipboard: 'Vložit ze schránky',
|
paste_from_clipboard: 'Vložit ze schránky',
|
||||||
paste_request: 'Vložit požadavek',
|
paste_request: 'Vložit požadavek',
|
||||||
@@ -153,8 +153,8 @@ window.localisation.cs = {
|
|||||||
expiry: 'Expirace',
|
expiry: 'Expirace',
|
||||||
webhook: 'Webhook',
|
webhook: 'Webhook',
|
||||||
payment_proof: 'Důkaz platby',
|
payment_proof: 'Důkaz platby',
|
||||||
update_available: 'Dostupná aktualizace %{version}!',
|
update_available: 'Dostupná aktualizace {version}!',
|
||||||
latest_update: 'Máte nejnovější verzi %{version}.',
|
latest_update: 'Máte nejnovější verzi {version}.',
|
||||||
notifications: 'Notifikace',
|
notifications: 'Notifikace',
|
||||||
no_notifications: 'Žádné notifikace',
|
no_notifications: 'Žádné notifikace',
|
||||||
notifications_disabled: 'Notifikace stavu LNbits jsou zakázány.',
|
notifications_disabled: 'Notifikace stavu LNbits jsou zakázány.',
|
||||||
@@ -244,8 +244,8 @@ window.localisation.cs = {
|
|||||||
look_and_feel: 'Vzhled a chování',
|
look_and_feel: 'Vzhled a chování',
|
||||||
language: 'Jazyk',
|
language: 'Jazyk',
|
||||||
color_scheme: 'Barevné schéma',
|
color_scheme: 'Barevné schéma',
|
||||||
extension_cost: 'Toto vydání vyžaduje minimální platbu %{cost} satoshi.',
|
extension_cost: 'Toto vydání vyžaduje minimální platbu {cost} satoshi.',
|
||||||
extension_paid_sats: 'Již jste zaplatili %{paid_sats} sats.',
|
extension_paid_sats: 'Již jste zaplatili {paid_sats} sats.',
|
||||||
release_details_error: 'Nelze získat podrobnosti o vydání.',
|
release_details_error: 'Nelze získat podrobnosti o vydání.',
|
||||||
pay_from_wallet: 'Platit z peněženky',
|
pay_from_wallet: 'Platit z peněženky',
|
||||||
show_qr: 'Zobrazit QR',
|
show_qr: 'Zobrazit QR',
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user