Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d4da96597e | ||
|
|
6a0b645316 | ||
|
|
35bb3cc94b | ||
|
|
7a6c3646fb | ||
|
|
b84161c49d | ||
|
|
63ce506d29 | ||
|
|
5114bd4a47 | ||
|
|
05a244d8fd | ||
|
|
019995078c | ||
|
|
f37cb6481c | ||
|
|
365f9a3923 | ||
|
|
bb4dd4fe35 | ||
|
|
d1ae531750 | ||
|
|
8ee2948f71 | ||
|
|
ab3fe79a7e | ||
|
|
32596758cc | ||
|
|
a5623ef7c3 | ||
|
|
9933484558 | ||
|
|
78fc28558c | ||
|
|
1e752dc3d2 | ||
|
|
6730c6ed67 | ||
|
|
9f8942a921 | ||
|
|
33c68065d5 | ||
|
|
9ca14f200d | ||
|
|
eae5002b69 | ||
|
|
b9e62bfceb | ||
|
|
f60122c64a | ||
|
|
c030ccc4f8 | ||
|
|
0076a85fdb | ||
|
|
c3d37a460c | ||
|
|
c04c13b2f8 | ||
|
|
4b4bed59cd | ||
|
|
4a0fb59461 | ||
|
|
f5293ca645 | ||
|
|
b2ff2d8cee | ||
|
|
8d3b156738 | ||
|
|
00f39a2007 | ||
|
|
4ac30116a9 | ||
|
|
e91096c535 | ||
|
|
e607ab7a3e | ||
|
|
67fdb77339 | ||
|
|
4f118c5f98 | ||
|
|
33ace85f7d | ||
|
|
33c50100ab | ||
|
|
782cbfc77f | ||
|
|
bbfc301440 | ||
|
|
98ec59df96 | ||
|
|
e13a37c193 | ||
|
|
0c3aabf77a | ||
|
|
d9880c4de8 | ||
|
|
0a4eb78ef0 | ||
|
|
daa1b5a313 | ||
|
|
d78f6a1f9e | ||
|
|
839fe8b96d | ||
|
|
25661ddff5 | ||
|
|
55eb3be5d5 | ||
|
|
0714570242 | ||
|
|
69ce0e565b | ||
|
|
b145bff566 | ||
|
|
a158056b99 | ||
|
|
e8479941c8 | ||
|
|
6d5ad9e229 | ||
|
|
055426ab53 | ||
|
|
4bafe97167 | ||
|
|
8aef6cd416 | ||
|
|
adb8f9bdec | ||
|
|
e9e69d9d17 | ||
|
|
2c5ccc0b1b | ||
|
|
ea58b51619 | ||
|
|
bfda0b62da | ||
|
|
b0a8e0d942 | ||
|
|
2161b2511c | ||
|
|
1b2a9c54b6 | ||
|
|
209e2c5bff | ||
|
|
820882db28 | ||
|
|
d64239f1ad | ||
|
|
2066fff994 | ||
|
|
e0b7d2f739 | ||
|
|
cd66b7d70c | ||
|
|
dc74f5816f | ||
|
|
c67fcf0e45 | ||
|
|
073b117528 | ||
|
|
ef6bc4c3ae | ||
|
|
468121a453 | ||
|
|
4679d321de | ||
|
|
c9af15b320 | ||
|
|
9b5bb1b07a | ||
|
|
aaa6573272 | ||
|
|
741ecac78b | ||
|
|
1dd096213e | ||
|
|
ed44244291 | ||
|
|
0b22c41d55 | ||
|
|
25c6ee40f7 | ||
|
|
cdc102af07 | ||
|
|
b9f0af0e79 | ||
|
|
86821f4606 | ||
|
|
0ef9c4db10 | ||
|
|
c101b85054 | ||
|
|
fe3b00292a | ||
|
|
10fe113099 | ||
|
|
1bf5f10f53 | ||
|
|
e2bbcaabbd | ||
|
|
4265915201 | ||
|
|
7e3c511027 | ||
|
|
7783f34998 | ||
|
|
7dcb2fcdd5 | ||
|
|
033a57cad4 | ||
|
|
299228b7b5 | ||
|
|
5b022e2ef3 | ||
|
|
c8818f5774 | ||
|
|
1398857688 | ||
|
|
2a1505bc0d | ||
|
|
4bba6ecc82 | ||
|
|
522c4df44c | ||
|
|
c03b81d2ea | ||
|
|
43a797444a | ||
|
|
d0cb961b49 | ||
|
|
d208e68885 | ||
|
|
8dcb53aea0 | ||
|
|
241b286e21 | ||
|
|
17839f6a25 | ||
|
|
e4d3faefa0 | ||
|
|
116ca7f011 | ||
|
|
d44339b018 | ||
|
|
352fd23c0b | ||
|
|
65b8868c36 | ||
|
|
54dec171f9 | ||
|
|
5b4398911a | ||
|
|
4c0bd132b1 | ||
|
|
7ce4eddb0e | ||
|
|
14519135d8 | ||
|
|
60839481ad | ||
|
|
3ef1941fc0 | ||
|
|
16cb1a8026 | ||
|
|
e3b9bd6a70 | ||
|
|
a1ea04acf8 | ||
|
|
3e341a3555 | ||
|
|
cb5c9b03bf | ||
|
|
884a1b9d6f | ||
|
|
e8aa498683 | ||
|
|
1b7efd854a | ||
|
|
8b32c3dcb6 | ||
|
|
0821b28eac | ||
|
|
4c71d5ac42 | ||
|
|
a8e0b5a5ac | ||
|
|
9b9014a042 | ||
|
|
96b42b1784 | ||
|
|
26d854ebe8 | ||
|
|
ed36d01b40 | ||
|
|
646556a457 | ||
|
|
d20a35eddc | ||
|
|
d69946db8a | ||
|
|
ddab4075d2 | ||
|
|
d6c8ad1d0d | ||
|
|
54c6faa4b6 | ||
|
|
c88f05c5c0 | ||
|
|
e419c74ebb | ||
|
|
b6dc66b070 | ||
|
|
7b4d9bf5db | ||
|
|
c51e7351e8 | ||
|
|
fe12eccc42 | ||
|
|
17b7826753 | ||
|
|
14ae6d8b1a | ||
|
|
7b559991c7 | ||
|
|
decd4cdf0a | ||
|
|
dc7a6551c5 | ||
|
|
526467747e | ||
|
|
b8d295a5b7 | ||
|
|
293b6267be | ||
|
|
20e70854a1 | ||
|
|
11173e6460 | ||
|
|
069a7922fc | ||
|
|
98338ffb00 | ||
|
|
62dec118af | ||
|
|
84b406534d | ||
|
|
d9d2d59b73 | ||
|
|
20d4b954c0 | ||
|
|
5edb845d8f | ||
|
|
cfe2b43d2a | ||
|
|
a0ac41a9ce | ||
|
|
194a527736 | ||
|
|
6f135c0696 | ||
|
|
9a767b627f | ||
|
|
c1c4eda69d | ||
|
|
6c46867698 | ||
|
|
a73eb569ae |
@@ -9,6 +9,7 @@ lnbits/static/bundle.js
|
||||
lnbits/static/bundle.css
|
||||
|
||||
*.md
|
||||
!README.md
|
||||
*.log
|
||||
|
||||
.env
|
||||
|
||||
+59
-26
@@ -1,16 +1,42 @@
|
||||
#For more information on .env files, their content and format: https://pypi.org/project/python-dotenv/
|
||||
|
||||
######################################
|
||||
########### Admin Settings ###########
|
||||
######################################
|
||||
|
||||
# Enable Admin GUI, available for the first user in LNBITS_ADMIN_USERS if available.
|
||||
# Warning: Enabling this will make LNbits ignore most configurations in file. Only the
|
||||
# configurations defined in `ReadOnlySettings` will still be read from the environment variables.
|
||||
# The rest of the settings will be stored in your database and you will be able to change them
|
||||
# only through the Admin UI.
|
||||
# Disable this to make LNbits use this config file again.
|
||||
LNBITS_ADMIN_UI=false
|
||||
|
||||
# Change theme
|
||||
LNBITS_SITE_TITLE="LNbits"
|
||||
LNBITS_SITE_TAGLINE="free and open-source lightning wallet"
|
||||
LNBITS_SITE_DESCRIPTION="The world's most powerful suite of bitcoin tools. Run for yourself, for others, or as part of a stack."
|
||||
# Choose from bitcoin, mint, flamingo, freedom, salvador, autumn, monochrome, classic, cyber
|
||||
LNBITS_THEME_OPTIONS="classic, bitcoin, flamingo, freedom, mint, autumn, monochrome, salvador, cyber"
|
||||
# LNBITS_CUSTOM_LOGO="https://lnbits.com/assets/images/logo/logo.svg"
|
||||
|
||||
HOST=127.0.0.1
|
||||
PORT=5000
|
||||
|
||||
######################################
|
||||
########## Funding Source ############
|
||||
######################################
|
||||
|
||||
# which fundingsources are allowed in the admin ui
|
||||
LNBITS_ALLOWED_FUNDING_SOURCES="VoidWallet, FakeWallet, CoreLightningWallet, CoreLightningRestWallet, LndRestWallet, EclairWallet, LndWallet, LnTipsWallet, LNPayWallet, LNbitsWallet, AlbyWallet, OpenNodeWallet"
|
||||
LNBITS_ALLOWED_FUNDING_SOURCES="VoidWallet, FakeWallet, CoreLightningWallet, CoreLightningRestWallet, LndRestWallet, EclairWallet, LndWallet, LnTipsWallet, LNPayWallet, LNbitsWallet, AlbyWallet, ZBDWallet, PhoenixdWallet, OpenNodeWallet"
|
||||
|
||||
LNBITS_BACKEND_WALLET_CLASS=VoidWallet
|
||||
# VoidWallet is just a fallback that works without any actual Lightning capabilities,
|
||||
# just so you can see the UI before dealing with this file.
|
||||
|
||||
# How many times to retry connectiong to the Funding Source before defaulting to the VoidWallet
|
||||
# FUNDING_SOURCE_MAX_RETRIES=4
|
||||
|
||||
# Invoice expiry for LND, CLN, Eclair, LNbits funding sources
|
||||
LIGHTNING_INVOICE_EXPIRY=3600
|
||||
|
||||
@@ -32,7 +58,7 @@ CORELIGHTNING_REST_MACAROON="/path/to/clnrest/access.macaroon" # or BASE64/HEXS
|
||||
CORELIGHTNING_REST_CERT="/path/to/clnrest/tls.cert"
|
||||
|
||||
# LnbitsWallet
|
||||
LNBITS_ENDPOINT=https://legend.lnbits.com
|
||||
LNBITS_ENDPOINT=https://demo.lnbits.com
|
||||
LNBITS_KEY=LNBITS_ADMIN_KEY
|
||||
|
||||
# LndWallet
|
||||
@@ -61,6 +87,14 @@ LNPAY_WALLET_KEY=LNPAY_ADMIN_KEY
|
||||
ALBY_API_ENDPOINT=https://api.getalby.com/
|
||||
ALBY_ACCESS_TOKEN=ALBY_ACCESS_TOKEN
|
||||
|
||||
# ZBDWallet
|
||||
ZBD_API_ENDPOINT=https://api.zebedee.io/v0/
|
||||
ZBD_API_KEY=ZBD_ACCESS_TOKEN
|
||||
|
||||
# PhoenixdWallet
|
||||
PHOENIXD_API_ENDPOINT=http://localhost:9740/
|
||||
PHOENIXD_API_PASSWORD=PHOENIXD_KEY
|
||||
|
||||
# OpenNodeWallet
|
||||
OPENNODE_API_ENDPOINT=https://api.opennode.com/
|
||||
OPENNODE_KEY=OPENNODE_ADMIN_KEY
|
||||
@@ -78,51 +112,39 @@ ECLAIR_PASS=eclairpw
|
||||
LNTIPS_API_KEY=LNTIPS_ADMIN_KEY
|
||||
LNTIPS_API_ENDPOINT=https://ln.tips
|
||||
|
||||
######################################
|
||||
########### Admin Settings ###########
|
||||
######################################
|
||||
|
||||
# Enable Admin GUI, available for the first user in LNBITS_ADMIN_USERS if available
|
||||
# Warning: Enabling this will make LNbits ignore this configuration file. Your settings will
|
||||
# be stored in your database and you will be able to change them only through the Admin UI.
|
||||
# Disable this to make LNbits use this config file again.
|
||||
LNBITS_ADMIN_UI=false
|
||||
|
||||
# Change theme
|
||||
LNBITS_SITE_TITLE="LNbits"
|
||||
LNBITS_SITE_TAGLINE="free and open-source lightning wallet"
|
||||
LNBITS_SITE_DESCRIPTION="Some description about your service, will display if title is not 'LNbits'"
|
||||
# Choose from bitcoin, mint, flamingo, freedom, salvador, autumn, monochrome, classic, cyber
|
||||
LNBITS_THEME_OPTIONS="classic, bitcoin, flamingo, freedom, mint, autumn, monochrome, salvador, cyber"
|
||||
# LNBITS_CUSTOM_LOGO="https://lnbits.com/assets/images/logo/logo.svg"
|
||||
|
||||
HOST=127.0.0.1
|
||||
PORT=5000
|
||||
|
||||
######################################
|
||||
####### Auth Configurations ##########
|
||||
######################################
|
||||
# Secret Key: will default to the hash of the super user. It is strongly recommended that you set your own value.
|
||||
AUTH_SECRET_KEY=""
|
||||
AUTH_TOKEN_EXPIRE_MINUTES=525600
|
||||
# Possible authorization methods: user-id-only, username-password, google-auth, github-auth
|
||||
# Possible authorization methods: user-id-only, username-password, google-auth, github-auth, keycloak-auth
|
||||
AUTH_ALLOWED_METHODS="user-id-only, username-password"
|
||||
# Set this flag if HTTP is used for OAuth
|
||||
# OAUTHLIB_INSECURE_TRANSPORT="1"
|
||||
|
||||
# Google OAuth Config
|
||||
# Make sure thant the authorized redirect URIs contain https://{domain}/api/v1/auth/google/token
|
||||
# Make sure that the authorized redirect URIs contain https://{domain}/api/v1/auth/google/token
|
||||
GOOGLE_CLIENT_ID=""
|
||||
GOOGLE_CLIENT_SECRET=""
|
||||
|
||||
# GitHub OAuth Config
|
||||
# Make sure thant the authorization callback URL is set to https://{domain}/api/v1/auth/github/token
|
||||
# Make sure that the authorization callback URL is set to https://{domain}/api/v1/auth/github/token
|
||||
GITHUB_CLIENT_ID=""
|
||||
GITHUB_CLIENT_SECRET=""
|
||||
|
||||
# Keycloak OAuth Config
|
||||
# Make sure that the valid redirect URIs contain https://{domain}/api/v1/auth/keycloak/token
|
||||
KEYCLOAK_CLIENT_ID=""
|
||||
KEYCLOAK_CLIENT_SECRET=""
|
||||
KEYCLOAK_DISCOVERY_URL=""
|
||||
|
||||
|
||||
######################################
|
||||
|
||||
# uvicorn variable, uncomment to allow https behind a proxy
|
||||
# IMPORTANT: this also needs the webserver to be configured to forward the headers
|
||||
# http://docs.lnbits.org/guide/installation.html#running-behind-an-apache2-reverse-proxy-over-https
|
||||
# FORWARDED_ALLOW_IPS="*"
|
||||
|
||||
# Server security, rate limiting ips, blocked ips, allowed ips
|
||||
@@ -160,6 +182,9 @@ LNBITS_DEFAULT_WALLET_NAME="LNbits wallet"
|
||||
# LNBITS_AD_SPACE_TITLE="Supported by"
|
||||
# csv ad space, format "<url>;<img-light>;<img-dark>, <url>;<img-light>;<img-dark>", extensions can choose to honor
|
||||
# LNBITS_AD_SPACE="https://shop.lnbits.com/;https://raw.githubusercontent.com/lnbits/lnbits/main/lnbits/static/images/lnbits-shop-light.png;https://raw.githubusercontent.com/lnbits/lnbits/main/lnbits/static/images/lnbits-shop-dark.png"
|
||||
# LNBITS_SHOW_HOME_PAGE_ELEMENTS=true # if set to true, the ad space will be displayed on the home page
|
||||
# LNBITS_CUSTOM_BADGE="USE WITH CAUTION - LNbits wallet is still in BETA"
|
||||
# LNBITS_CUSTOM_BADGE_COLOR="warning"
|
||||
|
||||
# Hides wallet api, extensions can choose to honor
|
||||
LNBITS_HIDE_API=false
|
||||
@@ -198,6 +223,13 @@ LNBITS_RESERVE_FEE_MIN=2000
|
||||
# value in percent
|
||||
LNBITS_RESERVE_FEE_PERCENT=1.0
|
||||
|
||||
# limit the maximum balance for each wallet
|
||||
# throw an error if the wallet attempts to create a new invoice
|
||||
|
||||
# LNBITS_WALLET_LIMIT_MAX_BALANCE=1000000
|
||||
# LNBITS_WALLET_LIMIT_DAILY_MAX_WITHDRAW=1000000
|
||||
# LNBITS_WALLET_LIMIT_SECS_BETWEEN_TRANS=60
|
||||
|
||||
# Limit fiat currencies allowed to see in UI
|
||||
# LNBITS_ALLOWED_CURRENCIES="EUR, USD"
|
||||
|
||||
@@ -206,6 +238,7 @@ LNBITS_RESERVE_FEE_PERCENT=1.0
|
||||
######################################
|
||||
|
||||
DEBUG=false
|
||||
DEBUG_DATABASE=false
|
||||
BUNDLE_ASSETS=true
|
||||
|
||||
# logging into LNBITS_DATA_FOLDER/logs/
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
custom: https://legend.lnbits.com/paywall/GAqKguK5S8f6w5VNjS9DfK
|
||||
custom: https://demo.lnbits.com/lnurlp/link/fH59GD
|
||||
|
||||
@@ -10,7 +10,7 @@ inputs:
|
||||
default: "1.7.0"
|
||||
node-version:
|
||||
description: "Node Version"
|
||||
default: "18.x"
|
||||
default: "20.x"
|
||||
npm:
|
||||
description: "use npm"
|
||||
default: false
|
||||
@@ -21,7 +21,7 @@ runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Set up Python ${{ inputs.python-version }}
|
||||
uses: actions/setup-python@v4
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ inputs.python-version }}
|
||||
# cache poetry install via pip
|
||||
@@ -38,7 +38,7 @@ runs:
|
||||
poetry config virtualenvs.create true --local
|
||||
poetry config virtualenvs.in-project true --local
|
||||
|
||||
- uses: actions/cache@v3
|
||||
- uses: actions/cache@v4
|
||||
name: Define a cache for the virtual environment based on the dependencies lock file
|
||||
with:
|
||||
path: ./.venv
|
||||
@@ -50,11 +50,11 @@ runs:
|
||||
|
||||
- name: Use Node.js ${{ inputs.node-version }}
|
||||
if: ${{ (inputs.npm == 'true') }}
|
||||
uses: actions/setup-node@v3
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ inputs.node-version }}
|
||||
|
||||
- uses: actions/cache@v3
|
||||
- uses: actions/cache@v4
|
||||
if: ${{ (inputs.npm == 'true') }}
|
||||
name: Define a cache for the npm based on the dependencies lock file
|
||||
with:
|
||||
|
||||
@@ -12,7 +12,7 @@ jobs:
|
||||
lint:
|
||||
uses: ./.github/workflows/lint.yml
|
||||
|
||||
tests:
|
||||
test-api:
|
||||
needs: [ lint ]
|
||||
strategy:
|
||||
matrix:
|
||||
@@ -20,17 +20,48 @@ jobs:
|
||||
db-url: ["", "postgres://lnbits:lnbits@0.0.0.0:5432/lnbits"]
|
||||
uses: ./.github/workflows/tests.yml
|
||||
with:
|
||||
custom-pytest: "poetry run pytest tests/api"
|
||||
python-version: ${{ matrix.python-version }}
|
||||
db-url: ${{ matrix.db-url }}
|
||||
secrets:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
migrations:
|
||||
test-wallets:
|
||||
needs: [ lint ]
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.9"]
|
||||
python-version: ["3.9", "3.10"]
|
||||
db-url: ["", "postgres://lnbits:lnbits@0.0.0.0:5432/lnbits"]
|
||||
uses: ./.github/workflows/tests.yml
|
||||
with:
|
||||
make: test-migration
|
||||
db-name: migration
|
||||
custom-pytest: "poetry run pytest tests/wallets"
|
||||
python-version: ${{ matrix.python-version }}
|
||||
db-url: ${{ matrix.db-url }}
|
||||
secrets:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
test-unit:
|
||||
needs: [ lint ]
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.9", "3.10"]
|
||||
db-url: ["", "postgres://lnbits:lnbits@0.0.0.0:5432/lnbits"]
|
||||
uses: ./.github/workflows/tests.yml
|
||||
with:
|
||||
custom-pytest: "poetry run pytest tests/unit"
|
||||
python-version: ${{ matrix.python-version }}
|
||||
db-url: ${{ matrix.db-url }}
|
||||
secrets:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
migration:
|
||||
needs: [ lint ]
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.9", "3.10"]
|
||||
uses: ./.github/workflows/migration.yml
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
openapi:
|
||||
needs: [ lint ]
|
||||
@@ -46,5 +77,19 @@ jobs:
|
||||
python-version: ["3.9"]
|
||||
backend-wallet-class: ["LndRestWallet", "LndWallet", "CoreLightningWallet", "CoreLightningRestWallet", "LNbitsWallet", "EclairWallet"]
|
||||
with:
|
||||
custom-pytest: "poetry run pytest tests/regtest"
|
||||
python-version: ${{ matrix.python-version }}
|
||||
backend-wallet-class: ${{ matrix.backend-wallet-class }}
|
||||
secrets:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
jmeter:
|
||||
needs: [ lint ]
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.9"]
|
||||
poetry-version: ["1.5.1"]
|
||||
uses: ./.github/workflows/jmeter.yml
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
poetry-version: ${{ matrix.poetry-version }}
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 2
|
||||
- run: git checkout HEAD^2
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
name: docker
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
default: latest
|
||||
type: string
|
||||
workflow_call:
|
||||
inputs:
|
||||
tag:
|
||||
default: latest
|
||||
type: string
|
||||
secrets:
|
||||
DOCKER_USERNAME:
|
||||
required: true
|
||||
DOCKER_PASSWORD:
|
||||
required: true
|
||||
|
||||
jobs:
|
||||
push_to_dockerhub:
|
||||
@@ -38,16 +48,6 @@ jobs:
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ secrets.DOCKER_USERNAME }}/lnbits:${{ inputs.tag }}
|
||||
platforms: [ linux/amd64, linux/arm64 ]
|
||||
cache-from: type=local,src=/tmp/.buildx-cache
|
||||
cache-to: type=local,dest=/tmp/.buildx-cache
|
||||
|
||||
- name: Build and push latest tag
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ secrets.DOCKER_USERNAME }}/lnbits:latest
|
||||
platforms: [ linux/amd64, linux/arm64 ]
|
||||
platforms: linux/amd64,linux/arm64
|
||||
cache-from: type=local,src=/tmp/.buildx-cache
|
||||
cache-to: type=local,dest=/tmp/.buildx-cache
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
name: JMeter Extension Tests
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
python-version:
|
||||
description: "Python Version"
|
||||
required: true
|
||||
default: "3.9"
|
||||
type: string
|
||||
poetry-version:
|
||||
description: "Poetry Version"
|
||||
required: true
|
||||
default: "1.5.1"
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
jmeter:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: ./.github/actions/prepare
|
||||
with:
|
||||
python-version: ${{ inputs.python-version }}
|
||||
|
||||
- name: run LNbits
|
||||
env:
|
||||
LNBITS_ADMIN_UI: true
|
||||
LNBITS_EXTENSIONS_DEFAULT_INSTALL: "watchonly, satspay, tipjar, tpos, lnurlp, withdraw"
|
||||
LNBITS_BACKEND_WALLET_CLASS: FakeWallet
|
||||
run: |
|
||||
poetry run lnbits &
|
||||
sleep 5
|
||||
|
||||
- name: clone lnbits-extensions, install jmeter and run tests
|
||||
run: |
|
||||
git clone https://github.com/lnbits/lnbits-extensions
|
||||
cd lnbits-extensions
|
||||
mkdir logs
|
||||
mkdir reports
|
||||
make install-jmeter
|
||||
make start-mirror-server
|
||||
make test
|
||||
|
||||
- name: upload jmeter test results
|
||||
uses: actions/upload-artifact@v4
|
||||
if: ${{ always() }}
|
||||
with:
|
||||
name: jmeter-extension-test-results
|
||||
path: |
|
||||
lnbits-extensions/reports/
|
||||
lnbits-extensions/logs/
|
||||
@@ -25,8 +25,8 @@ jobs:
|
||||
node-version: ["18.x"]
|
||||
runs-on: ${{ matrix.os-version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: ./.github/actions/prepare
|
||||
- uses: actions/checkout@v4
|
||||
- uses: lnbits/lnbits/.github/actions/prepare@dev
|
||||
with:
|
||||
python-version: ${{ inputs.python-version }}
|
||||
node-version: ${{ matrix.node-version }}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
name: migration
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
python-version:
|
||||
description: "python version"
|
||||
type: string
|
||||
default: "3.10"
|
||||
|
||||
jobs:
|
||||
make:
|
||||
name: migration (${{ inputs.python-version }})
|
||||
strategy:
|
||||
matrix:
|
||||
os-version: ["ubuntu-latest"]
|
||||
runs-on: ${{ matrix.os-version }}
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:latest
|
||||
env:
|
||||
POSTGRES_USER: lnbits
|
||||
POSTGRES_PASSWORD: lnbits
|
||||
POSTGRES_DB: migration
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ./.github/actions/prepare
|
||||
with:
|
||||
python-version: ${{ inputs.python-version }}
|
||||
- run: make test-migration
|
||||
@@ -3,8 +3,9 @@ name: regtest
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
make:
|
||||
default: test
|
||||
custom-pytest:
|
||||
description: "Custom pytest arguments"
|
||||
required: true
|
||||
type: string
|
||||
python-version:
|
||||
default: "3.9"
|
||||
@@ -15,27 +16,21 @@ on:
|
||||
backend-wallet-class:
|
||||
required: true
|
||||
type: string
|
||||
secrets:
|
||||
CODECOV_TOKEN:
|
||||
required: true
|
||||
|
||||
jobs:
|
||||
regtest:
|
||||
runs-on: ${{ inputs.os-version }}
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
- name: docker build
|
||||
if: ${{ inputs.backend-wallet-class == 'LNbitsWallet' }}
|
||||
uses: docker/setup-buildx-action@v2
|
||||
|
||||
- name: Build and push
|
||||
if: ${{ inputs.backend-wallet-class == 'LNbitsWallet' }}
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
push: false
|
||||
tags: lnbitsdocker/lnbits-legend:latest
|
||||
cache-from: type=registry,ref=lnbitsdocker/lnbits-legend:latest
|
||||
cache-to: type=inline
|
||||
run: |
|
||||
docker build -t lnbits/lnbits .
|
||||
|
||||
- name: Setup Regtest
|
||||
run: |
|
||||
@@ -49,11 +44,8 @@ jobs:
|
||||
with:
|
||||
python-version: ${{ inputs.python-version }}
|
||||
|
||||
- name: Create fake admin
|
||||
if: ${{ inputs.backend-wallet-class == 'LNbitsWallet' }}
|
||||
run: docker exec lnbits-legend-lnbits-1 poetry run python tools/create_fake_admin.py
|
||||
|
||||
- name: Run Tests
|
||||
- name: Run pytest
|
||||
uses: pavelzw/pytest-action@v2
|
||||
env:
|
||||
LNBITS_DATABASE_URL: ${{ inputs.db-url }}
|
||||
LNBITS_BACKEND_WALLET_CLASS: ${{ inputs.backend-wallet-class }}
|
||||
@@ -72,10 +64,24 @@ jobs:
|
||||
LNBITS_KEY: "d08a3313322a4514af75d488bcc27eee"
|
||||
ECLAIR_URL: http://127.0.0.1:8082
|
||||
ECLAIR_PASS: lnbits
|
||||
run: make test-real-wallet
|
||||
PYTHONUNBUFFERED: 1
|
||||
DEBUG: true
|
||||
with:
|
||||
verbose: true
|
||||
job-summary: true
|
||||
emoji: false
|
||||
click-to-expand: true
|
||||
custom-pytest: ${{ inputs.custom-pytest }}
|
||||
report-title: "regtest (${{ inputs.python-version }}, ${{ inputs.backend-wallet-class }}"
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v3
|
||||
uses: codecov/codecov-action@v4
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
file: ./coverage.xml
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
verbose: true
|
||||
|
||||
- name: docker lnbits logs
|
||||
if: ${{ inputs.backend-wallet-class == 'LNbitsWallet' }}
|
||||
run: |
|
||||
docker logs lnbits-lnbits-1
|
||||
|
||||
@@ -10,9 +10,11 @@ permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Create github release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -25,3 +27,15 @@ jobs:
|
||||
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:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Build and publish to pypi
|
||||
uses: JRubics/poetry-publish@v1.15
|
||||
with:
|
||||
pypi_token: ${{ secrets.PYPI_API_KEY }}
|
||||
|
||||
@@ -3,8 +3,9 @@ name: tests
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
make:
|
||||
default: test
|
||||
custom-pytest:
|
||||
description: "Custom pytest arguments"
|
||||
required: true
|
||||
type: string
|
||||
python-version:
|
||||
default: "3.9"
|
||||
@@ -18,6 +19,9 @@ on:
|
||||
db-name:
|
||||
default: "lnbits"
|
||||
type: string
|
||||
secrets:
|
||||
CODECOV_TOKEN:
|
||||
required: true
|
||||
|
||||
jobs:
|
||||
tests:
|
||||
@@ -39,20 +43,30 @@ jobs:
|
||||
--health-retries 5
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: ./.github/actions/prepare
|
||||
with:
|
||||
python-version: ${{ inputs.python-version }}
|
||||
|
||||
- name: Run Tests
|
||||
- name: Run pytest
|
||||
uses: pavelzw/pytest-action@v2
|
||||
env:
|
||||
LNBITS_DATABASE_URL: ${{ inputs.db-url }}
|
||||
LNBITS_BACKEND_WALLET_CLASS: FakeWallet
|
||||
run: make ${{ inputs.make }}
|
||||
PYTHONUNBUFFERED: 1
|
||||
DEBUG: true
|
||||
with:
|
||||
verbose: true
|
||||
job-summary: true
|
||||
emoji: false
|
||||
click-to-expand: true
|
||||
custom-pytest: ${{ inputs.custom-pytest }}
|
||||
report-title: "test (${{ inputs.python-version }}, ${{ inputs.db-url }})"
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v3
|
||||
uses: codecov/codecov-action@v4
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
file: ./coverage.xml
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
verbose: false
|
||||
|
||||
@@ -11,6 +11,7 @@ __pycache__
|
||||
*.egg
|
||||
*.egg-info
|
||||
.coverage
|
||||
.coverage.*
|
||||
.pytest_cache
|
||||
.webassets-cache
|
||||
htmlcov
|
||||
@@ -50,3 +51,6 @@ lnbits-backup.zip
|
||||
# Ignore extensions (post installable extension PR)
|
||||
extensions
|
||||
upgrades/
|
||||
|
||||
# builded python package
|
||||
dist
|
||||
|
||||
@@ -14,16 +14,16 @@ repos:
|
||||
- id: mixed-line-ending
|
||||
- id: check-case-conflict
|
||||
- repo: https://github.com/psf/black
|
||||
rev: 23.7.0
|
||||
rev: 24.2.0
|
||||
hooks:
|
||||
- id: black
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.0.283
|
||||
rev: v0.3.2
|
||||
hooks:
|
||||
- id: ruff
|
||||
args: [ --fix, --exit-non-zero-on-fix ]
|
||||
- repo: https://github.com/pre-commit/mirrors-prettier
|
||||
rev: '50c5478ed9e10bf360335449280cf2a67f4edb7a'
|
||||
rev: "v4.0.0-alpha.8"
|
||||
hooks:
|
||||
- id: prettier
|
||||
types_or: [css, javascript, html, json]
|
||||
|
||||
@@ -11,3 +11,7 @@
|
||||
**/lnbits/static/vendor
|
||||
**/lnbits/static/bundle.*
|
||||
**/lnbits/static/css/*
|
||||
|
||||
flake.lock
|
||||
|
||||
.venv
|
||||
|
||||
+6
-5
@@ -1,16 +1,17 @@
|
||||
FROM python:3.10-slim-bullseye
|
||||
FROM python:3.10-slim-bookworm
|
||||
|
||||
RUN apt-get clean
|
||||
RUN apt-get update
|
||||
RUN apt-get install -y curl pkg-config build-essential
|
||||
RUN apt-get install -y curl pkg-config build-essential libnss-myhostname
|
||||
|
||||
RUN curl -sSL https://install.python-poetry.org | python3 -
|
||||
ENV PATH="/root/.local/bin:$PATH"
|
||||
|
||||
# needed for backups postgresql-client version 14 (pg_dump)
|
||||
RUN apt-get install -y apt-utils wget
|
||||
RUN echo "deb http://apt.postgresql.org/pub/repos/apt bullseye-pgdg main" > /etc/apt/sources.list.d/pgdg.list
|
||||
RUN wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | apt-key add -
|
||||
RUN apt install -y postgresql-common ca-certificates
|
||||
RUN install -d /usr/share/postgresql-common/pgdg
|
||||
RUN curl -o /usr/share/postgresql-common/pgdg/apt.postgresql.org.asc --fail https://www.postgresql.org/media/keys/ACCC4CF8.asc
|
||||
RUN sh -c 'echo "deb [signed-by=/usr/share/postgresql-common/pgdg/apt.postgresql.org.asc] https://apt.postgresql.org/pub/repos/apt bookworm-pgdg main" > /etc/apt/sources.list.d/pgdg.list'
|
||||
RUN apt-get update
|
||||
RUN apt-get install -y postgresql-client-14
|
||||
|
||||
|
||||
@@ -6,8 +6,10 @@ format: prettier black ruff
|
||||
|
||||
check: mypy pyright checkblack checkruff checkprettier checkbundle
|
||||
|
||||
test: test-unit test-wallets test-api test-regtest
|
||||
|
||||
prettier:
|
||||
poetry run ./node_modules/.bin/prettier --write lnbits
|
||||
poetry run ./node_modules/.bin/prettier --write .
|
||||
|
||||
pyright:
|
||||
poetry run ./node_modules/.bin/pyright
|
||||
@@ -25,7 +27,7 @@ checkruff:
|
||||
poetry run ruff check .
|
||||
|
||||
checkprettier:
|
||||
poetry run ./node_modules/.bin/prettier --check lnbits
|
||||
poetry run ./node_modules/.bin/prettier --check .
|
||||
|
||||
checkblack:
|
||||
poetry run black --check .
|
||||
@@ -36,23 +38,32 @@ checkeditorconfig:
|
||||
dev:
|
||||
poetry run lnbits --reload
|
||||
|
||||
test:
|
||||
test-wallets:
|
||||
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
|
||||
FAKE_WALLET_SECRET="ToTheMoon1" \
|
||||
LNBITS_DATA_FOLDER="./tests/data" \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
DEBUG=true \
|
||||
poetry run pytest
|
||||
poetry run pytest tests/wallets
|
||||
|
||||
test-real-wallet:
|
||||
LNBITS_DATA_FOLDER="./tests/data" \
|
||||
test-unit:
|
||||
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
DEBUG=true \
|
||||
poetry run pytest
|
||||
poetry run pytest tests/unit
|
||||
|
||||
test-api:
|
||||
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
DEBUG=true \
|
||||
poetry run pytest tests/api
|
||||
|
||||
test-regtest:
|
||||
PYTHONUNBUFFERED=1 \
|
||||
DEBUG=true \
|
||||
poetry run pytest tests/regtest
|
||||
|
||||
test-migration:
|
||||
LNBITS_ADMIN_UI=True \
|
||||
make test
|
||||
make test-api
|
||||
HOST=0.0.0.0 \
|
||||
PORT=5002 \
|
||||
LNBITS_DATA_FOLDER="./tests/data" \
|
||||
@@ -86,7 +97,7 @@ bak:
|
||||
sass:
|
||||
npm run sass
|
||||
|
||||
bundle_no_bump:
|
||||
bundle:
|
||||
npm install
|
||||
npm run sass
|
||||
npm run vendor_copy
|
||||
@@ -97,16 +108,10 @@ bundle_no_bump:
|
||||
npm run vendor_bundle_js
|
||||
npm run vendor_minify_js
|
||||
|
||||
bundle:
|
||||
make bundle_no_bump
|
||||
# increment serviceworker version
|
||||
awk '/CACHE_VERSION =/ {sub(/[0-9]+$$/, $$NF+1)} 1' lnbits/static/js/service-worker.js > lnbits/static/js/service-worker.js.new
|
||||
mv lnbits/static/js/service-worker.js.new lnbits/static/js/service-worker.js
|
||||
|
||||
checkbundle:
|
||||
cp lnbits/static/bundle.min.js lnbits/static/bundle.min.js.old
|
||||
cp lnbits/static/bundle.min.css lnbits/static/bundle.min.css.old
|
||||
make bundle_no_bump
|
||||
make bundle
|
||||
diff -q lnbits/static/bundle.min.js lnbits/static/bundle.min.js.old || exit 1
|
||||
diff -q lnbits/static/bundle.min.css lnbits/static/bundle.min.css.old || exit 1
|
||||
@echo "Bundle is OK"
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
# LNbits BETA
|
||||
<picture >
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://i.imgur.com/QE6SIrs.png" style="width:300px">
|
||||
<img src="https://i.imgur.com/fyKPgVT.png" style="width:300px">
|
||||
</picture>
|
||||
|
||||
[![license-badge]](LICENSE)
|
||||
[![docs-badge]][docs]
|
||||
 [![license-badge]](LICENSE) [![docs-badge]][docs]  [<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits) [<img src="https://img.shields.io/badge/supported_by-%3E__OpenSats-f97316">](https://opensats.org)
|
||||

|
||||
|
||||

|
||||
# The world's most powerful suite of bitcoin tools.
|
||||
|
||||
# Free and Open-Source Lightning Wallet Accounts System
|
||||
## Run for yourself, for others, or as part of a stack.
|
||||
|
||||
(Join us on [https://t.me/lnbits](https://t.me/lnbits))
|
||||
|
||||
LNbits is beta, for responsible disclosure of any concerns please contact lnbits@pm.me
|
||||
|
||||
Use [legend.lnbits.com](https://legend.lnbits.com), or run your own LNbits server!
|
||||
LNbits is beta, for responsible disclosure of any concerns please contact an admin in the community chat.
|
||||
|
||||
LNbits is a Python server that sits on top of any funding source. It can be used as:
|
||||
|
||||
@@ -21,15 +20,7 @@ LNbits is a Python server that sits on top of any funding source. It can be used
|
||||
- Fallback wallet for the LNURL scheme
|
||||
- Instant wallet for LN demonstrations
|
||||
|
||||
LNbits can run on top of any Lightning funding source. It currently supports the following, but more and more are added regularly:
|
||||
- LND (REST and gRPC)
|
||||
- Core Lightning aka CLN (gRPC, REST and Spark)
|
||||
- Eclair
|
||||
- LNPay
|
||||
- LNbits
|
||||
- OpenNode
|
||||
- Alby
|
||||
- LightningTipBot
|
||||
LNbits can run on top of almost all Lightning funding sources.
|
||||
|
||||
See [LNbits manual](https://docs.lnbits.org/guide/wallets.html) for more detailed documentation about each funding source.
|
||||
|
||||
@@ -39,40 +30,45 @@ LNbits is inspired by all the great work of [opennode.com](https://www.opennode.
|
||||
|
||||
## Running LNbits
|
||||
|
||||
Test on our demo server [demo.lnbits.com](https://demo.lnbits.com), or on [lnbits.com](https://lnbits.com) software as a service, where you can spin up an LNbits instance for 21sats per hr.
|
||||
|
||||
See the [install guide](https://github.com/lnbits/lnbits/blob/main/docs/guide/installation.md) for details on installation and setup.
|
||||
|
||||
## LNbits as an account system
|
||||
## LNbits account system
|
||||
|
||||
LNbits is packaged with tools to help manage funds, such as a table of transactions, line chart of spending, export to csv + more to come..
|
||||
LNbits is packaged with tools to help manage funds, such as a table of transactions, line chart of spending, export to csv. Each wallet also comes with its own API keys, to help partition the exposure of your funding source.
|
||||
|
||||

|
||||
<img src="https://i.imgur.com/w8jdGpF.png" style="width:800px">
|
||||
|
||||
Each wallet also comes with its own API keys, to help partition the exposure of your funding source.
|
||||
## LNbits extension universe
|
||||
|
||||
(LNbits M5StackSats available here https://github.com/arcbtc/M5StackSats)
|
||||
Extend YOUR LNbits to meet YOUR needs.
|
||||
|
||||

|
||||
All non-core features are installed as extensions, reducing your code base and making your LNbits unique to you. Extend your LNbits install in any direction, and even create and share your own extensions.
|
||||
|
||||
## LNbits as an LNURL-withdraw fallback
|
||||
<img src="https://i.imgur.com/aEBpwJF.png" style="width:800px">
|
||||
|
||||
LNURL has a fallback scheme, so if scanned by a regular QR code reader it can default to a URL. LNbits exploits this to generate an instant wallet using the [LNURL-withdraw](https://github.com/btcontract/lnurl-rfc/blob/master/lnurl-withdraw.md).
|
||||
## LNbits API
|
||||
|
||||

|
||||
LNbits has a powerful API, many projects use LNbits to do the heavy lifting for their bitcoin/lightning services.
|
||||
|
||||
Using **lnbits.com/?lightning="LNURL-withdraw"** will trigger a withdraw that builds an LNbits wallet.
|
||||
Example use would be an ATM, which utilizes LNURL, if the user scans the QR with a regular QR code scanner app, they will still be able to access the funds.
|
||||
<img src="https://i.imgur.com/V742sb9.png" style="width:800px">
|
||||
|
||||

|
||||
## LNbits node manager
|
||||
|
||||
## LNbits as an instant wallet
|
||||
LNbits comes packaged with a light node management UI, to make running your node that much easier.
|
||||
|
||||
Wallets can be easily generated and given out to people at events. "Go to this website", has a lot less friction than "Download this app".
|
||||
<img src="https://i.imgur.com/TYqIK60.png" style="width:800px">
|
||||
|
||||

|
||||
## LNbits across all your devices
|
||||
|
||||
As well as working great in a browser, LNbits has native IoS and Android apps as well as a chrome extension. So you can enjoy the same UI across ALL your devices.
|
||||
|
||||
<img src="https://i.imgur.com/J96EbRf.png" style="width:800px">
|
||||
|
||||
## Tip us
|
||||
|
||||
If you like this project [send some tip love](https://legend.lnbits.com/paywall/GAqKguK5S8f6w5VNjS9DfK)!
|
||||
If you like this project [send some tip love](https://demo.lnbits.com/lnurlp/link/fH59GD)!
|
||||
|
||||
[docs]: https://github.com/lnbits/lnbits/wiki
|
||||
[docs-badge]: https://img.shields.io/badge/docs-lnbits.org-673ab7.svg
|
||||
|
||||
+2
-4
@@ -5,8 +5,6 @@ title: API reference
|
||||
nav_order: 3
|
||||
---
|
||||
|
||||
# API reference
|
||||
|
||||
API reference
|
||||
=============
|
||||
|
||||
[Swagger Docs](https://legend.lnbits.com/docs)
|
||||
[Swagger Docs](https://demo.lnbits.com/docs)
|
||||
|
||||
+11
-10
@@ -5,30 +5,27 @@ nav_order: 4
|
||||
has_children: true
|
||||
---
|
||||
|
||||
|
||||
For developers
|
||||
==============
|
||||
# For developers
|
||||
|
||||
Thanks for contributing :)
|
||||
|
||||
|
||||
Run
|
||||
=====
|
||||
# Run
|
||||
|
||||
This starts the lnbits uvicorn server
|
||||
|
||||
```bash
|
||||
poetry run lnbits
|
||||
```
|
||||
|
||||
This starts the lnbits uvicorn with hot reloading.
|
||||
|
||||
```bash
|
||||
make dev
|
||||
# or
|
||||
poetry run lnbits --reload
|
||||
```
|
||||
|
||||
Precommit hooks
|
||||
=====
|
||||
# Precommit hooks
|
||||
|
||||
This ensures that all commits adhere to the formatting and linting rules.
|
||||
|
||||
@@ -36,31 +33,35 @@ This ensures that all commits adhere to the formatting and linting rules.
|
||||
make install-pre-commit-hook
|
||||
```
|
||||
|
||||
Tests
|
||||
=====
|
||||
# Tests
|
||||
|
||||
This project has unit tests that help prevent regressions. Before you can run the tests, you must install a few dependencies:
|
||||
|
||||
```bash
|
||||
poetry install
|
||||
npm i
|
||||
```
|
||||
|
||||
Then to run the tests:
|
||||
|
||||
```bash
|
||||
make test
|
||||
```
|
||||
|
||||
Run formatting:
|
||||
|
||||
```bash
|
||||
make format
|
||||
```
|
||||
|
||||
Run mypy checks:
|
||||
|
||||
```bash
|
||||
poetry run mypy
|
||||
```
|
||||
|
||||
Run everything:
|
||||
|
||||
```bash
|
||||
make all
|
||||
```
|
||||
|
||||
+21
-16
@@ -5,11 +5,10 @@ title: Making extensions
|
||||
nav_order: 2
|
||||
---
|
||||
|
||||
|
||||
Extension set up
|
||||
=================
|
||||
# Extension set up
|
||||
|
||||
Start off by creating a fork of the [example extension](https://github.com/lnbits/example) into own GitHub repository and rename the repository to `mysuperplugin`:
|
||||
|
||||
```sh
|
||||
cd [my-working-folder]
|
||||
git clone https://github.com/[my-user-name]/mysuperplugin.git --depth=1 # Let's not use dashes or anything; it doesn't like those.
|
||||
@@ -18,6 +17,7 @@ rm -rf .git/
|
||||
find . -type f -print0 | xargs -0 sed -i 's/example/mysuperplugin/g' # Change all occurrences of 'example' to your plugin name 'mysuperplugin'.
|
||||
mv templates/example templates/mysuperplugin # Rename templates folder.
|
||||
```
|
||||
|
||||
- if you are on macOS and having difficulty with 'sed', consider `brew install gnu-sed` and use 'gsed', without -0 option after xargs.
|
||||
|
||||
1. Edit `manifest.json` and change the organisation name to your GitHub username.
|
||||
@@ -30,19 +30,17 @@ mv templates/example templates/mysuperplugin # Rename templates folder.
|
||||
1. ...
|
||||
1. Profit!!!
|
||||
|
||||
Extension structure explained
|
||||
-----------------------------
|
||||
* views_api.py: This is where your public API would go. It will be exposed at "$DOMAIN/$PLUGIN/$ROUTE". For example: https://lnbits.com/mysuperplugin/api/v1/tools.
|
||||
* views.py: The `/` path will show up as your plugin's home page in lnbits' UI. Other pages you can define yourself. The `templates` folder should explain itself in relation to this.
|
||||
* migrations.py: Create database tables for your plugin. They'll be created automatically when you start lnbits.
|
||||
## Extension structure explained
|
||||
|
||||
- views_api.py: This is where your public API would go. It will be exposed at "$DOMAIN/$PLUGIN/$ROUTE". For example: https://lnbits.com/mysuperplugin/api/v1/tools.
|
||||
- views.py: The `/` path will show up as your plugin's home page in lnbits' UI. Other pages you can define yourself. The `templates` folder should explain itself in relation to this.
|
||||
- migrations.py: Create database tables for your plugin. They'll be created automatically when you start lnbits.
|
||||
|
||||
... This document is a work-in-progress. Send pull requests if you get stuck, so others don't.
|
||||
|
||||
## Adding new dependencies
|
||||
|
||||
Adding new dependencies
|
||||
-----------------------
|
||||
|
||||
DO NOT ADD NEW DEPENDENCIES. Try to use the dependencies that are availabe in `pyproject.toml`. Getting the LNbits project to accept a new dependency is time consuming and uncertain, and may result in your extension NOT being made available to others.
|
||||
DO NOT ADD NEW DEPENDENCIES. Try to use the dependencies that are available in `pyproject.toml`. Getting the LNbits project to accept a new dependency is time consuming and uncertain, and may result in your extension NOT being made available to others.
|
||||
|
||||
If for some reason your extensions must have a new python package to work, and its nees are not met in `pyproject.toml`, you can add a new package using `poerty`:
|
||||
|
||||
@@ -51,11 +49,9 @@ $ poetry add <package>
|
||||
```
|
||||
|
||||
**But we need an extra step to make sure LNbits doesn't break in production.**
|
||||
Dependencies need to be added to `pyproject.toml`, then tested by running on `poetry` compatability can be tested with `nix build .#checks.x86_64-linux.vmTest`.
|
||||
Dependencies need to be added to `pyproject.toml`, then tested by running on `poetry` compatibility can be tested with `nix build .#checks.x86_64-linux.vmTest`.
|
||||
|
||||
|
||||
SQLite to PostgreSQL migration
|
||||
-----------------------
|
||||
## SQLite to PostgreSQL migration
|
||||
|
||||
LNbits currently supports SQLite and PostgreSQL databases. There is a migration script `tools/conv.py` that helps users migrate from SQLite to PostgreSQL. This script also copies all extension databases to the new backend.
|
||||
|
||||
@@ -64,22 +60,31 @@ LNbits currently supports SQLite and PostgreSQL databases. There is a migration
|
||||
`mock_data.zip` contains a few lines of sample SQLite data and is used in automated GitHub test to see whether your migration in `conv.py` works. Run your extension and save a few lines of data into a SQLite `your_extension.sqlite3` file. Unzip `tests/data/mock_data.zip`, add `your_extension.sqlite3`, updated `database.sqlite3` and zip it again. Add the updated `mock_data.zip` to your PR.
|
||||
|
||||
### running migration locally
|
||||
|
||||
you will need a running postgres database
|
||||
|
||||
#### create lnbits user for migration database
|
||||
|
||||
```console
|
||||
sudo su - postgres -c "psql -c 'CREATE ROLE lnbits LOGIN PASSWORD 'lnbits';'"
|
||||
```
|
||||
|
||||
#### create migration database
|
||||
|
||||
```console
|
||||
sudo su - postgres -c "psql -c 'CREATE DATABASE migration;'"
|
||||
```
|
||||
|
||||
#### run the migration
|
||||
|
||||
```console
|
||||
make test-migration
|
||||
```
|
||||
|
||||
sudo su - postgres -c "psql -c 'CREATE ROLE lnbits LOGIN PASSWORD 'lnbits';'"
|
||||
|
||||
#### clean migration database afterwards, fails if you try again
|
||||
|
||||
```console
|
||||
sudo su - postgres -c "psql -c 'DROP DATABASE IF EXISTS migration;'"
|
||||
```
|
||||
|
||||
+30
-27
@@ -1,29 +1,32 @@
|
||||
<html>
|
||||
<head>
|
||||
<!-- Load the latest Swagger UI code and style from npm using unpkg.com -->
|
||||
<script src="https://unpkg.com/swagger-ui-dist@3/swagger-ui-bundle.js"></script>
|
||||
<link rel="stylesheet" type="text/css" href="https://unpkg.com/swagger-ui-dist@3/swagger-ui.css"/>
|
||||
<title>My New API</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="swagger-ui"></div> <!-- Div to hold the UI component -->
|
||||
<script>
|
||||
window.onload = function () {
|
||||
// Begin Swagger UI call region
|
||||
const ui = SwaggerUIBundle({
|
||||
url: "https://legend.lnbits.com/openapi.json", //Location of Open API spec in the repo
|
||||
dom_id: '#swagger-ui',
|
||||
deepLinking: true,
|
||||
presets: [
|
||||
SwaggerUIBundle.presets.apis,
|
||||
SwaggerUIBundle.SwaggerUIStandalonePreset
|
||||
],
|
||||
plugins: [
|
||||
SwaggerUIBundle.plugins.DownloadUrl
|
||||
],
|
||||
})
|
||||
window.ui = ui
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
<head>
|
||||
<!-- Load the latest Swagger UI code and style from npm using unpkg.com -->
|
||||
<script src="https://unpkg.com/swagger-ui-dist@3/swagger-ui-bundle.js"></script>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
type="text/css"
|
||||
href="https://unpkg.com/swagger-ui-dist@3/swagger-ui.css"
|
||||
/>
|
||||
<title>My New API</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="swagger-ui"></div>
|
||||
<!-- Div to hold the UI component -->
|
||||
<script>
|
||||
window.onload = function () {
|
||||
// Begin Swagger UI call region
|
||||
const ui = SwaggerUIBundle({
|
||||
url: 'https://demo.lnbits.com/openapi.json', //Location of Open API spec in the repo
|
||||
dom_id: '#swagger-ui',
|
||||
deepLinking: true,
|
||||
presets: [
|
||||
SwaggerUIBundle.presets.apis,
|
||||
SwaggerUIBundle.SwaggerUIStandalonePreset
|
||||
],
|
||||
plugins: [SwaggerUIBundle.plugins.DownloadUrl]
|
||||
})
|
||||
window.ui = ui
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -5,14 +5,11 @@ title: Websockets
|
||||
nav_order: 2
|
||||
---
|
||||
|
||||
|
||||
Websockets
|
||||
=================
|
||||
# Websockets
|
||||
|
||||
`websockets` are a great way to add a two way instant data channel between server and client.
|
||||
|
||||
LNbits has a useful in built websocket tool. With a websocket client connect to (obv change `somespecificid`) `wss://legend.lnbits.com/api/v1/ws/somespecificid` (you can use an online websocket tester). Now make a get to `https://legend.lnbits.com/api/v1/ws/somespecificid/somedata`. You can send data to that websocket by using `from lnbits.core.services import websocketUpdater` and the function `websocketUpdater("somespecificid", "somdata")`.
|
||||
|
||||
LNbits has a useful in built websocket tool. With a websocket client connect to (obv change `somespecificid`) `wss://demo.lnbits.com/api/v1/ws/somespecificid` (you can use an online websocket tester). Now make a get to `https://demo.lnbits.com/api/v1/ws/somespecificid/somedata`. You can send data to that websocket by using `from lnbits.core.services import websocketUpdater` and the function `websocketUpdater("somespecificid", "somdata")`.
|
||||
|
||||
Example vue-js function for listening to the websocket:
|
||||
|
||||
|
||||
+15
-13
@@ -4,17 +4,15 @@ title: Admin UI
|
||||
nav_order: 4
|
||||
---
|
||||
|
||||
# Admin UI
|
||||
|
||||
Admin UI
|
||||
========
|
||||
The LNbits Admin UI lets you change LNbits settings via the LNbits frontend.
|
||||
It is disabled by default and the first time you set the environment variable `LNBITS_ADMIN_UI=true`
|
||||
the settings are initialized and saved to the database and will be used from there as long the UI is enabled.
|
||||
From there on the settings from the database are used.
|
||||
|
||||
# Super User
|
||||
|
||||
Super User
|
||||
==========
|
||||
With the Admin UI we introduced the super user, it is created with the initialisation of the Admin UI and will be shown with a success message in the server logs.
|
||||
The super user has access to the server and can change settings that may crash the server and make it unresponsive via the frontend and api, like changing funding sources.
|
||||
|
||||
@@ -29,48 +27,52 @@ We also added a decorator for the API routes to check for super user.
|
||||
|
||||
There is also the possibility of posting the super user via webhook to another service when it is created. you can look it up here https://github.com/lnbits/lnbits/blob/main/lnbits/settings.py `class SaaSSettings`
|
||||
|
||||
# Admin Users
|
||||
|
||||
Admin Users
|
||||
===========
|
||||
environment variable: `LNBITS_ADMIN_USERS`, comma-separated list of user ids
|
||||
Admin Users can change settings in the admin ui as well, with the exception of funding source settings, because they require e server restart and could potentially make the server inaccessable. Also they have access to all the extension defined in `LNBITS_ADMIN_EXTENSIONS`.
|
||||
Admin Users can change settings in the admin ui as well, with the exception of funding source settings, because they require e server restart and could potentially make the server inaccessible. Also they have access to all the extension defined in `LNBITS_ADMIN_EXTENSIONS`.
|
||||
|
||||
# Allowed Users
|
||||
|
||||
Allowed Users
|
||||
=============
|
||||
environment variable: `LNBITS_ALLOWED_USERS`, comma-separated list of user ids
|
||||
By defining this users, LNbits will no longer be usable by the public, only defined users and admins can then access the LNbits frontend.
|
||||
|
||||
Setting this environment variable also disables account creation.
|
||||
Account creation can be also disabled by setting `LNBITS_ALLOW_NEW_ACCOUNTS=false`
|
||||
|
||||
# How to activate
|
||||
|
||||
How to activate
|
||||
=============
|
||||
```
|
||||
$ sudo systemctl stop lnbits.service
|
||||
$ cd ~/lnbits-legend
|
||||
$ cd ~/lnbits
|
||||
$ sudo nano .env
|
||||
```
|
||||
|
||||
-> set: `LNBITS_ADMIN_UI=true`
|
||||
|
||||
Now start LNbits once in the terminal window
|
||||
|
||||
```
|
||||
$ poetry run lnbits
|
||||
```
|
||||
|
||||
You can now `cat` the Super User ID:
|
||||
|
||||
```
|
||||
$ cat data/.super_user
|
||||
123de4bfdddddbbeb48c8bc8382fe123
|
||||
```
|
||||
|
||||
You can access your super user account at `/wallet?usr=super_user_id`. You just have to append it to your normal LNbits web domain.
|
||||
|
||||
After that you will find the __`Admin` / `Manage Server`__ between `Wallets` and `Extensions`
|
||||
After that you will find the **`Admin` / `Manage Server`** between `Wallets` and `Extensions`
|
||||
|
||||
Here you can design the interface, it has TOPUP to fill wallets and you can restrict access rights to extensions only for admins or generally deactivated for everyone. You can make users admins or set up Allowed Users if you want to restrict access. And of course the classic settings of the .env file, e.g. to change the funding source wallet or set a charge fee.
|
||||
|
||||
Do not forget
|
||||
|
||||
```
|
||||
sudo systemctl start lnbits.service
|
||||
```
|
||||
|
||||
A little hint, if you set `RESET TO DEFAULTS`, then a new Super User Account will also be created. The old one is then no longer valid.
|
||||
|
||||
@@ -10,16 +10,16 @@ Go to `Manage Server` > `Server` > `Extensions Manifests`
|
||||
|
||||

|
||||
|
||||
|
||||
An `Extension Manifest` is a link to a `JSON` file which contains information about various extensions that can be installed (repository of extensions).
|
||||
Multiple repositories can be configured. For more information check the [Manifest File](https://github.com/lnbits/lnbits/blob/main/docs/guide/extension-install.md#manifest-file) section.
|
||||
|
||||
|
||||
**LNbits** administrators should configure their instances to use repositories that they trust (like the [lnbits-extensions](https://github.com/lnbits/lnbits-extensions/) one).
|
||||
|
||||
> **Warning**
|
||||
> Extensions can have bugs or malicious code, be careful what you install!!
|
||||
|
||||
## Install New Extension
|
||||
|
||||
Only administrator users can install or upgrade extensions.
|
||||
|
||||
Go to `Manage Extensions` > `Add Remove Extensions`
|
||||
@@ -45,13 +45,12 @@ Select the version to be installed (usually the last one) and click `Install`. O
|
||||
>
|
||||
> For Explicit Release: the order of the releases is the one in the "extensions" object
|
||||
|
||||
|
||||
The extension has been installed but it cannot be accessed yet. In order to activate the extension toggle it in the `Activated` state.
|
||||
|
||||
Go to `Manage Extensions` (as admin user or regular user). Search for the extension and enable it.
|
||||
|
||||
|
||||
## Uninstall Extension
|
||||
|
||||
On the `Install` page click `Manage` for the extension you want to uninstall:
|
||||

|
||||
|
||||
@@ -65,6 +64,7 @@ Users will no longer be able to access the extension.
|
||||
> The database for the extension is not removed. If the extension is re-installed later, the data will be accessible.
|
||||
|
||||
## Manifest File
|
||||
|
||||
The manifest file is just a `JSON` file that lists a collection of extensions that can be installed. This file is of the form:
|
||||
|
||||
```json
|
||||
@@ -77,30 +77,32 @@ The manifest file is just a `JSON` file that lists a collection of extensions th
|
||||
There are two ways to specify installable extensions:
|
||||
|
||||
### Explicit Release
|
||||
|
||||
It goes under the `extensions` object and it is of the form:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "lnurlp",
|
||||
"name": "LNURL Pay Links",
|
||||
"version": 1,
|
||||
"shortDescription": "Upgrade to version 111111111",
|
||||
"icon": "receipt",
|
||||
"details": "All charge names should be <code>111111111</code>. API panel must show: <br>",
|
||||
"archive": "https://github.com/lnbits/lnbits-extensions/raw/main/new/lnurlp/1/lnurlp.zip",
|
||||
"hash": "a22d02de6bf306a7a504cd344e032cc6d48837a1d4aeb569a55a57507bf9a43a",
|
||||
"htmlUrl": "https://github.com/lnbits/lnbits-extensions/tree/main/new/lnurlp/1",
|
||||
"infoNotification": "This is a very old version",
|
||||
"dependencies": ["other-ext-id"]
|
||||
}
|
||||
{
|
||||
"id": "lnurlp",
|
||||
"name": "LNURL Pay Links",
|
||||
"version": 1,
|
||||
"shortDescription": "Upgrade to version 111111111",
|
||||
"icon": "receipt",
|
||||
"details": "All charge names should be <code>111111111</code>. API panel must show: <br>",
|
||||
"archive": "https://github.com/lnbits/lnbits-extensions/raw/main/new/lnurlp/1/lnurlp.zip",
|
||||
"hash": "a22d02de6bf306a7a504cd344e032cc6d48837a1d4aeb569a55a57507bf9a43a",
|
||||
"htmlUrl": "https://github.com/lnbits/lnbits-extensions/tree/main/new/lnurlp/1",
|
||||
"infoNotification": "This is a very old version",
|
||||
"dependencies": ["other-ext-id"]
|
||||
}
|
||||
```
|
||||
|
||||
<details><summary>Fields Detailed Description</summary>
|
||||
|
||||
| Field | Type | | Description |
|
||||
|----------------------|---------------|-----------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| id | string | mandatory | The ID of the extension. Must be unique for each extension. It is also used as the path in the URL. |
|
||||
| -------------------- | ------------- | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| id | string | mandatory | The ID of the extension. Must be unique for each extension. It is also used as the path in the URL. |
|
||||
| name | string | mandatory | User friendly name for the extension. It will be displayed on the installation page. |
|
||||
| version | string | mandatory | Version of this release. [Semantic versioning](https://semver.org/) is recommended. |
|
||||
| version | string | mandatory | Version of this release. [Semantic versioning](https://semver.org/) is recommended. |
|
||||
| shortDescription | string | optional | A few words about the extension. It will be displayed on the installation page. |
|
||||
| icon | string | optional | quasar valid icon name |
|
||||
| details | string (html) | optional | Details about this particular release |
|
||||
@@ -109,31 +111,30 @@ It goes under the `extensions` object and it is of the form:
|
||||
| htmlUrl | string | optional | Link to the extension home page. |
|
||||
| infoNotification | string | optional | Users that have this release installed will see a info message for their extension. For example if the extension support will be terminated soon. |
|
||||
| criticalNotification | string | optional | Reserved for urgent notifications. The admin user will receive a message each time it visits the `Install` page. One example is if the extension has a critical bug. |
|
||||
| dependencies | list | optional | A list of extension IDs. It signals that those extensions must be installed BEFORE the this one can be installed.
|
||||
| dependencies | list | optional | A list of extension IDs. It signals that those extensions must be installed BEFORE the this one can be installed. |
|
||||
|
||||
</details>
|
||||
|
||||
This mode has the advantage of strictly specifying what releases of an extension can be installed.
|
||||
|
||||
### GitHub Repository
|
||||
|
||||
It goes under the `repos` object and it is of the form:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "withdraw",
|
||||
"organisation": "lnbits",
|
||||
"repository": "withdraw-extension"
|
||||
"id": "withdraw",
|
||||
"organisation": "lnbits",
|
||||
"repository": "withdraw-extension"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
|--------------|--------|-------------------------------------------------------|
|
||||
| id | string | The ID of the extension. Must be unique for each extension. It is also used as the path in the URL. |
|
||||
| organisation | string | The GitHub organisation (eg: `lnbits`) |
|
||||
| repository | string | The GitHub repository name (eg: `withdraw-extension`) |
|
||||
| Field | Type | Description |
|
||||
| ------------ | ------ | --------------------------------------------------------------------------------------------------- |
|
||||
| id | string | The ID of the extension. Must be unique for each extension. It is also used as the path in the URL. |
|
||||
| organisation | string | The GitHub organisation (eg: `lnbits`) |
|
||||
| repository | string | The GitHub repository name (eg: `withdraw-extension`) |
|
||||
|
||||
The admin user will see all releases from the Github repository:
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
+8
-5
@@ -4,10 +4,10 @@ title: FAQ
|
||||
nav_order: 5
|
||||
---
|
||||
|
||||
|
||||
# FAQ - Frequently Asked Questions
|
||||
|
||||
## Install options
|
||||
|
||||
<ul><p>LNbits is not a node management software but a ⚡️LN only accounting system on top of a funding source.</p>
|
||||
|
||||
<details><summary>Funding my LNbits wallet from my node it doesn't work.</summary>
|
||||
@@ -54,6 +54,7 @@ allow-self-payment=1
|
||||
</ul>
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
<ul><details><summary>Message "https error" or network error" when scanning a LNbits QR</summary>
|
||||
<p>Bad news, this is a routing error that might have quite a lot of reasons. Let´s try a few of the most possible problems and their solutions. First choose your setup</p>
|
||||
<ul>
|
||||
@@ -83,7 +84,7 @@ allow-self-payment=1
|
||||
<details><summary>Wallet-URL deleted, are my funds safu ?</summary>
|
||||
<ul>
|
||||
<li>
|
||||
<details><summary>Wallet on demo server legend.lnbits</summary>
|
||||
<details><summary>Wallet on demo server demo.lnbits.com</summary>
|
||||
<p>Always save a copy of your wallet-URL, Export2phone-QR or LNDhub for your own wallets in a safe place. LNbits CANNOT help you to recover them when lost.</p>
|
||||
</details>
|
||||
</li>
|
||||
@@ -100,7 +101,7 @@ allow-self-payment=1
|
||||
|
||||
<details><summary>Configure a comment that people see when paying to my LNURLp QR</summary>
|
||||
<p>When you create a LNURL-p, by default the comment box is not filled. That means comments are not allowed to be attached to payments.<p>
|
||||
<p>In order to allow comments, add the characters lenght of the box, from 1 to 250. Once you put a number there,
|
||||
<p>In order to allow comments, add the characters length of the box, from 1 to 250. Once you put a number there,
|
||||
the comment box will be displayed in the payment process. You can also edit a LNURL-p already created and add that number.</p>
|
||||
|
||||

|
||||
@@ -150,12 +151,11 @@ allow-self-payment=1
|
||||
</details>
|
||||
|
||||
<details><summary>Can I configure a name to the payments i make?</summary>
|
||||
<p>In LNbits this is currently not possible to do - but to receive. This is only possible if the sender's LN wallet supports <a href="https://github.com/lnurl/luds">LUD-18</a> (nameDesc) like e.g. <a href="https://darthcoin.substack.com/p/obw-open-bitcoin-wallet">Open Bitcion Wallet - OBW</a> does. You will then see an alias/pseudonym in the details section of your LNbits transactions (click the arrows). Note that you can give any name there and it might not be related to the real sender´s name(!) if your receive such.</p>
|
||||
<p>In LNbits this is currently not possible to do - but to receive. This is only possible if the sender's LN wallet supports <a href="https://github.com/lnurl/luds">LUD-18</a> (nameDesc) like e.g. <a href="https://darthcoin.substack.com/p/obw-open-bitcoin-wallet">Open Bitcoin Wallet - OBW</a> does. You will then see an alias/pseudonym in the details section of your LNbits transactions (click the arrows). Note that you can give any name there and it might not be related to the real sender´s name(!) if your receive such.</p>
|
||||

|
||||
</p>
|
||||
</details>
|
||||
|
||||
|
||||
<details><summary>How can I use a LNbits lndhub account in other wallet apps?</summary>
|
||||
<p>Open your LNbits with the account / wallet you want to use, go to "manage extensions" and activate the <a href="https://github.com/lnbits/lndhub">LNDHUB extension</a>.</p>
|
||||
<p>Then open the LNDHUB extension, choose the wallet you want to use and scan the QR code you want to use: "admin" or "invoice only", depending on the security level you want for that wallet.</p>
|
||||
@@ -166,6 +166,7 @@ allow-self-payment=1
|
||||
</ul>
|
||||
|
||||
## Building hardware tools
|
||||
|
||||
<ul> <p>LNbits has all sorts of open APIs and tools to program and connect to a lot of different devices for a gazillion of use-cases. Let us know what you did with it ! Come to the <a href="https://t.me/makerbits">Makerbits Telegram Group</a> if you are interested in building or if you need help with a project - we got you!</p>
|
||||
|
||||
<details><summary>ATM - deposit and withdraw in your shop or at your meetup</summary>
|
||||
@@ -216,6 +217,7 @@ allow-self-payment=1
|
||||
</ul>
|
||||
|
||||
## Use cases of LNbits
|
||||
|
||||
<ul><details><summary>Merchant</summary>
|
||||
<p>LNbits is a powerful solution for merchants, due to the easy setup with various extensions, that can be used for many scenarios.</p>
|
||||
<p><a href="https://darthcoin.substack.com/p/lnbits-for-small-merchants">Here is an overview of the LNbits tools available for a small restaurant as well as a hotel</a></p>
|
||||
@@ -262,6 +264,7 @@ allow-self-payment=1
|
||||
</ul>
|
||||
|
||||
## Developing for LNbits
|
||||
|
||||
<ul>
|
||||
<li><a href="https://docs.lnbits.org/devs/development.html">Making extensions / How to use Websockets / API reference</a></li>
|
||||
<li><a href="https://t.me/lnbits">Telegram LNbits Support Group</a></li></ul>
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
|
||||
## Defining a route with path parameters
|
||||
|
||||
**old:**
|
||||
|
||||
```python
|
||||
# with <>
|
||||
@offlineshop_ext.route("/lnurl/<item_id>", methods=["GET"])
|
||||
```
|
||||
|
||||
**new:**
|
||||
|
||||
```python
|
||||
# with curly braces: {}
|
||||
@offlineshop_ext.get("/lnurl/{item_id}")
|
||||
```
|
||||
|
||||
## Check if a user exists and access user object
|
||||
|
||||
**old:**
|
||||
|
||||
```python
|
||||
# decorators
|
||||
@check_user_exists()
|
||||
@@ -24,14 +28,18 @@ async def do_routing_stuff():
|
||||
**new:**
|
||||
If user doesn't exist, `Depends(check_user_exists)` will raise an exception.
|
||||
If user exists, `user` will be the user object
|
||||
|
||||
```python
|
||||
# depends calls
|
||||
@core_html_routes.get("/my_route")
|
||||
async def extensions(user: User = Depends(check_user_exists)):
|
||||
pass
|
||||
```
|
||||
|
||||
## Returning data from API calls
|
||||
|
||||
**old:**
|
||||
|
||||
```python
|
||||
return (
|
||||
{
|
||||
@@ -42,9 +50,11 @@ return (
|
||||
HTTPStatus.OK,
|
||||
)
|
||||
```
|
||||
|
||||
FastAPI returns `HTTPStatus.OK` by default id no Exception is raised
|
||||
|
||||
**new:**
|
||||
|
||||
```python
|
||||
return {
|
||||
"id": wallet.wallet.id,
|
||||
@@ -54,6 +64,7 @@ return {
|
||||
```
|
||||
|
||||
To change the default HTTPStatus, add it to the path decorator
|
||||
|
||||
```python
|
||||
@core_app.post("/api/v1/payments", status_code=HTTPStatus.CREATED)
|
||||
async def payments():
|
||||
@@ -61,7 +72,9 @@ async def payments():
|
||||
```
|
||||
|
||||
## Raise exceptions
|
||||
|
||||
**old:**
|
||||
|
||||
```python
|
||||
return (
|
||||
{"message": f"Failed to connect to {domain}."},
|
||||
@@ -74,6 +87,7 @@ abort(HTTPStatus.INTERNAL_SERVER_ERROR, "Could not process withdraw LNURL.")
|
||||
**new:**
|
||||
|
||||
Raise an exception to return a status code other than the default status code.
|
||||
|
||||
```python
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
@@ -82,7 +96,9 @@ raise HTTPException(
|
||||
```
|
||||
|
||||
## Extensions
|
||||
|
||||
**old:**
|
||||
|
||||
```python
|
||||
from quart import Blueprint
|
||||
|
||||
@@ -92,6 +108,7 @@ amilk_ext: Blueprint = Blueprint(
|
||||
```
|
||||
|
||||
**new:**
|
||||
|
||||
```python
|
||||
from fastapi import APIRouter
|
||||
from lnbits.jinja2_templating import Jinja2Templates
|
||||
@@ -114,9 +131,12 @@ offlineshop_rndr = template_renderer([
|
||||
```
|
||||
|
||||
## Possible optimizations
|
||||
|
||||
### Use Redis as a cache server
|
||||
|
||||
Instead of hitting the database over and over again, we can store a short lived object in [Redis](https://redis.io) for an arbitrary key.
|
||||
Example:
|
||||
* Get transactions for a wallet ID
|
||||
* User data for a user id
|
||||
* Wallet data for a Admin / Invoice key
|
||||
|
||||
- Get transactions for a wallet ID
|
||||
- User data for a user id
|
||||
- Wallet data for a Admin / Invoice key
|
||||
|
||||
+41
-21
@@ -13,21 +13,31 @@ By default, LNbits will use SQLite as its database. You can also use PostgreSQL
|
||||
## Option 1 (recommended): poetry
|
||||
|
||||
Mininum poetry version has is ^1.2, but it is recommended to use latest poetry. (including OSX)
|
||||
Make sure you have Python 3.9 or 3.10 installed.
|
||||
|
||||
### install python on ubuntu
|
||||
|
||||
```sh
|
||||
# for making sure python 3.9 is installed, skip if installed. To check your installed version: python3 --version
|
||||
sudo apt update
|
||||
sudo apt install software-properties-common
|
||||
sudo add-apt-repository ppa:deadsnakes/ppa
|
||||
sudo apt install python3.9 python3.9-distutils
|
||||
```
|
||||
|
||||
### install poetry
|
||||
|
||||
```sh
|
||||
curl -sSL https://install.python-poetry.org | python3 -
|
||||
# Once the above poetry install is completed, use the installation path printed to terminal and replace in the following command
|
||||
export PATH="/home/user/.local/bin:$PATH"
|
||||
```
|
||||
|
||||
```sh
|
||||
git clone https://github.com/lnbits/lnbits.git
|
||||
cd lnbits
|
||||
git checkout main
|
||||
|
||||
# for making sure python 3.9 is installed, skip if installed. To check your installed version: python3 --version
|
||||
sudo apt update
|
||||
sudo apt install software-properties-common
|
||||
sudo add-apt-repository ppa:deadsnakes/ppa
|
||||
sudo apt install python3.9 python3.9-distutils
|
||||
|
||||
curl -sSL https://install.python-poetry.org | python3 -
|
||||
# Once the above poetry install is completed, use the installation path printed to terminal and replace in the following command
|
||||
export PATH="/home/user/.local/bin:$PATH"
|
||||
# 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
|
||||
@@ -48,6 +58,7 @@ poetry run lnbits
|
||||
# adding --debug in the start-up command above to help your troubleshooting and generate a more verbose output
|
||||
# Note that you have to add the line DEBUG=true in your .env file, too.
|
||||
```
|
||||
|
||||
#### Updating the server
|
||||
|
||||
```
|
||||
@@ -89,7 +100,7 @@ nix run
|
||||
Ideally you would set the environment via the `.env` file,
|
||||
but you can also set the env variables or pass command line arguments:
|
||||
|
||||
``` sh
|
||||
```sh
|
||||
# .env variables are currently passed when running, but LNbits can be managed with the admin UI.
|
||||
LNBITS_ADMIN_UI=true ./result/bin/lnbits --port 9000
|
||||
|
||||
@@ -100,20 +111,23 @@ SUPER_USER=be54db7f245346c8833eaa430e1e0405 LNBITS_ADMIN_UI=true ./result/bin/ln
|
||||
## Option 3: Docker
|
||||
|
||||
use latest version from docker hub
|
||||
|
||||
```sh
|
||||
docker pull lnbitsdocker/lnbits-legend
|
||||
docker pull lnbits/lnbits
|
||||
wget https://raw.githubusercontent.com/lnbits/lnbits/main/.env.example -O .env
|
||||
mkdir data
|
||||
docker run --detach --publish 5000:5000 --name lnbits --volume ${PWD}/.env:/app/.env --volume ${PWD}/data/:/app/data lnbitsdocker/lnbits-legend
|
||||
docker run --detach --publish 5000:5000 --name lnbits --volume ${PWD}/.env:/app/.env --volume ${PWD}/data/:/app/data lnbits/lnbits
|
||||
```
|
||||
|
||||
build the image yourself
|
||||
|
||||
```sh
|
||||
git clone https://github.com/lnbits/lnbits.git
|
||||
cd lnbits
|
||||
docker build -t lnbitsdocker/lnbits-legend .
|
||||
docker build -t lnbits/lnbits .
|
||||
cp .env.example .env
|
||||
mkdir data
|
||||
docker run --detach --publish 5000:5000 --name lnbits --volume ${PWD}/.env:/app/.env --volume ${PWD}/data/:/app/data lnbitsdocker/lnbits-legend
|
||||
docker run --detach --publish 5000:5000 --name lnbits --volume ${PWD}/.env:/app/.env --volume ${PWD}/data/:/app/data lnbits/lnbits
|
||||
```
|
||||
|
||||
## Option 4: Fly.io
|
||||
@@ -149,7 +163,7 @@ You'll be prompted to enter an app name, region, postgres (choose no), deploy no
|
||||
|
||||
You'll now find a file in the directory called `fly.toml`. Open that file and modify/add the following settings.
|
||||
|
||||
Note: Be sure to replace `${PUT_YOUR_LNBITS_ENV_VARS_HERE}` with all relevant environment variables in `.env` or `.env.example`. Environment variable strings should be quoted here, so if in `.env` you have `LNBITS_ENDPOINT=https://legend.lnbits.com` in `fly.toml` you should have `LNBITS_ENDPOINT="https://legend.lnbits.com"`.
|
||||
Note: Be sure to replace `${PUT_YOUR_LNBITS_ENV_VARS_HERE}` with all relevant environment variables in `.env` or `.env.example`. Environment variable strings should be quoted here, so if in `.env` you have `LNBITS_ENDPOINT=https://demo.lnbits.com` in `fly.toml` you should have `LNBITS_ENDPOINT="https://demo.lnbits.com"`.
|
||||
|
||||
Note: Don't enter secret environment variables here. Fly.io offers secrets (via the `fly secrets` command) that are exposed as environment variables in your runtime. So, for example, if using the LND_REST funding source, you can run `fly secrets set LND_REST_MACAROON=<hex_macaroon_data>`.
|
||||
|
||||
@@ -246,11 +260,10 @@ You might also need to install additional packages or perform additional setup s
|
||||
|
||||
Take a look at [Polar](https://lightningpolar.com/) for an excellent way of spinning up a Lightning Network dev environment.
|
||||
|
||||
|
||||
|
||||
# Additional guides
|
||||
|
||||
## SQLite to PostgreSQL migration
|
||||
|
||||
If you already have LNbits installed and running, on an SQLite database, we **highly** recommend you migrate to postgres if you are planning to run LNbits on scale.
|
||||
|
||||
There's a script included that can do the migration easy. You should have Postgres already installed and there should be a password for the user (see Postgres install guide above). Additionally, your LNbits instance should run once on postgres to implement the database schema before the migration works:
|
||||
@@ -272,7 +285,6 @@ make migration
|
||||
|
||||
Hopefully, everything works and get migrated... Launch LNbits again and check if everything is working properly.
|
||||
|
||||
|
||||
## LNbits as a systemd service
|
||||
|
||||
Systemd is great for taking care of your LNbits instance. It will start it on boot and restart it in case it crashes. If you want to run LNbits as a systemd service on your Debian/Ubuntu/Raspbian server, create a file at `/etc/systemd/system/lnbits.service` with the following content:
|
||||
@@ -449,28 +461,36 @@ service restart nginx
|
||||
```
|
||||
|
||||
## Using https without reverse proxy
|
||||
|
||||
The most common way of using LNbits via https is to use a reverse proxy such as Caddy, nginx, or ngriok. However, you can also run LNbits via https without additional software. This is useful for development purposes or if you want to use LNbits in your local network.
|
||||
|
||||
We have to create a self-signed certificate using `mkcert`. Note that this certificate is not "trusted" by most browsers but that's fine (since you know that you have created it) and encryption is always better than clear text.
|
||||
|
||||
#### Install mkcert
|
||||
|
||||
You can find the install instructions for `mkcert` [here](https://github.com/FiloSottile/mkcert).
|
||||
|
||||
Install mkcert on Ubuntu:
|
||||
|
||||
```sh
|
||||
sudo apt install libnss3-tools
|
||||
curl -JLO "https://dl.filippo.io/mkcert/latest?for=linux/amd64"
|
||||
chmod +x mkcert-v*-linux-amd64
|
||||
sudo cp mkcert-v*-linux-amd64 /usr/local/bin/mkcert
|
||||
```
|
||||
|
||||
#### Create certificate
|
||||
|
||||
To create a certificate, first `cd` into your LNbits folder and execute the following command on Linux:
|
||||
|
||||
```sh
|
||||
openssl req -new -newkey rsa:4096 -x509 -sha256 -days 3650 -nodes -out cert.pem -keyout key.pem
|
||||
```
|
||||
|
||||
This will create two new files (`key.pem` and `cert.pem `).
|
||||
|
||||
Alternatively, you can use mkcert ([more info](https://kifarunix.com/how-to-create-self-signed-ssl-certificate-with-mkcert-on-ubuntu-18-04/)):
|
||||
|
||||
```sh
|
||||
# add your local IP (192.x.x.x) as well if you want to use it in your local network
|
||||
mkcert localhost 127.0.0.1 ::1
|
||||
@@ -482,7 +502,6 @@ You can then pass the certificate files to uvicorn when you start LNbits:
|
||||
poetry run uvicorn lnbits.__main__:app --host 0.0.0.0 --port 5000 --ssl-keyfile ./key.pem --ssl-certfile ./cert.pem
|
||||
```
|
||||
|
||||
|
||||
## LNbits running on Umbrel behind Tor
|
||||
|
||||
If you want to run LNbits on your Umbrel but want it to be reached through clearnet, _Uxellodunum_ made an extensive [guide](https://community.getumbrel.com/t/guide-lnbits-without-tor/604) on how to do it.
|
||||
@@ -494,7 +513,7 @@ To install using docker you first need to build the docker image as:
|
||||
```
|
||||
git clone https://github.com/lnbits/lnbits.git
|
||||
cd lnbits
|
||||
docker build -t lnbits-legend .
|
||||
docker build -t lnbits/lnbits .
|
||||
```
|
||||
|
||||
You can launch the docker in a different directory, but make sure to copy `.env.example` from lnbits there
|
||||
@@ -506,6 +525,7 @@ cp <lnbits_repo>/.env.example .env
|
||||
and change the configuration in `.env` as required.
|
||||
|
||||
Then create the data directory
|
||||
|
||||
```
|
||||
mkdir data
|
||||
```
|
||||
@@ -513,7 +533,7 @@ mkdir data
|
||||
Then the image can be run as:
|
||||
|
||||
```
|
||||
docker run --detach --publish 5000:5000 --name lnbits-legend -e "LNBITS_BACKEND_WALLET_CLASS='FakeWallet'" --volume ${PWD}/.env:/app/.env --volume ${PWD}/data/:/app/data lnbits-legend
|
||||
docker run --detach --publish 5000:5000 --name lnbits -e "LNBITS_BACKEND_WALLET_CLASS='FakeWallet'" --volume ${PWD}/.env:/app/.env --volume ${PWD}/data/:/app/data lnbits
|
||||
```
|
||||
|
||||
Finally you can access your lnbits on your machine at port 5000.
|
||||
|
||||
+19
-5
@@ -4,15 +4,12 @@ title: Backend wallets
|
||||
nav_order: 3
|
||||
---
|
||||
|
||||
# Backend wallets
|
||||
|
||||
Backend wallets
|
||||
===============
|
||||
|
||||
LNbits can run on top of many lightning-network funding sources with more being added regularly.
|
||||
LNbits can run on top of many Lightning Network funding sources with more being added regularly.
|
||||
|
||||
A backend wallet can be configured using the following LNbits environment variables:
|
||||
|
||||
|
||||
### CoreLightning
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: **CoreLightningWallet**
|
||||
@@ -87,6 +84,23 @@ For the invoice to work you must have a publicly accessible URL in your LNbits.
|
||||
- `ALBY_API_ENDPOINT`: https://api.getalby.com/
|
||||
- `ALBY_ACCESS_TOKEN`: AlbyAccessToken
|
||||
|
||||
### 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
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: **ZBDWallet**
|
||||
- `ZBD_API_ENDPOINT`: https://api.zebedee.io/v0/
|
||||
- `ZBD_API_KEY`: ZBDApiKey
|
||||
|
||||
### Phoenixd
|
||||
|
||||
For the invoice to work you must have a publicly accessible URL in your LNbits. You can get a phoenixd API key from the install
|
||||
~/.phoenix/phoenix.conf, also see the documentation for phoenixd.
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: **PhoenixdWallet**
|
||||
- `PHOENIXD_API_ENDPOINT`: http://localhost:9740/
|
||||
- `PHOENIXD_API_PASSWORD`: PhoenixdApiPassword
|
||||
|
||||
### Cliche Wallet
|
||||
|
||||
- `CLICHE_ENDPOINT`: ws://127.0.0.1:12000
|
||||
|
||||
+7
-11
@@ -4,21 +4,17 @@ title: User’s Guide
|
||||
nav_order: 1
|
||||
---
|
||||
|
||||
|
||||
LNbits, free and open-source lightning-network wallet/accounts system
|
||||
=====================================================================
|
||||
# LNbits, free and open-source Lightning Network wallet/accounts system
|
||||
|
||||
LNbits is a very simple Python application that sits on top of any funding source, and can be used as:
|
||||
|
||||
* Accounts system to mitigate the risk of exposing applications to your full balance, via unique API keys for each wallet
|
||||
* Extendable platform for exploring lightning-network functionality via LNbits extension framework
|
||||
* Part of a development stack via LNbits API
|
||||
* Fallback wallet for the LNURL scheme
|
||||
* Instant wallet for LN demonstrations
|
||||
- Accounts system to mitigate the risk of exposing applications to your full balance, via unique API keys for each wallet
|
||||
- Extendable platform for exploring Lightning Network functionality via LNbits extension framework
|
||||
- Part of a development stack via LNbits API
|
||||
- Fallback wallet for the LNURL scheme
|
||||
- Instant wallet for LN demonstrations
|
||||
|
||||
|
||||
LNbits as an account system
|
||||
---------------------------
|
||||
## LNbits as an account system
|
||||
|
||||
LNbits is packaged with tools to help manage funds, such as a table of transactions, line chart of spending,
|
||||
export to csv + more to come...
|
||||
|
||||
@@ -33,6 +33,16 @@
|
||||
protobuf = prev.protobuf.override { preferWheel = true; };
|
||||
ruff = prev.ruff.override { preferWheel = true; };
|
||||
wallycore = prev.wallycore.override { preferWheel = true; };
|
||||
# remove the following override when https://github.com/nix-community/poetry2nix/pull/1563 is merged
|
||||
asgi-lifespan = prev.asgi-lifespan.overridePythonAttrs (
|
||||
old: { buildInputs = (old.buildInputs or []) ++ [ prev.setuptools ]; }
|
||||
);
|
||||
pytest-md = prev.pytest-md.overridePythonAttrs (
|
||||
old: { buildInputs = (old.buildInputs or []) ++ [ prev.setuptools ]; }
|
||||
);
|
||||
types-mock = prev.pytest-md.overridePythonAttrs (
|
||||
old: { buildInputs = (old.buildInputs or []) ++ [ prev.setuptools ]; }
|
||||
);
|
||||
});
|
||||
};
|
||||
});
|
||||
|
||||
+126
-320
@@ -1,45 +1,47 @@
|
||||
import asyncio
|
||||
import glob
|
||||
import importlib
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import signal
|
||||
import sys
|
||||
import traceback
|
||||
from hashlib import sha256
|
||||
from http import HTTPStatus
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from typing import Callable, List, Optional
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from loguru import logger
|
||||
from slowapi import Limiter
|
||||
from slowapi.util import get_remote_address
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
from lnbits.core.crud import get_installed_extensions
|
||||
from lnbits.core.crud import get_dbversions, get_installed_extensions
|
||||
from lnbits.core.helpers import migrate_extension_database
|
||||
from lnbits.core.services import websocketUpdater
|
||||
from lnbits.core.tasks import ( # register_watchdog,; unregister_watchdog,
|
||||
register_killswitch,
|
||||
register_task_listeners,
|
||||
from lnbits.core.tasks import ( # watchdog_task
|
||||
killswitch_task,
|
||||
wait_for_paid_invoices,
|
||||
)
|
||||
from lnbits.exceptions import register_exception_handlers
|
||||
from lnbits.settings import settings
|
||||
from lnbits.tasks import cancel_all_tasks, create_permanent_task
|
||||
from lnbits.tasks import (
|
||||
cancel_all_tasks,
|
||||
create_permanent_task,
|
||||
register_invoice_listener,
|
||||
)
|
||||
from lnbits.utils.cache import cache
|
||||
from lnbits.wallets import get_wallet_class, set_wallet_class
|
||||
from lnbits.utils.logger import (
|
||||
configure_logger,
|
||||
initialize_server_websocket_logger,
|
||||
log_server_info,
|
||||
)
|
||||
from lnbits.wallets import get_funding_source, set_funding_source
|
||||
|
||||
from .commands import db_versions, migrate_databases
|
||||
from .commands import migrate_databases
|
||||
from .core import init_core_routers
|
||||
from .core.db import core_app_extra
|
||||
from .core.services import check_admin_settings, check_webpush_settings
|
||||
from .core.views.api import add_installed_extension
|
||||
from .core.views.extension_api import add_installed_extension
|
||||
from .core.views.generic import update_installed_extension_state
|
||||
from .extension_manager import (
|
||||
Extension,
|
||||
@@ -47,7 +49,6 @@ from .extension_manager import (
|
||||
get_valid_extensions,
|
||||
version_parse,
|
||||
)
|
||||
from .helpers import template_renderer
|
||||
from .middleware import (
|
||||
CustomGZipMiddleware,
|
||||
ExtensionsRedirectMiddleware,
|
||||
@@ -61,10 +62,63 @@ from .tasks import (
|
||||
check_pending_payments,
|
||||
internal_invoice_listener,
|
||||
invoice_listener,
|
||||
webhook_handler,
|
||||
)
|
||||
|
||||
|
||||
async def startup(app: FastAPI):
|
||||
settings.lnbits_running = True
|
||||
|
||||
# wait till migration is done
|
||||
await migrate_databases()
|
||||
|
||||
# setup admin settings
|
||||
await check_admin_settings()
|
||||
await check_webpush_settings()
|
||||
|
||||
log_server_info()
|
||||
|
||||
# initialize WALLET
|
||||
try:
|
||||
set_funding_source()
|
||||
except Exception as e:
|
||||
logger.error(f"Error initializing {settings.lnbits_backend_wallet_class}: {e}")
|
||||
set_void_wallet_class()
|
||||
|
||||
# initialize funding source
|
||||
await check_funding_source()
|
||||
|
||||
# register core routes
|
||||
init_core_routers(app)
|
||||
|
||||
# check extensions after restart
|
||||
if not settings.lnbits_extensions_deactivate_all:
|
||||
await check_installed_extensions(app)
|
||||
register_all_ext_routes(app)
|
||||
|
||||
# initialize tasks
|
||||
register_async_tasks()
|
||||
|
||||
|
||||
async def shutdown():
|
||||
logger.warning("LNbits shutting down...")
|
||||
settings.lnbits_running = False
|
||||
|
||||
# shutdown event
|
||||
cancel_all_tasks()
|
||||
|
||||
# wait a bit to allow them to finish, so that cleanup can run without problems
|
||||
await asyncio.sleep(0.1)
|
||||
funding_source = get_funding_source()
|
||||
await funding_source.cleanup()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
await startup(app)
|
||||
yield
|
||||
await shutdown()
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
configure_logger()
|
||||
app = FastAPI(
|
||||
@@ -74,6 +128,7 @@ def create_app() -> FastAPI:
|
||||
"accounts system with plugins."
|
||||
),
|
||||
version=settings.version,
|
||||
lifespan=lifespan,
|
||||
license_info={
|
||||
"name": "MIT License",
|
||||
"url": "https://raw.githubusercontent.com/lnbits/lnbits/main/LICENSE",
|
||||
@@ -81,8 +136,8 @@ def create_app() -> FastAPI:
|
||||
)
|
||||
|
||||
# Allow registering new extensions routes without direct access to the `app` object
|
||||
setattr(core_app_extra, "register_new_ext_routes", register_new_ext_routes(app))
|
||||
setattr(core_app_extra, "register_new_ratelimiter", register_new_ratelimiter(app))
|
||||
core_app_extra.register_new_ext_routes = register_new_ext_routes(app)
|
||||
core_app_extra.register_new_ratelimiter = register_new_ratelimiter(app)
|
||||
|
||||
# register static files
|
||||
static_path = Path("lnbits", "static")
|
||||
@@ -114,65 +169,50 @@ def create_app() -> FastAPI:
|
||||
add_ip_block_middleware(app)
|
||||
add_ratelimit_middleware(app)
|
||||
|
||||
register_startup(app)
|
||||
register_async_tasks(app)
|
||||
register_exception_handlers(app)
|
||||
register_shutdown(app)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
async def check_funding_source() -> None:
|
||||
original_sigint_handler = signal.getsignal(signal.SIGINT)
|
||||
funding_source = get_funding_source()
|
||||
|
||||
def signal_handler(signal, frame):
|
||||
logger.debug(
|
||||
f"SIGINT received, terminating LNbits. signal: {signal}, frame: {frame}"
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
signal.signal(signal.SIGINT, signal_handler)
|
||||
|
||||
WALLET = get_wallet_class()
|
||||
|
||||
# fallback to void after 30 seconds of failures
|
||||
sleep_time = 5
|
||||
timeout = int(30 / sleep_time)
|
||||
|
||||
balance = 0
|
||||
max_retries = settings.funding_source_max_retries
|
||||
retry_counter = 0
|
||||
|
||||
while True:
|
||||
while settings.lnbits_running:
|
||||
try:
|
||||
error_message, balance = await WALLET.status()
|
||||
logger.info(f"Connecting to backend {funding_source.__class__.__name__}...")
|
||||
error_message, balance = await funding_source.status()
|
||||
if not error_message:
|
||||
retry_counter = 0
|
||||
logger.success(
|
||||
f"✔️ Backend {funding_source.__class__.__name__} connected "
|
||||
f"and with a balance of {balance} msat."
|
||||
)
|
||||
break
|
||||
|
||||
logger.error(
|
||||
f"The backend for {WALLET.__class__.__name__} isn't "
|
||||
f"The backend for {funding_source.__class__.__name__} isn't "
|
||||
f"working properly: '{error_message}'",
|
||||
RuntimeWarning,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error connecting to {WALLET.__class__.__name__}: {e}")
|
||||
pass
|
||||
logger.error(
|
||||
f"Error connecting to {funding_source.__class__.__name__}: {e}"
|
||||
)
|
||||
|
||||
if settings.lnbits_admin_ui and retry_counter == timeout:
|
||||
if retry_counter >= max_retries:
|
||||
set_void_wallet_class()
|
||||
WALLET = get_wallet_class()
|
||||
funding_source = get_funding_source()
|
||||
break
|
||||
else:
|
||||
logger.warning(f"Retrying connection to backend in {sleep_time} seconds...")
|
||||
retry_counter += 1
|
||||
await asyncio.sleep(sleep_time)
|
||||
|
||||
signal.signal(signal.SIGINT, original_sigint_handler)
|
||||
|
||||
logger.success(
|
||||
f"✔️ Backend {WALLET.__class__.__name__} connected "
|
||||
f"and with a balance of {balance} msat."
|
||||
)
|
||||
retry_counter += 1
|
||||
sleep_time = 0.25 * (2**retry_counter)
|
||||
logger.warning(
|
||||
f"Retrying connection to backend in {sleep_time} seconds... "
|
||||
f"({retry_counter}/{max_retries})"
|
||||
)
|
||||
await asyncio.sleep(sleep_time)
|
||||
|
||||
|
||||
def set_void_wallet_class():
|
||||
@@ -180,7 +220,7 @@ def set_void_wallet_class():
|
||||
"Fallback to VoidWallet, because the backend for "
|
||||
f"{settings.lnbits_backend_wallet_class} isn't working properly"
|
||||
)
|
||||
set_wallet_class("VoidWallet")
|
||||
set_funding_source("VoidWallet")
|
||||
|
||||
|
||||
async def check_installed_extensions(app: FastAPI):
|
||||
@@ -195,7 +235,7 @@ async def check_installed_extensions(app: FastAPI):
|
||||
|
||||
for ext in installed_extensions:
|
||||
try:
|
||||
installed = check_installed_extension_files(ext)
|
||||
installed = await check_installed_extension_files(ext)
|
||||
if not installed:
|
||||
await restore_installed_extension(app, ext)
|
||||
logger.info(
|
||||
@@ -253,14 +293,14 @@ async def build_all_installed_extensions_list(
|
||||
]
|
||||
|
||||
|
||||
def check_installed_extension_files(ext: InstallableExtension) -> bool:
|
||||
async def check_installed_extension_files(ext: InstallableExtension) -> bool:
|
||||
if ext.has_installed_version:
|
||||
return True
|
||||
|
||||
zip_files = glob.glob(os.path.join(settings.lnbits_data_folder, "zips", "*.zip"))
|
||||
|
||||
if f"./{str(ext.zip_path)}" not in zip_files:
|
||||
ext.download_archive()
|
||||
if f"./{ext.zip_path!s}" not in zip_files:
|
||||
await ext.download_archive()
|
||||
ext.extract_archive()
|
||||
|
||||
return False
|
||||
@@ -273,24 +313,13 @@ async def restore_installed_extension(app: FastAPI, ext: InstallableExtension):
|
||||
extension = Extension.from_installable_ext(ext)
|
||||
register_ext_routes(app, extension)
|
||||
|
||||
current_version = (await db_versions()).get(ext.id, 0)
|
||||
current_version = (await get_dbversions()).get(ext.id, 0)
|
||||
await migrate_extension_database(extension, current_version)
|
||||
|
||||
# mount routes for the new version
|
||||
core_app_extra.register_new_ext_routes(extension)
|
||||
if extension.upgrade_hash:
|
||||
ext.nofiy_upgrade()
|
||||
|
||||
|
||||
def register_routes(app: FastAPI) -> None:
|
||||
"""Register FastAPI routes / LNbits extensions."""
|
||||
init_core_routers(app)
|
||||
|
||||
for ext in get_valid_extensions(False):
|
||||
try:
|
||||
register_ext_routes(app, ext)
|
||||
except Exception as e:
|
||||
logger.error(f"Could not load extension `{ext.code}`: {str(e)}")
|
||||
ext.notify_upgrade()
|
||||
|
||||
|
||||
def register_custom_extensions_path():
|
||||
@@ -368,253 +397,30 @@ def register_ext_routes(app: FastAPI, ext: Extension) -> None:
|
||||
app.include_router(router=ext_route, prefix=prefix)
|
||||
|
||||
|
||||
def register_startup(app: FastAPI):
|
||||
@app.on_event("startup")
|
||||
async def lnbits_startup():
|
||||
def register_all_ext_routes(app: FastAPI):
|
||||
for ext in get_valid_extensions(False):
|
||||
try:
|
||||
# wait till migration is done
|
||||
await migrate_databases()
|
||||
|
||||
# setup admin settings
|
||||
await check_admin_settings()
|
||||
await check_webpush_settings()
|
||||
|
||||
log_server_info()
|
||||
|
||||
# initialize WALLET
|
||||
try:
|
||||
set_wallet_class()
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error initializing {settings.lnbits_backend_wallet_class}: {e}"
|
||||
)
|
||||
set_void_wallet_class()
|
||||
|
||||
# initialize funding source
|
||||
await check_funding_source()
|
||||
|
||||
# check extensions after restart
|
||||
await check_installed_extensions(app)
|
||||
|
||||
# register core and extension routes
|
||||
register_routes(app)
|
||||
|
||||
if settings.lnbits_admin_ui:
|
||||
initialize_server_logger()
|
||||
|
||||
register_ext_routes(app, ext)
|
||||
except Exception as e:
|
||||
logger.error(str(e))
|
||||
raise ImportError("Failed to run 'startup' event.")
|
||||
logger.error(f"Could not load extension `{ext.code}`: {e!s}")
|
||||
|
||||
|
||||
def register_shutdown(app: FastAPI):
|
||||
@app.on_event("shutdown")
|
||||
async def on_shutdown():
|
||||
cancel_all_tasks()
|
||||
# wait a bit to allow them to finish, so that cleanup can run without problems
|
||||
await asyncio.sleep(0.1)
|
||||
WALLET = get_wallet_class()
|
||||
await WALLET.cleanup()
|
||||
def register_async_tasks():
|
||||
create_permanent_task(check_pending_payments)
|
||||
create_permanent_task(invoice_listener)
|
||||
create_permanent_task(internal_invoice_listener)
|
||||
create_permanent_task(cache.invalidate_forever)
|
||||
|
||||
# core invoice listener
|
||||
invoice_queue = asyncio.Queue(5)
|
||||
register_invoice_listener(invoice_queue, "core")
|
||||
create_permanent_task(lambda: wait_for_paid_invoices(invoice_queue))
|
||||
|
||||
def initialize_server_logger():
|
||||
super_user_hash = sha256(settings.super_user.encode("utf-8")).hexdigest()
|
||||
# TODO: implement watchdog properly
|
||||
# create_permanent_task(watchdog_task)
|
||||
create_permanent_task(killswitch_task)
|
||||
|
||||
serverlog_queue = asyncio.Queue()
|
||||
|
||||
async def update_websocket_serverlog():
|
||||
while True:
|
||||
msg = await serverlog_queue.get()
|
||||
await websocketUpdater(super_user_hash, msg)
|
||||
|
||||
create_permanent_task(update_websocket_serverlog)
|
||||
|
||||
logger.add(
|
||||
lambda msg: serverlog_queue.put_nowait(msg),
|
||||
format=Formatter().format,
|
||||
)
|
||||
|
||||
|
||||
def log_server_info():
|
||||
logger.info("Starting LNbits")
|
||||
logger.info(f"Version: {settings.version}")
|
||||
logger.info(f"Baseurl: {settings.lnbits_baseurl}")
|
||||
logger.info(f"Host: {settings.host}")
|
||||
logger.info(f"Port: {settings.port}")
|
||||
logger.info(f"Debug: {settings.debug}")
|
||||
logger.info(f"Site title: {settings.lnbits_site_title}")
|
||||
logger.info(f"Funding source: {settings.lnbits_backend_wallet_class}")
|
||||
logger.info(f"Data folder: {settings.lnbits_data_folder}")
|
||||
logger.info(f"Database: {get_db_vendor_name()}")
|
||||
logger.info(f"Service fee: {settings.lnbits_service_fee}")
|
||||
logger.info(f"Service fee max: {settings.lnbits_service_fee_max}")
|
||||
logger.info(f"Service fee wallet: {settings.lnbits_service_fee_wallet}")
|
||||
|
||||
|
||||
def get_db_vendor_name():
|
||||
db_url = settings.lnbits_database_url
|
||||
return (
|
||||
"PostgreSQL"
|
||||
if db_url and db_url.startswith("postgres://")
|
||||
else (
|
||||
"CockroachDB"
|
||||
if db_url and db_url.startswith("cockroachdb://")
|
||||
else "SQLite"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def register_async_tasks(app):
|
||||
@app.route("/wallet/webhook")
|
||||
async def webhook_listener():
|
||||
return await webhook_handler()
|
||||
|
||||
@app.on_event("startup")
|
||||
async def listeners():
|
||||
create_permanent_task(check_pending_payments)
|
||||
create_permanent_task(invoice_listener)
|
||||
create_permanent_task(internal_invoice_listener)
|
||||
create_permanent_task(cache.invalidate_forever)
|
||||
register_task_listeners()
|
||||
register_killswitch()
|
||||
# await run_deferred_async() # calle: doesn't do anyting?
|
||||
|
||||
|
||||
def register_exception_handlers(app: FastAPI):
|
||||
@app.exception_handler(Exception)
|
||||
async def exception_handler(request: Request, exc: Exception):
|
||||
etype, _, tb = sys.exc_info()
|
||||
traceback.print_exception(etype, exc, tb)
|
||||
logger.error(f"Exception: {str(exc)}")
|
||||
# Only the browser sends "text/html" request
|
||||
# not fail proof, but everything else get's a JSON response
|
||||
if (
|
||||
request.headers
|
||||
and "accept" in request.headers
|
||||
and "text/html" in request.headers["accept"]
|
||||
):
|
||||
return template_renderer().TemplateResponse(
|
||||
"error.html", {"request": request, "err": f"Error: {str(exc)}"}
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
content={"detail": str(exc)},
|
||||
)
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def validation_exception_handler(
|
||||
request: Request, exc: RequestValidationError
|
||||
):
|
||||
logger.error(f"RequestValidationError: {str(exc)}")
|
||||
# Only the browser sends "text/html" request
|
||||
# not fail proof, but everything else get's a JSON response
|
||||
|
||||
if (
|
||||
request.headers
|
||||
and "accept" in request.headers
|
||||
and "text/html" in request.headers["accept"]
|
||||
):
|
||||
return template_renderer().TemplateResponse(
|
||||
"error.html",
|
||||
{"request": request, "err": f"Error: {str(exc)}"},
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
content={"detail": str(exc)},
|
||||
)
|
||||
|
||||
@app.exception_handler(HTTPException)
|
||||
async def http_exception_handler(request: Request, exc: HTTPException):
|
||||
logger.error(f"HTTPException {exc.status_code}: {exc.detail}")
|
||||
# Only the browser sends "text/html" request
|
||||
# not fail proof, but everything else get's a JSON response
|
||||
|
||||
if (
|
||||
request.headers
|
||||
and "accept" in request.headers
|
||||
and "text/html" in request.headers["accept"]
|
||||
):
|
||||
if exc.headers and "token-expired" in exc.headers:
|
||||
response = RedirectResponse("/")
|
||||
response.delete_cookie("cookie_access_token")
|
||||
response.delete_cookie("is_lnbits_user_authorized")
|
||||
response.set_cookie(
|
||||
"is_access_token_expired", "true", samesite="none", secure=True
|
||||
)
|
||||
return response
|
||||
|
||||
return template_renderer().TemplateResponse(
|
||||
"error.html",
|
||||
{
|
||||
"request": request,
|
||||
"err": f"HTTP Error {exc.status_code}: {exc.detail}",
|
||||
},
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content={"detail": exc.detail},
|
||||
)
|
||||
|
||||
|
||||
def configure_logger() -> None:
|
||||
logger.remove()
|
||||
log_level: str = "DEBUG" if settings.debug else "INFO"
|
||||
formatter = Formatter()
|
||||
logger.add(sys.stdout, level=log_level, format=formatter.format)
|
||||
|
||||
if settings.enable_log_to_file:
|
||||
logger.add(
|
||||
Path(settings.lnbits_data_folder, "logs", "lnbits.log"),
|
||||
rotation=settings.log_rotation,
|
||||
retention=settings.log_retention,
|
||||
level="INFO",
|
||||
format=formatter.format,
|
||||
)
|
||||
logger.add(
|
||||
Path(settings.lnbits_data_folder, "logs", "debug.log"),
|
||||
rotation=settings.log_rotation,
|
||||
retention=settings.log_retention,
|
||||
level="DEBUG",
|
||||
format=formatter.format,
|
||||
)
|
||||
|
||||
logging.getLogger("uvicorn").handlers = [InterceptHandler()]
|
||||
logging.getLogger("uvicorn.access").handlers = [InterceptHandler()]
|
||||
logging.getLogger("uvicorn.error").handlers = [InterceptHandler()]
|
||||
logging.getLogger("uvicorn.error").propagate = False
|
||||
|
||||
|
||||
class Formatter:
|
||||
def __init__(self):
|
||||
self.padding = 0
|
||||
self.minimal_fmt = (
|
||||
"<green>{time:YYYY-MM-DD HH:mm:ss.SS}</green> | <level>{level}</level> | "
|
||||
"<level>{message}</level>\n"
|
||||
)
|
||||
if settings.debug:
|
||||
self.fmt = (
|
||||
"<green>{time:YYYY-MM-DD HH:mm:ss.SS}</green> | "
|
||||
"<level>{level: <4}</level> | "
|
||||
"<cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> | "
|
||||
"<level>{message}</level>\n"
|
||||
)
|
||||
else:
|
||||
self.fmt = self.minimal_fmt
|
||||
|
||||
def format(self, record):
|
||||
function = "{function}".format(**record)
|
||||
if function == "emit": # uvicorn logs
|
||||
return self.minimal_fmt
|
||||
return self.fmt
|
||||
|
||||
|
||||
class InterceptHandler(logging.Handler):
|
||||
def emit(self, record):
|
||||
try:
|
||||
level = logger.level(record.levelname).name
|
||||
except ValueError:
|
||||
level = record.levelno
|
||||
logger.log(level, record.getMessage())
|
||||
# server logs for websocket
|
||||
if settings.lnbits_admin_ui:
|
||||
server_log_task = initialize_server_websocket_logger()
|
||||
create_permanent_task(server_log_task)
|
||||
|
||||
+144
-16
@@ -1,7 +1,9 @@
|
||||
import asyncio
|
||||
import importlib
|
||||
import time
|
||||
from functools import wraps
|
||||
from pathlib import Path
|
||||
from typing import Optional, Tuple
|
||||
from typing import List, Optional, Tuple
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import click
|
||||
@@ -10,21 +12,29 @@ from fastapi.exceptions import HTTPException
|
||||
from loguru import logger
|
||||
from packaging import version
|
||||
|
||||
from lnbits.core.models import User
|
||||
from lnbits.core.models import Payment, User
|
||||
from lnbits.core.services import check_admin_settings
|
||||
from lnbits.core.views.api import api_install_extension, api_uninstall_extension
|
||||
from lnbits.core.views.extension_api import (
|
||||
api_install_extension,
|
||||
api_uninstall_extension,
|
||||
)
|
||||
from lnbits.settings import settings
|
||||
from lnbits.wallets.base import Wallet
|
||||
|
||||
from .core import db as core_db
|
||||
from .core import migrations as core_migrations
|
||||
from .core.crud import (
|
||||
delete_accounts_no_wallets,
|
||||
delete_unused_wallets,
|
||||
delete_wallet_by_id,
|
||||
delete_wallet_payment,
|
||||
get_dbversions,
|
||||
get_inactive_extensions,
|
||||
get_installed_extension,
|
||||
get_installed_extensions,
|
||||
get_payments,
|
||||
remove_deleted_wallets,
|
||||
update_payment_status,
|
||||
)
|
||||
from .core.helpers import migrate_extension_database, run_migration
|
||||
from .db import COCKROACH, POSTGRES, SQLITE
|
||||
@@ -73,7 +83,7 @@ def get_super_user() -> Optional[str]:
|
||||
"Superuser id not found. Please check that the file "
|
||||
+ f"'{superuser_file.absolute()}' exists and has read permissions."
|
||||
)
|
||||
with open(superuser_file, "r") as file:
|
||||
with open(superuser_file) as file:
|
||||
return file.readline()
|
||||
|
||||
|
||||
@@ -114,7 +124,8 @@ def database_migrate():
|
||||
|
||||
|
||||
async def db_migrate():
|
||||
asyncio.create_task(migrate_databases())
|
||||
task = asyncio.create_task(migrate_databases())
|
||||
await task
|
||||
|
||||
|
||||
async def migrate_databases():
|
||||
@@ -180,6 +191,37 @@ async def database_cleanup_deleted_wallets():
|
||||
await remove_deleted_wallets(conn)
|
||||
|
||||
|
||||
@db.command("delete-wallet")
|
||||
@click.option("-w", "--wallet", required=True, help="ID of wallet to be deleted.")
|
||||
@coro
|
||||
async def database_delete_wallet(wallet: str):
|
||||
"""Mark wallet as deleted"""
|
||||
async with core_db.connect() as conn:
|
||||
count = await delete_wallet_by_id(wallet_id=wallet, conn=conn)
|
||||
click.echo(f"Marked as deleted '{count}' rows.")
|
||||
|
||||
|
||||
@db.command("delete-wallet-payment")
|
||||
@click.option("-w", "--wallet", required=True, help="ID of wallet to be deleted.")
|
||||
@click.option("-c", "--checking-id", required=True, help="Payment checking Id.")
|
||||
@coro
|
||||
async def database_delete_wallet_payment(wallet: str, checking_id: str):
|
||||
"""Delete wallet payment"""
|
||||
async with core_db.connect() as conn:
|
||||
await delete_wallet_payment(
|
||||
wallet_id=wallet, checking_id=checking_id, conn=conn
|
||||
)
|
||||
|
||||
|
||||
@db.command("mark-payment-pending")
|
||||
@click.option("-c", "--checking-id", required=True, help="Payment checking Id.")
|
||||
@coro
|
||||
async def database_revert_payment(checking_id: str, pending: bool = True):
|
||||
"""Mark wallet as deleted"""
|
||||
async with core_db.connect() as conn:
|
||||
await update_payment_status(pending=pending, checking_id=checking_id, conn=conn)
|
||||
|
||||
|
||||
@db.command("cleanup-accounts")
|
||||
@click.argument("days", type=int, required=False)
|
||||
@coro
|
||||
@@ -191,6 +233,86 @@ async def database_cleanup_accounts(days: Optional[int] = None):
|
||||
await delete_accounts_no_wallets(delta, conn)
|
||||
|
||||
|
||||
@db.command("check-payments")
|
||||
@click.option("-d", "--days", help="Maximum age of payments in days.")
|
||||
@click.option("-l", "--limit", help="Maximum number of payments to be checked.")
|
||||
@click.option("-w", "--wallet", help="Only check for this wallet.")
|
||||
@click.option("-v", "--verbose", is_flag=True, help="Detailed log.")
|
||||
@coro
|
||||
async def check_invalid_payments(
|
||||
days: Optional[int] = None,
|
||||
limit: Optional[int] = None,
|
||||
wallet: Optional[str] = None,
|
||||
verbose: Optional[bool] = False,
|
||||
):
|
||||
"""Check payments that are settled in the DB but pending on the Funding Source"""
|
||||
await check_admin_settings()
|
||||
settled_db_payments = []
|
||||
|
||||
if verbose:
|
||||
click.echo(f"Get Payments: days={days}, limit={limit}, wallet={wallet}")
|
||||
async with core_db.connect() as conn:
|
||||
delta = int(days) if days else 3 # default to 3 days
|
||||
limit = int(limit) if limit else 1000
|
||||
since = int(time.time()) - delta * 24 * 60 * 60
|
||||
|
||||
settled_db_payments = await get_payments(
|
||||
complete=True,
|
||||
incoming=True,
|
||||
exclude_uncheckable=True,
|
||||
since=since,
|
||||
limit=limit,
|
||||
wallet_id=wallet,
|
||||
conn=conn,
|
||||
)
|
||||
|
||||
click.echo("Settled Payments: " + str(len(settled_db_payments)))
|
||||
|
||||
wallets_module = importlib.import_module("lnbits.wallets")
|
||||
wallet_class = getattr(wallets_module, settings.lnbits_backend_wallet_class)
|
||||
|
||||
funding_source: Wallet = wallet_class()
|
||||
|
||||
click.echo("Funding source: " + str(funding_source))
|
||||
|
||||
# payments that are settled in the DB, but not at the Funding source level
|
||||
invalid_payments: List[Payment] = []
|
||||
invalid_wallets = {}
|
||||
for db_payment in settled_db_payments:
|
||||
if verbose:
|
||||
click.echo(
|
||||
f"Checking Payment: '{db_payment.checking_id}' for wallet"
|
||||
+ f" '{db_payment.wallet_id}'."
|
||||
)
|
||||
payment_status = await funding_source.get_invoice_status(db_payment.checking_id)
|
||||
|
||||
if payment_status.pending:
|
||||
invalid_payments.append(db_payment)
|
||||
if db_payment.wallet_id not in invalid_wallets:
|
||||
invalid_wallets[f"{db_payment.wallet_id}"] = [0, 0]
|
||||
invalid_wallets[f"{db_payment.wallet_id}"][0] += 1
|
||||
invalid_wallets[f"{db_payment.wallet_id}"][1] += db_payment.amount
|
||||
|
||||
click.echo(
|
||||
"Invalid Payment: '"
|
||||
+ " ".join(
|
||||
[
|
||||
db_payment.checking_id,
|
||||
db_payment.wallet_id,
|
||||
str(db_payment.amount / 1000).ljust(10),
|
||||
db_payment.memo or "",
|
||||
]
|
||||
)
|
||||
+ "'"
|
||||
)
|
||||
|
||||
click.echo("Invalid Payments: " + str(len(invalid_payments)))
|
||||
click.echo("\nInvalid Wallets: " + str(len(invalid_wallets)))
|
||||
for w in invalid_wallets:
|
||||
data = invalid_wallets[f"{w}"]
|
||||
click.echo(" ".join([w, str(data[0]), str(data[1] / 1000).ljust(10)]))
|
||||
|
||||
|
||||
async def load_disabled_extension_list() -> None:
|
||||
"""Update list of extensions that have been explicitly disabled"""
|
||||
inactive_extensions = await get_inactive_extensions()
|
||||
@@ -212,7 +334,7 @@ async def extensions_list():
|
||||
|
||||
@extensions.command("update")
|
||||
@click.argument("extension", required=False)
|
||||
@click.option("-a", "--all", is_flag=True, help="Update all extensions.")
|
||||
@click.option("-a", "--all-extensions", is_flag=True, help="Update all extensions.")
|
||||
@click.option(
|
||||
"-i", "--repo-index", help="Select the index of the repository to be used."
|
||||
)
|
||||
@@ -239,7 +361,7 @@ async def extensions_list():
|
||||
@coro
|
||||
async def extensions_update(
|
||||
extension: Optional[str] = None,
|
||||
all: Optional[bool] = False,
|
||||
all_extensions: Optional[bool] = False,
|
||||
repo_index: Optional[str] = None,
|
||||
source_repo: Optional[str] = None,
|
||||
url: Optional[str] = None,
|
||||
@@ -249,11 +371,11 @@ async def extensions_update(
|
||||
Update extension to the latest version.
|
||||
If an extension is not present it will be instaled.
|
||||
"""
|
||||
if not extension and not all:
|
||||
if not extension and not all_extensions:
|
||||
click.echo("Extension ID is required.")
|
||||
click.echo("Or specify the '--all' flag to update all extensions")
|
||||
click.echo("Or specify the '--all-extensions' flag to update all extensions")
|
||||
return
|
||||
if extension and all:
|
||||
if extension and all_extensions:
|
||||
click.echo("Only one of extension ID or the '--all' flag must be specified")
|
||||
return
|
||||
if url and not _is_url(url):
|
||||
@@ -371,7 +493,7 @@ async def extensions_uninstall(
|
||||
click.echo(f"Failed to uninstall '{extension}' Error: '{ex.detail}'.")
|
||||
return False, ex.detail
|
||||
except Exception as ex:
|
||||
click.echo(f"Failed to uninstall '{extension}': {str(ex)}.")
|
||||
click.echo(f"Failed to uninstall '{extension}': {ex!s}.")
|
||||
return False, str(ex)
|
||||
|
||||
|
||||
@@ -397,7 +519,10 @@ async def install_extension(
|
||||
return False, "No release selected"
|
||||
|
||||
data = CreateExtension(
|
||||
ext_id=extension, archive=release.archive, source_repo=release.source_repo
|
||||
ext_id=extension,
|
||||
archive=release.archive,
|
||||
source_repo=release.source_repo,
|
||||
version=release.version,
|
||||
)
|
||||
await _call_install_extension(data, url, admin_user)
|
||||
click.echo(f"Extension '{extension}' ({release.version}) installed.")
|
||||
@@ -406,7 +531,7 @@ async def install_extension(
|
||||
click.echo(f"Failed to install '{extension}' Error: '{ex.detail}'.")
|
||||
return False, ex.detail
|
||||
except Exception as ex:
|
||||
click.echo(f"Failed to install '{extension}': {str(ex)}.")
|
||||
click.echo(f"Failed to install '{extension}': {ex!s}.")
|
||||
return False, str(ex)
|
||||
|
||||
|
||||
@@ -445,7 +570,10 @@ async def update_extension(
|
||||
click.echo(f"Updating '{extension}' extension to version: {release.version }")
|
||||
|
||||
data = CreateExtension(
|
||||
ext_id=extension, archive=release.archive, source_repo=release.source_repo
|
||||
ext_id=extension,
|
||||
archive=release.archive,
|
||||
source_repo=release.source_repo,
|
||||
version=release.version,
|
||||
)
|
||||
|
||||
await _call_install_extension(data, url, admin_user)
|
||||
@@ -455,7 +583,7 @@ async def update_extension(
|
||||
click.echo(f"Failed to update '{extension}' Error: '{ex.detail}.")
|
||||
return False, ex.detail
|
||||
except Exception as ex:
|
||||
click.echo(f"Failed to update '{extension}': {str(ex)}.")
|
||||
click.echo(f"Failed to update '{extension}': {ex!s}.")
|
||||
return False, str(ex)
|
||||
|
||||
|
||||
@@ -478,7 +606,7 @@ async def _select_release(
|
||||
return latest_repo_releases[source_repo]
|
||||
|
||||
if len(latest_repo_releases) == 1:
|
||||
return latest_repo_releases[list(latest_repo_releases.keys())[0]]
|
||||
return latest_repo_releases[next(iter(latest_repo_releases.keys()))]
|
||||
|
||||
repos = list(latest_repo_releases.keys())
|
||||
repos.sort()
|
||||
|
||||
+16
-6
@@ -1,30 +1,40 @@
|
||||
from fastapi import APIRouter
|
||||
from fastapi import APIRouter, FastAPI
|
||||
|
||||
from .db import core_app_extra, db
|
||||
from .views.admin_api import admin_router
|
||||
from .views.api import api_router
|
||||
from .views.auth_api import auth_router
|
||||
from .views.extension_api import extension_router
|
||||
|
||||
# this compat is needed for usermanager extension
|
||||
from .views.generic import generic_router, update_user_extension
|
||||
from .views.node_api import node_router, public_node_router, super_node_router
|
||||
from .views.payment_api import payment_router
|
||||
from .views.public_api import public_router
|
||||
from .views.tinyurl_api import tinyurl_router
|
||||
from .views.user_api import users_router
|
||||
from .views.wallet_api import wallet_router
|
||||
from .views.webpush_api import webpush_router
|
||||
from .views.websocket_api import websocket_router
|
||||
|
||||
# backwards compatibility for extensions
|
||||
core_app = APIRouter(tags=["Core"])
|
||||
|
||||
|
||||
def init_core_routers(app):
|
||||
def init_core_routers(app: FastAPI):
|
||||
app.include_router(core_app)
|
||||
app.include_router(generic_router)
|
||||
app.include_router(public_router)
|
||||
app.include_router(api_router)
|
||||
app.include_router(auth_router)
|
||||
app.include_router(admin_router)
|
||||
app.include_router(node_router)
|
||||
app.include_router(extension_router)
|
||||
app.include_router(super_node_router)
|
||||
app.include_router(public_node_router)
|
||||
app.include_router(admin_router)
|
||||
app.include_router(public_router)
|
||||
app.include_router(payment_router)
|
||||
app.include_router(wallet_router)
|
||||
app.include_router(api_router)
|
||||
app.include_router(websocket_router)
|
||||
app.include_router(tinyurl_router)
|
||||
app.include_router(webpush_router)
|
||||
app.include_router(auth_router)
|
||||
app.include_router(users_router)
|
||||
|
||||
+114
-82
@@ -2,14 +2,12 @@ import datetime
|
||||
import json
|
||||
from time import time
|
||||
from typing import Any, Dict, List, Literal, Optional
|
||||
from urllib.parse import urlparse
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import shortuuid
|
||||
from passlib.context import CryptContext
|
||||
|
||||
from lnbits.core.db import db
|
||||
from lnbits.core.models import WalletType
|
||||
from lnbits.db import DB_TYPE, SQLITE, Connection, Database, Filters, Page
|
||||
from lnbits.extension_manager import InstallableExtension
|
||||
from lnbits.settings import (
|
||||
@@ -21,7 +19,8 @@ from lnbits.settings import (
|
||||
)
|
||||
|
||||
from .models import (
|
||||
BalanceCheck,
|
||||
Account,
|
||||
AccountFilters,
|
||||
CreateUser,
|
||||
Payment,
|
||||
PaymentFilters,
|
||||
@@ -147,6 +146,46 @@ async def update_account(
|
||||
return user
|
||||
|
||||
|
||||
async def delete_account(user_id: str, conn: Optional[Connection] = None) -> None:
|
||||
await (conn or db).execute(
|
||||
"DELETE from accounts WHERE id = ?",
|
||||
(user_id,),
|
||||
)
|
||||
|
||||
|
||||
async def get_accounts(
|
||||
filters: Optional[Filters[AccountFilters]] = None,
|
||||
conn: Optional[Connection] = None,
|
||||
) -> Page[Account]:
|
||||
return await (conn or db).fetch_page(
|
||||
"""
|
||||
SELECT
|
||||
accounts.id,
|
||||
accounts.username,
|
||||
accounts.email,
|
||||
SUM(COALESCE((
|
||||
SELECT balance FROM balances WHERE wallet = wallets.id
|
||||
), 0)) as balance_msat,
|
||||
SUM((
|
||||
SELECT COUNT(*) FROM apipayments WHERE wallet = wallets.id
|
||||
)) as transaction_count,
|
||||
(
|
||||
SELECT COUNT(*) FROM wallets WHERE wallets.user = accounts.id
|
||||
) as wallet_count,
|
||||
MAX((
|
||||
SELECT time FROM apipayments
|
||||
WHERE wallet = wallets.id ORDER BY time DESC LIMIT 1
|
||||
)) as last_payment
|
||||
FROM accounts LEFT JOIN wallets ON accounts.id = wallets.user
|
||||
""",
|
||||
[],
|
||||
[],
|
||||
filters=filters,
|
||||
model=Account,
|
||||
group_by=["accounts.id"],
|
||||
)
|
||||
|
||||
|
||||
async def get_account(
|
||||
user_id: str, conn: Optional[Connection] = None
|
||||
) -> Optional[User]:
|
||||
@@ -168,14 +207,21 @@ 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 < {db.timestamp_placeholder}
|
||||
) AND (
|
||||
(updated_at is null AND created_at < {db.timestamp_placeholder})
|
||||
OR updated_at < {db.timestamp_placeholder}
|
||||
)
|
||||
""",
|
||||
(int(time()) - time_delta,),
|
||||
(
|
||||
delta,
|
||||
delta,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -322,6 +368,7 @@ async def add_installed_extension(
|
||||
dict(ext.installed_release) if ext.installed_release else None
|
||||
),
|
||||
"dependencies": ext.dependencies,
|
||||
"payments": [dict(p) for p in ext.payments] if ext.payments else None,
|
||||
}
|
||||
|
||||
version = ext.installed_release.version if ext.installed_release else ""
|
||||
@@ -499,19 +546,47 @@ async def update_wallet(
|
||||
|
||||
|
||||
async def delete_wallet(
|
||||
*, user_id: str, wallet_id: str, conn: Optional[Connection] = None
|
||||
*,
|
||||
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 = true, updated_at = {db.timestamp_placeholder}
|
||||
SET deleted = ?, updated_at = {db.timestamp_placeholder}
|
||||
WHERE id = ? AND "user" = ?
|
||||
""",
|
||||
(now, wallet_id, user_id),
|
||||
(deleted, now, wallet_id, user_id),
|
||||
)
|
||||
|
||||
|
||||
async def force_delete_wallet(
|
||||
wallet_id: str, conn: Optional[Connection] = None
|
||||
) -> None:
|
||||
await (conn or db).execute(
|
||||
"DELETE FROM wallets WHERE id = ?",
|
||||
(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}
|
||||
WHERE id = ?
|
||||
""",
|
||||
(now, wallet_id),
|
||||
)
|
||||
return result.rowcount
|
||||
|
||||
|
||||
async def remove_deleted_wallets(conn: Optional[Connection] = None) -> None:
|
||||
await (conn or db).execute("DELETE FROM wallets WHERE deleted = true")
|
||||
|
||||
@@ -520,14 +595,21 @@ async def delete_unused_wallets(
|
||||
time_delta: int,
|
||||
conn: Optional[Connection] = None,
|
||||
) -> None:
|
||||
delta = int(time()) - time_delta
|
||||
await (conn or db).execute(
|
||||
f"""
|
||||
DELETE FROM wallets
|
||||
WHERE (
|
||||
SELECT COUNT(*) FROM apipayments WHERE wallet = wallets.id
|
||||
) = 0 AND updated_at < {db.timestamp_placeholder}
|
||||
) = 0 AND (
|
||||
(updated_at is null AND created_at < {db.timestamp_placeholder})
|
||||
OR updated_at < {db.timestamp_placeholder}
|
||||
)
|
||||
""",
|
||||
(int(time()) - time_delta,),
|
||||
(
|
||||
delta,
|
||||
delta,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -545,15 +627,27 @@ async def get_wallet(
|
||||
return Wallet(**row) if row else None
|
||||
|
||||
|
||||
async def get_wallets(user_id: str, conn: Optional[Connection] = None) -> List[Wallet]:
|
||||
rows = await (conn or db).fetchall(
|
||||
"""
|
||||
SELECT *, COALESCE((SELECT balance FROM balances WHERE wallet = wallets.id), 0)
|
||||
AS balance_msat FROM wallets WHERE "user" = ?
|
||||
""",
|
||||
(user_id,),
|
||||
)
|
||||
|
||||
return [Wallet(**row) for row in rows]
|
||||
|
||||
|
||||
async def get_wallet_for_key(
|
||||
key: str,
|
||||
key_type: WalletType = WalletType.invoice,
|
||||
conn: Optional[Connection] = None,
|
||||
) -> Optional[Wallet]:
|
||||
row = await (conn or db).fetchone(
|
||||
"""
|
||||
SELECT *, COALESCE((SELECT balance FROM balances WHERE wallet = wallets.id), 0)
|
||||
AS balance_msat FROM wallets WHERE adminkey = ? OR inkey = ?
|
||||
AS balance_msat FROM wallets
|
||||
WHERE (adminkey = ? OR inkey = ?) AND deleted = false
|
||||
""",
|
||||
(key, key),
|
||||
)
|
||||
@@ -561,9 +655,6 @@ async def get_wallet_for_key(
|
||||
if not row:
|
||||
return None
|
||||
|
||||
if key_type == WalletType.admin and row["adminkey"] != key:
|
||||
return None
|
||||
|
||||
return Wallet(**row)
|
||||
|
||||
|
||||
@@ -572,11 +663,6 @@ async def get_total_balance(conn: Optional[Connection] = None):
|
||||
return 0 if row[0] is None else row[0]
|
||||
|
||||
|
||||
async def get_active_wallet_total_balance(conn: Optional[Connection] = None):
|
||||
row = await (conn or db).fetchone("SELECT SUM(balance) FROM balances")
|
||||
return 0 if row[0] is None else row[0]
|
||||
|
||||
|
||||
# wallet payments
|
||||
# ---------------
|
||||
|
||||
@@ -601,6 +687,7 @@ async def get_standalone_payment(
|
||||
SELECT *
|
||||
FROM apipayments
|
||||
WHERE {clause}
|
||||
ORDER BY amount
|
||||
LIMIT 1
|
||||
""",
|
||||
tuple(values),
|
||||
@@ -1012,73 +1099,16 @@ async def check_internal_pending(
|
||||
return row["pending"]
|
||||
|
||||
|
||||
# balance_check
|
||||
# -------------
|
||||
|
||||
|
||||
async def save_balance_check(
|
||||
wallet_id: str, url: str, conn: Optional[Connection] = None
|
||||
):
|
||||
domain = urlparse(url).netloc
|
||||
|
||||
await (conn or db).execute(
|
||||
async def mark_webhook_sent(payment_hash: str, status: int) -> None:
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO balance_check (wallet, service, url) VALUES (?, ?, ?)
|
||||
ON CONFLICT (wallet, service) DO UPDATE SET url = ?
|
||||
UPDATE apipayments SET webhook_status = ?
|
||||
WHERE hash = ?
|
||||
""",
|
||||
(wallet_id, domain, url, url),
|
||||
(status, payment_hash),
|
||||
)
|
||||
|
||||
|
||||
async def get_balance_check(
|
||||
wallet_id: str, domain: str, conn: Optional[Connection] = None
|
||||
) -> Optional[BalanceCheck]:
|
||||
row = await (conn or db).fetchone(
|
||||
"""
|
||||
SELECT wallet, service, url
|
||||
FROM balance_check
|
||||
WHERE wallet = ? AND service = ?
|
||||
""",
|
||||
(wallet_id, domain),
|
||||
)
|
||||
return BalanceCheck.from_row(row) if row else None
|
||||
|
||||
|
||||
async def get_balance_checks(conn: Optional[Connection] = None) -> List[BalanceCheck]:
|
||||
rows = await (conn or db).fetchall("SELECT wallet, service, url FROM balance_check")
|
||||
return [BalanceCheck.from_row(row) for row in rows]
|
||||
|
||||
|
||||
# balance_notify
|
||||
# --------------
|
||||
|
||||
|
||||
async def save_balance_notify(
|
||||
wallet_id: str, url: str, conn: Optional[Connection] = None
|
||||
):
|
||||
await (conn or db).execute(
|
||||
"""
|
||||
INSERT INTO balance_notify (wallet, url) VALUES (?, ?)
|
||||
ON CONFLICT (wallet) DO UPDATE SET url = ?
|
||||
""",
|
||||
(wallet_id, url, url),
|
||||
)
|
||||
|
||||
|
||||
async def get_balance_notify(
|
||||
wallet_id: str, conn: Optional[Connection] = None
|
||||
) -> Optional[str]:
|
||||
row = await (conn or db).fetchone(
|
||||
"""
|
||||
SELECT url
|
||||
FROM balance_notify
|
||||
WHERE wallet = ?
|
||||
""",
|
||||
(wallet_id,),
|
||||
)
|
||||
return row[0] if row else None
|
||||
|
||||
|
||||
# admin
|
||||
# --------
|
||||
|
||||
@@ -1097,6 +1127,8 @@ async def get_admin_settings(is_super_user: bool = False) -> Optional[AdminSetti
|
||||
return None
|
||||
row_dict = dict(sets)
|
||||
row_dict.pop("super_user")
|
||||
row_dict.pop("auth_all_methods")
|
||||
|
||||
admin_settings = AdminSettings(
|
||||
is_super_user=is_super_user,
|
||||
lnbits_allowed_funding_sources=settings.lnbits_allowed_funding_sources,
|
||||
|
||||
+47
-6
@@ -18,11 +18,11 @@ async def migrate_extension_database(ext: Extension, current_version):
|
||||
try:
|
||||
ext_migrations = importlib.import_module(f"{ext.module_name}.migrations")
|
||||
ext_db = importlib.import_module(ext.module_name).db
|
||||
except ImportError as e:
|
||||
logger.error(e)
|
||||
except ImportError as exc:
|
||||
logger.error(exc)
|
||||
raise ImportError(
|
||||
f"Please make sure that the extension `{ext.code}` has a migrations file."
|
||||
)
|
||||
) from exc
|
||||
|
||||
async with ext_db.connect() as ext_conn:
|
||||
await run_migration(ext_conn, ext_migrations, ext.code, current_version)
|
||||
@@ -53,8 +53,45 @@ async def stop_extension_background_work(
|
||||
):
|
||||
"""
|
||||
Stop background work for extension (like asyncio.Tasks, WebSockets, etc).
|
||||
Extensions SHOULD expose a DELETE enpoint at the root level of their API.
|
||||
Extensions SHOULD expose a `api_stop()` function and/or a DELETE enpoint
|
||||
at the root level of their API.
|
||||
"""
|
||||
stopped = await _stop_extension_background_work(ext_id)
|
||||
|
||||
if not stopped:
|
||||
# fallback to REST API call
|
||||
await _stop_extension_background_work_via_api(ext_id, user, access_token)
|
||||
|
||||
|
||||
async def _stop_extension_background_work(ext_id) -> bool:
|
||||
upgrade_hash = settings.extension_upgrade_hash(ext_id) or ""
|
||||
ext = Extension(ext_id, True, False, upgrade_hash=upgrade_hash)
|
||||
|
||||
try:
|
||||
logger.info(f"Stopping background work for extension '{ext.module_name}'.")
|
||||
old_module = importlib.import_module(ext.module_name)
|
||||
|
||||
# Extensions must expose an `{ext_id}_stop()` function at the module level
|
||||
# The `api_stop()` function is for backwards compatibility (will be deprecated)
|
||||
stop_fns = [f"{ext_id}_stop", "api_stop"]
|
||||
stop_fn_name = next((fn for fn in stop_fns if hasattr(old_module, fn)), None)
|
||||
assert stop_fn_name, "No stop function found for '{ext.module_name}'"
|
||||
|
||||
await getattr(old_module, stop_fn_name)()
|
||||
|
||||
logger.info(f"Stopped background work for extension '{ext.module_name}'.")
|
||||
except Exception as ex:
|
||||
logger.warning(f"Failed to stop background work for '{ext.module_name}'.")
|
||||
logger.warning(ex)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def _stop_extension_background_work_via_api(ext_id, user, access_token):
|
||||
logger.info(
|
||||
f"Stopping background work for extension '{ext_id}' using the REST API."
|
||||
)
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
url = f"http://{settings.host}:{settings.port}/{ext_id}/api/v1?usr={user}"
|
||||
@@ -63,7 +100,11 @@ async def stop_extension_background_work(
|
||||
)
|
||||
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)
|
||||
|
||||
|
||||
@@ -72,7 +113,7 @@ def to_valid_user_id(user_id: str) -> UUID:
|
||||
raise ValueError("User ID must have at least 128 bits")
|
||||
try:
|
||||
int(user_id, 16)
|
||||
except Exception:
|
||||
raise ValueError("Invalid hex string for User ID.")
|
||||
except Exception as exc:
|
||||
raise ValueError("Invalid hex string for User ID.") from exc
|
||||
|
||||
return UUID(hex=user_id[:32], version=4)
|
||||
|
||||
@@ -471,3 +471,44 @@ async def m017_add_timestamp_columns_to_accounts_and_wallets(db):
|
||||
except OperationalError as exc:
|
||||
logger.error(f"Migration 17 failed: {exc}")
|
||||
pass
|
||||
|
||||
|
||||
async def m018_balances_view_exclude_deleted(db):
|
||||
"""
|
||||
Make deleted wallets not show up in the balances view.
|
||||
"""
|
||||
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 apipayments
|
||||
LEFT JOIN wallets ON apipayments.wallet = wallets.id
|
||||
WHERE (wallets.deleted = false OR wallets.deleted is NULL)
|
||||
AND ((apipayments.pending = false AND apipayments.amount > 0)
|
||||
OR apipayments.amount < 0)
|
||||
GROUP BY wallet
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
async def m019_balances_view_based_on_wallets(db):
|
||||
"""
|
||||
Make deleted wallets not show up in the balances view.
|
||||
Important for querying whole lnbits balances.
|
||||
"""
|
||||
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.pending = false AND apipayments.amount > 0)
|
||||
OR apipayments.amount < 0)
|
||||
GROUP BY apipayments.wallet
|
||||
"""
|
||||
)
|
||||
|
||||
+55
-23
@@ -17,18 +17,21 @@ 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_wallet_class
|
||||
from lnbits.wallets.base import PaymentStatus
|
||||
from lnbits.wallets import get_funding_source
|
||||
from lnbits.wallets.base import PaymentPendingStatus, PaymentStatus
|
||||
|
||||
|
||||
class Wallet(BaseModel):
|
||||
class BaseWallet(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
user: str
|
||||
adminkey: str
|
||||
inkey: str
|
||||
currency: Optional[str]
|
||||
balance_msat: int
|
||||
|
||||
|
||||
class Wallet(BaseWallet):
|
||||
user: str
|
||||
currency: Optional[str]
|
||||
deleted: bool
|
||||
created_at: Optional[int] = None
|
||||
updated_at: Optional[int] = None
|
||||
@@ -65,7 +68,7 @@ class Wallet(BaseModel):
|
||||
return await get_standalone_payment(payment_hash)
|
||||
|
||||
|
||||
class WalletType(Enum):
|
||||
class KeyType(Enum):
|
||||
admin = 0
|
||||
invoice = 1
|
||||
invalid = 2
|
||||
@@ -77,7 +80,7 @@ class WalletType(Enum):
|
||||
|
||||
@dataclass
|
||||
class WalletTypeInfo:
|
||||
wallet_type: WalletType
|
||||
key_type: KeyType
|
||||
wallet: Wallet
|
||||
|
||||
|
||||
@@ -94,6 +97,37 @@ class UserConfig(BaseModel):
|
||||
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
|
||||
@@ -255,18 +289,18 @@ class Payment(FromRowModel):
|
||||
conn: Optional[Connection] = None,
|
||||
) -> PaymentStatus:
|
||||
if self.is_uncheckable:
|
||||
return PaymentStatus(None)
|
||||
return PaymentPendingStatus()
|
||||
|
||||
logger.debug(
|
||||
f"Checking {'outgoing' if self.is_out else 'incoming'} "
|
||||
f"pending payment {self.checking_id}"
|
||||
)
|
||||
|
||||
WALLET = get_wallet_class()
|
||||
funding_source = get_funding_source()
|
||||
if self.is_out:
|
||||
status = await WALLET.get_payment_status(self.checking_id)
|
||||
status = await funding_source.get_payment_status(self.checking_id)
|
||||
else:
|
||||
status = await WALLET.get_invoice_status(self.checking_id)
|
||||
status = await funding_source.get_invoice_status(self.checking_id)
|
||||
|
||||
logger.debug(f"Status: {status}")
|
||||
|
||||
@@ -328,16 +362,6 @@ class PaymentHistoryPoint(BaseModel):
|
||||
balance: int
|
||||
|
||||
|
||||
class BalanceCheck(BaseModel):
|
||||
wallet: str
|
||||
service: str
|
||||
url: str
|
||||
|
||||
@classmethod
|
||||
def from_row(cls, row: Row):
|
||||
return cls(wallet=row["wallet"], service=row["service"], url=row["url"])
|
||||
|
||||
|
||||
def _do_nothing(*_):
|
||||
pass
|
||||
|
||||
@@ -391,11 +415,10 @@ class CreateInvoice(BaseModel):
|
||||
description_hash: Optional[str] = None
|
||||
unhashed_description: Optional[str] = None
|
||||
expiry: Optional[int] = None
|
||||
lnurl_callback: Optional[str] = None
|
||||
lnurl_balance_check: Optional[str] = None
|
||||
extra: Optional[dict] = None
|
||||
webhook: Optional[str] = None
|
||||
bolt11: Optional[str] = None
|
||||
lnurl_callback: Optional[str] = None
|
||||
|
||||
|
||||
class CreateTopup(BaseModel):
|
||||
@@ -421,3 +444,12 @@ class WebPushSubscription(BaseModel):
|
||||
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
|
||||
|
||||
+146
-68
@@ -1,13 +1,13 @@
|
||||
import asyncio
|
||||
import datetime
|
||||
import json
|
||||
import time
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Tuple, TypedDict
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import httpx
|
||||
from bolt11 import Bolt11
|
||||
from bolt11 import decode as bolt11_decode
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from fastapi import Depends, WebSocket
|
||||
@@ -29,8 +29,13 @@ 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_wallet_class, set_wallet_class
|
||||
from lnbits.wallets.base import PaymentResponse, PaymentStatus
|
||||
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,
|
||||
@@ -41,6 +46,7 @@ from .crud import (
|
||||
create_wallet,
|
||||
delete_wallet_payment,
|
||||
get_account,
|
||||
get_payments,
|
||||
get_standalone_payment,
|
||||
get_super_settings,
|
||||
get_total_balance,
|
||||
@@ -52,15 +58,19 @@ from .crud import (
|
||||
update_super_user,
|
||||
)
|
||||
from .helpers import to_valid_user_id
|
||||
from .models import Payment, UserConfig, Wallet
|
||||
from .models import BalanceDelta, Payment, UserConfig, Wallet
|
||||
|
||||
|
||||
class PaymentFailure(Exception):
|
||||
pass
|
||||
class PaymentError(Exception):
|
||||
def __init__(self, message: str, status: str = "pending"):
|
||||
self.message = message
|
||||
self.status = status
|
||||
|
||||
|
||||
class InvoiceFailure(Exception):
|
||||
pass
|
||||
class InvoiceError(Exception):
|
||||
def __init__(self, message: str, status: str = "pending"):
|
||||
self.message = message
|
||||
self.status = status
|
||||
|
||||
|
||||
async def calculate_fiat_amounts(
|
||||
@@ -116,21 +126,36 @@ async def create_invoice(
|
||||
conn: Optional[Connection] = None,
|
||||
) -> Tuple[str, str]:
|
||||
if not amount > 0:
|
||||
raise InvoiceFailure("Amountless invoices not supported.")
|
||||
raise InvoiceError("Amountless invoices not supported.", status="failed")
|
||||
|
||||
if await get_wallet(wallet_id, conn=conn) is None:
|
||||
raise InvoiceFailure("Wallet does not exist.")
|
||||
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
|
||||
wallet = FAKE_WALLET if internal else get_wallet_class()
|
||||
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
|
||||
)
|
||||
|
||||
ok, checking_id, payment_request, error_message = await wallet.create_invoice(
|
||||
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,
|
||||
@@ -138,7 +163,9 @@ async def create_invoice(
|
||||
expiry=expiry or settings.lightning_invoice_expiry,
|
||||
)
|
||||
if not ok or not payment_request or not checking_id:
|
||||
raise InvoiceFailure(error_message or "unexpected backend error.")
|
||||
raise InvoiceError(
|
||||
error_message or "unexpected backend error.", status="pending"
|
||||
)
|
||||
|
||||
invoice = bolt11_decode(payment_request)
|
||||
|
||||
@@ -149,7 +176,7 @@ async def create_invoice(
|
||||
payment_request=payment_request,
|
||||
payment_hash=invoice.payment_hash,
|
||||
amount=amount_msat,
|
||||
expiry=get_bolt11_expiry(invoice),
|
||||
expiry=invoice.expiry_date,
|
||||
memo=memo,
|
||||
extra=extra,
|
||||
webhook=webhook,
|
||||
@@ -178,22 +205,22 @@ async def pay_invoice(
|
||||
If the payment is still in flight, we hope that some other process
|
||||
will regularly check for the payment.
|
||||
"""
|
||||
invoice = bolt11_decode(payment_request)
|
||||
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 ValueError("Amountless invoices not supported.")
|
||||
raise PaymentError("Amountless invoices not supported.", status="failed")
|
||||
if max_sat and invoice.amount_msat > max_sat * 1000:
|
||||
raise ValueError("Amount in invoice is too high.")
|
||||
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}"
|
||||
|
||||
if invoice.amount_msat == 0:
|
||||
raise ValueError("Amountless invoices not supported.")
|
||||
if max_sat and invoice.amount_msat > max_sat * 1000:
|
||||
raise ValueError("Amount in invoice is too high.")
|
||||
|
||||
_, extra = await calculate_fiat_amounts(
|
||||
invoice.amount_msat / 1000, wallet_id, extra=extra, conn=conn
|
||||
)
|
||||
@@ -213,7 +240,7 @@ async def pay_invoice(
|
||||
payment_request=payment_request,
|
||||
payment_hash=invoice.payment_hash,
|
||||
amount=-invoice.amount_msat,
|
||||
expiry=get_bolt11_expiry(invoice),
|
||||
expiry=invoice.expiry_date,
|
||||
memo=description or invoice.description or "",
|
||||
extra=extra,
|
||||
)
|
||||
@@ -221,7 +248,7 @@ async def pay_invoice(
|
||||
# we check if an internal invoice exists that has already been paid
|
||||
# (not pending anymore)
|
||||
if not await check_internal_pending(invoice.payment_hash, conn=conn):
|
||||
raise PaymentFailure("Internal invoice already paid.")
|
||||
raise PaymentError("Internal invoice already paid.", status="failed")
|
||||
|
||||
# check_internal() returns the checking_id of the invoice we're waiting for
|
||||
# (pending only)
|
||||
@@ -240,7 +267,7 @@ async def pay_invoice(
|
||||
internal_invoice.amount != invoice.amount_msat
|
||||
or internal_invoice.bolt11 != payment_request.lower()
|
||||
):
|
||||
raise PaymentFailure("Invalid invoice.")
|
||||
raise PaymentError("Invalid invoice.", status="failed")
|
||||
|
||||
logger.debug(f"creating temporary internal payment with id {internal_id}")
|
||||
# create a new payment from this wallet
|
||||
@@ -265,10 +292,10 @@ async def pay_invoice(
|
||||
conn=conn,
|
||||
**payment_kwargs,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"could not create temporary payment: {e}")
|
||||
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 PaymentFailure("Could not make payment.")
|
||||
raise PaymentError("Could not make payment.", status="failed") from exc
|
||||
|
||||
# do the balance check
|
||||
wallet = await get_wallet(wallet_id, conn=conn)
|
||||
@@ -279,11 +306,12 @@ async def pay_invoice(
|
||||
not internal_checking_id
|
||||
and wallet.balance_msat > -fee_reserve_total_msat
|
||||
):
|
||||
raise PaymentFailure(
|
||||
raise PaymentError(
|
||||
f"You must reserve at least ({round(fee_reserve_total_msat/1000)}"
|
||||
" sat) to cover potential routing fees."
|
||||
" sat) to cover potential routing fees.",
|
||||
status="failed",
|
||||
)
|
||||
raise PermissionError("Insufficient balance.")
|
||||
raise PaymentError("Insufficient balance.", status="failed")
|
||||
|
||||
if internal_checking_id:
|
||||
service_fee_msat = service_fee(invoice.amount_msat, internal=True)
|
||||
@@ -307,8 +335,8 @@ async def pay_invoice(
|
||||
service_fee_msat = service_fee(invoice.amount_msat, internal=False)
|
||||
logger.debug(f"backend: sending payment {temp_id}")
|
||||
# actually pay the external invoice
|
||||
WALLET = get_wallet_class()
|
||||
payment: PaymentResponse = await WALLET.pay_invoice(
|
||||
funding_source = get_funding_source()
|
||||
payment: PaymentResponse = await funding_source.pay_invoice(
|
||||
payment_request, fee_reserve_msat
|
||||
)
|
||||
|
||||
@@ -319,6 +347,7 @@ async def pay_invoice(
|
||||
)
|
||||
|
||||
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}")
|
||||
@@ -347,9 +376,10 @@ async def pay_invoice(
|
||||
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 PaymentFailure(
|
||||
raise PaymentError(
|
||||
f"Payment failed: {payment.error_message}"
|
||||
or "Payment failed, but backend didn't give us an error message."
|
||||
or "Payment failed, but backend didn't give us an error message.",
|
||||
status="failed",
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
@@ -372,6 +402,59 @@ async def pay_invoice(
|
||||
return invoice.payment_hash
|
||||
|
||||
|
||||
async def check_wallet_limits(wallet_id, conn, amount_msat):
|
||||
await check_time_limit_between_transactions(conn, wallet_id)
|
||||
await check_wallet_daily_withdraw_limit(conn, wallet_id, amount_msat)
|
||||
|
||||
|
||||
async def check_time_limit_between_transactions(conn, wallet_id):
|
||||
limit = settings.lnbits_wallet_limit_secs_between_trans
|
||||
if not limit or limit <= 0:
|
||||
return
|
||||
|
||||
payments = await get_payments(
|
||||
since=int(time.time()) - limit,
|
||||
wallet_id=wallet_id,
|
||||
limit=1,
|
||||
conn=conn,
|
||||
)
|
||||
|
||||
if len(payments) == 0:
|
||||
return
|
||||
|
||||
raise PaymentError(
|
||||
status="failed",
|
||||
message=f"The time limit of {limit} seconds between payments has been reached.",
|
||||
)
|
||||
|
||||
|
||||
async def check_wallet_daily_withdraw_limit(conn, wallet_id, amount_msat):
|
||||
limit = settings.lnbits_wallet_limit_daily_max_withdraw
|
||||
if not limit or limit <= 0:
|
||||
return
|
||||
|
||||
payments = await get_payments(
|
||||
since=int(time.time()) - 60 * 60 * 24,
|
||||
outgoing=True,
|
||||
wallet_id=wallet_id,
|
||||
limit=1,
|
||||
conn=conn,
|
||||
)
|
||||
if len(payments) == 0:
|
||||
return
|
||||
|
||||
total = 0
|
||||
for pay in payments:
|
||||
total += pay.amount
|
||||
total = total - amount_msat
|
||||
if limit * 1000 + total < 0:
|
||||
raise ValueError(
|
||||
"Daily withdrawal limit of "
|
||||
+ str(settings.lnbits_wallet_limit_daily_max_withdraw)
|
||||
+ " sats reached."
|
||||
)
|
||||
|
||||
|
||||
async def redeem_lnurl_withdraw(
|
||||
wallet_id: str,
|
||||
lnurl_request: str,
|
||||
@@ -431,7 +514,6 @@ async def redeem_lnurl_withdraw(
|
||||
async def perform_lnurlauth(
|
||||
callback: str,
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||
conn: Optional[Connection] = None,
|
||||
) -> Optional[LnurlErrorResponse]:
|
||||
cb = urlparse(callback)
|
||||
|
||||
@@ -514,17 +596,17 @@ async def check_transaction_status(
|
||||
wallet_id, payment_hash, conn=conn
|
||||
)
|
||||
if not payment:
|
||||
return PaymentStatus(None)
|
||||
return PaymentPendingStatus()
|
||||
if not payment.pending:
|
||||
# note: before, we still checked the status of the payment again
|
||||
return PaymentStatus(True, fee_msat=payment.fee)
|
||||
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
|
||||
# WALLET.pay_invoice(), it may cause a vulnerability if the values differ
|
||||
# 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
|
||||
@@ -553,7 +635,7 @@ def fee_reserve_total(amount_msat: int, internal: bool = False) -> int:
|
||||
|
||||
|
||||
async def send_payment_notification(wallet: Wallet, payment: Payment):
|
||||
await websocketUpdater(
|
||||
await websocket_updater(
|
||||
wallet.id,
|
||||
json.dumps(
|
||||
{
|
||||
@@ -646,13 +728,16 @@ async def check_webpush_settings():
|
||||
|
||||
def update_cached_settings(sets_dict: dict):
|
||||
for key, value in sets_dict.items():
|
||||
if key not in readonly_variables:
|
||||
try:
|
||||
setattr(settings, key, value)
|
||||
except Exception:
|
||||
logger.warning(f"Failed overriding setting: {key}, value: {value}")
|
||||
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:
|
||||
setattr(settings, "super_user", sets_dict["super_user"])
|
||||
settings.super_user = sets_dict["super_user"]
|
||||
|
||||
|
||||
async def init_admin_settings(super_user: Optional[str] = None) -> SuperSettings:
|
||||
@@ -689,33 +774,26 @@ class WebsocketConnectionManager:
|
||||
await connection.send_text(message)
|
||||
|
||||
|
||||
websocketManager = WebsocketConnectionManager()
|
||||
websocket_manager = WebsocketConnectionManager()
|
||||
|
||||
|
||||
async def websocketUpdater(item_id, data):
|
||||
return await websocketManager.send_data(f"{data}", item_id)
|
||||
async def websocket_updater(item_id, data):
|
||||
return await websocket_manager.send_data(f"{data}", item_id)
|
||||
|
||||
|
||||
async def switch_to_voidwallet() -> None:
|
||||
WALLET = get_wallet_class()
|
||||
if WALLET.__class__.__name__ == "VoidWallet":
|
||||
funding_source = get_funding_source()
|
||||
if funding_source.__class__.__name__ == "VoidWallet":
|
||||
return
|
||||
set_wallet_class("VoidWallet")
|
||||
set_funding_source("VoidWallet")
|
||||
settings.lnbits_backend_wallet_class = "VoidWallet"
|
||||
|
||||
|
||||
async def get_balance_delta() -> Tuple[int, int, int]:
|
||||
WALLET = get_wallet_class()
|
||||
total_balance = await get_total_balance()
|
||||
error_message, node_balance = await WALLET.status()
|
||||
if error_message:
|
||||
raise Exception(error_message)
|
||||
return node_balance - total_balance, node_balance, total_balance
|
||||
|
||||
|
||||
def get_bolt11_expiry(invoice: Bolt11) -> datetime.datetime:
|
||||
if invoice.expiry:
|
||||
return datetime.datetime.fromtimestamp(invoice.date + invoice.expiry)
|
||||
else:
|
||||
# assume maximum bolt11 expiry of 31 days to be on the safe side
|
||||
return datetime.datetime.now() + datetime.timedelta(days=31)
|
||||
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,37 @@
|
||||
"""Keycloak SSO Login Helper
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
from fastapi_sso.sso.base import DiscoveryDocument, OpenID, SSOBase
|
||||
|
||||
|
||||
class KeycloakSSO(SSOBase):
|
||||
"""Class providing login via Keycloak OAuth"""
|
||||
|
||||
provider = "keycloak"
|
||||
scope = ["openid", "email", "profile"]
|
||||
discovery_url = ""
|
||||
|
||||
async def openid_from_response(
|
||||
self, response: dict, session: Optional["httpx.AsyncClient"] = None
|
||||
) -> OpenID:
|
||||
"""Return OpenID from user information provided by Keycloak"""
|
||||
return OpenID(
|
||||
email=response.get("email", ""),
|
||||
provider=self.provider,
|
||||
id=response.get("sub"),
|
||||
first_name=response.get("given_name"),
|
||||
last_name=response.get("family_name"),
|
||||
display_name=response.get("name"),
|
||||
picture=response.get("picture"),
|
||||
)
|
||||
|
||||
async def get_discovery_document(self) -> DiscoveryDocument:
|
||||
"""Get document containing handy urls"""
|
||||
async with httpx.AsyncClient() as session:
|
||||
response = await session.get(self.discovery_url)
|
||||
content = response.json()
|
||||
|
||||
return content
|
||||
+45
-82
@@ -5,44 +5,33 @@ import httpx
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.core.crud import (
|
||||
get_balance_notify,
|
||||
get_wallet,
|
||||
get_webpush_subscriptions_for_user,
|
||||
mark_webhook_sent,
|
||||
)
|
||||
from lnbits.core.db import db
|
||||
from lnbits.core.models import Payment
|
||||
from lnbits.core.services import (
|
||||
get_balance_delta,
|
||||
send_payment_notification,
|
||||
switch_to_voidwallet,
|
||||
)
|
||||
from lnbits.settings import get_wallet_class, settings
|
||||
from lnbits.tasks import (
|
||||
SseListenersDict,
|
||||
create_permanent_task,
|
||||
create_task,
|
||||
register_invoice_listener,
|
||||
send_push_notification,
|
||||
)
|
||||
from lnbits.settings import get_funding_source, settings
|
||||
from lnbits.tasks import send_push_notification
|
||||
|
||||
api_invoice_listeners: Dict[str, asyncio.Queue] = SseListenersDict(
|
||||
"api_invoice_listeners"
|
||||
)
|
||||
|
||||
|
||||
def register_killswitch():
|
||||
"""
|
||||
Registers a killswitch which will check lnbits-status repository for a signal from
|
||||
LNbits and will switch to VoidWallet if the killswitch is triggered.
|
||||
"""
|
||||
logger.debug("Starting killswitch task")
|
||||
create_permanent_task(killswitch_task)
|
||||
api_invoice_listeners: Dict[str, asyncio.Queue] = {}
|
||||
|
||||
|
||||
async def killswitch_task():
|
||||
while True:
|
||||
WALLET = get_wallet_class()
|
||||
if settings.lnbits_killswitch and WALLET.__class__.__name__ != "VoidWallet":
|
||||
"""
|
||||
killswitch will check lnbits-status repository for a signal from
|
||||
LNbits and will switch to VoidWallet if the killswitch is triggered.
|
||||
"""
|
||||
while settings.lnbits_running:
|
||||
funding_source = get_funding_source()
|
||||
if (
|
||||
settings.lnbits_killswitch
|
||||
and funding_source.__class__.__name__ != "VoidWallet"
|
||||
):
|
||||
with httpx.Client() as client:
|
||||
try:
|
||||
r = client.get(settings.lnbits_status_manifest, timeout=4)
|
||||
@@ -54,7 +43,7 @@ async def killswitch_task():
|
||||
"Switching to VoidWallet. Killswitch triggered."
|
||||
)
|
||||
await switch_to_voidwallet()
|
||||
except (httpx.ConnectError, httpx.RequestError):
|
||||
except (httpx.RequestError, httpx.HTTPStatusError):
|
||||
logger.error(
|
||||
"Cannot fetch lnbits status manifest."
|
||||
f" {settings.lnbits_status_manifest}"
|
||||
@@ -62,22 +51,20 @@ async def killswitch_task():
|
||||
await asyncio.sleep(settings.lnbits_killswitch_interval * 60)
|
||||
|
||||
|
||||
async def register_watchdog():
|
||||
async def watchdog_task():
|
||||
"""
|
||||
Registers a watchdog which will check lnbits balance and nodebalance
|
||||
and will switch to VoidWallet if the watchdog delta is reached.
|
||||
"""
|
||||
# TODO: implement watchdog properly
|
||||
# logger.debug("Starting watchdog task")
|
||||
# create_permanent_task(watchdog_task)
|
||||
|
||||
|
||||
async def watchdog_task():
|
||||
while True:
|
||||
WALLET = get_wallet_class()
|
||||
if settings.lnbits_watchdog and WALLET.__class__.__name__ != "VoidWallet":
|
||||
while settings.lnbits_running:
|
||||
funding_source = get_funding_source()
|
||||
if (
|
||||
settings.lnbits_watchdog
|
||||
and funding_source.__class__.__name__ != "VoidWallet"
|
||||
):
|
||||
try:
|
||||
delta, *_ = await get_balance_delta()
|
||||
balance = await get_balance_delta()
|
||||
delta = balance.delta_msats
|
||||
logger.debug(f"Running watchdog task. current delta: {delta}")
|
||||
if delta + settings.lnbits_watchdog_delta <= 0:
|
||||
logger.error(f"Switching to VoidWallet. current delta: {delta}")
|
||||
@@ -87,47 +74,23 @@ async def watchdog_task():
|
||||
await asyncio.sleep(settings.lnbits_watchdog_interval * 60)
|
||||
|
||||
|
||||
def register_task_listeners():
|
||||
"""
|
||||
Registers an invoice listener queue for the core tasks. Incoming payments in this
|
||||
queue will eventually trigger the signals sent to all other extensions
|
||||
and fulfill other core tasks such as dispatching webhooks.
|
||||
"""
|
||||
invoice_paid_queue = asyncio.Queue(5)
|
||||
# we register invoice_paid_queue to receive all incoming invoices
|
||||
register_invoice_listener(invoice_paid_queue, "core/tasks.py")
|
||||
# register a worker that will react to invoices
|
||||
create_task(wait_for_paid_invoices(invoice_paid_queue))
|
||||
|
||||
|
||||
async def wait_for_paid_invoices(invoice_paid_queue: asyncio.Queue):
|
||||
"""
|
||||
This worker dispatches events to all extensions,
|
||||
dispatches webhooks and balance notifys.
|
||||
This worker dispatches events to all extensions and dispatches webhooks.
|
||||
"""
|
||||
while True:
|
||||
while settings.lnbits_running:
|
||||
payment = await invoice_paid_queue.get()
|
||||
logger.trace("received invoice paid event")
|
||||
# send information to sse channel
|
||||
# dispatch api_invoice_listeners
|
||||
await dispatch_api_invoice_listeners(payment)
|
||||
# payment notification
|
||||
wallet = await get_wallet(payment.wallet_id)
|
||||
if wallet:
|
||||
await send_payment_notification(wallet, payment)
|
||||
# dispatch webhook
|
||||
if payment.webhook and not payment.webhook_status:
|
||||
await dispatch_webhook(payment)
|
||||
|
||||
# dispatch balance_notify
|
||||
url = await get_balance_notify(payment.wallet_id)
|
||||
if url:
|
||||
headers = {"User-Agent": settings.user_agent}
|
||||
async with httpx.AsyncClient(headers=headers) as client:
|
||||
try:
|
||||
r = await client.post(url, timeout=4)
|
||||
await mark_webhook_sent(payment, r.status_code)
|
||||
except (httpx.ConnectError, httpx.RequestError):
|
||||
pass
|
||||
|
||||
# dispatch push notification
|
||||
await send_payment_push_notification(payment)
|
||||
|
||||
|
||||
@@ -137,10 +100,12 @@ async def dispatch_api_invoice_listeners(payment: Payment):
|
||||
"""
|
||||
for chan_name, send_channel in api_invoice_listeners.items():
|
||||
try:
|
||||
logger.debug(f"sending invoice paid event to {chan_name}")
|
||||
logger.debug(f"api invoice listener: sending paid event to {chan_name}")
|
||||
send_channel.put_nowait(payment)
|
||||
except asyncio.QueueFull:
|
||||
logger.error(f"removing sse listener {send_channel}:{chan_name}")
|
||||
logger.error(
|
||||
f"api invoice listener: QueueFull, removing {send_channel}:{chan_name}"
|
||||
)
|
||||
api_invoice_listeners.pop(chan_name)
|
||||
|
||||
|
||||
@@ -151,26 +116,24 @@ async def dispatch_webhook(payment: Payment):
|
||||
logger.debug("sending webhook", payment.webhook)
|
||||
|
||||
if not payment.webhook:
|
||||
return await mark_webhook_sent(payment, -1)
|
||||
return await mark_webhook_sent(payment.payment_hash, -1)
|
||||
|
||||
headers = {"User-Agent": settings.user_agent}
|
||||
async with httpx.AsyncClient(headers=headers) as client:
|
||||
data = payment.dict()
|
||||
try:
|
||||
r = await client.post(payment.webhook, json=data, timeout=40)
|
||||
await mark_webhook_sent(payment, r.status_code)
|
||||
except (httpx.ConnectError, httpx.RequestError):
|
||||
await mark_webhook_sent(payment, -1)
|
||||
|
||||
|
||||
async def mark_webhook_sent(payment: Payment, status: int) -> None:
|
||||
await db.execute(
|
||||
"""
|
||||
UPDATE apipayments SET webhook_status = ?
|
||||
WHERE hash = ?
|
||||
""",
|
||||
(status, payment.payment_hash),
|
||||
)
|
||||
r.raise_for_status()
|
||||
await mark_webhook_sent(payment.payment_hash, r.status_code)
|
||||
except httpx.HTTPStatusError as exc:
|
||||
await mark_webhook_sent(payment.payment_hash, exc.response.status_code)
|
||||
logger.warning(
|
||||
f"webhook returned a bad status_code: {exc.response.status_code} "
|
||||
f"while requesting {exc.request.url!r}."
|
||||
)
|
||||
except httpx.RequestError:
|
||||
await mark_webhook_sent(payment.payment_hash, -1)
|
||||
logger.warning(f"Could not send webhook to {payment.webhook}")
|
||||
|
||||
|
||||
async def send_payment_push_notification(payment: Payment):
|
||||
|
||||
@@ -7,21 +7,21 @@
|
||||
<div class="col">
|
||||
<p>Funding Source Info</p>
|
||||
<ul>
|
||||
{%raw%}
|
||||
<li>Funding Source: {{settings.lnbits_backend_wallet_class}}</li>
|
||||
<li>
|
||||
Node Balance: {{(auditData.node_balance_msats /
|
||||
1000).toLocaleString()}} sats
|
||||
</li>
|
||||
<li>
|
||||
LNbits Balance: {{(auditData.lnbits_balance_msats /
|
||||
1000).toLocaleString()}} sats
|
||||
</li>
|
||||
<li>
|
||||
Reserve Percent: {{(auditData.node_balance_msats /
|
||||
auditData.lnbits_balance_msats * 100).toFixed(2)}} %
|
||||
</li>
|
||||
{%endraw%}
|
||||
<li
|
||||
v-text="'Funding Source: '+ settings.lnbits_backend_wallet_class"
|
||||
></li>
|
||||
<li
|
||||
v-text="'Node Balance: ' + (auditData.node_balance_msats /
|
||||
1000).toLocaleString() + ' sats'"
|
||||
></li>
|
||||
<li
|
||||
v-text="'LNbits Balance: ' + (auditData.lnbits_balance_msats /
|
||||
1000).toLocaleString() + ' sats'"
|
||||
></li>
|
||||
<li
|
||||
v-text="'Reserve Percent: ' + (auditData.node_balance_msats /
|
||||
auditData.lnbits_balance_msats * 100).toFixed(2) + ' %'"
|
||||
></li>
|
||||
</ul>
|
||||
<br />
|
||||
</div>
|
||||
|
||||
@@ -78,6 +78,41 @@
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
<q-card-section
|
||||
v-if="formData.auth_allowed_methods?.includes('keycloak-auth')"
|
||||
class="q-pl-xl"
|
||||
>
|
||||
<strong class="q-my-none q-mb-sm">Keycloak Auth</strong>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-4 col-sm-12 q-pr-sm">
|
||||
<q-input
|
||||
filled
|
||||
v-model="formData.keycloak_discovery_url"
|
||||
label="Keycloak Discovey URL"
|
||||
>
|
||||
</q-input>
|
||||
</div>
|
||||
<div class="col-md-4 col-sm-12 q-pr-sm">
|
||||
<q-input
|
||||
filled
|
||||
v-model="formData.keycloak_client_id"
|
||||
label="Keycloak Client ID"
|
||||
hint="Make sure thant the authorization callback URL is set to https://{domain}/api/v1/auth/keycloak/token"
|
||||
>
|
||||
</q-input>
|
||||
</div>
|
||||
<div class="col-md-4 col-sm-12">
|
||||
<q-input
|
||||
filled
|
||||
v-model="formData.keycloak_client_secret"
|
||||
type="password"
|
||||
label="Keycloak Client Secret"
|
||||
>
|
||||
</q-input>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
<q-separator></q-separator>
|
||||
<q-card-section class="q-pa-none">
|
||||
<br />
|
||||
@@ -96,7 +131,7 @@
|
||||
style="padding: 10px; color: #fafafa; height: 320px"
|
||||
>
|
||||
<small v-for="log in logs"
|
||||
>{% raw %}{{ log }}{% endraw %}<br
|
||||
><span v-text="log"></span><br
|
||||
/></small>
|
||||
</q-scroll-area>
|
||||
</div>
|
||||
@@ -131,7 +166,6 @@
|
||||
></q-btn>
|
||||
</q-input>
|
||||
<div>
|
||||
{%raw%}
|
||||
<q-chip
|
||||
v-for="blocked_ip in formData.lnbits_blocked_ips"
|
||||
:key="blocked_ip"
|
||||
@@ -139,10 +173,8 @@
|
||||
@remove="removeBlockedIPs(blocked_ip)"
|
||||
color="primary"
|
||||
text-color="white"
|
||||
>
|
||||
{{ blocked_ip }}
|
||||
</q-chip>
|
||||
{%endraw%}
|
||||
v-text="blocked_ip"
|
||||
></q-chip>
|
||||
</div>
|
||||
<br />
|
||||
</div>
|
||||
@@ -163,7 +195,6 @@
|
||||
></q-btn>
|
||||
</q-input>
|
||||
<div>
|
||||
{%raw%}
|
||||
<q-chip
|
||||
v-for="allowed_ip in formData.lnbits_allowed_ips"
|
||||
:key="allowed_ip"
|
||||
@@ -171,15 +202,14 @@
|
||||
@remove="removeAllowedIPs(allowed_ip)"
|
||||
color="primary"
|
||||
text-color="white"
|
||||
>
|
||||
{{ allowed_ip }}
|
||||
</q-chip>
|
||||
{%endraw%}
|
||||
v-text="allowed_ip"
|
||||
></q-chip>
|
||||
</div>
|
||||
<br />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-12">
|
||||
<p v-text="$t('rate_limiter')"></p>
|
||||
<div class="row q-col-gutter-md">
|
||||
@@ -201,6 +231,36 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-12">
|
||||
<p v-text="$t('wallet_limiter')"></p>
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-4">
|
||||
<q-input
|
||||
filled
|
||||
type="number"
|
||||
v-model.number="formData.lnbits_wallet_limit_max_balance"
|
||||
:label="$t('wallet_max_ballance')"
|
||||
></q-input>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<q-input
|
||||
filled
|
||||
type="number"
|
||||
v-model.number="formData.lnbits_wallet_limit_daily_max_withdraw"
|
||||
:label="$t('wallet_limit_max_withdraw_per_day')"
|
||||
></q-input>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<q-input
|
||||
filled
|
||||
type="number"
|
||||
v-model.number="formData.lnbits_wallet_limit_secs_between_trans"
|
||||
:label="$t('wallet_limit_secs_between_trans')"
|
||||
></q-input>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<br />
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
{% raw %}
|
||||
<q-banner v-if="updateAvailable" class="bg-primary text-white">
|
||||
<q-icon size="28px" name="update"></q-icon>
|
||||
|
||||
@@ -30,9 +29,12 @@
|
||||
<template v-slot:header="props">
|
||||
<q-tr :props="props">
|
||||
<q-th auto-width> </q-th>
|
||||
<q-th v-for="col in props.cols" :key="col.name" :props="props"
|
||||
>{{ col.label }}</q-th
|
||||
>
|
||||
<q-th
|
||||
v-for="col in props.cols"
|
||||
:key="col.name"
|
||||
:props="props"
|
||||
v-text="col.label"
|
||||
></q-th>
|
||||
</q-tr>
|
||||
</template>
|
||||
<template v-slot:body="props">
|
||||
@@ -51,12 +53,16 @@
|
||||
color="red"
|
||||
></q-icon>
|
||||
</q-td>
|
||||
<q-td auto-width key="date" :props="props">
|
||||
{{ formatDate(props.row.date) }}
|
||||
<q-td
|
||||
auto-width
|
||||
key="date"
|
||||
:props="props"
|
||||
v-text="formatDate(props.row.date)"
|
||||
>
|
||||
</q-td>
|
||||
<q-td key="message" :props="props"
|
||||
>{{ props.row.message }}
|
||||
<a
|
||||
><span v-text="props.row.message"></span
|
||||
><a
|
||||
v-if="props.row.link"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
@@ -69,4 +75,3 @@
|
||||
</q-table>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
{% endraw %}
|
||||
|
||||
@@ -7,14 +7,14 @@
|
||||
<div class="col">
|
||||
<p>Server Info</p>
|
||||
<ul>
|
||||
{%raw%}
|
||||
<li v-if="settings.lnbits_data_folder">
|
||||
SQlite: {{settings.lnbits_data_folder}}
|
||||
</li>
|
||||
<li v-if="settings.lnbits_database_url">
|
||||
Postgres: {{settings.lnbits_database_url}}
|
||||
</li>
|
||||
{%endraw%}
|
||||
<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>
|
||||
@@ -60,6 +60,21 @@
|
||||
</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>
|
||||
@@ -154,7 +169,6 @@
|
||||
<q-btn @click="addExtensionsManifest" dense flat icon="add"></q-btn>
|
||||
</q-input>
|
||||
<div>
|
||||
{%raw%}
|
||||
<q-chip
|
||||
v-for="manifestUrl in formData.lnbits_extensions_manifests"
|
||||
:key="manifestUrl"
|
||||
@@ -162,10 +176,8 @@
|
||||
@remove="removeExtensionsManifest(manifestUrl)"
|
||||
color="primary"
|
||||
text-color="white"
|
||||
>
|
||||
{{ manifestUrl }}
|
||||
</q-chip>
|
||||
{%endraw%}
|
||||
><span v-text="manifestUrl"></span
|
||||
></q-chip>
|
||||
</div>
|
||||
<br />
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<br />
|
||||
<div>
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 col-md-6">
|
||||
<div class="col-12 col-md-5">
|
||||
<p>Site Title</p>
|
||||
<q-input
|
||||
filled
|
||||
@@ -14,7 +14,7 @@
|
||||
></q-input>
|
||||
<br />
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<div class="col-12 col-md-5">
|
||||
<p>Site Tagline</p>
|
||||
<q-input
|
||||
filled
|
||||
@@ -24,7 +24,15 @@
|
||||
></q-input>
|
||||
<br />
|
||||
</div>
|
||||
<div class="col-12 col-md-2 q-mt-xl">
|
||||
<q-toggle
|
||||
tip="Remove homepage elements like 'runs on' etc"
|
||||
v-model="formData.LNBITS_SHOW_HOME_PAGE_ELEMENTS"
|
||||
:label="formData.LNBITS_SHOW_HOME_PAGE_ELEMENTS ? 'Enable elements on homepage' : 'Disable elements on homepage'"
|
||||
></q-toggle>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p>Site Description</p>
|
||||
<q-input
|
||||
@@ -44,7 +52,6 @@
|
||||
v-model="formData.lnbits_default_wallet_name"
|
||||
label="LNbits wallet"
|
||||
></q-input>
|
||||
<br />
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<p>Denomination</p>
|
||||
@@ -55,7 +62,6 @@
|
||||
label="sats"
|
||||
hint="The name for the FakeWallet token"
|
||||
></q-input>
|
||||
<br />
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<p>QR code logo</p>
|
||||
@@ -66,9 +72,33 @@
|
||||
label="https://example.com/image.svg"
|
||||
hint="URL to logo image in QR code"
|
||||
></q-input>
|
||||
<br />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 col-md-4">
|
||||
<p>Custom Badge</p>
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 col-md-8">
|
||||
<q-input
|
||||
filled
|
||||
type="text"
|
||||
tip="Custom Badge"
|
||||
v-model="formData.lnbits_custom_badge"
|
||||
label="Custom Badge 'USE WITH CAUTION - LNbits wallet is still in BETA'"
|
||||
></q-input>
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<q-select
|
||||
filled
|
||||
v-model="formData.lnbits_custom_badge_color"
|
||||
:options="colors"
|
||||
label="Custom badge color"
|
||||
></q-select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<br />
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 col-md-6">
|
||||
<p>Themes</p>
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
<q-btn @click="addAdminUser" dense flat icon="add"></q-btn>
|
||||
</q-input>
|
||||
<div>
|
||||
{%raw%}
|
||||
<q-chip
|
||||
v-for="user in formData.lnbits_admin_users"
|
||||
:key="user"
|
||||
@@ -25,9 +24,8 @@
|
||||
color="primary"
|
||||
text-color="white"
|
||||
>
|
||||
{{ user }}
|
||||
<span v-text="user"></span>
|
||||
</q-chip>
|
||||
{%endraw%}
|
||||
</div>
|
||||
<br />
|
||||
</div>
|
||||
@@ -44,7 +42,6 @@
|
||||
<q-btn @click="addAllowedUser" dense flat icon="add"></q-btn>
|
||||
</q-input>
|
||||
<div>
|
||||
{% raw %}
|
||||
<q-chip
|
||||
v-for="user in formData.lnbits_allowed_users"
|
||||
:key="user"
|
||||
@@ -53,11 +50,28 @@
|
||||
color="primary"
|
||||
text-color="white"
|
||||
>
|
||||
{{ user }}
|
||||
<span v-text="user" />
|
||||
</q-chip>
|
||||
{% endraw %}
|
||||
</div>
|
||||
<br />
|
||||
<q-item tag="label" v-ripple>
|
||||
<q-item-section>
|
||||
<q-item-label>Allow creation of new users</q-item-label>
|
||||
<q-item-label caption
|
||||
>Allow creation of new users on the index page</q-item-label
|
||||
>
|
||||
</q-item-section>
|
||||
<q-item-section avatar>
|
||||
<q-toggle
|
||||
size="md"
|
||||
v-model="formData.lnbits_allow_new_accounts"
|
||||
checked-icon="check"
|
||||
color="green"
|
||||
unchecked-icon="clear"
|
||||
/>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<br />
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
|
||||
@@ -8,9 +8,9 @@
|
||||
@click="updateSettings"
|
||||
:disabled="!checkChanges"
|
||||
>
|
||||
<q-tooltip v-if="checkChanges"
|
||||
>{%raw%}{{ $t('save_tooltip') }}{%endraw%}</q-tooltip
|
||||
>
|
||||
<q-tooltip v-if="checkChanges">
|
||||
<span v-text="$t('save_tooltip')"></span>
|
||||
</q-tooltip>
|
||||
|
||||
<q-badge
|
||||
v-if="checkChanges"
|
||||
@@ -27,9 +27,9 @@
|
||||
color="primary"
|
||||
@click="restartServer"
|
||||
>
|
||||
<q-tooltip v-if="needsRestart"
|
||||
>{%raw%}{{ $t('restart_tooltip') }}{%endraw%}</q-tooltip
|
||||
>
|
||||
<q-tooltip v-if="needsRestart">
|
||||
<span v-text="$t('restart_tooltip')"></span>
|
||||
</q-tooltip>
|
||||
|
||||
<q-badge
|
||||
v-if="needsRestart"
|
||||
@@ -40,15 +40,6 @@
|
||||
/>
|
||||
</q-btn>
|
||||
|
||||
<q-btn
|
||||
v-if="isSuperUser"
|
||||
:label="$t('topup')"
|
||||
color="primary"
|
||||
@click="topUpDialog.show = true"
|
||||
>
|
||||
<q-tooltip>{%raw%}{{ $t('add_funds_tooltip') }}{%endraw%}</q-tooltip>
|
||||
</q-btn>
|
||||
|
||||
<q-btn :label="$t('download_backup')" flat @click="downloadBackup"></q-btn>
|
||||
|
||||
<q-btn
|
||||
@@ -59,7 +50,9 @@
|
||||
@click="deleteSettings"
|
||||
class="float-right"
|
||||
>
|
||||
<q-tooltip>{%raw%}{{ $t('reset_defaults_tooltip') }}{%endraw%}</q-tooltip>
|
||||
<q-tooltip>
|
||||
<span v-text="$t('reset_defaults_tooltip')"></span>
|
||||
</q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
@@ -115,54 +108,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<q-dialog v-if="isSuperUser" v-model="topUpDialog.show" position="top">
|
||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<q-form class="q-gutter-md">
|
||||
<p v-text="$t('topup_wallet')"></p>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<q-input
|
||||
dense
|
||||
type="text"
|
||||
filled
|
||||
v-model="wallet.id"
|
||||
label="Wallet ID"
|
||||
:hint="$t('topup_hint')"
|
||||
></q-input>
|
||||
|
||||
<br />
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<q-input
|
||||
dense
|
||||
type="number"
|
||||
filled
|
||||
v-model="wallet.amount"
|
||||
:label="$t('amount')"
|
||||
></q-input>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
:label="$t('topup')"
|
||||
color="primary"
|
||||
@click="topupWallet"
|
||||
></q-btn>
|
||||
|
||||
<q-btn
|
||||
v-close-popup
|
||||
flat
|
||||
color="grey"
|
||||
class="q-ml-auto"
|
||||
:label="$t('cancel')"
|
||||
></q-btn>
|
||||
</div>
|
||||
</q-form>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
{% endblock %} {% block scripts %} {{ window_vars(user) }}
|
||||
<script src="{{ static_url_for('static', 'js/admin.js') }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
:content-inset-level="0.5"
|
||||
>
|
||||
<q-card-section>
|
||||
<strong>Node URL: </strong><em v-text="origin"></em><br />
|
||||
<strong>Wallet ID: </strong><em>{{ wallet.id }}</em><br />
|
||||
<strong>Admin key: </strong><em>{{ wallet.adminkey }}</em><br />
|
||||
<strong>Invoice/read key: </strong><em>{{ wallet.inkey }}</em>
|
||||
|
||||
@@ -401,6 +401,23 @@
|
||||
></lnbits-notifications-btn>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-mb-md">
|
||||
<div class="col-4">
|
||||
<span v-text="$t('payment_reactions')"></span>
|
||||
</div>
|
||||
<div class="col-8">
|
||||
<q-select
|
||||
v-model="reactionChoice"
|
||||
:options="reactionOptions"
|
||||
label="Reactions"
|
||||
@input="reactionChoiceFunc"
|
||||
>
|
||||
<q-tooltip
|
||||
><span v-text="$t('payment_reactions')"></span
|
||||
></q-tooltip>
|
||||
</q-select>
|
||||
</div>
|
||||
</div>
|
||||
</q-tab-panel>
|
||||
</q-tab-panels>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
%} {{ window_vars(user, extensions) }}{% block page %}
|
||||
<div class="row q-col-gutter-md q-mb-md">
|
||||
<div class="col-sm-9 col-xs-12">
|
||||
<p class="text-h4 gt-sm">{%raw%}{{ $t('extensions') }}{%endraw%}</p>
|
||||
<p class="text-h4 gt-sm" v-text="$t('extensions')"></p>
|
||||
</div>
|
||||
|
||||
<div class="col-sm-3 col-xs-12 q-ml-auto">
|
||||
@@ -43,9 +43,10 @@
|
||||
:label="$t('featured')"
|
||||
@update="val => tab = val.name"
|
||||
></q-tab>
|
||||
<i v-if="!g.user.admin && tab != 'installed'"
|
||||
>{%raw%}{{ $t('only_admins_can_install') }}{%endraw%}</i
|
||||
>
|
||||
<i
|
||||
v-if="!g.user.admin && tab != 'installed'"
|
||||
v-text="$t('only_admins_can_install')"
|
||||
></i>
|
||||
</q-tabs>
|
||||
</div>
|
||||
</div>
|
||||
@@ -89,45 +90,42 @@
|
||||
color="green"
|
||||
class="float-right"
|
||||
>
|
||||
<small>{%raw%}{{ $t('new_version') }}{%endraw%}</small>
|
||||
<small v-text="$t('new_version')"></small>
|
||||
<q-tooltip
|
||||
><span v-text="extension.latestRelease.version"></span
|
||||
></q-tooltip>
|
||||
</q-badge>
|
||||
{% raw %}
|
||||
<div class="text-h5 gt-sm q-mt-sm q-mb-xs gt-sm">
|
||||
{{ extension.name }}
|
||||
</div>
|
||||
<div
|
||||
class="text-h5 gt-sm q-mt-sm q-mb-xs gt-sm"
|
||||
v-text="extension.name"
|
||||
></div>
|
||||
<div
|
||||
class="text-h5 gt-sm q-mt-sm q-mb-xs lt-md"
|
||||
style="min-height: 60px"
|
||||
>
|
||||
{{ extension.name }}
|
||||
</div>
|
||||
v-text="extension.name"
|
||||
></div>
|
||||
<div
|
||||
class="text-subtitle2 gt-sm"
|
||||
style="font-size: 11px; height: 34px"
|
||||
>
|
||||
{{ extension.shortDescription ||
|
||||
extension.installedRelease?.description }}
|
||||
</div>
|
||||
<div class="text-subtitle1 lt-md q-mt-sm q-mb-xs">
|
||||
{{ extension.name }}
|
||||
</div>
|
||||
v-text="extension.shortDescription || extension.installedRelease?.description"
|
||||
></div>
|
||||
<div
|
||||
class="text-subtitle1 lt-md q-mt-sm q-mb-xs"
|
||||
v-text="extension.name"
|
||||
></div>
|
||||
<div
|
||||
class="text-subtitle2 lt-md"
|
||||
style="font-size: 9px; height: 34px"
|
||||
>
|
||||
{{ extension.shortDescription }}
|
||||
</div>
|
||||
{% endraw %}
|
||||
v-text="extension.shortDescription"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-pt-sm">
|
||||
<div class="col">
|
||||
<small v-if="extension.dependencies?.length"
|
||||
>{%raw%}{{ $t('extension_depends_on') }}{%endraw%}</small
|
||||
>
|
||||
<small
|
||||
v-if="extension.dependencies?.length"
|
||||
v-text="$t('extension_depends_on')"
|
||||
></small>
|
||||
<small v-else> </small>
|
||||
<q-badge
|
||||
v-for="dep in extension.dependencies"
|
||||
@@ -148,20 +146,18 @@
|
||||
size="1.5em"
|
||||
:max="5"
|
||||
color="primary"
|
||||
><q-tooltip
|
||||
>{%raw%}{{ $t('extension_rating_soon') }}{%endraw%}</q-tooltip
|
||||
></q-rating
|
||||
>
|
||||
><q-tooltip>
|
||||
<span v-text="$t('extension_rating_soon')"></span> </q-tooltip
|
||||
></q-rating>
|
||||
<q-rating
|
||||
v-model="maxStars"
|
||||
class="lt-md"
|
||||
size="1.5em"
|
||||
:max="5"
|
||||
color="primary"
|
||||
><q-tooltip
|
||||
>{%raw%}{{ $t('extension_rating_soon') }}{%endraw%}</q-tooltip
|
||||
></q-rating
|
||||
>
|
||||
><q-tooltip>
|
||||
<span v-text="$t('extension_rating_soon')"></span> </q-tooltip
|
||||
></q-rating>
|
||||
<q-toggle
|
||||
v-if="extension.isAvailable && extension.isInstalled && g.user.admin"
|
||||
:label="extension.isActive ? $t('activated'): $t('deactivated') "
|
||||
@@ -169,11 +165,11 @@
|
||||
style="max-height: 21px"
|
||||
v-model="extension.isActive"
|
||||
@input="toggleExtension(extension)"
|
||||
><q-tooltip
|
||||
>{%raw%}{{ $t('activate_extension_details')
|
||||
}}{%endraw%}</q-tooltip
|
||||
></q-toggle
|
||||
>
|
||||
><q-tooltip>
|
||||
<span
|
||||
v-text="$t('activate_extension_details')"
|
||||
></span> </q-tooltip
|
||||
></q-toggle>
|
||||
</div>
|
||||
</q-card-section>
|
||||
<q-separator></q-separator>
|
||||
@@ -186,8 +182,8 @@
|
||||
color="primary"
|
||||
type="a"
|
||||
:href="extension.id + '/'"
|
||||
>{%raw%}{{ $t('open') }}{%endraw%}</q-btn
|
||||
>
|
||||
:label="$t('open')"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
v-if="user.extensions.includes(extension.id) && extension.isActive && extension.isInstalled"
|
||||
flat
|
||||
@@ -196,11 +192,12 @@
|
||||
:href="['{{
|
||||
url_for('install.extensions')
|
||||
}}', '?disable=', extension.id].join('')"
|
||||
:label="$t('disable')"
|
||||
></q-btn>
|
||||
<q-badge
|
||||
v-if="extension.isAdminOnly && !user.admin"
|
||||
v-text="$t('admin_only')"
|
||||
>
|
||||
{%raw%}{{ $t('disable') }}{%endraw%}</q-btn
|
||||
>
|
||||
<q-badge v-if="extension.isAdminOnly && !user.admin">
|
||||
{%raw%}{{ $t('admin_only') }}{%endraw%}
|
||||
</q-badge>
|
||||
<q-btn
|
||||
v-else-if="extension.isInstalled && extension.isActive && !user.extensions.includes(extension.id)"
|
||||
@@ -210,8 +207,8 @@
|
||||
:href="['{{
|
||||
url_for('install.extensions')
|
||||
}}', '?enable=', extension.id].join('')"
|
||||
:label="$t('enable')"
|
||||
>
|
||||
{%raw%}{{ $t('enable') }}{%endraw%}
|
||||
<q-tooltip>
|
||||
<span v-text="$t('enable_extension_details')">
|
||||
</span> </q-tooltip
|
||||
@@ -222,12 +219,11 @@
|
||||
flat
|
||||
color="primary"
|
||||
v-if="g.user.admin"
|
||||
>
|
||||
{%raw%}{{ $t('manage') }}{%endraw%}<q-tooltip
|
||||
>{%raw%}{{ $t('manage_extension_details')
|
||||
}}{%endraw%}</q-tooltip
|
||||
></q-btn
|
||||
>
|
||||
:label="$t('manage')"
|
||||
><q-tooltip
|
||||
><span v-text="$t('manage_extension_details')"></span
|
||||
></q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
<div v-else>
|
||||
<q-spinner color="primary" size="2.55em"></q-spinner>
|
||||
@@ -240,7 +236,7 @@
|
||||
class="float-right"
|
||||
>
|
||||
<q-badge>
|
||||
{% raw %}{{ extension.installedRelease.version }}{% endraw %}
|
||||
<span v-text="extension.installedRelease.version"></span>
|
||||
<q-tooltip>
|
||||
<span v-text="$t('extension_installed_version')"></span>
|
||||
</q-tooltip>
|
||||
@@ -252,10 +248,10 @@
|
||||
</div>
|
||||
<q-dialog v-model="showUninstallDialog">
|
||||
<q-card class="q-pa-lg">
|
||||
<h6 class="q-my-md text-primary">{%raw%}{{ $t('warning') }}{%endraw%}</h6>
|
||||
<h6 class="q-my-md text-primary" v-text="$t('warning')"></h6>
|
||||
<p>
|
||||
{%raw%}{{ $t('extension_uninstall_warning') }}{%endraw%} <br />
|
||||
{%raw%}{{ $t('confirm_continue') }}{%endraw%}
|
||||
<span v-text="$t('extension_uninstall_warning')"></span><br />
|
||||
<span v-text="$t('confirm_continue')"></span>
|
||||
</p>
|
||||
|
||||
<div class="row q-mt-lg">
|
||||
@@ -264,32 +260,39 @@
|
||||
value="false"
|
||||
label="Cleanup database tables"
|
||||
>
|
||||
<q-tooltip class="bg-grey-8" anchor="bottom left" self="top left">
|
||||
{%raw%}{{ $t('extension_db_drop_info') }}{%endraw%}
|
||||
<q-tooltip class="bg-grey-8" anchor="bottom left" self="top left"
|
||||
><span v-text="$t('extension_db_drop_info')"></span>
|
||||
</q-tooltip>
|
||||
</q-checkbox>
|
||||
</div>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn outline color="grey" @click="uninstallExtension()"
|
||||
>{%raw%}{{ $t('uninstall_confirm') }}{%endraw%}</q-btn
|
||||
>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
|
||||
>{%raw%}{{ $t('cancel') }}{%endraw%}</q-btn
|
||||
>
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
@click="uninstallExtension()"
|
||||
v-text="$t('uninstall_confirm')"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
v-close-popup
|
||||
flat
|
||||
color="grey"
|
||||
class="q-ml-auto"
|
||||
v-text="$t('cancel')"
|
||||
></q-btn>
|
||||
</div>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<q-dialog v-model="showDropDbDialog">
|
||||
<q-card v-if="selectedExtension" class="q-pa-lg">
|
||||
<h6 class="q-my-md text-primary">{%raw%}{{ $t('warning') }}{%endraw%}</h6>
|
||||
<p>{%raw%}{{ $t('extension_db_drop_warning') }}{%endraw%} <br /></p>
|
||||
<h6 class="q-my-md text-primary" v-text="$t('warning')"></h6>
|
||||
<p><span v-text="$t('extension_db_drop_warning')"></span><br /></p>
|
||||
<q-input
|
||||
v-model="dropDbExtensionId"
|
||||
:label="selectedExtension.id"
|
||||
></q-input>
|
||||
<br />
|
||||
<p>{%raw%}{{ $t('confirm_continue') }}{%endraw%}</p>
|
||||
<p v-text="$t('confirm_continue')"></p>
|
||||
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
@@ -297,17 +300,58 @@
|
||||
outline
|
||||
color="red"
|
||||
@click="dropExtensionDb()"
|
||||
>{%raw%}{{ $t('confirm') }}{%endraw%}</q-btn
|
||||
>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
|
||||
>{%raw%}{{ $t('cancel') }}{%endraw%}</q-btn
|
||||
>
|
||||
v-text="$t('confirm')"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
v-close-popup
|
||||
flat
|
||||
color="grey"
|
||||
class="q-ml-auto"
|
||||
v-text="$t('cancel')"
|
||||
></q-btn>
|
||||
</div>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<q-dialog v-model="showUpgradeDialog">
|
||||
<q-card class="q-pa-lg lnbits__dialog-card">
|
||||
<q-card v-if="selectedRelease" class="q-pa-lg lnbits__dialog-card">
|
||||
<q-card-section>
|
||||
<div v-if="selectedRelease.paymentRequest">
|
||||
<a :href="'lightning:' + selectedRelease.paymentRequest">
|
||||
<q-responsive :ratio="1" class="q-mx-xl">
|
||||
<lnbits-qrcode
|
||||
:value="'lightning:' + selectedRelease.paymentRequest.toUpperCase()"
|
||||
></lnbits-qrcode>
|
||||
</q-responsive>
|
||||
</a>
|
||||
</div>
|
||||
<div v-else>
|
||||
<q-spinner color="primary" size="2.55em"></q-spinner>
|
||||
</div>
|
||||
</q-card-section>
|
||||
|
||||
<div class="row q-mt-lg">
|
||||
<div class="col">
|
||||
<q-btn
|
||||
v-if="selectedRelease.paymentRequest"
|
||||
outline
|
||||
color="grey"
|
||||
@click="copyText(selectedRelease.paymentRequest)"
|
||||
:label="$t('copy_invoice')"
|
||||
></q-btn>
|
||||
</div>
|
||||
<div class="col">
|
||||
<q-btn
|
||||
v-close-popup
|
||||
flat
|
||||
color="grey"
|
||||
class="float-right q-ml-lg"
|
||||
v-text="$t('close')"
|
||||
></q-btn>
|
||||
</div>
|
||||
</div>
|
||||
</q-card>
|
||||
<q-card v-else class="q-pa-lg lnbits__dialog-card">
|
||||
<q-card-section>
|
||||
<div class="text-h6" v-text="selectedExtension?.name"></div>
|
||||
</q-card-section>
|
||||
@@ -337,7 +381,7 @@
|
||||
<q-item-section>
|
||||
<div class="row">
|
||||
<div class="col-10">
|
||||
{%raw%}{{ $t('repository') }}{%endraw%}
|
||||
<span v-text="$t('repository')"></span>
|
||||
<br />
|
||||
<small v-text="repoName"></small>
|
||||
<q-tooltip
|
||||
@@ -376,19 +420,104 @@
|
||||
color="pink"
|
||||
size="70px"
|
||||
></q-icon>
|
||||
Cannot get the release details.
|
||||
<span v-text="$t('release_details_error')"></span>
|
||||
</div>
|
||||
<q-card v-else>
|
||||
<q-card-section v-if="release.is_version_compatible">
|
||||
<q-btn
|
||||
v-if="!release.isInstalled"
|
||||
@click="installExtension(release)"
|
||||
color="primary unelevated mt-lg pt-lg"
|
||||
>{%raw%}{{ $t('install') }}{%endraw%}</q-btn
|
||||
>
|
||||
<q-btn v-else @click="showUninstall()" flat color="red">
|
||||
{%raw%}{{ $t('uninstall') }}{%endraw%}</q-btn
|
||||
<span
|
||||
v-if="release.requiresPayment && !release.paid_sats"
|
||||
v-text="$t('extension_cost', {cost: release.cost_sats})"
|
||||
class="q-mb-lg"
|
||||
></span>
|
||||
<span
|
||||
v-if="release.requiresPayment && release.paid_sats"
|
||||
class="q-mb-lg"
|
||||
v-text="$t('extension_paid_sats', {paid_sats: release.paid_sats})"
|
||||
></span>
|
||||
<div
|
||||
v-if="!release.requiresPayment || (release.requiresPayment && release.paid_sats)"
|
||||
>
|
||||
<q-btn
|
||||
v-if="!release.isInstalled"
|
||||
@click="installExtension(release)"
|
||||
color="primary unelevated mt-lg pt-lg"
|
||||
:label="$t('install')"
|
||||
></q-btn>
|
||||
</div>
|
||||
|
||||
<div v-if="release.requiresPayment && !release.paid_sats">
|
||||
<div v-if="!release.payment_hash">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.number="release.paidAmount"
|
||||
type="number"
|
||||
:min="release.cost_sats"
|
||||
suffix="sat"
|
||||
class="q-mt-sm"
|
||||
>
|
||||
</q-input>
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
emit-value
|
||||
v-model="release.wallet"
|
||||
:options="g.user.walletOptions"
|
||||
label="Wallet *"
|
||||
class="q-mt-sm"
|
||||
>
|
||||
</q-select>
|
||||
<q-btn
|
||||
unelevated
|
||||
color="primary"
|
||||
@click="payAndInstall(release)"
|
||||
:disabled="!release.wallet"
|
||||
class="q-mt-sm"
|
||||
:label="$t('pay_from_wallet')"
|
||||
></q-btn>
|
||||
|
||||
<q-btn
|
||||
unelevated
|
||||
color="primary"
|
||||
@click="showQRCode(release)"
|
||||
class="q-mt-sm float-right"
|
||||
:label="$t('show_qr')"
|
||||
></q-btn>
|
||||
</div>
|
||||
<div v-else>
|
||||
<br />
|
||||
<span
|
||||
class="q-mb-lg q-mt-lg"
|
||||
v-text="'There is a previous pending invoice for this release.'"
|
||||
></span>
|
||||
<br />
|
||||
<q-btn
|
||||
unelevated
|
||||
@click="installExtension(release)"
|
||||
color="primary"
|
||||
class="q-mt-sm"
|
||||
:label="$t('retry_install')"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
unelevated
|
||||
@click="clearHangingInvoice(release)"
|
||||
color="primary"
|
||||
class="q-mt-sm float-right"
|
||||
:label="$t('new_payment')"
|
||||
></q-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<q-btn
|
||||
v-if="release.isInstalled"
|
||||
@click="showUninstall()"
|
||||
:label="$t('uninstall')"
|
||||
flat
|
||||
color="red"
|
||||
></q-btn>
|
||||
</div>
|
||||
|
||||
<a
|
||||
v-if="release.html_url"
|
||||
class="text-secondary float-right"
|
||||
@@ -396,11 +525,11 @@
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style="color: inherit"
|
||||
>{%raw%}{{ $t('release_notes') }}{%endraw%}</a
|
||||
>
|
||||
v-text="$t('release_notes')"
|
||||
></a>
|
||||
</q-card-section>
|
||||
<q-card-section v-else>
|
||||
{%raw%}{{ $t('extension_min_lnbits_version') }}{%endraw%}
|
||||
<span v-text="$t('extension_min_lnbits_version')"></span>
|
||||
<strong>
|
||||
<span v-text="release.min_lnbits_version"></span>
|
||||
</strong>
|
||||
@@ -408,8 +537,11 @@
|
||||
<q-card v-if="release.warning">
|
||||
<q-card-section>
|
||||
<div class="text-h6">
|
||||
<q-badge color="yellow" text-color="black">
|
||||
{%raw%}{{ $t('warning') }}{%endraw%}
|
||||
<q-badge
|
||||
color="yellow"
|
||||
text-color="black"
|
||||
v-text="$t('warning')"
|
||||
>
|
||||
</q-badge>
|
||||
</div>
|
||||
<div class="text-subtitle2">
|
||||
@@ -432,9 +564,8 @@
|
||||
@click="showUninstall()"
|
||||
flat
|
||||
color="red"
|
||||
>
|
||||
{%raw%}{{ $t('uninstall') }}{%endraw%}</q-btn
|
||||
>
|
||||
v-text="$t('uninstall')"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
v-else-if="selectedExtension?.hasDatabaseTables"
|
||||
@click="showDropDb()"
|
||||
@@ -442,9 +573,13 @@
|
||||
color="red"
|
||||
:label="$t('drop_db')"
|
||||
></q-btn>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto">
|
||||
{%raw%}{{ $t('close') }}{%endraw%}</q-btn
|
||||
>
|
||||
<q-btn
|
||||
v-close-popup
|
||||
flat
|
||||
color="grey"
|
||||
class="q-ml-auto"
|
||||
v-text="$t('close')"
|
||||
></q-btn>
|
||||
</div>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
@@ -464,8 +599,10 @@
|
||||
dropDbExtensionId: '',
|
||||
selectedExtension: null,
|
||||
selectedExtensionRepos: null,
|
||||
selectedRelease: null,
|
||||
uninstallAndDropDb: false,
|
||||
maxStars: 5,
|
||||
paylinkWebsocket: null,
|
||||
user: null
|
||||
}
|
||||
},
|
||||
@@ -504,10 +641,18 @@
|
||||
.filter(extensionNameContains(term))
|
||||
this.tab = tab
|
||||
},
|
||||
|
||||
installExtension: async function (release) {
|
||||
// no longer required to check if the invoice was paid
|
||||
// the install logic has been triggered one way or another
|
||||
this.unsubscribeFromPaylinkWs()
|
||||
|
||||
const extension = this.selectedExtension
|
||||
extension.inProgress = true
|
||||
this.showUpgradeDialog = false
|
||||
release.payment_hash =
|
||||
release.payment_hash || this.getPaylinkHash(release.pay_link)
|
||||
|
||||
LNbits.api
|
||||
.request(
|
||||
'POST',
|
||||
@@ -516,7 +661,9 @@
|
||||
{
|
||||
ext_id: extension.id,
|
||||
archive: release.archive,
|
||||
source_repo: release.source_repo
|
||||
source_repo: release.source_repo,
|
||||
payment_hash: release.payment_hash,
|
||||
version: release.version
|
||||
}
|
||||
)
|
||||
.then(response => {
|
||||
@@ -530,8 +677,9 @@
|
||||
this.tab = 'installed'
|
||||
})
|
||||
.catch(err => {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
console.warn(err)
|
||||
extension.inProgress = false
|
||||
LNbits.utils.notifyApiError(err)
|
||||
})
|
||||
},
|
||||
uninstallExtension: async function () {
|
||||
@@ -623,8 +771,10 @@
|
||||
|
||||
showUpgrade: async function (extension) {
|
||||
this.selectedExtension = extension
|
||||
this.showUpgradeDialog = true
|
||||
this.selectedRelease = null
|
||||
this.selectedExtensionRepos = null
|
||||
this.showUpgradeDialog = true
|
||||
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
'GET',
|
||||
@@ -648,6 +798,12 @@
|
||||
if (release.isInstalled) {
|
||||
repos[release.source_repo].isInstalled = true
|
||||
}
|
||||
if (release.pay_link) {
|
||||
release.requiresPayment = true
|
||||
release.paidAmount = release.cost_sats
|
||||
release.payment_hash = this.getPaylinkHash(release.pay_link)
|
||||
}
|
||||
|
||||
repos[release.source_repo].releases.push(release)
|
||||
return repos
|
||||
}, {})
|
||||
@@ -656,6 +812,122 @@
|
||||
extension.inProgress = false
|
||||
}
|
||||
},
|
||||
|
||||
async payAndInstall(release) {
|
||||
try {
|
||||
this.selectedExtension.inProgress = true
|
||||
this.showUpgradeDialog = false
|
||||
const paymentInfo = await this.requestPayment(
|
||||
this.selectedExtension.id,
|
||||
release
|
||||
)
|
||||
this.rememberPaylinkHash(release.pay_link, paymentInfo.payment_hash)
|
||||
const wallet = this.g.user.wallets.find(w => w.id === release.wallet)
|
||||
const {data} = await LNbits.api.payInvoice(
|
||||
wallet,
|
||||
paymentInfo.payment_request
|
||||
)
|
||||
|
||||
release.payment_hash = data.payment_hash
|
||||
|
||||
await this.installExtension(release)
|
||||
} catch (err) {
|
||||
console.warn(err)
|
||||
LNbits.utils.notifyApiError(err)
|
||||
} finally {
|
||||
this.selectedExtension.inProgress = false
|
||||
}
|
||||
},
|
||||
async showQRCode(release) {
|
||||
this.selectedRelease = release
|
||||
|
||||
try {
|
||||
const data = await this.requestPayment(
|
||||
this.selectedExtension.id,
|
||||
release
|
||||
)
|
||||
|
||||
this.selectedRelease.paymentRequest = data.payment_request
|
||||
this.selectedRelease.payment_hash = data.payment_hash
|
||||
this.selectedRelease = _.clone(this.selectedRelease)
|
||||
this.rememberPaylinkHash(
|
||||
this.selectedRelease.pay_link,
|
||||
this.selectedRelease.payment_hash
|
||||
)
|
||||
|
||||
this.subscribeToPaylinkWs(
|
||||
this.selectedRelease.pay_link,
|
||||
data.payment_hash
|
||||
)
|
||||
} catch (err) {
|
||||
console.warn(err)
|
||||
LNbits.utils.notifyApiError(err)
|
||||
}
|
||||
},
|
||||
|
||||
async requestPayment(extId, release) {
|
||||
const {data} = await LNbits.api.request(
|
||||
'PUT',
|
||||
`/api/v1/extension/invoice`,
|
||||
this.g.user.wallets[0].adminkey,
|
||||
{
|
||||
ext_id: extId,
|
||||
archive: release.archive,
|
||||
source_repo: release.source_repo,
|
||||
cost_sats: release.paidAmount,
|
||||
version: release.version
|
||||
}
|
||||
)
|
||||
return data
|
||||
},
|
||||
|
||||
clearHangingInvoice(release) {
|
||||
this.forgetPaylinkHash(release.pay_link)
|
||||
release.payment_hash = null
|
||||
},
|
||||
|
||||
rememberPaylinkHash(pay_link, payment_hash) {
|
||||
this.$q.localStorage.set(
|
||||
`lnbits.extensions.paylink.${pay_link}`,
|
||||
payment_hash
|
||||
)
|
||||
},
|
||||
getPaylinkHash(pay_link) {
|
||||
return this.$q.localStorage.getItem(
|
||||
`lnbits.extensions.paylink.${pay_link}`
|
||||
)
|
||||
},
|
||||
forgetPaylinkHash(pay_link) {
|
||||
this.$q.localStorage.remove(`lnbits.extensions.paylink.${pay_link}`)
|
||||
},
|
||||
subscribeToPaylinkWs(pay_link, payment_hash) {
|
||||
const url = new URL(`${pay_link}/${payment_hash}`)
|
||||
url.protocol = url.protocol === 'https:' ? 'wss' : 'ws'
|
||||
this.paylinkWebsocket = new WebSocket(url)
|
||||
this.paylinkWebsocket.addEventListener('message', async ({data}) => {
|
||||
const resp = JSON.parse(data)
|
||||
if (resp.paid) {
|
||||
this.$q.notify({
|
||||
type: 'positive',
|
||||
message: 'Invoice Paid!'
|
||||
})
|
||||
this.installExtension(this.selectedRelease)
|
||||
} else {
|
||||
this.$q.notify({
|
||||
type: 'warning',
|
||||
message: 'Invoice tracking lost!'
|
||||
})
|
||||
}
|
||||
})
|
||||
},
|
||||
unsubscribeFromPaylinkWs() {
|
||||
try {
|
||||
this.paylinkWebsocket && this.paylinkWebsocket.close()
|
||||
} catch (error) {
|
||||
console.warn(error)
|
||||
}
|
||||
},
|
||||
|
||||
hasNewVersion: function (extension) {
|
||||
if (extension.installedRelease && extension.latestRelease) {
|
||||
return (
|
||||
|
||||
@@ -4,12 +4,23 @@
|
||||
<div class="row q-col-gutter-md justify-center">
|
||||
<div
|
||||
v-if="isUserAuthorized"
|
||||
class="col-12 col-md-7 col-lg-6 q-gutter-y-md"
|
||||
class="col-12 col-md-6 col-lg-6 q-gutter-y-md"
|
||||
></div>
|
||||
<div v-else class="col-12 col-md-7 col-lg-6 q-gutter-y-md">
|
||||
<div v-else class="col-12 col-md-4 col-lg-4 q-gutter-y-md">
|
||||
<div class="gt-sm">
|
||||
<h3 class="q-my-none">{{SITE_TITLE}}</h3>
|
||||
<h5 class="q-my-md">{{SITE_TAGLINE}}</h5>
|
||||
<h3
|
||||
class="q-my-none"
|
||||
v-if="'{{LNBITS_SHOW_HOME_PAGE_ELEMENTS}}' == 'True'"
|
||||
>
|
||||
{{SITE_TITLE}}
|
||||
</h3>
|
||||
<h5 class="q-my-md" v-if="'{{LNBITS_SHOW_HOME_PAGE_ELEMENTS}}' == 'True'">
|
||||
{{SITE_TAGLINE}}
|
||||
</h5>
|
||||
<div
|
||||
v-html="formatDescription"
|
||||
v-if="'{{LNBITS_SHOW_HOME_PAGE_ELEMENTS}}' == 'True'"
|
||||
></div>
|
||||
</div>
|
||||
{% if lnurl and LNBITS_NEW_ACCOUNTS_ALLOWED and ("user-id-only" in
|
||||
LNBITS_AUTH_METHODS)%}
|
||||
@@ -33,7 +44,7 @@
|
||||
{%else%} {% endif %}
|
||||
|
||||
<div class="row q-mt-md">
|
||||
<div class="col-12 col-md-8 col-lg-7 q-gutter-y-md">
|
||||
<div class="col-12 col-md-10 col-lg-10 q-gutter-y-md">
|
||||
<q-badge v-if="isAccessTokenExpired" color="primary" rounded>
|
||||
<div class="text-h5">
|
||||
<span v-text="$t('session_has_expired')"></span>
|
||||
@@ -259,6 +270,25 @@
|
||||
<div><span v-text="$t('signin_with_github')"></span></div>
|
||||
</q-btn>
|
||||
</div>
|
||||
{%endif%} {% if "keycloak-auth" in LNBITS_AUTH_METHODS %}
|
||||
<div class="col-12 full-width q-pa-sm">
|
||||
<q-btn
|
||||
href="/api/v1/auth/keycloak"
|
||||
type="a"
|
||||
outline
|
||||
no-caps
|
||||
color="grey"
|
||||
rounded
|
||||
class="full-width"
|
||||
>
|
||||
<q-avatar size="32px" class="q-mr-md">
|
||||
<q-img
|
||||
:src="'{{ static_url_for('static', 'images/keycloak-logo.png') }}'"
|
||||
></q-img>
|
||||
</q-avatar>
|
||||
<div><span v-text="$t('signin_with_keycloak')"></span></div>
|
||||
</q-btn>
|
||||
</div>
|
||||
{%endif%}
|
||||
</div>
|
||||
</q-card-section>
|
||||
@@ -282,11 +312,16 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ads -->
|
||||
<div
|
||||
class="col-12 col-md-3 col-lg-3 gt-sm"
|
||||
v-if="'{{SITE_TITLE}}' == 'LNbits'"
|
||||
v-if="'{{LNBITS_SHOW_HOME_PAGE_ELEMENTS}}' == 'False'"
|
||||
class="col-12 col-md-5 col-lg-5 q-pt-xl"
|
||||
>
|
||||
<h3 class="q-my-none">{{SITE_TITLE}}</h3>
|
||||
<h5 class="q-my-md">{{SITE_TAGLINE}}</h5>
|
||||
<div v-html="formatDescription"></div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-3 col-lg-3 gt-sm" v-else>
|
||||
<div class="row q-col-gutter-lg justify-center">
|
||||
<div class="col-6 col-sm-4 col-md-8 q-gutter-y-sm">
|
||||
<q-btn
|
||||
@@ -303,7 +338,7 @@
|
||||
outline
|
||||
color="grey"
|
||||
type="a"
|
||||
href="https://legend.lnbits.com/paywall/GAqKguK5S8f6w5VNjS9DfK"
|
||||
href="https://demo.lnbits.com/lnurlp/link/fH59GD"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
:label="$t('donate')"
|
||||
@@ -462,30 +497,60 @@
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col q-pl-md">
|
||||
<a href="https://zbd.gg" target="_blank" rel="noopener noreferrer">
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/zbd.png') }}' : '{{ static_url_for('static', 'images/zbdl.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<a
|
||||
href="https://phoenix.acinq.co/server"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/phoenixd.png') }}' : '{{ static_url_for('static', 'images/phoenixdl.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col q-pl-md"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if AD_SPACE %} {% for ADS in AD_SPACE %} {% set AD = ADS.split(';') %}
|
||||
<div class="col-6 col-sm-4 col-md-8 q-gutter-y-sm">
|
||||
<q-btn flat color="secondary" class="full-width q-mb-md"
|
||||
>{{ AD_SPACE_TITLE }}</q-btn
|
||||
>
|
||||
|
||||
<a href="{{ AD[0] }}" class="q-ma-md">
|
||||
<img
|
||||
v-if="($q.dark.isActive)"
|
||||
src="{{ AD[1] }}"
|
||||
style="max-width: 90%"
|
||||
/>
|
||||
<img v-else src="{{ AD[2] }}" style="max-width: 90%" />
|
||||
</a>
|
||||
</div>
|
||||
{% endfor %} {% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row gt-sm q-mt-xl">
|
||||
{% if AD_SPACE %}
|
||||
<div class="row justify-center">
|
||||
<q-btn flat color="secondary" class="full-width q-mb-md"
|
||||
>{{ AD_SPACE_TITLE }}</q-btn
|
||||
>
|
||||
|
||||
{% for ADS in AD_SPACE %} {% set AD = ADS.split(';') %}
|
||||
<div class="flex flex-center column">
|
||||
<a href="{{ AD[0] }}">
|
||||
<img
|
||||
v-if="($q.dark.isActive)"
|
||||
src="{{ AD[1] }}"
|
||||
style="max-width: 420px"
|
||||
/>
|
||||
<img v-else src="{{ AD[2] }}" style="max-width: 420px" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div
|
||||
v-if="'{{LNBITS_SHOW_HOME_PAGE_ELEMENTS}}' == 'True'"
|
||||
class="row gt-sm q-mt-xl"
|
||||
>
|
||||
<div class="col-1"></div>
|
||||
<div class="col-10 q-pl-xl">
|
||||
<span v-text="$t('lnbits_description')"></span>
|
||||
|
||||
@@ -102,226 +102,11 @@
|
||||
</div>
|
||||
</div>
|
||||
</q-card>
|
||||
|
||||
<q-card
|
||||
:style="$q.screen.lt.md ? {
|
||||
background: $q.screen.lt.md ? 'none !important': ''
|
||||
, boxShadow: $q.screen.lt.md ? 'none !important': ''
|
||||
, marginTop: $q.screen.lt.md ? '0px !important': ''
|
||||
} : ''"
|
||||
>
|
||||
<q-card-section>
|
||||
<div class="row items-center no-wrap q-mb-sm">
|
||||
<div class="col">
|
||||
<h5
|
||||
class="text-subtitle1 q-my-none"
|
||||
:v-text="$t('transactions')"
|
||||
></h5>
|
||||
</div>
|
||||
<div class="gt-sm col-auto">
|
||||
<q-btn
|
||||
flat
|
||||
color="grey"
|
||||
@click="exportCSV"
|
||||
:label="$t('export_csv')"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
dense
|
||||
flat
|
||||
round
|
||||
icon="show_chart"
|
||||
color="grey"
|
||||
@click="showChart"
|
||||
>
|
||||
<q-tooltip>
|
||||
<span v-text="$t('chart_tooltip')"></span
|
||||
></q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
<q-input
|
||||
:style="$q.screen.lt.md ? {
|
||||
display: mobileSimple ? 'none !important': ''
|
||||
} : ''"
|
||||
filled
|
||||
dense
|
||||
clearable
|
||||
v-model="paymentsTable.search"
|
||||
debounce="300"
|
||||
:placeholder="$t('search_by_tag_memo_amount')"
|
||||
class="q-mb-md"
|
||||
>
|
||||
</q-input>
|
||||
<q-table
|
||||
dense
|
||||
flat
|
||||
:data="paymentsOmitter"
|
||||
:row-key="paymentTableRowKey"
|
||||
:columns="paymentsTable.columns"
|
||||
:pagination.sync="paymentsTable.pagination"
|
||||
:no-data-label="$t('no_transactions')"
|
||||
:filter="paymentsTable.search"
|
||||
:loading="paymentsTable.loading"
|
||||
:hide-header="mobileSimple"
|
||||
:hide-bottom="mobileSimple"
|
||||
@request="fetchPayments"
|
||||
>
|
||||
<template v-slot:header="props">
|
||||
<q-tr :props="props">
|
||||
<q-th auto-width></q-th>
|
||||
<q-th
|
||||
v-for="col in props.cols"
|
||||
:key="col.name"
|
||||
:props="props"
|
||||
v-text="col.label"
|
||||
></q-th>
|
||||
</q-tr>
|
||||
</template>
|
||||
<template v-slot:body="props">
|
||||
<q-tr :props="props">
|
||||
<q-td auto-width class="text-center">
|
||||
<q-icon
|
||||
v-if="props.row.isPaid"
|
||||
size="14px"
|
||||
:name="props.row.isOut ? 'call_made' : 'call_received'"
|
||||
:color="props.row.isOut ? 'pink' : 'green'"
|
||||
@click="props.expand = !props.expand"
|
||||
></q-icon>
|
||||
<q-icon
|
||||
v-else
|
||||
name="settings_ethernet"
|
||||
color="grey"
|
||||
@click="props.expand = !props.expand"
|
||||
>
|
||||
<q-tooltip
|
||||
><span v-text="$t('pending')"></span
|
||||
></q-tooltip>
|
||||
</q-icon>
|
||||
</q-td>
|
||||
<q-td
|
||||
key="time"
|
||||
:props="props"
|
||||
style="white-space: normal; word-break: break-all"
|
||||
>
|
||||
<q-badge
|
||||
v-if="props.row.tag"
|
||||
color="yellow"
|
||||
text-color="black"
|
||||
>
|
||||
<a
|
||||
v-text="'#'+props.row.tag"
|
||||
class="inherit"
|
||||
:href="['/', props.row.tag].join('')"
|
||||
></a>
|
||||
</q-badge>
|
||||
<span v-text="props.row.memo"></span>
|
||||
<br />
|
||||
|
||||
<i>
|
||||
<span v-text="props.row.dateFrom"></span>
|
||||
<q-tooltip
|
||||
><span v-text="props.row.date"></span
|
||||
></q-tooltip>
|
||||
</i>
|
||||
</q-td>
|
||||
<q-td
|
||||
auto-width
|
||||
key="amount"
|
||||
v-if="'{{LNBITS_DENOMINATION}}' != 'sats'"
|
||||
:props="props"
|
||||
v-text="parseFloat(String(props.row.fsat).replaceAll(',', '')) / 100"
|
||||
>
|
||||
</q-td>
|
||||
<q-td auto-width key="amount" v-else :props="props">
|
||||
<span v-text="props.row.fsat"></span>
|
||||
<br />
|
||||
<i v-if="props.row.extra.wallet_fiat_currency">
|
||||
<span
|
||||
v-text="formatFiat(props.row.extra.wallet_fiat_currency, props.row.extra.wallet_fiat_amount)"
|
||||
></span>
|
||||
<br />
|
||||
</i>
|
||||
<i v-if="props.row.extra.fiat_currency">
|
||||
<span
|
||||
v-text="formatFiat(props.row.extra.fiat_currency, props.row.extra.fiat_amount)"
|
||||
></span>
|
||||
</i>
|
||||
</q-td>
|
||||
</q-tr>
|
||||
|
||||
<q-dialog v-model="props.expand" :props="props" position="top">
|
||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<div class="text-center q-mb-lg">
|
||||
<div v-if="props.row.isIn && props.row.pending">
|
||||
<q-icon name="settings_ethernet" color="grey"></q-icon>
|
||||
<span v-text="$t('invoice_waiting')"></span>
|
||||
<lnbits-payment-details
|
||||
:payment="props.row"
|
||||
></lnbits-payment-details>
|
||||
<div
|
||||
v-if="props.row.bolt11"
|
||||
class="text-center q-mb-lg"
|
||||
>
|
||||
<a :href="'lightning:' + props.row.bolt11">
|
||||
<q-responsive :ratio="1" class="q-mx-xl">
|
||||
<lnbits-qrcode
|
||||
:value="'lightning:' + props.row.bolt11.toUpperCase()"
|
||||
></lnbits-qrcode>
|
||||
</q-responsive>
|
||||
</a>
|
||||
</div>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
@click="copyText(props.row.bolt11)"
|
||||
:label="$t('copy_invoice')"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
v-close-popup
|
||||
flat
|
||||
color="grey"
|
||||
class="q-ml-auto"
|
||||
:label="$t('close')"
|
||||
></q-btn>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="props.row.isPaid && props.row.isIn">
|
||||
<q-icon
|
||||
size="18px"
|
||||
:name="'call_received'"
|
||||
:color="'green'"
|
||||
></q-icon>
|
||||
<span v-text="$t('payment_received')"></span>
|
||||
<lnbits-payment-details
|
||||
:payment="props.row"
|
||||
></lnbits-payment-details>
|
||||
</div>
|
||||
<div v-else-if="props.row.isPaid && props.row.isOut">
|
||||
<q-icon
|
||||
size="18px"
|
||||
:name="'call_made'"
|
||||
:color="'pink'"
|
||||
></q-icon>
|
||||
<span v-text="$t('payment_sent')"></span>
|
||||
<lnbits-payment-details
|
||||
:payment="props.row"
|
||||
></lnbits-payment-details>
|
||||
</div>
|
||||
<div v-else-if="props.row.isOut && props.row.pending">
|
||||
<q-icon name="settings_ethernet" color="grey"></q-icon>
|
||||
<span v-text="$t('outgoing_payment_pending')"></span>
|
||||
<lnbits-payment-details
|
||||
:payment="props.row"
|
||||
></lnbits-payment-details>
|
||||
</div>
|
||||
</div>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</template>
|
||||
</q-table>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
<payment-list
|
||||
:update="updatePayments"
|
||||
:wallet="this.g.wallet"
|
||||
:mobile-simple="mobileSimple"
|
||||
/>
|
||||
</div>
|
||||
{% if HIDE_API %}
|
||||
<div class="col-12 col-md-4 q-gutter-y-md">
|
||||
@@ -370,7 +155,7 @@
|
||||
<q-card-section class="text-center">
|
||||
<p v-text="$t('export_to_phone_desc')"></p>
|
||||
<qrcode
|
||||
:value="'{{request.base_url}}' +'wallet?wal={{wallet.id}}'"
|
||||
:value="'{{request.base_url}}wallet?usr={{user.id}}&wal={{wallet.id}}'"
|
||||
:options="{width:240}"
|
||||
></qrcode>
|
||||
</q-card-section>
|
||||
@@ -459,19 +244,21 @@
|
||||
</q-list>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
{% endif %} {% if AD_SPACE %} {% for ADS in AD_SPACE %} {% set AD =
|
||||
ADS.split(";") %}
|
||||
{% endif %} {% if AD_SPACE %}
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<h6 class="text-subtitle1 q-mt-none q-mb-sm">
|
||||
{{ AD_SPACE_TITLE }}
|
||||
</h6>
|
||||
</q-card-section>
|
||||
{% for ADS in AD_SPACE %} {% set AD = ADS.split(";") %}
|
||||
|
||||
<q-card-section class="q-pa-none">
|
||||
<a
|
||||
style="display: inline-block"
|
||||
href="{{ AD[0] }}"
|
||||
class="q-ma-md"
|
||||
class="q-ml-md q-mb-xs q-mr-md"
|
||||
style="max-width: 80%"
|
||||
>
|
||||
<img
|
||||
style="max-width: 100%; height: auto"
|
||||
@@ -483,8 +270,10 @@
|
||||
v-else
|
||||
src="{{ AD[2] }}"
|
||||
/>
|
||||
</a> </q-card-section></q-card
|
||||
>{% endfor %} {% endif %}
|
||||
</a> </q-card-section
|
||||
>{% endfor %}
|
||||
</q-card>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -873,27 +662,6 @@
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<q-dialog v-model="paymentsChart.show" position="top">
|
||||
<q-card class="q-pa-sm" style="width: 800px; max-width: unset">
|
||||
<q-card-section>
|
||||
<div class="row q-gutter-sm justify-between">
|
||||
<div class="text-h6">Payments Chart</div>
|
||||
<q-select
|
||||
label="Group"
|
||||
filled
|
||||
dense
|
||||
v-model="paymentsChart.group"
|
||||
style="min-width: 120px"
|
||||
:options="paymentsChart.groupOptions"
|
||||
>
|
||||
</q-select>
|
||||
</div>
|
||||
|
||||
<canvas ref="canvas" width="600" height="400"></canvas>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<q-tabs
|
||||
class="lt-md fixed-bottom left-0 right-0 bg-primary text-white shadow-2 z-top"
|
||||
active-class="px-0"
|
||||
@@ -922,8 +690,11 @@
|
||||
|
||||
<q-dialog v-model="disclaimerDialog.show" position="top">
|
||||
<q-card class="q-pa-lg">
|
||||
<h6 class="q-my-md text-primary">Warning</h6>
|
||||
<p v-text="$t('disclaimer_dialog')"></p>
|
||||
<h6
|
||||
class="q-my-md text-primary"
|
||||
v-text="$t('disclaimer_dialog_title')"
|
||||
></h6>
|
||||
<p class="whitespace-pre-line" v-text="$t('disclaimer_dialog')"></p>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
outline
|
||||
|
||||
@@ -150,7 +150,6 @@
|
||||
Open channel
|
||||
</q-btn>
|
||||
</div>
|
||||
{% raw %}
|
||||
<div>
|
||||
<div class="text-subtitle1 col-grow">Total</div>
|
||||
<lnbits-channel-balance
|
||||
@@ -172,11 +171,12 @@
|
||||
<q-tr :props="props">
|
||||
<div class="q-pb-sm">
|
||||
<div class="row items-center q-gutter-sm">
|
||||
<div class="text-subtitle1 col-grow">
|
||||
{{props.row.name}}
|
||||
</div>
|
||||
<div
|
||||
class="text-subtitle1 col-grow"
|
||||
v-text="props.row.name"
|
||||
></div>
|
||||
<div class="text-caption" v-if="props.row.short_id">
|
||||
{{ props.row.short_id }}
|
||||
<span v-text="props.row.short_id"></span>
|
||||
<q-btn
|
||||
size="xs"
|
||||
flat
|
||||
@@ -188,9 +188,8 @@
|
||||
<q-badge
|
||||
rounded
|
||||
:color="states.find(s => s.value == props.row.state)?.color"
|
||||
v-text="states.find(s => s.value == props.row.state)?.label"
|
||||
>
|
||||
{{ states.find(s => s.value == props.row.state)?.label
|
||||
}}
|
||||
</q-badge>
|
||||
<q-btn
|
||||
:disable='props.row.state !== "active"'
|
||||
@@ -210,15 +209,12 @@
|
||||
</q-tr>
|
||||
</template>
|
||||
</q-table>
|
||||
|
||||
{% endraw %}
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
<div class="col-12 col-xl-6">
|
||||
<q-card class="full-height">
|
||||
<q-card-section class="column q-gutter-y-sm">
|
||||
{% raw %}
|
||||
<div
|
||||
class="row items-center q-mt-none justify-between q-gutter-x-md no-wrap"
|
||||
>
|
||||
@@ -254,19 +250,21 @@
|
||||
<q-tr :props="props">
|
||||
<div class="row no-wrap items-center q-gutter-sm">
|
||||
<div class="q-my-sm col-grow">
|
||||
<div class="text-subtitle1 text-bold">
|
||||
{{ props.row.alias }}
|
||||
</div>
|
||||
<div
|
||||
class="text-subtitle1 text-bold"
|
||||
v-text="props.row.alias"
|
||||
></div>
|
||||
<div class="row items-center q-gutter-sm">
|
||||
<q-badge
|
||||
:style="`background-color: #${props.row.color}`"
|
||||
class="text-bold"
|
||||
v-text="'#'+props.row.color"
|
||||
>
|
||||
#{{ props.row.color }}
|
||||
</q-badge>
|
||||
<div class="text-bold">
|
||||
{{ shortenNodeId(props.row.id) }}
|
||||
</div>
|
||||
<div
|
||||
class="text-bold"
|
||||
v-text="shortenNodeId(props.row.id)"
|
||||
></div>
|
||||
<q-btn
|
||||
size="xs"
|
||||
flat
|
||||
@@ -302,8 +300,6 @@
|
||||
</q-tr>
|
||||
</template>
|
||||
</q-table>
|
||||
|
||||
{% endraw %}
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<q-tab-panel name="dashboard">
|
||||
<q-card-section class="q-pa-none">
|
||||
{% raw %}
|
||||
<lnbits-node-info :info="this.info"></lnbits-node-info>
|
||||
<div class="row q-col-gutter-lg q-mt-sm">
|
||||
<div class="col-12 col-md-8 q-gutter-y-md">
|
||||
@@ -65,6 +64,5 @@
|
||||
></lnbits-channel-stats>
|
||||
</div>
|
||||
</div>
|
||||
{% endraw %}
|
||||
</q-card-section>
|
||||
</q-tab-panel>
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
<q-dialog v-model="transactionDetailsDialog.show">
|
||||
<q-card class="my-card">
|
||||
<q-card-section>
|
||||
{% raw %}
|
||||
<div class="text-center q-mb-lg">
|
||||
<div
|
||||
v-if="transactionDetailsDialog.data.isIn && transactionDetailsDialog.data.pending"
|
||||
@@ -18,7 +17,9 @@
|
||||
<div class="row q-my-md">
|
||||
<div class="col-3"><b v-text="$t('payment_hash')"></b>:</div>
|
||||
<div class="col-9 text-wrap mono">
|
||||
{{ transactionDetailsDialog.data.payment_hash }}
|
||||
<span
|
||||
v-text="transactionDetailsDialog.data.payment_hash"
|
||||
></span>
|
||||
<q-icon
|
||||
name="content_copy"
|
||||
@click="copyText(transactionDetailsDialog.data.payment_hash)"
|
||||
@@ -33,7 +34,7 @@
|
||||
>
|
||||
<div class="col-3"><b v-text="$t('payment_proof')"></b>:</div>
|
||||
<div class="col-9 text-wrap mono">
|
||||
{{ transactionDetailsDialog.data.preimage }}
|
||||
<span v-text="transactionDetailsDialog.data.preimage"></span>
|
||||
<q-icon
|
||||
name="content_copy"
|
||||
@click="copyText(transactionDetailsDialog.data.preimage)"
|
||||
@@ -66,7 +67,6 @@
|
||||
></q-btn>
|
||||
</div>
|
||||
</div>
|
||||
{% endraw %}
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
@@ -102,7 +102,6 @@
|
||||
:filter="paymentsTable.filter"
|
||||
@request="getPayments"
|
||||
>
|
||||
{% raw %}
|
||||
<template v-slot:body-cell-pending="props">
|
||||
<q-td auto-width class="text-center">
|
||||
<q-icon
|
||||
@@ -211,9 +210,8 @@
|
||||
<q-badge
|
||||
:style="`background-color: #${props.row.destination?.color}`"
|
||||
class="text-bold"
|
||||
>
|
||||
{{ props.row.destination?.alias }}
|
||||
</q-badge>
|
||||
v-text="props.row.destination?.alias"
|
||||
></q-badge>
|
||||
<div>
|
||||
<q-btn
|
||||
size="xs"
|
||||
@@ -233,7 +231,6 @@
|
||||
</div>
|
||||
</q-td>
|
||||
</template>
|
||||
{% endraw %}
|
||||
</q-table>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
@@ -266,7 +263,6 @@
|
||||
:filter="invoiceTable.filter"
|
||||
@request="getInvoices"
|
||||
>
|
||||
{% raw %}
|
||||
<template v-slot:body-cell-pending="props">
|
||||
<q-td auto-width class="text-center">
|
||||
<q-icon
|
||||
@@ -305,8 +301,6 @@
|
||||
></lnbits-date>
|
||||
</q-td>
|
||||
</template>
|
||||
|
||||
{% endraw %}
|
||||
</q-table>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// update cache version every time there is a new deployment
|
||||
// so the service worker reinitializes the cache
|
||||
const CACHE_VERSION = 107
|
||||
const CURRENT_CACHE = `lnbits-${CACHE_VERSION}-`
|
||||
const CURRENT_CACHE = 'lnbits-{{ cache_version }}-'
|
||||
|
||||
const getApiKey = request => {
|
||||
let api_key = request.headers.get('X-Api-Key')
|
||||
@@ -17,8 +16,7 @@ self.addEventListener('activate', evt =>
|
||||
caches.keys().then(cacheNames => {
|
||||
return Promise.all(
|
||||
cacheNames.map(cacheName => {
|
||||
const currentCacheVersion = cacheName.split('-').slice(-2, 2)
|
||||
if (currentCacheVersion !== CACHE_VERSION) {
|
||||
if (!cacheName.startsWith(CURRENT_CACHE)) {
|
||||
return caches.delete(cacheName)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
<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>
|
||||
@@ -0,0 +1,23 @@
|
||||
<q-dialog v-model="createWalletDialog.show">
|
||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<p>Create Wallet</p>
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<q-form @submit="createWallet">
|
||||
<lnbits-dynamic-fields
|
||||
:options="createWalletDialog.fields"
|
||||
v-model="createUserDialog.data"
|
||||
></lnbits-dynamic-fields>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn v-close-popup unelevated color="primary" type="submit"
|
||||
>Create</q-btn
|
||||
>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
|
||||
>Cancel</q-btn
|
||||
>
|
||||
</div>
|
||||
</q-form>
|
||||
</div>
|
||||
</div>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
@@ -0,0 +1,49 @@
|
||||
<q-dialog v-model="topupDialog.show" position="top">
|
||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<q-form class="q-gutter-md">
|
||||
<p v-text="$t('topup_wallet')"></p>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<q-input
|
||||
dense
|
||||
type="text"
|
||||
filled
|
||||
v-model="wallet.id"
|
||||
label="Wallet ID"
|
||||
:hint="$t('topup_hint')"
|
||||
></q-input>
|
||||
|
||||
<br />
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<q-input
|
||||
dense
|
||||
type="number"
|
||||
filled
|
||||
v-model="wallet.amount"
|
||||
:label="$t('amount')"
|
||||
></q-input>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
:label="$t('topup')"
|
||||
color="primary"
|
||||
@click="topupWallet"
|
||||
v-close-popup
|
||||
></q-btn>
|
||||
|
||||
<q-btn
|
||||
v-close-popup
|
||||
flat
|
||||
color="grey"
|
||||
class="q-ml-auto"
|
||||
:label="$t('cancel')"
|
||||
></q-btn>
|
||||
</div>
|
||||
</q-form>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
@@ -0,0 +1,106 @@
|
||||
<q-dialog v-model="walletDialog.show">
|
||||
<q-card class="q-pa-lg" style="width: 700px; max-width: 80vw">
|
||||
<h2 class="text-h6 q-mb-md">Wallets</h2>
|
||||
<q-dialog v-model="paymentDialog.show">
|
||||
<q-card class="q-pa-lg" style="width: 700px; max-width: 80vw">
|
||||
<payment-list :wallet="activeWallet" />
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
<q-table :data="wallets" :columns="walletTable.columns">
|
||||
<template v-slot:header="props">
|
||||
<q-tr :props="props">
|
||||
<q-th auto-width></q-th>
|
||||
<q-th
|
||||
auto-width
|
||||
v-for="col in props.cols"
|
||||
v-text="col.label"
|
||||
:key="col.name"
|
||||
:props="props"
|
||||
></q-th>
|
||||
</q-tr>
|
||||
</template>
|
||||
<template v-slot:body="props">
|
||||
<q-tr :props="props">
|
||||
<q-td auto-width>
|
||||
<q-btn
|
||||
round
|
||||
icon="menu"
|
||||
size="sm"
|
||||
color="secondary"
|
||||
@click="showPayments(props.row.id)"
|
||||
>
|
||||
<q-tooltip>Show Payments</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
v-if="!props.row.deleted"
|
||||
round
|
||||
icon="content_copy"
|
||||
size="sm"
|
||||
color="primary"
|
||||
@click="copyText(props.row.id)"
|
||||
>
|
||||
<q-tooltip>Copy Wallet ID</q-tooltip>
|
||||
</q-btn>
|
||||
<lnbits-update-balance
|
||||
v-if="!props.row.deleted"
|
||||
:wallet_id="props.row.id"
|
||||
:callback="topupCallback"
|
||||
></lnbits-update-balance>
|
||||
<q-btn
|
||||
round
|
||||
v-if="!props.row.deleted"
|
||||
icon="vpn_key"
|
||||
size="sm"
|
||||
color="primary"
|
||||
@click="copyText(props.row.adminkey)"
|
||||
>
|
||||
<q-tooltip>Copy Admin Key</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
round
|
||||
v-if="!props.row.deleted"
|
||||
icon="vpn_key"
|
||||
size="sm"
|
||||
color="secondary"
|
||||
@click="copyText(props.row.inkey)"
|
||||
>
|
||||
<q-tooltip>Copy Invoice Key</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
round
|
||||
v-if="props.row.deleted"
|
||||
icon="toggle_off"
|
||||
size="sm"
|
||||
color="secondary"
|
||||
@click="undeleteUserWallet(props.row.user, props.row.id)"
|
||||
>
|
||||
<q-tooltip>Undelete Wallet</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
round
|
||||
icon="delete"
|
||||
size="sm"
|
||||
color="negative"
|
||||
@click="deleteUserWallet(props.row.user, props.row.id, props.row.deleted)"
|
||||
>
|
||||
<q-tooltip>Delete Wallet</q-tooltip>
|
||||
</q-btn>
|
||||
</q-td>
|
||||
<q-td auto-width v-text="props.row.name"></q-td>
|
||||
<q-td auto-width v-text="props.row.currency"></q-td>
|
||||
<q-td auto-width v-text="formatSat(props.row.balance_msat)"></q-td>
|
||||
<q-td auto-width v-text="props.row.deleted"></q-td>
|
||||
</q-tr>
|
||||
</template>
|
||||
</q-table>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
v-close-popup
|
||||
flat
|
||||
color="grey"
|
||||
class="q-ml-auto"
|
||||
:label="$t('close')"
|
||||
></q-btn>
|
||||
</div>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
@@ -0,0 +1,116 @@
|
||||
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
|
||||
%} {% block page %} {% include "users/_walletDialog.html" %} {% include
|
||||
"users/_topupDialog.html" %} {% include "users/_createUserDialog.html" %} {%
|
||||
include "users/_createWalletDialog.html" %}
|
||||
|
||||
<h3 class="text-subtitle q-my-none" v-text="$t('users')"></h3>
|
||||
|
||||
<div class="row q-col-gutter-md justify-center">
|
||||
<div class="col q-gutter-y-md" style="width: 300px">
|
||||
<div style="width: 600px">
|
||||
<canvas ref="chart1"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-col-gutter-md justify-center">
|
||||
<div class="col q-gutter-y-md">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<div class="row items-center no-wrap q-mb-sm">
|
||||
<q-btn :label="$t('topup')" @click="topupDialog.show = true">
|
||||
<q-tooltip
|
||||
>{%raw%}{{ $t('add_funds_tooltip') }}{%endraw%}</q-tooltip
|
||||
>
|
||||
</q-btn>
|
||||
</div>
|
||||
<q-table
|
||||
:data="users"
|
||||
:row-key="usersTableRowKey"
|
||||
:columns="usersTable.columns"
|
||||
:pagination.sync="usersTable.pagination"
|
||||
:no-data-label="$t('no_users')"
|
||||
:filter="usersTable.search"
|
||||
:loading="usersTable.loading"
|
||||
@request="fetchUsers"
|
||||
>
|
||||
<template v-slot:header="props">
|
||||
<q-tr :props="props">
|
||||
<q-th auto-width></q-th>
|
||||
<q-th
|
||||
v-for="col in props.cols"
|
||||
v-text="col.label"
|
||||
:key="col.name"
|
||||
:props="props"
|
||||
></q-th>
|
||||
</q-tr>
|
||||
</template>
|
||||
<template v-slot:body="props">
|
||||
<q-tr auto-width :props="props">
|
||||
<q-td>
|
||||
<q-btn
|
||||
round
|
||||
icon="list"
|
||||
size="sm"
|
||||
color="secondary"
|
||||
@click="fetchWallets(props.row.id)"
|
||||
>
|
||||
<q-tooltip>Show Wallets</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
round
|
||||
icon="content_copy"
|
||||
size="sm"
|
||||
color="primary"
|
||||
@click="copyText(props.row.id)"
|
||||
>
|
||||
<q-tooltip>Copy User ID</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
round
|
||||
v-if="!props.row.is_super_user"
|
||||
icon="build"
|
||||
size="sm"
|
||||
:color="props.row.is_admin ? 'primary' : ''"
|
||||
@click="toggleAdmin(props.row.id)"
|
||||
>
|
||||
<q-tooltip>Toggle Admin</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
round
|
||||
v-if="props.row.is_super_user"
|
||||
icon="build"
|
||||
size="sm"
|
||||
color="positive"
|
||||
>
|
||||
<q-tooltip>Super User</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
round
|
||||
icon="delete"
|
||||
size="sm"
|
||||
color="negative"
|
||||
@click="deleteUser(props.row.id, props)"
|
||||
>
|
||||
<q-tooltip>Delete User</q-tooltip>
|
||||
</q-btn>
|
||||
</q-td>
|
||||
<q-td
|
||||
auto-width
|
||||
v-text="formatSat(props.row.balance_msat)"
|
||||
></q-td>
|
||||
<q-td auto-width v-text="props.row.wallet_count"></q-td>
|
||||
<q-td auto-width v-text="props.row.transaction_count"></q-td>
|
||||
<q-td auto-width v-text="props.row.username"></q-td>
|
||||
<q-td auto-width v-text="props.row.email"></q-td>
|
||||
<q-td auto-width v-text="props.row.last_payment"></q-td>
|
||||
</q-tr>
|
||||
</template>
|
||||
</q-table>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %} {% block scripts %} {{ window_vars(user) }}
|
||||
<script src="{{ static_url_for('static', 'js/users.js') }}"></script>
|
||||
{% endblock %}
|
||||
@@ -8,47 +8,48 @@ from urllib.parse import urlparse
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi.responses import FileResponse
|
||||
from starlette.exceptions import HTTPException
|
||||
|
||||
from lnbits.core.crud import get_wallet
|
||||
from lnbits.core.models import CreateTopup, User
|
||||
from lnbits.core.models import User
|
||||
from lnbits.core.services import (
|
||||
get_balance_delta,
|
||||
update_cached_settings,
|
||||
update_wallet_balance,
|
||||
)
|
||||
from lnbits.core.tasks import api_invoice_listeners
|
||||
from lnbits.decorators import check_admin, check_super_user
|
||||
from lnbits.server import server_restart
|
||||
from lnbits.settings import AdminSettings, UpdateSettings, settings
|
||||
from lnbits.tasks import invoice_listeners
|
||||
|
||||
from .. import core_app_extra
|
||||
from ..crud import delete_admin_settings, get_admin_settings, update_admin_settings
|
||||
|
||||
admin_router = APIRouter()
|
||||
admin_router = APIRouter(tags=["Admin UI"], prefix="/admin")
|
||||
|
||||
|
||||
@admin_router.get(
|
||||
"/admin/api/v1/audit",
|
||||
"/api/v1/audit",
|
||||
name="Audit",
|
||||
description="show the current balance of the node and the LNbits database",
|
||||
dependencies=[Depends(check_admin)],
|
||||
)
|
||||
async def api_auditor():
|
||||
try:
|
||||
delta, node_balance, total_balance = await get_balance_delta()
|
||||
return {
|
||||
"delta_msats": int(delta),
|
||||
"node_balance_msats": int(node_balance),
|
||||
"lnbits_balance_msats": int(total_balance),
|
||||
}
|
||||
except Exception:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
detail="Could not audit balance.",
|
||||
)
|
||||
return await get_balance_delta()
|
||||
|
||||
|
||||
@admin_router.get("/admin/api/v1/settings/", response_model=Optional[AdminSettings])
|
||||
@admin_router.get(
|
||||
"/api/v1/monitor",
|
||||
name="Monitor",
|
||||
description="show the current listeners and other monitoring data",
|
||||
dependencies=[Depends(check_admin)],
|
||||
)
|
||||
async def api_monitor():
|
||||
return {
|
||||
"invoice_listeners": list(invoice_listeners.keys()),
|
||||
"api_invoice_listeners": list(api_invoice_listeners.keys()),
|
||||
}
|
||||
|
||||
|
||||
@admin_router.get("/api/v1/settings", response_model=Optional[AdminSettings])
|
||||
async def api_get_settings(
|
||||
user: User = Depends(check_admin),
|
||||
) -> Optional[AdminSettings]:
|
||||
@@ -57,7 +58,7 @@ async def api_get_settings(
|
||||
|
||||
|
||||
@admin_router.put(
|
||||
"/admin/api/v1/settings/",
|
||||
"/api/v1/settings",
|
||||
status_code=HTTPStatus.OK,
|
||||
)
|
||||
async def api_update_settings(data: UpdateSettings, user: User = Depends(check_admin)):
|
||||
@@ -70,7 +71,7 @@ async def api_update_settings(data: UpdateSettings, user: User = Depends(check_a
|
||||
|
||||
|
||||
@admin_router.delete(
|
||||
"/admin/api/v1/settings/",
|
||||
"/api/v1/settings",
|
||||
status_code=HTTPStatus.OK,
|
||||
dependencies=[Depends(check_super_user)],
|
||||
)
|
||||
@@ -80,7 +81,7 @@ async def api_delete_settings() -> None:
|
||||
|
||||
|
||||
@admin_router.get(
|
||||
"/admin/api/v1/restart/",
|
||||
"/api/v1/restart",
|
||||
status_code=HTTPStatus.OK,
|
||||
dependencies=[Depends(check_super_user)],
|
||||
)
|
||||
@@ -89,32 +90,8 @@ async def api_restart_server() -> dict[str, str]:
|
||||
return {"status": "Success"}
|
||||
|
||||
|
||||
@admin_router.put(
|
||||
"/admin/api/v1/topup/",
|
||||
name="Topup",
|
||||
status_code=HTTPStatus.OK,
|
||||
dependencies=[Depends(check_super_user)],
|
||||
)
|
||||
async def api_topup_balance(data: CreateTopup) -> dict[str, str]:
|
||||
try:
|
||||
await get_wallet(data.id)
|
||||
except Exception:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.FORBIDDEN, detail="wallet does not exist."
|
||||
)
|
||||
|
||||
if settings.lnbits_backend_wallet_class == "VoidWallet":
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.FORBIDDEN, detail="VoidWallet active"
|
||||
)
|
||||
|
||||
await update_wallet_balance(wallet_id=data.id, amount=int(data.amount))
|
||||
|
||||
return {"status": "Success"}
|
||||
|
||||
|
||||
@admin_router.get(
|
||||
"/admin/api/v1/backup/",
|
||||
"/api/v1/backup",
|
||||
status_code=HTTPStatus.OK,
|
||||
dependencies=[Depends(check_super_user)],
|
||||
response_class=FileResponse,
|
||||
|
||||
+28
-741
@@ -1,111 +1,52 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import uuid
|
||||
from http import HTTPStatus
|
||||
from io import BytesIO
|
||||
from math import ceil
|
||||
from typing import Dict, List, Optional, Union
|
||||
from typing import Dict, List
|
||||
from urllib.parse import ParseResult, parse_qs, urlencode, urlparse, urlunparse
|
||||
|
||||
import httpx
|
||||
import pyqrcode
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
Body,
|
||||
Depends,
|
||||
Header,
|
||||
Request,
|
||||
WebSocket,
|
||||
WebSocketDisconnect,
|
||||
)
|
||||
from fastapi.exceptions import HTTPException
|
||||
from fastapi.responses import JSONResponse
|
||||
from loguru import logger
|
||||
from sse_starlette.sse import EventSourceResponse
|
||||
from starlette.responses import StreamingResponse
|
||||
|
||||
from lnbits import bolt11
|
||||
from lnbits.core.db import core_app_extra, db
|
||||
from lnbits.core.helpers import (
|
||||
migrate_extension_database,
|
||||
stop_extension_background_work,
|
||||
)
|
||||
from lnbits.core.models import (
|
||||
BaseWallet,
|
||||
ConversionData,
|
||||
CreateInvoice,
|
||||
CreateLnurl,
|
||||
CreateLnurlAuth,
|
||||
CreateWallet,
|
||||
DecodePayment,
|
||||
Payment,
|
||||
PaymentFilters,
|
||||
PaymentHistoryPoint,
|
||||
Query,
|
||||
User,
|
||||
Wallet,
|
||||
WalletType,
|
||||
)
|
||||
from lnbits.db import Filters, Page
|
||||
from lnbits.decorators import (
|
||||
WalletTypeInfo,
|
||||
check_access_token,
|
||||
check_admin,
|
||||
get_key_type,
|
||||
parse_filters,
|
||||
check_user_exists,
|
||||
require_admin_key,
|
||||
require_invoice_key,
|
||||
)
|
||||
from lnbits.extension_manager import (
|
||||
CreateExtension,
|
||||
Extension,
|
||||
ExtensionRelease,
|
||||
InstallableExtension,
|
||||
fetch_github_release_config,
|
||||
get_valid_extensions,
|
||||
)
|
||||
from lnbits.helpers import generate_filter_params_openapi, url_for
|
||||
from lnbits.lnurl import decode as lnurl_decode
|
||||
from lnbits.settings import settings
|
||||
from lnbits.utils.exchange_rates import (
|
||||
currencies,
|
||||
allowed_currencies,
|
||||
fiat_amount_as_satoshis,
|
||||
satoshis_amount_as_fiat,
|
||||
)
|
||||
|
||||
from ..crud import (
|
||||
DateTrunc,
|
||||
add_installed_extension,
|
||||
create_account,
|
||||
create_wallet,
|
||||
delete_dbversion,
|
||||
delete_installed_extension,
|
||||
delete_wallet,
|
||||
drop_extension_db,
|
||||
get_dbversions,
|
||||
get_payments,
|
||||
get_payments_history,
|
||||
get_payments_paginated,
|
||||
get_standalone_payment,
|
||||
get_wallet_for_key,
|
||||
save_balance_check,
|
||||
update_pending_payments,
|
||||
update_wallet,
|
||||
)
|
||||
from ..services import (
|
||||
InvoiceFailure,
|
||||
PaymentFailure,
|
||||
check_transaction_status,
|
||||
create_invoice,
|
||||
fee_reserve_total,
|
||||
pay_invoice,
|
||||
perform_lnurlauth,
|
||||
websocketManager,
|
||||
websocketUpdater,
|
||||
)
|
||||
from ..tasks import api_invoice_listeners
|
||||
from ..services import perform_lnurlauth
|
||||
|
||||
api_router = APIRouter()
|
||||
# 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.get("/api/v1/health", status_code=HTTPStatus.OK)
|
||||
@@ -113,465 +54,34 @@ async def health():
|
||||
return
|
||||
|
||||
|
||||
@api_router.get("/api/v1/wallet")
|
||||
async def api_wallet(wallet: WalletTypeInfo = Depends(get_key_type)):
|
||||
if wallet.wallet_type == WalletType.admin:
|
||||
return {
|
||||
"id": wallet.wallet.id,
|
||||
"name": wallet.wallet.name,
|
||||
"balance": wallet.wallet.balance_msat,
|
||||
}
|
||||
else:
|
||||
return {"name": wallet.wallet.name, "balance": wallet.wallet.balance_msat}
|
||||
|
||||
|
||||
@api_router.put("/api/v1/wallet/{new_name}")
|
||||
async def api_update_wallet_name(
|
||||
new_name: str, wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||
):
|
||||
await update_wallet(wallet.wallet.id, new_name)
|
||||
return {
|
||||
"id": wallet.wallet.id,
|
||||
"name": wallet.wallet.name,
|
||||
"balance": wallet.wallet.balance_msat,
|
||||
}
|
||||
|
||||
|
||||
@api_router.patch("/api/v1/wallet", response_model=Wallet)
|
||||
async def api_update_wallet(
|
||||
name: Optional[str] = Body(None),
|
||||
currency: Optional[str] = Body(None),
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||
):
|
||||
return await update_wallet(wallet.wallet.id, name, currency)
|
||||
|
||||
|
||||
@api_router.delete("/api/v1/wallet")
|
||||
async def api_delete_wallet(
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||
) -> None:
|
||||
await delete_wallet(
|
||||
user_id=wallet.wallet.user,
|
||||
wallet_id=wallet.wallet.id,
|
||||
)
|
||||
|
||||
|
||||
@api_router.post("/api/v1/wallet", response_model=Wallet)
|
||||
async def api_create_wallet(
|
||||
data: CreateWallet,
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||
) -> Wallet:
|
||||
return await create_wallet(user_id=wallet.wallet.user, wallet_name=data.name)
|
||||
@api_router.get(
|
||||
"/api/v1/wallets",
|
||||
name="Wallets",
|
||||
description="Get basic info for all of user's wallets.",
|
||||
)
|
||||
async def api_wallets(user: User = Depends(check_user_exists)) -> List[BaseWallet]:
|
||||
return [BaseWallet(**w.dict()) for w in user.wallets]
|
||||
|
||||
|
||||
@api_router.post("/api/v1/account", response_model=Wallet)
|
||||
async def api_create_account(data: CreateWallet) -> Wallet:
|
||||
if not settings.new_accounts_allowed:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
status_code=HTTPStatus.FORBIDDEN,
|
||||
detail="Account creation is disabled.",
|
||||
)
|
||||
account = await create_account()
|
||||
return await create_wallet(user_id=account.id, wallet_name=data.name)
|
||||
|
||||
|
||||
@api_router.get(
|
||||
"/api/v1/payments",
|
||||
name="Payment List",
|
||||
summary="get list of payments",
|
||||
response_description="list of payments",
|
||||
response_model=List[Payment],
|
||||
openapi_extra=generate_filter_params_openapi(PaymentFilters),
|
||||
)
|
||||
async def api_payments(
|
||||
wallet: WalletTypeInfo = Depends(get_key_type),
|
||||
filters: Filters = Depends(parse_filters(PaymentFilters)),
|
||||
):
|
||||
await update_pending_payments(wallet.wallet.id)
|
||||
return await get_payments(
|
||||
wallet_id=wallet.wallet.id,
|
||||
pending=True,
|
||||
complete=True,
|
||||
filters=filters,
|
||||
)
|
||||
|
||||
|
||||
@api_router.get(
|
||||
"/api/v1/payments/history",
|
||||
name="Get payments history",
|
||||
response_model=List[PaymentHistoryPoint],
|
||||
openapi_extra=generate_filter_params_openapi(PaymentFilters),
|
||||
)
|
||||
async def api_payments_history(
|
||||
wallet: WalletTypeInfo = Depends(get_key_type),
|
||||
group: DateTrunc = Query("day"),
|
||||
filters: Filters[PaymentFilters] = Depends(parse_filters(PaymentFilters)),
|
||||
):
|
||||
await update_pending_payments(wallet.wallet.id)
|
||||
return await get_payments_history(wallet.wallet.id, group, filters)
|
||||
|
||||
|
||||
@api_router.get(
|
||||
"/api/v1/payments/paginated",
|
||||
name="Payment List",
|
||||
summary="get paginated list of payments",
|
||||
response_description="list of payments",
|
||||
response_model=Page[Payment],
|
||||
openapi_extra=generate_filter_params_openapi(PaymentFilters),
|
||||
)
|
||||
async def api_payments_paginated(
|
||||
wallet: WalletTypeInfo = Depends(get_key_type),
|
||||
filters: Filters = Depends(parse_filters(PaymentFilters)),
|
||||
):
|
||||
await update_pending_payments(wallet.wallet.id)
|
||||
page = await get_payments_paginated(
|
||||
wallet_id=wallet.wallet.id,
|
||||
pending=True,
|
||||
complete=True,
|
||||
filters=filters,
|
||||
)
|
||||
return page
|
||||
|
||||
|
||||
async def api_payments_create_invoice(data: CreateInvoice, wallet: Wallet):
|
||||
description_hash = b""
|
||||
unhashed_description = b""
|
||||
memo = data.memo or settings.lnbits_site_title
|
||||
if data.description_hash or data.unhashed_description:
|
||||
if data.description_hash:
|
||||
try:
|
||||
description_hash = bytes.fromhex(data.description_hash)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail="'description_hash' must be a valid hex string",
|
||||
)
|
||||
if data.unhashed_description:
|
||||
try:
|
||||
unhashed_description = bytes.fromhex(data.unhashed_description)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail="'unhashed_description' must be a valid hex string",
|
||||
)
|
||||
# do not save memo if description_hash or unhashed_description is set
|
||||
memo = ""
|
||||
|
||||
async with db.connect() as conn:
|
||||
try:
|
||||
payment_hash, payment_request = await create_invoice(
|
||||
wallet_id=wallet.id,
|
||||
amount=data.amount,
|
||||
memo=memo,
|
||||
currency=data.unit,
|
||||
description_hash=description_hash,
|
||||
unhashed_description=unhashed_description,
|
||||
expiry=data.expiry,
|
||||
extra=data.extra,
|
||||
webhook=data.webhook,
|
||||
internal=data.internal,
|
||||
conn=conn,
|
||||
)
|
||||
# NOTE: we get the checking_id with a seperate query because create_invoice
|
||||
# does not return it and it would be a big hustle to change its return type
|
||||
# (used across extensions)
|
||||
payment_db = await get_standalone_payment(payment_hash, conn=conn)
|
||||
assert payment_db is not None, "payment not found"
|
||||
checking_id = payment_db.checking_id
|
||||
except InvoiceFailure as e:
|
||||
raise HTTPException(status_code=520, detail=str(e))
|
||||
except Exception as exc:
|
||||
raise exc
|
||||
|
||||
invoice = bolt11.decode(payment_request)
|
||||
|
||||
lnurl_response: Union[None, bool, str] = None
|
||||
if data.lnurl_callback:
|
||||
if data.lnurl_balance_check is not None:
|
||||
await save_balance_check(wallet.id, data.lnurl_balance_check)
|
||||
|
||||
headers = {"User-Agent": settings.user_agent}
|
||||
async with httpx.AsyncClient(headers=headers) as client:
|
||||
try:
|
||||
r = await client.get(
|
||||
data.lnurl_callback,
|
||||
params={
|
||||
"pr": payment_request,
|
||||
"balanceNotify": url_for(
|
||||
f"/withdraw/notify/{urlparse(data.lnurl_callback).netloc}",
|
||||
external=True,
|
||||
wal=wallet.id,
|
||||
),
|
||||
},
|
||||
timeout=10,
|
||||
)
|
||||
if r.is_error:
|
||||
lnurl_response = r.text
|
||||
else:
|
||||
resp = json.loads(r.text)
|
||||
if resp["status"] != "OK":
|
||||
lnurl_response = resp["reason"]
|
||||
else:
|
||||
lnurl_response = True
|
||||
except (httpx.ConnectError, httpx.RequestError) as ex:
|
||||
logger.error(ex)
|
||||
lnurl_response = False
|
||||
|
||||
return {
|
||||
"payment_hash": invoice.payment_hash,
|
||||
"payment_request": payment_request,
|
||||
# maintain backwards compatibility with API clients:
|
||||
"checking_id": checking_id,
|
||||
"lnurl_response": lnurl_response,
|
||||
}
|
||||
|
||||
|
||||
async def api_payments_pay_invoice(
|
||||
bolt11: str, wallet: Wallet, extra: Optional[dict] = None
|
||||
):
|
||||
try:
|
||||
payment_hash = await pay_invoice(
|
||||
wallet_id=wallet.id, payment_request=bolt11, extra=extra
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e))
|
||||
except PermissionError as e:
|
||||
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail=str(e))
|
||||
except PaymentFailure as e:
|
||||
raise HTTPException(status_code=520, detail=str(e))
|
||||
except Exception as exc:
|
||||
raise exc
|
||||
|
||||
return {
|
||||
"payment_hash": payment_hash,
|
||||
# maintain backwards compatibility with API clients:
|
||||
"checking_id": payment_hash,
|
||||
}
|
||||
|
||||
|
||||
@api_router.post(
|
||||
"/api/v1/payments",
|
||||
summary="Create or pay an invoice",
|
||||
description="""
|
||||
This endpoint can be used both to generate and pay a BOLT11 invoice.
|
||||
To generate a new invoice for receiving funds into the authorized account,
|
||||
specify at least the first four fields in the POST body: `out: false`,
|
||||
`amount`, `unit`, and `memo`. To pay an arbitrary invoice from the funds
|
||||
already in the authorized account, specify `out: true` and use the `bolt11`
|
||||
field to supply the BOLT11 invoice to be paid.
|
||||
""",
|
||||
status_code=HTTPStatus.CREATED,
|
||||
)
|
||||
async def api_payments_create(
|
||||
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
||||
invoiceData: CreateInvoice = Body(...),
|
||||
):
|
||||
if invoiceData.out is True and wallet.wallet_type == WalletType.admin:
|
||||
if not invoiceData.bolt11:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail="BOLT11 string is invalid or not given",
|
||||
)
|
||||
return await api_payments_pay_invoice(
|
||||
invoiceData.bolt11, wallet.wallet, invoiceData.extra
|
||||
) # admin key
|
||||
elif not invoiceData.out:
|
||||
# invoice key
|
||||
return await api_payments_create_invoice(invoiceData, wallet.wallet)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.UNAUTHORIZED,
|
||||
detail="Invoice (or Admin) key required.",
|
||||
)
|
||||
|
||||
|
||||
@api_router.get("/api/v1/payments/fee-reserve")
|
||||
async def api_payments_fee_reserve(invoice: str = Query("invoice")) -> JSONResponse:
|
||||
invoice_obj = bolt11.decode(invoice)
|
||||
if invoice_obj.amount_msat:
|
||||
response = {
|
||||
"fee_reserve": fee_reserve_total(invoice_obj.amount_msat),
|
||||
}
|
||||
return JSONResponse(response)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail="Invoice has no amount.",
|
||||
)
|
||||
|
||||
|
||||
@api_router.post("/api/v1/payments/lnurl")
|
||||
async def api_payments_pay_lnurl(
|
||||
data: CreateLnurl, wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||
):
|
||||
domain = urlparse(data.callback).netloc
|
||||
|
||||
headers = {"User-Agent": settings.user_agent}
|
||||
async with httpx.AsyncClient(headers=headers, follow_redirects=True) as client:
|
||||
try:
|
||||
if data.unit and data.unit != "sat":
|
||||
amount_msat = await fiat_amount_as_satoshis(data.amount, data.unit)
|
||||
# no msat precision
|
||||
amount_msat = ceil(amount_msat // 1000) * 1000
|
||||
else:
|
||||
amount_msat = data.amount
|
||||
r = await client.get(
|
||||
data.callback,
|
||||
params={"amount": amount_msat, "comment": data.comment},
|
||||
timeout=40,
|
||||
)
|
||||
if r.is_error:
|
||||
raise httpx.ConnectError("LNURL callback connection error")
|
||||
r.raise_for_status()
|
||||
except (httpx.ConnectError, httpx.RequestError):
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail=f"Failed to connect to {domain}.",
|
||||
)
|
||||
|
||||
params = json.loads(r.text)
|
||||
if params.get("status") == "ERROR":
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail=f"{domain} said: '{params.get('reason', '')}'",
|
||||
)
|
||||
|
||||
if not params.get("pr"):
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail=f"{domain} did not return a payment request.",
|
||||
)
|
||||
|
||||
invoice = bolt11.decode(params["pr"])
|
||||
if invoice.amount_msat != amount_msat:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail=(
|
||||
(
|
||||
f"{domain} returned an invalid invoice. Expected"
|
||||
f" {amount_msat} msat, got {invoice.amount_msat}."
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
extra = {}
|
||||
|
||||
if params.get("successAction"):
|
||||
extra["success_action"] = params["successAction"]
|
||||
if data.comment:
|
||||
extra["comment"] = data.comment
|
||||
if data.unit and data.unit != "sat":
|
||||
extra["fiat_currency"] = data.unit
|
||||
extra["fiat_amount"] = data.amount / 1000
|
||||
assert data.description is not None, "description is required"
|
||||
payment_hash = await pay_invoice(
|
||||
wallet_id=wallet.wallet.id,
|
||||
payment_request=params["pr"],
|
||||
description=data.description,
|
||||
extra=extra,
|
||||
)
|
||||
|
||||
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):
|
||||
"""
|
||||
Subscribe to new invoices for a wallet. Can be wrapped in EventSourceResponse.
|
||||
Listenes invoming payments for a wallet and yields jsons with payment details.
|
||||
"""
|
||||
this_wallet_id = wallet.id
|
||||
|
||||
payment_queue: asyncio.Queue[Payment] = asyncio.Queue(0)
|
||||
|
||||
uid = f"{this_wallet_id}_{str(uuid.uuid4())[:8]}"
|
||||
logger.debug(f"adding sse listener for wallet: {uid}")
|
||||
api_invoice_listeners[uid] = payment_queue
|
||||
|
||||
try:
|
||||
while True:
|
||||
if await request.is_disconnected():
|
||||
await request.close()
|
||||
break
|
||||
payment: Payment = await payment_queue.get()
|
||||
if payment.wallet_id == this_wallet_id:
|
||||
logger.debug("sse listener: payment received", payment)
|
||||
yield dict(data=payment.json(), event="payment-received")
|
||||
except asyncio.CancelledError:
|
||||
logger.debug(f"removing listener for wallet {uid}")
|
||||
except Exception as exc:
|
||||
logger.error(f"Error in sse: {exc}")
|
||||
finally:
|
||||
api_invoice_listeners.pop(uid)
|
||||
|
||||
|
||||
@api_router.get("/api/v1/payments/sse")
|
||||
async def api_payments_sse(
|
||||
request: Request, wallet: WalletTypeInfo = Depends(get_key_type)
|
||||
):
|
||||
return EventSourceResponse(
|
||||
subscribe_wallet_invoices(request, wallet.wallet),
|
||||
ping=20,
|
||||
media_type="text/event-stream",
|
||||
)
|
||||
|
||||
|
||||
# TODO: refactor this route into a public and admin one
|
||||
@api_router.get("/api/v1/payments/{payment_hash}")
|
||||
async def api_payment(payment_hash, X_Api_Key: Optional[str] = Header(None)):
|
||||
# We use X_Api_Key here because we want this call to work with and without keys
|
||||
# If a valid key is given, we also return the field "details", otherwise not
|
||||
wallet = await get_wallet_for_key(X_Api_Key) if isinstance(X_Api_Key, str) else None
|
||||
wallet = wallet if wallet and not wallet.deleted else None
|
||||
# we have to specify the wallet id here, because postgres and sqlite return
|
||||
# internal payments in different order and get_standalone_payment otherwise
|
||||
# just fetches the first one, causing unpredictable results
|
||||
payment = await get_standalone_payment(
|
||||
payment_hash, wallet_id=wallet.id if wallet else None
|
||||
)
|
||||
if payment is None:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Payment does not exist."
|
||||
)
|
||||
await check_transaction_status(payment.wallet_id, payment_hash)
|
||||
payment = await get_standalone_payment(
|
||||
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:
|
||||
return {"paid": True, "preimage": payment.preimage, "details": payment}
|
||||
return {"paid": True, "preimage": payment.preimage}
|
||||
|
||||
try:
|
||||
await payment.check_status()
|
||||
except Exception:
|
||||
if wallet and wallet.id == payment.wallet_id:
|
||||
return {"paid": False, "details": payment}
|
||||
return {"paid": False}
|
||||
|
||||
if wallet and wallet.id == payment.wallet_id:
|
||||
return {
|
||||
"paid": not payment.pending,
|
||||
"preimage": payment.preimage,
|
||||
"details": payment,
|
||||
}
|
||||
return {"paid": not payment.pending, "preimage": payment.preimage}
|
||||
|
||||
|
||||
@api_router.get("/api/v1/lnurlscan/{code}")
|
||||
async def api_lnurlscan(code: str, wallet: WalletTypeInfo = Depends(get_key_type)):
|
||||
async def api_lnurlscan(
|
||||
code: str, wallet: WalletTypeInfo = Depends(require_invoice_key)
|
||||
):
|
||||
try:
|
||||
url = str(lnurl_decode(code))
|
||||
domain = urlparse(url).netloc
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
# parse internet identifier (user@domain.com)
|
||||
name_domain = code.split("@")
|
||||
if len(name_domain) == 2 and len(name_domain[1].split(".")) >= 2:
|
||||
@@ -586,7 +96,7 @@ async def api_lnurlscan(code: str, wallet: WalletTypeInfo = Depends(get_key_type
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST, detail="invalid lnurl"
|
||||
)
|
||||
) from exc
|
||||
|
||||
# params is what will be returned to the client
|
||||
params: Dict = {"domain": domain}
|
||||
@@ -611,14 +121,14 @@ async def api_lnurlscan(code: str, wallet: WalletTypeInfo = Depends(get_key_type
|
||||
|
||||
try:
|
||||
data = json.loads(r.text)
|
||||
except json.decoder.JSONDecodeError:
|
||||
except json.decoder.JSONDecodeError as exc:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.SERVICE_UNAVAILABLE,
|
||||
detail={
|
||||
"domain": domain,
|
||||
"message": f"got invalid response '{r.text[:200]}'",
|
||||
},
|
||||
)
|
||||
) from exc
|
||||
|
||||
try:
|
||||
tag: str = data.get("tag")
|
||||
@@ -677,28 +187,11 @@ async def api_lnurlscan(code: str, wallet: WalletTypeInfo = Depends(get_key_type
|
||||
"domain": domain,
|
||||
"message": f"lnurl JSON response invalid: {exc}",
|
||||
},
|
||||
)
|
||||
) from exc
|
||||
|
||||
return params
|
||||
|
||||
|
||||
@api_router.post("/api/v1/payments/decode", status_code=HTTPStatus.OK)
|
||||
async def api_payments_decode(data: DecodePayment) -> JSONResponse:
|
||||
payment_str = data.data
|
||||
try:
|
||||
if payment_str[:5] == "LNURL":
|
||||
url = str(lnurl_decode(payment_str))
|
||||
return JSONResponse({"domain": url})
|
||||
else:
|
||||
invoice = bolt11.decode(payment_str)
|
||||
return JSONResponse(invoice.data)
|
||||
except Exception as exc:
|
||||
return JSONResponse(
|
||||
{"message": f"Failed to decode: {str(exc)}"},
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
@api_router.post("/api/v1/lnurlauth")
|
||||
async def api_perform_lnurlauth(
|
||||
data: CreateLnurlAuth, wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||
@@ -712,14 +205,8 @@ async def api_perform_lnurlauth(
|
||||
|
||||
|
||||
@api_router.get("/api/v1/currencies")
|
||||
async def api_list_currencies_available():
|
||||
if len(settings.lnbits_allowed_currencies) > 0:
|
||||
return [
|
||||
item
|
||||
for item in currencies.keys()
|
||||
if item.upper() in settings.lnbits_allowed_currencies
|
||||
]
|
||||
return list(currencies.keys())
|
||||
async def api_list_currencies_available() -> List[str]:
|
||||
return allowed_currencies()
|
||||
|
||||
|
||||
@api_router.post("/api/v1/conversion")
|
||||
@@ -759,203 +246,3 @@ async def img(data):
|
||||
"Expires": "0",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@api_router.websocket("/api/v1/ws/{item_id}")
|
||||
async def websocket_connect(websocket: WebSocket, item_id: str):
|
||||
await websocketManager.connect(websocket, item_id)
|
||||
try:
|
||||
while True:
|
||||
await websocket.receive_text()
|
||||
except WebSocketDisconnect:
|
||||
websocketManager.disconnect(websocket)
|
||||
|
||||
|
||||
@api_router.post("/api/v1/ws/{item_id}")
|
||||
async def websocket_update_post(item_id: str, data: str):
|
||||
try:
|
||||
await websocketUpdater(item_id, data)
|
||||
return {"sent": True, "data": data}
|
||||
except Exception:
|
||||
return {"sent": False, "data": data}
|
||||
|
||||
|
||||
@api_router.get("/api/v1/ws/{item_id}/{data}")
|
||||
async def websocket_update_get(item_id: str, data: str):
|
||||
try:
|
||||
await websocketUpdater(item_id, data)
|
||||
return {"sent": True, "data": data}
|
||||
except Exception:
|
||||
return {"sent": False, "data": data}
|
||||
|
||||
|
||||
@api_router.post("/api/v1/extension")
|
||||
async def api_install_extension(
|
||||
data: CreateExtension,
|
||||
user: User = Depends(check_admin),
|
||||
access_token: Optional[str] = Depends(check_access_token),
|
||||
):
|
||||
release = await InstallableExtension.get_extension_release(
|
||||
data.ext_id, data.source_repo, data.archive
|
||||
)
|
||||
if not release:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Release not found"
|
||||
)
|
||||
|
||||
if not release.is_version_compatible:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST, detail="Incompatible extension version"
|
||||
)
|
||||
|
||||
ext_info = InstallableExtension(
|
||||
id=data.ext_id, name=data.ext_id, installed_release=release, icon=release.icon
|
||||
)
|
||||
|
||||
ext_info.download_archive()
|
||||
|
||||
try:
|
||||
ext_info.extract_archive()
|
||||
|
||||
extension = Extension.from_installable_ext(ext_info)
|
||||
|
||||
db_version = (await get_dbversions()).get(data.ext_id, 0)
|
||||
await migrate_extension_database(extension, db_version)
|
||||
|
||||
await add_installed_extension(ext_info)
|
||||
|
||||
# call stop while the old routes are still active
|
||||
await stop_extension_background_work(data.ext_id, user.id, access_token)
|
||||
|
||||
if data.ext_id not in settings.lnbits_deactivated_extensions:
|
||||
settings.lnbits_deactivated_extensions += [data.ext_id]
|
||||
|
||||
# mount routes for the new version
|
||||
core_app_extra.register_new_ext_routes(extension)
|
||||
|
||||
if extension.upgrade_hash:
|
||||
ext_info.nofiy_upgrade()
|
||||
|
||||
return extension
|
||||
|
||||
except Exception as ex:
|
||||
logger.warning(ex)
|
||||
ext_info.clean_extension_files()
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
detail=(
|
||||
f"Failed to install extension {ext_info.id} "
|
||||
f"({ext_info.installed_version})."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@api_router.delete("/api/v1/extension/{ext_id}")
|
||||
async def api_uninstall_extension(
|
||||
ext_id: str,
|
||||
user: User = Depends(check_admin),
|
||||
access_token: Optional[str] = Depends(check_access_token),
|
||||
):
|
||||
installable_extensions = await InstallableExtension.get_installable_extensions()
|
||||
|
||||
extensions = [e for e in installable_extensions if e.id == ext_id]
|
||||
if len(extensions) == 0:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail=f"Unknown extension id: {ext_id}",
|
||||
)
|
||||
|
||||
# check that other extensions do not depend on this one
|
||||
for valid_ext_id in list(map(lambda e: e.code, get_valid_extensions())):
|
||||
installed_ext = next(
|
||||
(ext for ext in installable_extensions if ext.id == valid_ext_id), None
|
||||
)
|
||||
if installed_ext and ext_id in installed_ext.dependencies:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail=(
|
||||
f"Cannot uninstall. Extension '{installed_ext.name}' "
|
||||
"depends on this one."
|
||||
),
|
||||
)
|
||||
|
||||
try:
|
||||
# call stop while the old routes are still active
|
||||
await stop_extension_background_work(ext_id, user.id, access_token)
|
||||
|
||||
if ext_id not in settings.lnbits_deactivated_extensions:
|
||||
settings.lnbits_deactivated_extensions += [ext_id]
|
||||
|
||||
for ext_info in extensions:
|
||||
ext_info.clean_extension_files()
|
||||
await delete_installed_extension(ext_id=ext_info.id)
|
||||
|
||||
logger.success(f"Extension '{ext_id}' uninstalled.")
|
||||
except Exception as ex:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(ex)
|
||||
)
|
||||
|
||||
|
||||
@api_router.get(
|
||||
"/api/v1/extension/{ext_id}/releases", dependencies=[Depends(check_admin)]
|
||||
)
|
||||
async def get_extension_releases(ext_id: str):
|
||||
try:
|
||||
extension_releases: List[
|
||||
ExtensionRelease
|
||||
] = await InstallableExtension.get_extension_releases(ext_id)
|
||||
|
||||
return extension_releases
|
||||
|
||||
except Exception as ex:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(ex)
|
||||
)
|
||||
|
||||
|
||||
@api_router.get(
|
||||
"/api/v1/extension/release/{org}/{repo}/{tag_name}",
|
||||
dependencies=[Depends(check_admin)],
|
||||
)
|
||||
async def get_extension_release(org: str, repo: str, tag_name: str):
|
||||
try:
|
||||
config = await fetch_github_release_config(org, repo, tag_name)
|
||||
if not config:
|
||||
return {}
|
||||
|
||||
return {
|
||||
"min_lnbits_version": config.min_lnbits_version,
|
||||
"is_version_compatible": config.is_version_compatible(),
|
||||
"warning": config.warning,
|
||||
}
|
||||
except Exception as ex:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(ex)
|
||||
)
|
||||
|
||||
|
||||
@api_router.delete(
|
||||
"/api/v1/extension/{ext_id}/db",
|
||||
dependencies=[Depends(check_admin)],
|
||||
)
|
||||
async def delete_extension_db(ext_id: str):
|
||||
try:
|
||||
db_version = (await get_dbversions()).get(ext_id, None)
|
||||
if not db_version:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail=f"Unknown extension id: {ext_id}",
|
||||
)
|
||||
await drop_extension_db(ext_id=ext_id)
|
||||
await delete_dbversion(ext_id=ext_id)
|
||||
logger.success(f"Database removed for extension '{ext_id}'")
|
||||
except HTTPException as ex:
|
||||
logger.error(ex)
|
||||
raise ex
|
||||
except Exception as ex:
|
||||
logger.error(ex)
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
detail=f"Cannot delete data for extension '{ext_id}'",
|
||||
)
|
||||
|
||||
+118
-136
@@ -1,10 +1,9 @@
|
||||
from typing import Optional
|
||||
import importlib
|
||||
from typing import Callable, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from fastapi.responses import JSONResponse, RedirectResponse
|
||||
from fastapi_sso.sso.base import OpenID
|
||||
from fastapi_sso.sso.github import GithubSSO
|
||||
from fastapi_sso.sso.google import GoogleSSO
|
||||
from fastapi_sso.sso.base import OpenID, SSOBase
|
||||
from loguru import logger
|
||||
from starlette.status import (
|
||||
HTTP_400_BAD_REQUEST,
|
||||
@@ -45,15 +44,15 @@ from ..models import (
|
||||
UserConfig,
|
||||
)
|
||||
|
||||
auth_router = APIRouter()
|
||||
auth_router = APIRouter(prefix="/api/v1/auth", tags=["Auth"])
|
||||
|
||||
|
||||
@auth_router.get("/api/v1/auth", description="Get the authenticated user")
|
||||
@auth_router.get("", description="Get the authenticated user")
|
||||
async def get_auth_user(user: User = Depends(check_user_exists)) -> User:
|
||||
return user
|
||||
|
||||
|
||||
@auth_router.post("/api/v1/auth", description="Login via the username and password")
|
||||
@auth_router.post("", description="Login via the username and password")
|
||||
async def login(data: LoginUsernamePassword) -> JSONResponse:
|
||||
if not settings.is_auth_method_allowed(AuthMethods.username_and_password):
|
||||
raise HTTPException(
|
||||
@@ -69,14 +68,14 @@ async def login(data: LoginUsernamePassword) -> JSONResponse:
|
||||
raise HTTPException(HTTP_401_UNAUTHORIZED, "Invalid credentials.")
|
||||
|
||||
return _auth_success_response(user.username, user.id)
|
||||
except HTTPException as e:
|
||||
raise e
|
||||
except Exception as e:
|
||||
logger.debug(e)
|
||||
raise HTTPException(HTTP_500_INTERNAL_SERVER_ERROR, "Cannot login.")
|
||||
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.post("/api/v1/auth/usr", description="Login via the User ID")
|
||||
@auth_router.post("/usr", description="Login via the User ID")
|
||||
async def login_usr(data: LoginUsr) -> JSONResponse:
|
||||
if not settings.is_auth_method_allowed(AuthMethods.user_id_only):
|
||||
raise HTTPException(HTTP_401_UNAUTHORIZED, "Login by 'User ID' not allowed.")
|
||||
@@ -87,100 +86,68 @@ async def login_usr(data: LoginUsr) -> JSONResponse:
|
||||
raise HTTPException(HTTP_401_UNAUTHORIZED, "User ID does not exist.")
|
||||
|
||||
return _auth_success_response(user.username or "", user.id)
|
||||
except HTTPException as e:
|
||||
raise e
|
||||
except Exception as e:
|
||||
logger.debug(e)
|
||||
raise HTTPException(HTTP_500_INTERNAL_SERVER_ERROR, "Cannot login.")
|
||||
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("/api/v1/auth/google", description="Google SSO")
|
||||
async def login_with_google(request: Request, user_id: Optional[str] = None):
|
||||
google_sso = _new_google_sso()
|
||||
if not google_sso:
|
||||
raise HTTPException(HTTP_401_UNAUTHORIZED, "Login by 'Google' not allowed.")
|
||||
@auth_router.get("/{provider}", description="SSO Provider")
|
||||
async def login_with_sso_provider(
|
||||
request: Request, provider: str, user_id: Optional[str] = None
|
||||
):
|
||||
provider_sso = _new_sso(provider)
|
||||
if not provider_sso:
|
||||
raise HTTPException(
|
||||
HTTP_401_UNAUTHORIZED, f"Login by '{provider}' not allowed."
|
||||
)
|
||||
|
||||
google_sso.redirect_uri = str(request.base_url) + "api/v1/auth/google/token"
|
||||
with google_sso:
|
||||
provider_sso.redirect_uri = str(request.base_url) + f"api/v1/auth/{provider}/token"
|
||||
with provider_sso:
|
||||
state = encrypt_internal_message(user_id)
|
||||
return await google_sso.get_login_redirect(state=state)
|
||||
return await provider_sso.get_login_redirect(state=state)
|
||||
|
||||
|
||||
@auth_router.get("/api/v1/auth/github", description="Github SSO")
|
||||
async def login_with_github(request: Request, user_id: Optional[str] = None):
|
||||
github_sso = _new_github_sso()
|
||||
if not github_sso:
|
||||
raise HTTPException(HTTP_401_UNAUTHORIZED, "Login by 'GitHub' not allowed.")
|
||||
|
||||
github_sso.redirect_uri = str(request.base_url) + "api/v1/auth/github/token"
|
||||
with github_sso:
|
||||
state = decrypt_internal_message(user_id)
|
||||
return await github_sso.get_login_redirect(state=state)
|
||||
|
||||
|
||||
@auth_router.get(
|
||||
"/api/v1/auth/google/token", description="Handle Google OAuth callback"
|
||||
)
|
||||
async def handle_google_token(request: Request) -> RedirectResponse:
|
||||
google_sso = _new_google_sso()
|
||||
if not google_sso:
|
||||
raise HTTPException(HTTP_401_UNAUTHORIZED, "Login by 'Google' not allowed.")
|
||||
|
||||
try:
|
||||
with google_sso:
|
||||
userinfo = await google_sso.verify_and_process(request)
|
||||
assert userinfo is not None
|
||||
user_id = decrypt_internal_message(google_sso.state)
|
||||
request.session.pop("user", None)
|
||||
return await _handle_sso_login(userinfo, user_id)
|
||||
except HTTPException as e:
|
||||
raise e
|
||||
except ValueError as e:
|
||||
raise HTTPException(HTTP_403_FORBIDDEN, str(e))
|
||||
except Exception as e:
|
||||
logger.debug(e)
|
||||
@auth_router.get("/{provider}/token", description="Handle OAuth callback")
|
||||
async def handle_oauth_token(request: Request, provider: str) -> RedirectResponse:
|
||||
provider_sso = _new_sso(provider)
|
||||
if not provider_sso:
|
||||
raise HTTPException(
|
||||
HTTP_500_INTERNAL_SERVER_ERROR, "Cannot authenticate user with Google Auth."
|
||||
HTTP_401_UNAUTHORIZED, f"Login by '{provider}' not allowed."
|
||||
)
|
||||
|
||||
|
||||
@auth_router.get(
|
||||
"/api/v1/auth/github/token", description="Handle Github OAuth callback"
|
||||
)
|
||||
async def handle_github_token(request: Request) -> RedirectResponse:
|
||||
github_sso = _new_github_sso()
|
||||
if not github_sso:
|
||||
raise HTTPException(HTTP_401_UNAUTHORIZED, "Login by 'GitHub' not allowed.")
|
||||
|
||||
try:
|
||||
with github_sso:
|
||||
userinfo = await github_sso.verify_and_process(request)
|
||||
with provider_sso:
|
||||
userinfo = await provider_sso.verify_and_process(request)
|
||||
assert userinfo is not None
|
||||
user_id = decrypt_internal_message(github_sso.state)
|
||||
user_id = decrypt_internal_message(provider_sso.state)
|
||||
request.session.pop("user", None)
|
||||
return await _handle_sso_login(userinfo, user_id)
|
||||
|
||||
except HTTPException as e:
|
||||
raise e
|
||||
except ValueError as e:
|
||||
raise HTTPException(HTTP_403_FORBIDDEN, str(e))
|
||||
except Exception as e:
|
||||
logger.debug(e)
|
||||
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, "Cannot authenticate user with GitHub Auth."
|
||||
)
|
||||
HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
f"Cannot authenticate user with {provider} Auth.",
|
||||
) from exc
|
||||
|
||||
|
||||
@auth_router.post("/api/v1/auth/logout")
|
||||
@auth_router.post("/logout")
|
||||
async def logout() -> JSONResponse:
|
||||
response = JSONResponse({"status": "success"}, status_code=status.HTTP_200_OK)
|
||||
response.delete_cookie("cookie_access_token")
|
||||
response.delete_cookie("is_lnbits_user_authorized")
|
||||
response.delete_cookie("is_access_token_expired")
|
||||
response.delete_cookie("lnbits_last_active_wallet")
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@auth_router.post("/api/v1/auth/register")
|
||||
@auth_router.post("/register")
|
||||
async def register(data: CreateUser) -> JSONResponse:
|
||||
if not settings.is_auth_method_allowed(AuthMethods.username_and_password):
|
||||
raise HTTPException(
|
||||
@@ -202,14 +169,16 @@ async def register(data: CreateUser) -> JSONResponse:
|
||||
user = await create_user(data)
|
||||
return _auth_success_response(user.username)
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(HTTP_403_FORBIDDEN, str(e))
|
||||
except Exception as e:
|
||||
logger.debug(e)
|
||||
raise HTTPException(HTTP_500_INTERNAL_SERVER_ERROR, "Cannot create user.")
|
||||
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, "Cannot create user."
|
||||
) from exc
|
||||
|
||||
|
||||
@auth_router.put("/api/v1/auth/password")
|
||||
@auth_router.put("/password")
|
||||
async def update_password(
|
||||
data: UpdateUserPassword, user: User = Depends(check_user_exists)
|
||||
) -> Optional[User]:
|
||||
@@ -222,16 +191,16 @@ async def update_password(
|
||||
|
||||
try:
|
||||
return await update_user_password(data)
|
||||
except AssertionError as e:
|
||||
raise HTTPException(HTTP_403_FORBIDDEN, str(e))
|
||||
except Exception as e:
|
||||
logger.debug(e)
|
||||
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
|
||||
|
||||
|
||||
@auth_router.put("/api/v1/auth/update")
|
||||
@auth_router.put("/update")
|
||||
async def update(
|
||||
data: UpdateUser, user: User = Depends(check_user_exists)
|
||||
) -> Optional[User]:
|
||||
@@ -244,14 +213,16 @@ async def update(
|
||||
|
||||
try:
|
||||
return await update_account(user.id, data.username, None, data.config)
|
||||
except AssertionError as e:
|
||||
raise HTTPException(HTTP_403_FORBIDDEN, str(e))
|
||||
except Exception as e:
|
||||
logger.debug(e)
|
||||
raise HTTPException(HTTP_500_INTERNAL_SERVER_ERROR, "Cannot update user.")
|
||||
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."
|
||||
) from exc
|
||||
|
||||
|
||||
@auth_router.put("/api/v1/auth/first_install")
|
||||
@auth_router.put("/first_install")
|
||||
async def first_install(data: UpdateSuperuserPassword) -> JSONResponse:
|
||||
if not settings.first_install:
|
||||
raise HTTPException(HTTP_401_UNAUTHORIZED, "This is not your first install")
|
||||
@@ -270,13 +241,13 @@ async def first_install(data: UpdateSuperuserPassword) -> JSONResponse:
|
||||
await update_user_password(super_user)
|
||||
settings.first_install = False
|
||||
return _auth_success_response(username=super_user.username)
|
||||
except AssertionError as e:
|
||||
raise HTTPException(HTTP_403_FORBIDDEN, str(e))
|
||||
except Exception as e:
|
||||
logger.debug(e)
|
||||
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):
|
||||
@@ -321,9 +292,7 @@ def _auth_success_response(
|
||||
)
|
||||
response = JSONResponse({"access_token": access_token, "token_type": "bearer"})
|
||||
response.set_cookie("cookie_access_token", access_token, httponly=True)
|
||||
response.set_cookie(
|
||||
"is_lnbits_user_authorized", "true", samesite="none", secure=True
|
||||
)
|
||||
response.set_cookie("is_lnbits_user_authorized", "true")
|
||||
response.delete_cookie("is_access_token_expired")
|
||||
|
||||
return response
|
||||
@@ -333,36 +302,49 @@ def _auth_redirect_response(path: str, email: str) -> RedirectResponse:
|
||||
access_token = create_access_token(data={"sub": "" or "", "email": email})
|
||||
response = RedirectResponse(path)
|
||||
response.set_cookie("cookie_access_token", access_token, httponly=True)
|
||||
response.set_cookie(
|
||||
"is_lnbits_user_authorized", "true", samesite="none", secure=True
|
||||
)
|
||||
response.set_cookie("is_lnbits_user_authorized", "true")
|
||||
response.delete_cookie("is_access_token_expired")
|
||||
return response
|
||||
|
||||
|
||||
def _new_google_sso() -> Optional[GoogleSSO]:
|
||||
if not settings.is_auth_method_allowed(AuthMethods.google_auth):
|
||||
return None
|
||||
if not settings.is_google_auth_configured:
|
||||
logger.warning("Google Auth allowed but not configured.")
|
||||
return None
|
||||
return GoogleSSO(
|
||||
settings.google_client_id,
|
||||
settings.google_client_secret,
|
||||
None,
|
||||
allow_insecure_http=True,
|
||||
)
|
||||
def _new_sso(provider: str) -> Optional[SSOBase]:
|
||||
try:
|
||||
if not settings.is_auth_method_allowed(AuthMethods(f"{provider}-auth")):
|
||||
return None
|
||||
|
||||
client_id = getattr(settings, f"{provider}_client_id", None)
|
||||
client_secret = getattr(settings, f"{provider}_client_secret", None)
|
||||
discovery_url = getattr(settings, f"{provider}_discovery_url", None)
|
||||
|
||||
if not client_id or not client_secret:
|
||||
logger.warning(f"{provider} auth allowed but not configured.")
|
||||
return None
|
||||
|
||||
sso_provider_class = _find_auth_provider_class(provider)
|
||||
sso_provider = sso_provider_class(
|
||||
client_id, client_secret, None, allow_insecure_http=True
|
||||
)
|
||||
if (
|
||||
discovery_url
|
||||
and getattr(sso_provider, "discovery_url", discovery_url) != discovery_url
|
||||
):
|
||||
sso_provider.discovery_url = discovery_url
|
||||
return sso_provider
|
||||
except Exception as e:
|
||||
logger.warning(e)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _new_github_sso() -> Optional[GithubSSO]:
|
||||
if not settings.is_auth_method_allowed(AuthMethods.github_auth):
|
||||
return None
|
||||
if not settings.is_github_auth_configured:
|
||||
logger.warning("Github Auth allowed but not configured.")
|
||||
return None
|
||||
return GithubSSO(
|
||||
settings.github_client_id,
|
||||
settings.github_client_secret,
|
||||
None,
|
||||
allow_insecure_http=True,
|
||||
)
|
||||
def _find_auth_provider_class(provider: str) -> Callable:
|
||||
sso_modules = ["lnbits.core.sso", "fastapi_sso.sso"]
|
||||
for module in sso_modules:
|
||||
try:
|
||||
provider_module = importlib.import_module(f"{module}.{provider}")
|
||||
provider_class = getattr(provider_module, f"{provider.title()}SSO")
|
||||
if provider_class:
|
||||
return provider_class
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
raise ValueError(f"No SSO provider found for '{provider}'.")
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
from http import HTTPStatus
|
||||
from typing import (
|
||||
List,
|
||||
Optional,
|
||||
)
|
||||
|
||||
from bolt11 import decode as bolt11_decode
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
Depends,
|
||||
HTTPException,
|
||||
)
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.core.db import core_app_extra
|
||||
from lnbits.core.helpers import (
|
||||
migrate_extension_database,
|
||||
stop_extension_background_work,
|
||||
)
|
||||
from lnbits.core.models import (
|
||||
User,
|
||||
)
|
||||
from lnbits.decorators import (
|
||||
check_access_token,
|
||||
check_admin,
|
||||
)
|
||||
from lnbits.extension_manager import (
|
||||
CreateExtension,
|
||||
Extension,
|
||||
ExtensionRelease,
|
||||
InstallableExtension,
|
||||
fetch_github_release_config,
|
||||
fetch_release_payment_info,
|
||||
get_valid_extensions,
|
||||
)
|
||||
from lnbits.settings import settings
|
||||
|
||||
from ..crud import (
|
||||
add_installed_extension,
|
||||
delete_dbversion,
|
||||
delete_installed_extension,
|
||||
drop_extension_db,
|
||||
get_dbversions,
|
||||
get_installed_extension,
|
||||
get_installed_extensions,
|
||||
)
|
||||
|
||||
extension_router = APIRouter(
|
||||
tags=["Extension Managment"],
|
||||
prefix="/api/v1/extension",
|
||||
)
|
||||
|
||||
|
||||
@extension_router.post("")
|
||||
async def api_install_extension(
|
||||
data: CreateExtension,
|
||||
user: User = Depends(check_admin),
|
||||
access_token: Optional[str] = Depends(check_access_token),
|
||||
):
|
||||
release = await InstallableExtension.get_extension_release(
|
||||
data.ext_id, data.source_repo, data.archive, data.version
|
||||
)
|
||||
if not release:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Release not found"
|
||||
)
|
||||
|
||||
if not release.is_version_compatible:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST, detail="Incompatible extension version"
|
||||
)
|
||||
|
||||
release.payment_hash = data.payment_hash
|
||||
ext_info = InstallableExtension(
|
||||
id=data.ext_id, name=data.ext_id, installed_release=release, icon=release.icon
|
||||
)
|
||||
|
||||
try:
|
||||
installed_ext = await get_installed_extension(data.ext_id)
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
if data.ext_id not in settings.lnbits_deactivated_extensions:
|
||||
settings.lnbits_deactivated_extensions += [data.ext_id]
|
||||
|
||||
# mount routes for the new version
|
||||
core_app_extra.register_new_ext_routes(extension)
|
||||
|
||||
if extension.upgrade_hash:
|
||||
ext_info.notify_upgrade()
|
||||
|
||||
return extension
|
||||
except AssertionError as exc:
|
||||
raise HTTPException(HTTPStatus.BAD_REQUEST, str(exc)) from exc
|
||||
except Exception as exc:
|
||||
logger.warning(exc)
|
||||
ext_info.clean_extension_files()
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
detail=(
|
||||
f"Failed to install extension {ext_info.id} "
|
||||
f"({ext_info.installed_version})."
|
||||
),
|
||||
) from exc
|
||||
|
||||
|
||||
@extension_router.delete("/{ext_id}")
|
||||
async def api_uninstall_extension(
|
||||
ext_id: str,
|
||||
user: User = Depends(check_admin),
|
||||
access_token: Optional[str] = Depends(check_access_token),
|
||||
):
|
||||
installed_extensions = await get_installed_extensions()
|
||||
|
||||
extensions = [e for e in installed_extensions if e.id == ext_id]
|
||||
if len(extensions) == 0:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail=f"Unknown extension id: {ext_id}",
|
||||
)
|
||||
|
||||
# check that other extensions do not depend on this one
|
||||
for valid_ext_id in [ext.code for ext in get_valid_extensions()]:
|
||||
installed_ext = next(
|
||||
(ext for ext in installed_extensions if ext.id == valid_ext_id), None
|
||||
)
|
||||
if installed_ext and ext_id in installed_ext.dependencies:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail=(
|
||||
f"Cannot uninstall. Extension '{installed_ext.name}' "
|
||||
"depends on this one."
|
||||
),
|
||||
)
|
||||
|
||||
try:
|
||||
# call stop while the old routes are still active
|
||||
await stop_extension_background_work(ext_id, user.id, access_token)
|
||||
|
||||
if ext_id not in settings.lnbits_deactivated_extensions:
|
||||
settings.lnbits_deactivated_extensions += [ext_id]
|
||||
|
||||
for ext_info in extensions:
|
||||
ext_info.clean_extension_files()
|
||||
await delete_installed_extension(ext_id=ext_info.id)
|
||||
|
||||
logger.success(f"Extension '{ext_id}' uninstalled.")
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(exc)
|
||||
) from exc
|
||||
|
||||
|
||||
@extension_router.get("/{ext_id}/releases", dependencies=[Depends(check_admin)])
|
||||
async def get_extension_releases(ext_id: str):
|
||||
try:
|
||||
extension_releases: List[ExtensionRelease] = (
|
||||
await InstallableExtension.get_extension_releases(ext_id)
|
||||
)
|
||||
|
||||
installed_ext = await get_installed_extension(ext_id)
|
||||
if not installed_ext:
|
||||
return extension_releases
|
||||
|
||||
for release in extension_releases:
|
||||
payment_info = installed_ext.find_existing_payment(release.pay_link)
|
||||
if payment_info:
|
||||
release.paid_sats = payment_info.amount
|
||||
|
||||
return extension_releases
|
||||
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(exc)
|
||||
) from exc
|
||||
|
||||
|
||||
@extension_router.put("/invoice", dependencies=[Depends(check_admin)])
|
||||
async def get_extension_invoice(data: CreateExtension):
|
||||
try:
|
||||
assert data.cost_sats, "A non-zero amount must be specified"
|
||||
release = await InstallableExtension.get_extension_release(
|
||||
data.ext_id, data.source_repo, data.archive, data.version
|
||||
)
|
||||
assert release, "Release not found"
|
||||
assert release.pay_link, "Pay link not found for release"
|
||||
|
||||
payment_info = await fetch_release_payment_info(
|
||||
release.pay_link, data.cost_sats
|
||||
)
|
||||
assert payment_info and payment_info.payment_request, "Cannot request invoice"
|
||||
invoice = bolt11_decode(payment_info.payment_request)
|
||||
|
||||
assert invoice.amount_msat is not None, "Invoic amount is missing"
|
||||
invoice_amount = int(invoice.amount_msat / 1000)
|
||||
assert (
|
||||
invoice_amount == data.cost_sats
|
||||
), f"Wrong invoice amount: {invoice_amount}."
|
||||
assert (
|
||||
payment_info.payment_hash == invoice.payment_hash
|
||||
), "Wroong invoice payment hash"
|
||||
|
||||
return payment_info
|
||||
|
||||
except AssertionError as exc:
|
||||
raise HTTPException(HTTPStatus.BAD_REQUEST, str(exc)) from exc
|
||||
except Exception as exc:
|
||||
logger.warning(exc)
|
||||
raise HTTPException(
|
||||
HTTPStatus.INTERNAL_SERVER_ERROR, "Cannot request invoice"
|
||||
) from exc
|
||||
|
||||
|
||||
@extension_router.get(
|
||||
"/release/{org}/{repo}/{tag_name}",
|
||||
dependencies=[Depends(check_admin)],
|
||||
)
|
||||
async def get_extension_release(org: str, repo: str, tag_name: str):
|
||||
try:
|
||||
config = await fetch_github_release_config(org, repo, tag_name)
|
||||
if not config:
|
||||
return {}
|
||||
|
||||
return {
|
||||
"min_lnbits_version": config.min_lnbits_version,
|
||||
"is_version_compatible": config.is_version_compatible(),
|
||||
"warning": config.warning,
|
||||
}
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(exc)
|
||||
) from exc
|
||||
|
||||
|
||||
@extension_router.delete(
|
||||
"/{ext_id}/db",
|
||||
dependencies=[Depends(check_admin)],
|
||||
)
|
||||
async def delete_extension_db(ext_id: str):
|
||||
try:
|
||||
db_version = (await get_dbversions()).get(ext_id, None)
|
||||
if not db_version:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail=f"Unknown extension id: {ext_id}",
|
||||
)
|
||||
await drop_extension_db(ext_id=ext_id)
|
||||
await delete_dbversion(ext_id=ext_id)
|
||||
logger.success(f"Database removed for extension '{ext_id}'")
|
||||
except HTTPException as ex:
|
||||
logger.error(ex)
|
||||
raise ex
|
||||
except Exception as exc:
|
||||
logger.error(exc)
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
detail=f"Cannot delete data for extension '{ext_id}'",
|
||||
) from exc
|
||||
+87
-177
@@ -1,40 +1,35 @@
|
||||
import asyncio
|
||||
import sys
|
||||
from http import HTTPStatus
|
||||
from pathlib import Path
|
||||
from typing import Annotated, List, Optional, Union
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from fastapi import Cookie, Depends, Query, Request, status
|
||||
from fastapi import Cookie, Depends, Query, Request
|
||||
from fastapi.exceptions import HTTPException
|
||||
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse
|
||||
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
|
||||
from fastapi.routing import APIRouter
|
||||
from loguru import logger
|
||||
from pydantic.types import UUID4
|
||||
|
||||
from lnbits.core.db import core_app_extra, db
|
||||
from lnbits.core.db import core_app_extra
|
||||
from lnbits.core.helpers import to_valid_user_id
|
||||
from lnbits.core.models import User
|
||||
from lnbits.decorators import check_admin, check_user_exists
|
||||
from lnbits.helpers import template_renderer, url_for
|
||||
from lnbits.helpers import template_renderer
|
||||
from lnbits.settings import settings
|
||||
from lnbits.wallets import get_wallet_class
|
||||
from lnbits.wallets import get_funding_source
|
||||
|
||||
from ...extension_manager import InstallableExtension, get_valid_extensions
|
||||
from ...utils.exchange_rates import currencies
|
||||
from ...utils.exchange_rates import allowed_currencies, currencies
|
||||
from ..crud import (
|
||||
create_account,
|
||||
create_wallet,
|
||||
get_balance_check,
|
||||
get_dbversions,
|
||||
get_inactive_extensions,
|
||||
get_installed_extensions,
|
||||
get_user,
|
||||
save_balance_notify,
|
||||
update_installed_extension_state,
|
||||
update_user_extension,
|
||||
)
|
||||
from ..services import pay_invoice, redeem_lnurl_withdraw
|
||||
|
||||
generic_router = APIRouter(
|
||||
tags=["Core NON-API Website Routes"], include_in_schema=False
|
||||
@@ -49,7 +44,7 @@ async def favicon():
|
||||
@generic_router.get("/", response_class=HTMLResponse)
|
||||
async def home(request: Request, lightning: str = ""):
|
||||
return template_renderer().TemplateResponse(
|
||||
"core/index.html", {"request": request, "lnurl": lightning}
|
||||
request, "core/index.html", {"lnurl": lightning}
|
||||
)
|
||||
|
||||
|
||||
@@ -57,15 +52,15 @@ async def home(request: Request, lightning: str = ""):
|
||||
async def first_install(request: Request):
|
||||
if not settings.first_install:
|
||||
return template_renderer().TemplateResponse(
|
||||
request,
|
||||
"error.html",
|
||||
{
|
||||
"request": request,
|
||||
"err": "Super user account has already been configured.",
|
||||
},
|
||||
)
|
||||
return template_renderer().TemplateResponse(
|
||||
request,
|
||||
"core/first_install.html",
|
||||
{"request": request},
|
||||
)
|
||||
|
||||
|
||||
@@ -95,11 +90,10 @@ async def extensions_install(
|
||||
installed_exts: List["InstallableExtension"] = await get_installed_extensions()
|
||||
installed_exts_ids = [e.id for e in installed_exts]
|
||||
|
||||
installable_exts: List[
|
||||
InstallableExtension
|
||||
] = await InstallableExtension.get_installable_extensions()
|
||||
installable_exts = await InstallableExtension.get_installable_extensions()
|
||||
installable_exts_ids = [e.id for e in installable_exts]
|
||||
installable_exts += [
|
||||
e for e in installed_exts if e.id not in installed_exts_ids
|
||||
e for e in installed_exts if e.id not in installable_exts_ids
|
||||
]
|
||||
|
||||
for e in installable_exts:
|
||||
@@ -114,6 +108,7 @@ async def extensions_install(
|
||||
except Exception as ex:
|
||||
logger.warning(ex)
|
||||
installable_exts = []
|
||||
installed_exts_ids = []
|
||||
|
||||
try:
|
||||
ext_id = activate or deactivate
|
||||
@@ -138,49 +133,49 @@ async def extensions_install(
|
||||
ext_id=ext_id, active=activate is not None
|
||||
)
|
||||
|
||||
all_ext_ids = list(map(lambda e: e.code, all_extensions))
|
||||
all_ext_ids = [ext.code for ext in all_extensions]
|
||||
inactive_extensions = await get_inactive_extensions()
|
||||
db_version = await get_dbversions()
|
||||
extensions = list(
|
||||
map(
|
||||
lambda ext: {
|
||||
"id": ext.id,
|
||||
"name": ext.name,
|
||||
"icon": ext.icon,
|
||||
"shortDescription": ext.short_description,
|
||||
"stars": ext.stars,
|
||||
"isFeatured": ext.featured,
|
||||
"dependencies": ext.dependencies,
|
||||
"isInstalled": ext.id in installed_exts_ids,
|
||||
"hasDatabaseTables": ext.id in db_version,
|
||||
"isAvailable": ext.id in all_ext_ids,
|
||||
"isAdminOnly": ext.id in settings.lnbits_admin_extensions,
|
||||
"isActive": ext.id not in inactive_extensions,
|
||||
"latestRelease": (
|
||||
dict(ext.latest_release) if ext.latest_release else None
|
||||
),
|
||||
"installedRelease": (
|
||||
dict(ext.installed_release) if ext.installed_release else None
|
||||
),
|
||||
},
|
||||
installable_exts,
|
||||
)
|
||||
)
|
||||
extensions = [
|
||||
{
|
||||
"id": ext.id,
|
||||
"name": ext.name,
|
||||
"icon": ext.icon,
|
||||
"shortDescription": ext.short_description,
|
||||
"stars": ext.stars,
|
||||
"isFeatured": ext.featured,
|
||||
"dependencies": ext.dependencies,
|
||||
"isInstalled": ext.id in installed_exts_ids,
|
||||
"hasDatabaseTables": ext.id in db_version,
|
||||
"isAvailable": ext.id in all_ext_ids,
|
||||
"isAdminOnly": ext.id in settings.lnbits_admin_extensions,
|
||||
"isActive": ext.id not in inactive_extensions,
|
||||
"latestRelease": (
|
||||
dict(ext.latest_release) if ext.latest_release else None
|
||||
),
|
||||
"installedRelease": (
|
||||
dict(ext.installed_release) if ext.installed_release else None
|
||||
),
|
||||
}
|
||||
for ext in installable_exts
|
||||
]
|
||||
|
||||
# refresh user state. Eg: enabled extensions.
|
||||
user = await get_user(user.id) or user
|
||||
|
||||
return template_renderer().TemplateResponse(
|
||||
request,
|
||||
"core/extensions.html",
|
||||
{
|
||||
"request": request,
|
||||
"user": user.dict(),
|
||||
"extensions": extensions,
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(e)
|
||||
raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e))
|
||||
except Exception as exc:
|
||||
logger.warning(exc)
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(exc)
|
||||
) from exc
|
||||
|
||||
|
||||
@generic_router.get(
|
||||
@@ -208,23 +203,22 @@ async def wallet(
|
||||
user_wallet = user.get_wallet(wallet_id)
|
||||
if not user_wallet or user_wallet.deleted:
|
||||
return template_renderer().TemplateResponse(
|
||||
"error.html", {"request": request, "err": "Wallet not found"}
|
||||
request, "error.html", {"err": "Wallet not found"}
|
||||
)
|
||||
|
||||
resp = template_renderer().TemplateResponse(
|
||||
request,
|
||||
"core/wallet.html",
|
||||
{
|
||||
"request": request,
|
||||
"user": user.dict(),
|
||||
"wallet": user_wallet.dict(),
|
||||
"currencies": allowed_currencies(),
|
||||
"service_fee": settings.lnbits_service_fee,
|
||||
"service_fee_max": settings.lnbits_service_fee_max,
|
||||
"web_manifest": f"/manifest/{user.id}.webmanifest",
|
||||
},
|
||||
)
|
||||
resp.set_cookie(
|
||||
"lnbits_last_active_wallet", wallet_id, samesite="none", secure=True
|
||||
)
|
||||
resp.set_cookie("lnbits_last_active_wallet", wallet_id)
|
||||
return resp
|
||||
|
||||
|
||||
@@ -238,127 +232,25 @@ async def account(
|
||||
user: User = Depends(check_user_exists),
|
||||
):
|
||||
return template_renderer().TemplateResponse(
|
||||
request,
|
||||
"core/account.html",
|
||||
{
|
||||
"request": request,
|
||||
"user": user.dict(),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@generic_router.get("/withdraw", response_class=JSONResponse)
|
||||
async def lnurl_full_withdraw(request: Request):
|
||||
usr_param = request.query_params.get("usr")
|
||||
if not usr_param:
|
||||
return {"status": "ERROR", "reason": "usr parameter not provided."}
|
||||
|
||||
user = await get_user(usr_param)
|
||||
if not user:
|
||||
return {"status": "ERROR", "reason": "User does not exist."}
|
||||
|
||||
wal_param = request.query_params.get("wal")
|
||||
if not wal_param:
|
||||
return {"status": "ERROR", "reason": "wal parameter not provided."}
|
||||
|
||||
wallet = user.get_wallet(wal_param)
|
||||
if not wallet:
|
||||
return {"status": "ERROR", "reason": "Wallet does not exist."}
|
||||
|
||||
return {
|
||||
"tag": "withdrawRequest",
|
||||
"callback": url_for("/withdraw/cb", external=True, usr=user.id, wal=wallet.id),
|
||||
"k1": "0",
|
||||
"minWithdrawable": 1000 if wallet.withdrawable_balance else 0,
|
||||
"maxWithdrawable": wallet.withdrawable_balance,
|
||||
"defaultDescription": (
|
||||
f"{settings.lnbits_site_title} balance withdraw from {wallet.id[0:5]}"
|
||||
),
|
||||
"balanceCheck": url_for("/withdraw", external=True, usr=user.id, wal=wallet.id),
|
||||
}
|
||||
|
||||
|
||||
@generic_router.get("/withdraw/cb", response_class=JSONResponse)
|
||||
async def lnurl_full_withdraw_callback(request: Request):
|
||||
usr_param = request.query_params.get("usr")
|
||||
if not usr_param:
|
||||
return {"status": "ERROR", "reason": "usr parameter not provided."}
|
||||
|
||||
user = await get_user(usr_param)
|
||||
if not user:
|
||||
return {"status": "ERROR", "reason": "User does not exist."}
|
||||
|
||||
wal_param = request.query_params.get("wal")
|
||||
if not wal_param:
|
||||
return {"status": "ERROR", "reason": "wal parameter not provided."}
|
||||
|
||||
wallet = user.get_wallet(wal_param)
|
||||
if not wallet:
|
||||
return {"status": "ERROR", "reason": "Wallet does not exist."}
|
||||
|
||||
pr = request.query_params.get("pr")
|
||||
if not pr:
|
||||
return {"status": "ERROR", "reason": "payment_request not provided."}
|
||||
|
||||
async def pay():
|
||||
try:
|
||||
await pay_invoice(wallet_id=wallet.id, payment_request=pr)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
asyncio.create_task(pay())
|
||||
|
||||
balance_notify = request.query_params.get("balanceNotify")
|
||||
if balance_notify:
|
||||
await save_balance_notify(wallet.id, balance_notify)
|
||||
|
||||
return {"status": "OK"}
|
||||
|
||||
|
||||
@generic_router.get("/withdraw/notify/{service}")
|
||||
async def lnurl_balance_notify(request: Request, service: str):
|
||||
wal_param = request.query_params.get("wal")
|
||||
if not wal_param:
|
||||
return {"status": "ERROR", "reason": "wal parameter not provided."}
|
||||
|
||||
bc = await get_balance_check(wal_param, service)
|
||||
if bc:
|
||||
await redeem_lnurl_withdraw(bc.wallet, bc.url)
|
||||
|
||||
|
||||
@generic_router.get(
|
||||
"/lnurlwallet", response_class=RedirectResponse, name="core.lnurlwallet"
|
||||
)
|
||||
async def lnurlwallet(request: Request):
|
||||
async with db.connect() as conn:
|
||||
account = await create_account(conn=conn)
|
||||
user = await get_user(account.id, conn=conn)
|
||||
assert user, "Newly created user not found."
|
||||
wallet = await create_wallet(user_id=user.id, conn=conn)
|
||||
|
||||
lightning_param = request.query_params.get("lightning")
|
||||
if not lightning_param:
|
||||
return {"status": "ERROR", "reason": "lightning parameter not provided."}
|
||||
|
||||
asyncio.create_task(
|
||||
redeem_lnurl_withdraw(
|
||||
wallet.id,
|
||||
lightning_param,
|
||||
"LNbits initial funding: voucher redeem.",
|
||||
{"tag": "lnurlwallet"},
|
||||
5, # wait 5 seconds before sending the invoice to the service
|
||||
)
|
||||
@generic_router.get("/service-worker.js")
|
||||
async def service_worker(request: Request):
|
||||
return template_renderer().TemplateResponse(
|
||||
request,
|
||||
"service-worker.js",
|
||||
{
|
||||
"cache_version": settings.server_startup_time,
|
||||
},
|
||||
media_type="text/javascript",
|
||||
)
|
||||
|
||||
return RedirectResponse(
|
||||
f"/wallet?usr={user.id}&wal={wallet.id}",
|
||||
status_code=status.HTTP_307_TEMPORARY_REDIRECT,
|
||||
)
|
||||
|
||||
|
||||
@generic_router.get("/service-worker.js", response_class=FileResponse)
|
||||
async def service_worker():
|
||||
return FileResponse(Path("lnbits", "static", "js", "service-worker.js"))
|
||||
|
||||
|
||||
@generic_router.get("/manifest/{usr}.webmanifest")
|
||||
async def manifest(request: Request, usr: str):
|
||||
@@ -448,13 +340,13 @@ async def node(request: Request, user: User = Depends(check_admin)):
|
||||
if not settings.lnbits_node_ui:
|
||||
raise HTTPException(status_code=HTTPStatus.SERVICE_UNAVAILABLE)
|
||||
|
||||
WALLET = get_wallet_class()
|
||||
_, balance = await WALLET.status()
|
||||
funding_source = get_funding_source()
|
||||
_, balance = await funding_source.status()
|
||||
|
||||
return template_renderer().TemplateResponse(
|
||||
request,
|
||||
"node/index.html",
|
||||
{
|
||||
"request": request,
|
||||
"user": user.dict(),
|
||||
"settings": settings.dict(),
|
||||
"balance": balance,
|
||||
@@ -468,13 +360,13 @@ async def node_public(request: Request):
|
||||
if not settings.lnbits_public_node_ui:
|
||||
raise HTTPException(status_code=HTTPStatus.SERVICE_UNAVAILABLE)
|
||||
|
||||
WALLET = get_wallet_class()
|
||||
_, balance = await WALLET.status()
|
||||
funding_source = get_funding_source()
|
||||
_, balance = await funding_source.status()
|
||||
|
||||
return template_renderer().TemplateResponse(
|
||||
request,
|
||||
"node/public.html",
|
||||
{
|
||||
"request": request,
|
||||
"settings": settings.dict(),
|
||||
"balance": balance,
|
||||
},
|
||||
@@ -482,20 +374,36 @@ async def node_public(request: Request):
|
||||
|
||||
|
||||
@generic_router.get("/admin", response_class=HTMLResponse)
|
||||
async def index(request: Request, user: User = Depends(check_admin)):
|
||||
async def admin_index(request: Request, user: User = Depends(check_admin)):
|
||||
if not settings.lnbits_admin_ui:
|
||||
raise HTTPException(status_code=HTTPStatus.NOT_FOUND)
|
||||
|
||||
WALLET = get_wallet_class()
|
||||
_, balance = await WALLET.status()
|
||||
funding_source = get_funding_source()
|
||||
_, balance = await funding_source.status()
|
||||
|
||||
return template_renderer().TemplateResponse(
|
||||
request,
|
||||
"admin/index.html",
|
||||
{
|
||||
"user": user.dict(),
|
||||
"settings": settings.dict(),
|
||||
"balance": balance,
|
||||
"currencies": list(currencies.keys()),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@generic_router.get("/users", response_class=HTMLResponse)
|
||||
async def users_index(request: Request, user: User = Depends(check_admin)):
|
||||
if not settings.lnbits_admin_ui:
|
||||
raise HTTPException(status_code=HTTPStatus.NOT_FOUND)
|
||||
|
||||
return template_renderer().TemplateResponse(
|
||||
"users/index.html",
|
||||
{
|
||||
"request": request,
|
||||
"user": user.dict(),
|
||||
"settings": settings.dict(),
|
||||
"balance": balance,
|
||||
"currencies": list(currencies.keys()),
|
||||
},
|
||||
)
|
||||
@@ -506,8 +414,10 @@ async def hex_to_uuid4(hex_value: str):
|
||||
try:
|
||||
user_id = to_valid_user_id(hex_value).hex
|
||||
return RedirectResponse(url=f"/wallet?usr={user_id}")
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e))
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST, detail=str(exc)
|
||||
) from exc
|
||||
|
||||
|
||||
async def toggle_extension(extension_to_enable, extension_to_disable, user_id):
|
||||
|
||||
@@ -27,8 +27,8 @@ from ...utils.cache import cache
|
||||
|
||||
|
||||
def require_node():
|
||||
NODE = get_node_class()
|
||||
if not NODE:
|
||||
node_class = get_node_class()
|
||||
if not node_class:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_IMPLEMENTED,
|
||||
detail="Active backend does not implement Node API",
|
||||
@@ -38,7 +38,7 @@ def require_node():
|
||||
status_code=HTTPStatus.SERVICE_UNAVAILABLE,
|
||||
detail="Not enabled",
|
||||
)
|
||||
return NODE
|
||||
return node_class
|
||||
|
||||
|
||||
def check_public():
|
||||
@@ -49,12 +49,20 @@ def check_public():
|
||||
)
|
||||
|
||||
|
||||
node_router = APIRouter(prefix="/node/api/v1", dependencies=[Depends(check_admin)])
|
||||
node_router = APIRouter(
|
||||
tags=["Node Managment"],
|
||||
prefix="/node/api/v1",
|
||||
dependencies=[Depends(check_admin)],
|
||||
)
|
||||
super_node_router = APIRouter(
|
||||
prefix="/node/api/v1", dependencies=[Depends(check_super_user)]
|
||||
tags=["Node Managment"],
|
||||
prefix="/node/api/v1",
|
||||
dependencies=[Depends(check_super_user)],
|
||||
)
|
||||
public_node_router = APIRouter(
|
||||
prefix="/node/public/api/v1", dependencies=[Depends(check_public)]
|
||||
tags=["Node Managment"],
|
||||
prefix="/node/public/api/v1",
|
||||
dependencies=[Depends(check_public)],
|
||||
)
|
||||
|
||||
|
||||
@@ -108,9 +116,11 @@ async def api_delete_channel(
|
||||
) -> Optional[List[NodeChannel]]:
|
||||
return await node.close_channel(
|
||||
short_id,
|
||||
ChannelPoint(funding_txid=funding_txid, output_index=output_index)
|
||||
if funding_txid is not None and output_index is not None
|
||||
else None,
|
||||
(
|
||||
ChannelPoint(funding_txid=funding_txid, output_index=output_index)
|
||||
if funding_txid is not None and output_index is not None
|
||||
else None
|
||||
),
|
||||
force,
|
||||
)
|
||||
|
||||
@@ -185,5 +195,7 @@ async def api_get_1ml_stats(node: Node = Depends(require_node)) -> Optional[Node
|
||||
try:
|
||||
r.raise_for_status()
|
||||
return r.json()["noderank"]
|
||||
except httpx.HTTPStatusError:
|
||||
raise HTTPException(status_code=404, detail="Node not found on 1ml.com")
|
||||
except httpx.HTTPStatusError as exc:
|
||||
raise HTTPException(
|
||||
status_code=404, detail="Node not found on 1ml.com"
|
||||
) from exc
|
||||
|
||||
@@ -0,0 +1,449 @@
|
||||
import asyncio
|
||||
import json
|
||||
import uuid
|
||||
from http import HTTPStatus
|
||||
from math import ceil
|
||||
from typing import List, Optional, Union
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
Body,
|
||||
Depends,
|
||||
Header,
|
||||
HTTPException,
|
||||
Query,
|
||||
Request,
|
||||
)
|
||||
from fastapi.responses import JSONResponse
|
||||
from loguru import logger
|
||||
from sse_starlette.sse import EventSourceResponse
|
||||
|
||||
from lnbits import bolt11
|
||||
from lnbits.core.db import db
|
||||
from lnbits.core.models import (
|
||||
CreateInvoice,
|
||||
CreateLnurl,
|
||||
DecodePayment,
|
||||
KeyType,
|
||||
Payment,
|
||||
PaymentFilters,
|
||||
PaymentHistoryPoint,
|
||||
Wallet,
|
||||
)
|
||||
from lnbits.db import Filters, Page
|
||||
from lnbits.decorators import (
|
||||
WalletTypeInfo,
|
||||
get_key_type,
|
||||
parse_filters,
|
||||
require_admin_key,
|
||||
require_invoice_key,
|
||||
)
|
||||
from lnbits.helpers import generate_filter_params_openapi
|
||||
from lnbits.lnurl import decode as lnurl_decode
|
||||
from lnbits.settings import settings
|
||||
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis
|
||||
|
||||
from ..crud import (
|
||||
DateTrunc,
|
||||
get_payments,
|
||||
get_payments_history,
|
||||
get_payments_paginated,
|
||||
get_standalone_payment,
|
||||
get_wallet_for_key,
|
||||
update_pending_payments,
|
||||
)
|
||||
from ..services import (
|
||||
check_transaction_status,
|
||||
create_invoice,
|
||||
fee_reserve_total,
|
||||
pay_invoice,
|
||||
)
|
||||
from ..tasks import api_invoice_listeners
|
||||
|
||||
payment_router = APIRouter(prefix="/api/v1/payments", tags=["Payments"])
|
||||
|
||||
|
||||
@payment_router.get(
|
||||
"",
|
||||
name="Payment List",
|
||||
summary="get list of payments",
|
||||
response_description="list of payments",
|
||||
response_model=List[Payment],
|
||||
openapi_extra=generate_filter_params_openapi(PaymentFilters),
|
||||
)
|
||||
async def api_payments(
|
||||
wallet: WalletTypeInfo = Depends(get_key_type),
|
||||
filters: Filters = Depends(parse_filters(PaymentFilters)),
|
||||
):
|
||||
await update_pending_payments(wallet.wallet.id)
|
||||
return await get_payments(
|
||||
wallet_id=wallet.wallet.id,
|
||||
pending=True,
|
||||
complete=True,
|
||||
filters=filters,
|
||||
)
|
||||
|
||||
|
||||
@payment_router.get(
|
||||
"/history",
|
||||
name="Get payments history",
|
||||
response_model=List[PaymentHistoryPoint],
|
||||
openapi_extra=generate_filter_params_openapi(PaymentFilters),
|
||||
)
|
||||
async def api_payments_history(
|
||||
wallet: WalletTypeInfo = Depends(get_key_type),
|
||||
group: DateTrunc = Query("day"),
|
||||
filters: Filters[PaymentFilters] = Depends(parse_filters(PaymentFilters)),
|
||||
):
|
||||
await update_pending_payments(wallet.wallet.id)
|
||||
return await get_payments_history(wallet.wallet.id, group, filters)
|
||||
|
||||
|
||||
@payment_router.get(
|
||||
"/paginated",
|
||||
name="Payment List",
|
||||
summary="get paginated list of payments",
|
||||
response_description="list of payments",
|
||||
response_model=Page[Payment],
|
||||
openapi_extra=generate_filter_params_openapi(PaymentFilters),
|
||||
)
|
||||
async def api_payments_paginated(
|
||||
wallet: WalletTypeInfo = Depends(get_key_type),
|
||||
filters: Filters = Depends(parse_filters(PaymentFilters)),
|
||||
):
|
||||
await update_pending_payments(wallet.wallet.id)
|
||||
page = await get_payments_paginated(
|
||||
wallet_id=wallet.wallet.id,
|
||||
pending=True,
|
||||
complete=True,
|
||||
filters=filters,
|
||||
)
|
||||
return page
|
||||
|
||||
|
||||
async def api_payments_create_invoice(data: CreateInvoice, wallet: Wallet):
|
||||
description_hash = b""
|
||||
unhashed_description = b""
|
||||
memo = data.memo or settings.lnbits_site_title
|
||||
if data.description_hash or data.unhashed_description:
|
||||
if data.description_hash:
|
||||
try:
|
||||
description_hash = bytes.fromhex(data.description_hash)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail="'description_hash' must be a valid hex string",
|
||||
) from exc
|
||||
if data.unhashed_description:
|
||||
try:
|
||||
unhashed_description = bytes.fromhex(data.unhashed_description)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail="'unhashed_description' must be a valid hex string",
|
||||
) from exc
|
||||
# do not save memo if description_hash or unhashed_description is set
|
||||
memo = ""
|
||||
|
||||
async with db.connect() as conn:
|
||||
payment_hash, payment_request = await create_invoice(
|
||||
wallet_id=wallet.id,
|
||||
amount=data.amount,
|
||||
memo=memo,
|
||||
currency=data.unit,
|
||||
description_hash=description_hash,
|
||||
unhashed_description=unhashed_description,
|
||||
expiry=data.expiry,
|
||||
extra=data.extra,
|
||||
webhook=data.webhook,
|
||||
internal=data.internal,
|
||||
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: Union[None, bool, str] = None
|
||||
if data.lnurl_callback:
|
||||
headers = {"User-Agent": settings.user_agent}
|
||||
async with httpx.AsyncClient(headers=headers) as client:
|
||||
try:
|
||||
r = await client.get(
|
||||
data.lnurl_callback,
|
||||
params={
|
||||
"pr": payment_request,
|
||||
},
|
||||
timeout=10,
|
||||
)
|
||||
if r.is_error:
|
||||
lnurl_response = r.text
|
||||
else:
|
||||
resp = json.loads(r.text)
|
||||
if resp["status"] != "OK":
|
||||
lnurl_response = resp["reason"]
|
||||
else:
|
||||
lnurl_response = True
|
||||
except (httpx.ConnectError, httpx.RequestError) as ex:
|
||||
logger.error(ex)
|
||||
lnurl_response = False
|
||||
|
||||
return {
|
||||
"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(
|
||||
"",
|
||||
summary="Create or pay an invoice",
|
||||
description="""
|
||||
This endpoint can be used both to generate and pay a BOLT11 invoice.
|
||||
To generate a new invoice for receiving funds into the authorized account,
|
||||
specify at least the first four fields in the POST body: `out: false`,
|
||||
`amount`, `unit`, and `memo`. To pay an arbitrary invoice from the funds
|
||||
already in the authorized account, specify `out: true` and use the `bolt11`
|
||||
field to supply the BOLT11 invoice to be paid.
|
||||
""",
|
||||
status_code=HTTPStatus.CREATED,
|
||||
responses={
|
||||
400: {"description": "Invalid BOLT11 string or missing fields."},
|
||||
401: {"description": "Invoice (or Admin) key required."},
|
||||
520: {"description": "Payment or Invoice error."},
|
||||
},
|
||||
)
|
||||
async def api_payments_create(
|
||||
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
||||
invoice_data: CreateInvoice = Body(...),
|
||||
):
|
||||
if invoice_data.out is True and wallet.key_type == KeyType.admin:
|
||||
if not invoice_data.bolt11:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail="BOLT11 string is invalid or not given",
|
||||
)
|
||||
|
||||
payment_hash = await pay_invoice(
|
||||
wallet_id=wallet.wallet.id,
|
||||
payment_request=invoice_data.bolt11,
|
||||
extra=invoice_data.extra,
|
||||
)
|
||||
return {
|
||||
"payment_hash": payment_hash,
|
||||
# maintain backwards compatibility with API clients:
|
||||
"checking_id": payment_hash,
|
||||
}
|
||||
|
||||
elif not invoice_data.out:
|
||||
# invoice key
|
||||
return await api_payments_create_invoice(invoice_data, wallet.wallet)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.UNAUTHORIZED,
|
||||
detail="Invoice (or Admin) key required.",
|
||||
)
|
||||
|
||||
|
||||
@payment_router.get("/fee-reserve")
|
||||
async def api_payments_fee_reserve(invoice: str = Query("invoice")) -> JSONResponse:
|
||||
invoice_obj = bolt11.decode(invoice)
|
||||
if invoice_obj.amount_msat:
|
||||
response = {
|
||||
"fee_reserve": fee_reserve_total(invoice_obj.amount_msat),
|
||||
}
|
||||
return JSONResponse(response)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail="Invoice has no amount.",
|
||||
)
|
||||
|
||||
|
||||
@payment_router.post("/lnurl")
|
||||
async def api_payments_pay_lnurl(
|
||||
data: CreateLnurl, wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||
):
|
||||
domain = urlparse(data.callback).netloc
|
||||
|
||||
headers = {"User-Agent": settings.user_agent}
|
||||
async with httpx.AsyncClient(headers=headers, follow_redirects=True) as client:
|
||||
try:
|
||||
if data.unit and data.unit != "sat":
|
||||
amount_msat = await fiat_amount_as_satoshis(data.amount, data.unit)
|
||||
# no msat precision
|
||||
amount_msat = ceil(amount_msat // 1000) * 1000
|
||||
else:
|
||||
amount_msat = data.amount
|
||||
r = await client.get(
|
||||
data.callback,
|
||||
params={"amount": amount_msat, "comment": data.comment},
|
||||
timeout=40,
|
||||
)
|
||||
if r.is_error:
|
||||
raise httpx.ConnectError("LNURL callback connection error")
|
||||
r.raise_for_status()
|
||||
except (httpx.ConnectError, httpx.RequestError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail=f"Failed to connect to {domain}.",
|
||||
) from exc
|
||||
|
||||
params = json.loads(r.text)
|
||||
if params.get("status") == "ERROR":
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail=f"{domain} said: '{params.get('reason', '')}'",
|
||||
)
|
||||
|
||||
if not params.get("pr"):
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail=f"{domain} did not return a payment request.",
|
||||
)
|
||||
|
||||
invoice = bolt11.decode(params["pr"])
|
||||
if invoice.amount_msat != amount_msat:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail=(
|
||||
(
|
||||
f"{domain} returned an invalid invoice. Expected"
|
||||
f" {amount_msat} msat, got {invoice.amount_msat}."
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
extra = {}
|
||||
|
||||
if params.get("successAction"):
|
||||
extra["success_action"] = params["successAction"]
|
||||
if data.comment:
|
||||
extra["comment"] = data.comment
|
||||
if data.unit and data.unit != "sat":
|
||||
extra["fiat_currency"] = data.unit
|
||||
extra["fiat_amount"] = data.amount / 1000
|
||||
assert data.description is not None, "description is required"
|
||||
payment_hash = await pay_invoice(
|
||||
wallet_id=wallet.wallet.id,
|
||||
payment_request=params["pr"],
|
||||
description=data.description,
|
||||
extra=extra,
|
||||
)
|
||||
|
||||
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):
|
||||
"""
|
||||
Subscribe to new invoices for a wallet. Can be wrapped in EventSourceResponse.
|
||||
Listenes invoming payments for a wallet and yields jsons with payment details.
|
||||
"""
|
||||
this_wallet_id = wallet.id
|
||||
|
||||
payment_queue: asyncio.Queue[Payment] = asyncio.Queue(0)
|
||||
|
||||
uid = f"{this_wallet_id}_{str(uuid.uuid4())[:8]}"
|
||||
logger.debug(f"adding sse listener for wallet: {uid}")
|
||||
api_invoice_listeners[uid] = payment_queue
|
||||
|
||||
try:
|
||||
while settings.lnbits_running:
|
||||
if await request.is_disconnected():
|
||||
await request.close()
|
||||
break
|
||||
payment: Payment = await payment_queue.get()
|
||||
if payment.wallet_id == this_wallet_id:
|
||||
logger.debug("sse listener: payment received", payment)
|
||||
yield {"data": payment.json(), "event": "payment-received"}
|
||||
except asyncio.CancelledError:
|
||||
logger.debug(f"removing listener for wallet {uid}")
|
||||
except Exception as exc:
|
||||
logger.error(f"Error in sse: {exc}")
|
||||
finally:
|
||||
api_invoice_listeners.pop(uid)
|
||||
|
||||
|
||||
@payment_router.get("/sse")
|
||||
async def api_payments_sse(
|
||||
request: Request, wallet: WalletTypeInfo = Depends(get_key_type)
|
||||
):
|
||||
return EventSourceResponse(
|
||||
subscribe_wallet_invoices(request, wallet.wallet),
|
||||
ping=20,
|
||||
media_type="text/event-stream",
|
||||
)
|
||||
|
||||
|
||||
# TODO: refactor this route into a public and admin one
|
||||
@payment_router.get("/{payment_hash}")
|
||||
async def api_payment(payment_hash, x_api_key: Optional[str] = Header(None)):
|
||||
# We use X_Api_Key here because we want this call to work with and without keys
|
||||
# If a valid key is given, we also return the field "details", otherwise not
|
||||
wallet = await get_wallet_for_key(x_api_key) if isinstance(x_api_key, str) else None
|
||||
|
||||
payment = await get_standalone_payment(
|
||||
payment_hash, wallet_id=wallet.id if wallet else None
|
||||
)
|
||||
if payment is None:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Payment does not exist."
|
||||
)
|
||||
await check_transaction_status(payment.wallet_id, payment_hash)
|
||||
payment = await get_standalone_payment(
|
||||
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:
|
||||
return {"paid": True, "preimage": payment.preimage, "details": payment}
|
||||
return {"paid": True, "preimage": payment.preimage}
|
||||
|
||||
try:
|
||||
status = await payment.check_status()
|
||||
except Exception:
|
||||
if wallet and wallet.id == payment.wallet_id:
|
||||
return {"paid": False, "details": payment}
|
||||
return {"paid": False}
|
||||
|
||||
if wallet and wallet.id == payment.wallet_id:
|
||||
return {
|
||||
"paid": not payment.pending,
|
||||
"status": f"{status!s}",
|
||||
"preimage": payment.preimage,
|
||||
"details": payment,
|
||||
}
|
||||
return {"paid": not payment.pending, "preimage": payment.preimage}
|
||||
|
||||
|
||||
@payment_router.post("/decode", status_code=HTTPStatus.OK)
|
||||
async def api_payments_decode(data: DecodePayment) -> JSONResponse:
|
||||
payment_str = data.data
|
||||
try:
|
||||
if payment_str[:5] == "LNURL":
|
||||
url = str(lnurl_decode(payment_str))
|
||||
return JSONResponse({"domain": url})
|
||||
else:
|
||||
invoice = bolt11.decode(payment_str)
|
||||
return JSONResponse(invoice.data)
|
||||
except Exception as exc:
|
||||
return JSONResponse(
|
||||
{"message": f"Failed to decode: {exc!s}"},
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
)
|
||||
@@ -9,7 +9,7 @@ from lnbits import bolt11
|
||||
from ..crud import get_standalone_payment
|
||||
from ..tasks import api_invoice_listeners
|
||||
|
||||
public_router = APIRouter()
|
||||
public_router = APIRouter(tags=["Core"])
|
||||
|
||||
|
||||
@public_router.get("/public/v1/payment/{payment_hash}")
|
||||
@@ -27,10 +27,10 @@ async def api_public_payment_longpolling(payment_hash):
|
||||
invoice = bolt11.decode(payment.bolt11)
|
||||
if invoice.has_expired():
|
||||
return {"status": "expired"}
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST, detail="Invalid bolt11 invoice."
|
||||
)
|
||||
) from exc
|
||||
|
||||
payment_queue = asyncio.Queue(0)
|
||||
|
||||
@@ -50,7 +50,7 @@ async def api_public_payment_longpolling(payment_hash):
|
||||
cancel_scope.cancel()
|
||||
|
||||
cancel_scope = asyncio.create_task(payment_info_receiver())
|
||||
asyncio.create_task(timeouter(cancel_scope))
|
||||
asyncio.create_task(timeouter(cancel_scope)) # noqa: RUF006
|
||||
|
||||
if response:
|
||||
return response
|
||||
|
||||
@@ -9,7 +9,8 @@ from starlette.responses import RedirectResponse
|
||||
|
||||
from lnbits.decorators import (
|
||||
WalletTypeInfo,
|
||||
get_key_type,
|
||||
require_admin_key,
|
||||
require_invoice_key,
|
||||
)
|
||||
|
||||
from ..crud import (
|
||||
@@ -19,7 +20,7 @@ from ..crud import (
|
||||
get_tinyurl_by_url,
|
||||
)
|
||||
|
||||
tinyurl_router = APIRouter()
|
||||
tinyurl_router = APIRouter(tags=["Tinyurl"])
|
||||
|
||||
|
||||
@tinyurl_router.post(
|
||||
@@ -28,19 +29,19 @@ tinyurl_router = APIRouter()
|
||||
description="creates a tinyurl",
|
||||
)
|
||||
async def api_create_tinyurl(
|
||||
url: str, endless: bool = False, wallet: WalletTypeInfo = Depends(get_key_type)
|
||||
url: str, endless: bool = False, wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||
):
|
||||
tinyurls = await get_tinyurl_by_url(url)
|
||||
try:
|
||||
for tinyurl in tinyurls:
|
||||
if tinyurl:
|
||||
if tinyurl.wallet == wallet.wallet.inkey:
|
||||
if tinyurl.wallet == wallet.wallet.id:
|
||||
return tinyurl
|
||||
return await create_tinyurl(url, endless, wallet.wallet.inkey)
|
||||
except Exception:
|
||||
return await create_tinyurl(url, endless, wallet.wallet.id)
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST, detail="Unable to create tinyurl"
|
||||
)
|
||||
) from exc
|
||||
|
||||
|
||||
@tinyurl_router.get(
|
||||
@@ -49,20 +50,20 @@ async def api_create_tinyurl(
|
||||
description="get a tinyurl by id",
|
||||
)
|
||||
async def api_get_tinyurl(
|
||||
tinyurl_id: str, wallet: WalletTypeInfo = Depends(get_key_type)
|
||||
tinyurl_id: str, wallet: WalletTypeInfo = Depends(require_invoice_key)
|
||||
):
|
||||
try:
|
||||
tinyurl = await get_tinyurl(tinyurl_id)
|
||||
if tinyurl:
|
||||
if tinyurl.wallet == wallet.wallet.inkey:
|
||||
if tinyurl.wallet == wallet.wallet.id:
|
||||
return tinyurl
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.FORBIDDEN, detail="Wrong key provided."
|
||||
)
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Unable to fetch tinyurl"
|
||||
)
|
||||
) from exc
|
||||
|
||||
|
||||
@tinyurl_router.delete(
|
||||
@@ -71,21 +72,21 @@ async def api_get_tinyurl(
|
||||
description="delete a tinyurl by id",
|
||||
)
|
||||
async def api_delete_tinyurl(
|
||||
tinyurl_id: str, wallet: WalletTypeInfo = Depends(get_key_type)
|
||||
tinyurl_id: str, wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||
):
|
||||
try:
|
||||
tinyurl = await get_tinyurl(tinyurl_id)
|
||||
if tinyurl:
|
||||
if tinyurl.wallet == wallet.wallet.inkey:
|
||||
if tinyurl.wallet == wallet.wallet.id:
|
||||
await delete_tinyurl(tinyurl_id)
|
||||
return {"deleted": True}
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.FORBIDDEN, detail="Wrong key provided."
|
||||
)
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST, detail="Unable to delete"
|
||||
)
|
||||
) from exc
|
||||
|
||||
|
||||
@tinyurl_router.get(
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
from http import HTTPStatus
|
||||
from typing import List
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from starlette.exceptions import HTTPException
|
||||
|
||||
from lnbits.core.crud import (
|
||||
delete_account,
|
||||
delete_wallet,
|
||||
force_delete_wallet,
|
||||
get_accounts,
|
||||
get_wallet,
|
||||
get_wallets,
|
||||
update_admin_settings,
|
||||
)
|
||||
from lnbits.core.models import (
|
||||
Account,
|
||||
AccountFilters,
|
||||
CreateTopup,
|
||||
User,
|
||||
Wallet,
|
||||
)
|
||||
from lnbits.core.services import update_wallet_balance
|
||||
from lnbits.db import Filters, Page
|
||||
from lnbits.decorators import check_admin, check_super_user, parse_filters
|
||||
from lnbits.helpers import generate_filter_params_openapi
|
||||
from lnbits.settings import EditableSettings, settings
|
||||
|
||||
users_router = APIRouter(prefix="/users/api/v1", dependencies=[Depends(check_admin)])
|
||||
|
||||
|
||||
@users_router.get(
|
||||
"/user",
|
||||
name="get accounts",
|
||||
summary="Get paginated list of accounts",
|
||||
openapi_extra=generate_filter_params_openapi(AccountFilters),
|
||||
)
|
||||
async def api_get_users(
|
||||
filters: Filters = Depends(parse_filters(AccountFilters)),
|
||||
) -> Page[Account]:
|
||||
try:
|
||||
filtered = await get_accounts(filters=filters)
|
||||
for user in filtered.data:
|
||||
user.is_super_user = user.id == settings.super_user
|
||||
user.is_admin = user.id in settings.lnbits_admin_users or user.is_super_user
|
||||
return filtered
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
detail=f"Could not fetch users. {exc!s}",
|
||||
) from exc
|
||||
|
||||
|
||||
@users_router.delete("/user/{user_id}", status_code=HTTPStatus.OK)
|
||||
async def api_users_delete_user(
|
||||
user_id: str, user: User = Depends(check_admin)
|
||||
) -> None:
|
||||
|
||||
try:
|
||||
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(
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
detail=f"{exc!s}",
|
||||
) from exc
|
||||
|
||||
|
||||
@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(
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
detail=f"Could not update admin settings. {exc}",
|
||||
) from exc
|
||||
|
||||
|
||||
@users_router.get("/user/{user_id}/wallet")
|
||||
async def api_users_get_user_wallet(user_id: str) -> List[Wallet]:
|
||||
try:
|
||||
return await get_wallets(user_id)
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
detail=f"Could not fetch user wallets. {exc}",
|
||||
) from exc
|
||||
|
||||
|
||||
@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(
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
detail=f"{exc!s}",
|
||||
) from exc
|
||||
|
||||
|
||||
@users_router.delete("/user/{user_id}/wallet/{wallet}")
|
||||
async def api_users_delete_user_wallet(user_id: str, wallet: str) -> None:
|
||||
try:
|
||||
wal = await get_wallet(wallet)
|
||||
if not wal:
|
||||
raise Exception("Wallet does not exist.")
|
||||
if wal.deleted:
|
||||
await force_delete_wallet(wallet)
|
||||
await delete_wallet(user_id=user_id, wallet_id=wallet)
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
detail=f"{exc!s}",
|
||||
) from exc
|
||||
|
||||
|
||||
@users_router.put(
|
||||
"/topup",
|
||||
name="Topup",
|
||||
status_code=HTTPStatus.OK,
|
||||
dependencies=[Depends(check_super_user)],
|
||||
)
|
||||
async def api_topup_balance(data: CreateTopup) -> dict[str, str]:
|
||||
try:
|
||||
await get_wallet(data.id)
|
||||
if settings.lnbits_backend_wallet_class == "VoidWallet":
|
||||
raise Exception("VoidWallet active")
|
||||
|
||||
await update_wallet_balance(wallet_id=data.id, amount=int(data.amount))
|
||||
return {"status": "Success"}
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=f"{exc!s}"
|
||||
) from exc
|
||||
@@ -0,0 +1,77 @@
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
Body,
|
||||
Depends,
|
||||
)
|
||||
|
||||
from lnbits.core.models import (
|
||||
CreateWallet,
|
||||
KeyType,
|
||||
Wallet,
|
||||
)
|
||||
from lnbits.decorators import (
|
||||
WalletTypeInfo,
|
||||
get_key_type,
|
||||
require_admin_key,
|
||||
)
|
||||
|
||||
from ..crud import (
|
||||
create_wallet,
|
||||
delete_wallet,
|
||||
update_wallet,
|
||||
)
|
||||
|
||||
wallet_router = APIRouter(prefix="/api/v1/wallet", tags=["Wallet"])
|
||||
|
||||
|
||||
@wallet_router.get("")
|
||||
async def api_wallet(wallet: WalletTypeInfo = Depends(get_key_type)):
|
||||
if wallet.key_type == KeyType.admin:
|
||||
return {
|
||||
"id": wallet.wallet.id,
|
||||
"name": wallet.wallet.name,
|
||||
"balance": wallet.wallet.balance_msat,
|
||||
}
|
||||
else:
|
||||
return {"name": wallet.wallet.name, "balance": wallet.wallet.balance_msat}
|
||||
|
||||
|
||||
@wallet_router.put("/{new_name}")
|
||||
async def api_update_wallet_name(
|
||||
new_name: str, wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||
):
|
||||
await update_wallet(wallet.wallet.id, new_name)
|
||||
return {
|
||||
"id": wallet.wallet.id,
|
||||
"name": wallet.wallet.name,
|
||||
"balance": wallet.wallet.balance_msat,
|
||||
}
|
||||
|
||||
|
||||
@wallet_router.patch("", response_model=Wallet)
|
||||
async def api_update_wallet(
|
||||
name: Optional[str] = Body(None),
|
||||
currency: Optional[str] = Body(None),
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||
):
|
||||
return await update_wallet(wallet.wallet.id, name, currency)
|
||||
|
||||
|
||||
@wallet_router.delete("")
|
||||
async def api_delete_wallet(
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||
) -> None:
|
||||
await delete_wallet(
|
||||
user_id=wallet.wallet.user,
|
||||
wallet_id=wallet.wallet.id,
|
||||
)
|
||||
|
||||
|
||||
@wallet_router.post("", response_model=Wallet)
|
||||
async def api_create_wallet(
|
||||
data: CreateWallet,
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||
) -> Wallet:
|
||||
return await create_wallet(user_id=wallet.wallet.user, wallet_name=data.name)
|
||||
@@ -24,10 +24,10 @@ from ..crud import (
|
||||
get_webpush_subscription,
|
||||
)
|
||||
|
||||
webpush_router = APIRouter(prefix="/api/v1/webpush", tags=["webpush"])
|
||||
webpush_router = APIRouter(prefix="/api/v1/webpush", tags=["Webpush"])
|
||||
|
||||
|
||||
@webpush_router.post("/", status_code=HTTPStatus.CREATED)
|
||||
@webpush_router.post("", status_code=HTTPStatus.CREATED)
|
||||
async def api_create_webpush_subscription(
|
||||
request: Request,
|
||||
data: CreateWebPushSubscription,
|
||||
@@ -49,7 +49,7 @@ async def api_create_webpush_subscription(
|
||||
)
|
||||
|
||||
|
||||
@webpush_router.delete("/", status_code=HTTPStatus.OK)
|
||||
@webpush_router.delete("", status_code=HTTPStatus.OK)
|
||||
async def api_delete_webpush_subscription(
|
||||
request: Request,
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
WebSocket,
|
||||
WebSocketDisconnect,
|
||||
)
|
||||
|
||||
from lnbits.settings import settings
|
||||
|
||||
from ..services import (
|
||||
websocket_manager,
|
||||
websocket_updater,
|
||||
)
|
||||
|
||||
websocket_router = APIRouter(prefix="/api/v1/ws", tags=["Websocket"])
|
||||
|
||||
|
||||
@websocket_router.websocket("/{item_id}")
|
||||
async def websocket_connect(websocket: WebSocket, item_id: str):
|
||||
await websocket_manager.connect(websocket, item_id)
|
||||
try:
|
||||
while settings.lnbits_running:
|
||||
await websocket.receive_text()
|
||||
except WebSocketDisconnect:
|
||||
websocket_manager.disconnect(websocket)
|
||||
|
||||
|
||||
@websocket_router.post("/{item_id}")
|
||||
async def websocket_update_post(item_id: str, data: str):
|
||||
try:
|
||||
await websocket_updater(item_id, data)
|
||||
return {"sent": True, "data": data}
|
||||
except Exception:
|
||||
return {"sent": False, "data": data}
|
||||
|
||||
|
||||
@websocket_router.get("/{item_id}/{data}")
|
||||
async def websocket_update_get(item_id: str, data: str):
|
||||
try:
|
||||
await websocket_updater(item_id, data)
|
||||
return {"sent": True, "data": data}
|
||||
except Exception:
|
||||
return {"sent": False, "data": data}
|
||||
+38
-23
@@ -8,7 +8,7 @@ import time
|
||||
from contextlib import asynccontextmanager
|
||||
from enum import Enum
|
||||
from sqlite3 import Row
|
||||
from typing import Any, Generic, List, Literal, Optional, Type, TypeVar
|
||||
from typing import Any, Generic, Literal, Optional, TypeVar
|
||||
|
||||
from loguru import logger
|
||||
from pydantic import BaseModel, ValidationError, root_validator
|
||||
@@ -140,14 +140,14 @@ class Connection(Compat):
|
||||
|
||||
def rewrite_values(self, values):
|
||||
# strip html
|
||||
CLEANR = 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});")
|
||||
|
||||
# tuple to list and back to tuple
|
||||
raw_values = [values] if isinstance(values, str) else list(values)
|
||||
values = []
|
||||
for raw_value in raw_values:
|
||||
if isinstance(raw_value, str):
|
||||
values.append(re.sub(CLEANR, "", raw_value))
|
||||
values.append(re.sub(clean_regex, "", raw_value))
|
||||
elif isinstance(raw_value, datetime.datetime):
|
||||
ts = raw_value.timestamp()
|
||||
if self.type == SQLITE:
|
||||
@@ -175,20 +175,31 @@ class Connection(Compat):
|
||||
async def fetch_page(
|
||||
self,
|
||||
query: str,
|
||||
where: Optional[List[str]] = None,
|
||||
values: Optional[List[str]] = None,
|
||||
where: Optional[list[str]] = None,
|
||||
values: Optional[list[str]] = None,
|
||||
filters: Optional[Filters] = None,
|
||||
model: Optional[Type[TRowModel]] = None,
|
||||
model: Optional[type[TRowModel]] = None,
|
||||
group_by: Optional[list[str]] = None,
|
||||
) -> Page[TRowModel]:
|
||||
if not filters:
|
||||
filters = Filters()
|
||||
clause = filters.where(where)
|
||||
parsed_values = filters.values(values)
|
||||
|
||||
group_by_string = ""
|
||||
if group_by:
|
||||
for field in group_by:
|
||||
if not re.fullmatch(
|
||||
r"[a-zA-Z_][a-zA-Z0-9_]*(\.[a-zA-Z_][a-zA-Z0-9_]*)?", field
|
||||
):
|
||||
raise ValueError("Value for GROUP BY is invalid")
|
||||
group_by_string = f"GROUP BY {', '.join(group_by)}"
|
||||
|
||||
rows = await self.fetchall(
|
||||
f"""
|
||||
{query}
|
||||
{clause}
|
||||
{group_by_string}
|
||||
{filters.order_by()}
|
||||
{filters.pagination()}
|
||||
""",
|
||||
@@ -202,6 +213,7 @@ class Connection(Compat):
|
||||
SELECT COUNT(*) FROM (
|
||||
{query}
|
||||
{clause}
|
||||
{group_by_string}
|
||||
) as count
|
||||
""",
|
||||
parsed_values,
|
||||
@@ -242,7 +254,9 @@ class Database(Compat):
|
||||
else:
|
||||
self.schema = None
|
||||
|
||||
self.engine = create_engine(database_uri, strategy=ASYNCIO_STRATEGY)
|
||||
self.engine = create_engine(
|
||||
database_uri, strategy=ASYNCIO_STRATEGY, echo=settings.debug_database
|
||||
)
|
||||
self.lock = asyncio.Lock()
|
||||
|
||||
logger.trace(f"database {self.type} added for {self.name}")
|
||||
@@ -284,13 +298,14 @@ class Database(Compat):
|
||||
async def fetch_page(
|
||||
self,
|
||||
query: str,
|
||||
where: Optional[List[str]] = None,
|
||||
values: Optional[List[str]] = None,
|
||||
where: Optional[list[str]] = None,
|
||||
values: Optional[list[str]] = None,
|
||||
filters: Optional[Filters] = None,
|
||||
model: Optional[Type[TRowModel]] = None,
|
||||
model: Optional[type[TRowModel]] = None,
|
||||
group_by: Optional[list[str]] = None,
|
||||
) -> Page[TRowModel]:
|
||||
async with self.connect() as conn:
|
||||
return await conn.fetch_page(query, where, values, filters, model)
|
||||
return await conn.fetch_page(query, where, values, filters, model, group_by)
|
||||
|
||||
async def execute(self, query: str, values: tuple = ()):
|
||||
async with self.connect() as conn:
|
||||
@@ -355,8 +370,8 @@ class FromRowModel(BaseModel):
|
||||
|
||||
|
||||
class FilterModel(BaseModel):
|
||||
__search_fields__: List[str] = []
|
||||
__sort_fields__: Optional[List[str]] = None
|
||||
__search_fields__: list[str] = []
|
||||
__sort_fields__: Optional[list[str]] = None
|
||||
|
||||
|
||||
T = TypeVar("T")
|
||||
@@ -375,10 +390,10 @@ class Filter(BaseModel, Generic[TFilterModel]):
|
||||
op: Operator = Operator.EQ
|
||||
values: list[Any]
|
||||
|
||||
model: Optional[Type[TFilterModel]]
|
||||
model: Optional[type[TFilterModel]]
|
||||
|
||||
@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]):
|
||||
# Key format:
|
||||
# key[operator]
|
||||
# e.g. name[eq]
|
||||
@@ -428,7 +443,7 @@ class Filters(BaseModel, Generic[TFilterModel]):
|
||||
the values can be validated. Otherwise, make sure to validate the inputs manually.
|
||||
"""
|
||||
|
||||
filters: List[Filter[TFilterModel]] = []
|
||||
filters: list[Filter[TFilterModel]] = []
|
||||
search: Optional[str] = None
|
||||
|
||||
offset: Optional[int] = None
|
||||
@@ -437,7 +452,7 @@ class Filters(BaseModel, Generic[TFilterModel]):
|
||||
sortby: Optional[str] = None
|
||||
direction: Optional[Literal["asc", "desc"]] = None
|
||||
|
||||
model: Optional[Type[TFilterModel]] = None
|
||||
model: Optional[type[TFilterModel]] = None
|
||||
|
||||
@root_validator(pre=True)
|
||||
def validate_sortby(cls, values):
|
||||
@@ -459,12 +474,12 @@ class Filters(BaseModel, Generic[TFilterModel]):
|
||||
stmt += f"OFFSET {self.offset}"
|
||||
return stmt
|
||||
|
||||
def where(self, where_stmts: Optional[List[str]] = None) -> str:
|
||||
def where(self, where_stmts: Optional[list[str]] = None) -> str:
|
||||
if not where_stmts:
|
||||
where_stmts = []
|
||||
if self.filters:
|
||||
for filter in self.filters:
|
||||
where_stmts.append(filter.statement)
|
||||
for page_filter in self.filters:
|
||||
where_stmts.append(page_filter.statement)
|
||||
if self.search and self.model:
|
||||
if DB_TYPE == POSTGRES:
|
||||
where_stmts.append(
|
||||
@@ -483,12 +498,12 @@ class Filters(BaseModel, Generic[TFilterModel]):
|
||||
return f"ORDER BY {self.sortby} {self.direction or 'asc'}"
|
||||
return ""
|
||||
|
||||
def values(self, values: Optional[List[str]] = None) -> tuple:
|
||||
def values(self, values: Optional[list[str]] = None) -> tuple:
|
||||
if not values:
|
||||
values = []
|
||||
if self.filters:
|
||||
for filter in self.filters:
|
||||
values.extend(filter.values)
|
||||
for page_filter in self.filters:
|
||||
values.extend(page_filter.values)
|
||||
if self.search and self.model:
|
||||
values.append(f"%{self.search}%")
|
||||
return tuple(values)
|
||||
|
||||
+101
-187
@@ -3,7 +3,7 @@ from typing import Annotated, Literal, Optional, Type, Union
|
||||
|
||||
from fastapi import Cookie, Depends, Query, Request, Security
|
||||
from fastapi.exceptions import HTTPException
|
||||
from fastapi.openapi.models import APIKey, APIKeyIn
|
||||
from fastapi.openapi.models import APIKey, APIKeyIn, SecuritySchemeType
|
||||
from fastapi.security import APIKeyHeader, APIKeyQuery, OAuth2PasswordBearer
|
||||
from fastapi.security.base import SecurityBase
|
||||
from jose import ExpiredSignatureError, JWTError, jwt
|
||||
@@ -17,101 +17,12 @@ from lnbits.core.crud import (
|
||||
get_user,
|
||||
get_wallet_for_key,
|
||||
)
|
||||
from lnbits.core.models import User, WalletType, WalletTypeInfo
|
||||
from lnbits.core.models import KeyType, User, WalletTypeInfo
|
||||
from lnbits.db import Filter, Filters, TFilterModel
|
||||
from lnbits.settings import AuthMethods, settings
|
||||
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="api/v1/auth", auto_error=False)
|
||||
|
||||
|
||||
# TODO: fix type ignores
|
||||
class KeyChecker(SecurityBase):
|
||||
def __init__(
|
||||
self,
|
||||
scheme_name: Optional[str] = None,
|
||||
auto_error: bool = True,
|
||||
api_key: Optional[str] = None,
|
||||
):
|
||||
self.scheme_name = scheme_name or self.__class__.__name__
|
||||
self.auto_error = auto_error
|
||||
self._key_type = WalletType.invoice
|
||||
self._api_key = api_key
|
||||
if api_key:
|
||||
key = APIKey(
|
||||
**{"in": APIKeyIn.query}, # type: ignore
|
||||
name="X-API-KEY",
|
||||
description="Wallet API Key - QUERY",
|
||||
)
|
||||
else:
|
||||
key = APIKey(
|
||||
**{"in": APIKeyIn.header}, # type: ignore
|
||||
name="X-API-KEY",
|
||||
description="Wallet API Key - HEADER",
|
||||
)
|
||||
self.wallet = None
|
||||
self.model: APIKey = key
|
||||
|
||||
async def __call__(self, request: Request):
|
||||
try:
|
||||
key_value = (
|
||||
self._api_key
|
||||
if self._api_key
|
||||
else request.headers.get("X-API-KEY") or request.query_params["api-key"]
|
||||
)
|
||||
# FIXME: Find another way to validate the key. A fetch from DB should be
|
||||
# avoided here. Also, we should not return the wallet here - thats
|
||||
# silly. Possibly store it in a Redis DB
|
||||
wallet = await get_wallet_for_key(key_value, self._key_type)
|
||||
if not wallet or wallet.deleted:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.UNAUTHORIZED,
|
||||
detail="Invalid key or wallet.",
|
||||
)
|
||||
self.wallet = wallet # type: ignore
|
||||
except KeyError:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST, detail="`X-API-KEY` header missing."
|
||||
)
|
||||
|
||||
|
||||
class WalletInvoiceKeyChecker(KeyChecker):
|
||||
"""
|
||||
WalletInvoiceKeyChecker will ensure that the provided invoice
|
||||
wallet key is correct and populate g().wallet with the wallet
|
||||
for the key in `X-API-key`.
|
||||
|
||||
The checker will raise an HTTPException when the key is wrong in some ways.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
scheme_name: Optional[str] = None,
|
||||
auto_error: bool = True,
|
||||
api_key: Optional[str] = None,
|
||||
):
|
||||
super().__init__(scheme_name, auto_error, api_key)
|
||||
self._key_type = WalletType.invoice
|
||||
|
||||
|
||||
class WalletAdminKeyChecker(KeyChecker):
|
||||
"""
|
||||
WalletAdminKeyChecker will ensure that the provided admin
|
||||
wallet key is correct and populate g().wallet with the wallet
|
||||
for the key in `X-API-key`.
|
||||
|
||||
The checker will raise an HTTPException when the key is wrong in some ways.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
scheme_name: Optional[str] = None,
|
||||
auto_error: bool = True,
|
||||
api_key: Optional[str] = None,
|
||||
):
|
||||
super().__init__(scheme_name, auto_error, api_key)
|
||||
self._key_type = WalletType.admin
|
||||
|
||||
|
||||
api_key_header = APIKeyHeader(
|
||||
name="X-API-KEY",
|
||||
auto_error=False,
|
||||
@@ -124,109 +35,105 @@ api_key_query = APIKeyQuery(
|
||||
)
|
||||
|
||||
|
||||
class KeyChecker(SecurityBase):
|
||||
def __init__(
|
||||
self,
|
||||
api_key: Optional[str] = None,
|
||||
expected_key_type: Optional[KeyType] = None,
|
||||
):
|
||||
self.auto_error: bool = True
|
||||
self.expected_key_type = expected_key_type
|
||||
self._api_key = api_key
|
||||
if api_key:
|
||||
openapi_model = APIKey(
|
||||
**{"in": APIKeyIn.query},
|
||||
type=SecuritySchemeType.apiKey,
|
||||
name="X-API-KEY",
|
||||
description="Wallet API Key - QUERY",
|
||||
)
|
||||
else:
|
||||
openapi_model = APIKey(
|
||||
**{"in": APIKeyIn.header},
|
||||
type=SecuritySchemeType.apiKey,
|
||||
name="X-API-KEY",
|
||||
description="Wallet API Key - HEADER",
|
||||
)
|
||||
self.model: APIKey = openapi_model
|
||||
|
||||
async def __call__(self, request: Request) -> WalletTypeInfo:
|
||||
|
||||
key_value = (
|
||||
self._api_key
|
||||
if self._api_key
|
||||
else request.headers.get("X-API-KEY") or request.query_params.get("api-key")
|
||||
)
|
||||
|
||||
if not key_value:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.UNAUTHORIZED,
|
||||
detail="No Api Key provided.",
|
||||
)
|
||||
|
||||
wallet = await get_wallet_for_key(key_value)
|
||||
|
||||
if not wallet:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND,
|
||||
detail="Wallet not found.",
|
||||
)
|
||||
|
||||
if self.expected_key_type is KeyType.admin and wallet.adminkey != key_value:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.UNAUTHORIZED,
|
||||
detail="Invalid adminkey.",
|
||||
)
|
||||
|
||||
if (
|
||||
wallet.user != settings.super_user
|
||||
and wallet.user not in settings.lnbits_admin_users
|
||||
and settings.lnbits_admin_extensions
|
||||
and request["path"].split("/")[1] in settings.lnbits_admin_extensions
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.FORBIDDEN,
|
||||
detail="User not authorized for this extension.",
|
||||
)
|
||||
|
||||
key_type = KeyType.admin if wallet.adminkey == key_value else KeyType.invoice
|
||||
return WalletTypeInfo(key_type, wallet)
|
||||
|
||||
|
||||
async def get_key_type(
|
||||
r: Request,
|
||||
request: Request,
|
||||
api_key_header: str = Security(api_key_header),
|
||||
api_key_query: str = Security(api_key_query),
|
||||
) -> WalletTypeInfo:
|
||||
token = api_key_header or api_key_query
|
||||
|
||||
if not token:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.UNAUTHORIZED,
|
||||
detail="Invoice (or Admin) key required.",
|
||||
)
|
||||
|
||||
for wallet_type, WalletChecker in zip(
|
||||
[WalletType.admin, WalletType.invoice],
|
||||
[WalletAdminKeyChecker, WalletInvoiceKeyChecker],
|
||||
):
|
||||
try:
|
||||
checker = WalletChecker(api_key=token)
|
||||
await checker.__call__(r)
|
||||
if checker.wallet is None:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Wallet does not exist."
|
||||
)
|
||||
wallet = WalletTypeInfo(wallet_type, checker.wallet)
|
||||
if (
|
||||
wallet.wallet.user != settings.super_user
|
||||
and wallet.wallet.user not in settings.lnbits_admin_users
|
||||
) and (
|
||||
settings.lnbits_admin_extensions
|
||||
and r["path"].split("/")[1] in settings.lnbits_admin_extensions
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.FORBIDDEN,
|
||||
detail="User not authorized for this extension.",
|
||||
)
|
||||
return wallet
|
||||
except HTTPException as exc:
|
||||
if exc.status_code == HTTPStatus.BAD_REQUEST:
|
||||
raise
|
||||
elif exc.status_code == HTTPStatus.UNAUTHORIZED:
|
||||
# we pass this in case it is not an invoice key, nor an admin key,
|
||||
# and then return NOT_FOUND at the end of this block
|
||||
pass
|
||||
else:
|
||||
raise
|
||||
except Exception:
|
||||
raise
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Wallet does not exist."
|
||||
)
|
||||
check: KeyChecker = KeyChecker(api_key=api_key_header or api_key_query)
|
||||
return await check(request)
|
||||
|
||||
|
||||
async def require_admin_key(
|
||||
r: Request,
|
||||
request: Request,
|
||||
api_key_header: str = Security(api_key_header),
|
||||
api_key_query: str = Security(api_key_query),
|
||||
):
|
||||
token = api_key_header or api_key_query
|
||||
|
||||
if not token:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.UNAUTHORIZED,
|
||||
detail="Admin key required.",
|
||||
)
|
||||
|
||||
wallet = await get_key_type(r, token)
|
||||
|
||||
if wallet.wallet_type != 0:
|
||||
# If wallet type is not admin then return the unauthorized status
|
||||
# This also covers when the user passes an invalid key type
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.UNAUTHORIZED, detail="Admin key required."
|
||||
)
|
||||
else:
|
||||
return wallet
|
||||
) -> WalletTypeInfo:
|
||||
check: KeyChecker = KeyChecker(
|
||||
api_key=api_key_header or api_key_query,
|
||||
expected_key_type=KeyType.admin,
|
||||
)
|
||||
return await check(request)
|
||||
|
||||
|
||||
async def require_invoice_key(
|
||||
r: Request,
|
||||
request: Request,
|
||||
api_key_header: str = Security(api_key_header),
|
||||
api_key_query: str = Security(api_key_query),
|
||||
):
|
||||
token = api_key_header or api_key_query
|
||||
|
||||
if not token:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.UNAUTHORIZED,
|
||||
detail="Invoice (or Admin) key required.",
|
||||
)
|
||||
|
||||
wallet = await get_key_type(r, token)
|
||||
|
||||
if (
|
||||
wallet.wallet_type != WalletType.admin
|
||||
and wallet.wallet_type != WalletType.invoice
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.UNAUTHORIZED,
|
||||
detail="Invoice (or Admin) key required.",
|
||||
)
|
||||
else:
|
||||
return wallet
|
||||
) -> WalletTypeInfo:
|
||||
check: KeyChecker = KeyChecker(
|
||||
api_key=api_key_header or api_key_query,
|
||||
expected_key_type=KeyType.invoice,
|
||||
)
|
||||
return await check(request)
|
||||
|
||||
|
||||
async def check_access_token(
|
||||
@@ -254,8 +161,15 @@ async def check_user_exists(
|
||||
user = await get_user(account.id)
|
||||
assert user, "User not found for account."
|
||||
|
||||
if not user.admin and r["path"].split("/")[1] in settings.lnbits_admin_extensions:
|
||||
raise HTTPException(HTTPStatus.FORBIDDEN, "User not authorized for extension.")
|
||||
if (
|
||||
user.id != settings.super_user
|
||||
and user.id not in settings.lnbits_admin_users
|
||||
and settings.lnbits_admin_extensions
|
||||
and r["path"].split("/")[1] in settings.lnbits_admin_extensions
|
||||
):
|
||||
raise HTTPException(
|
||||
HTTPStatus.UNAUTHORIZED, "User not authorized for extension."
|
||||
)
|
||||
|
||||
return user
|
||||
|
||||
@@ -323,10 +237,10 @@ async def _get_account_from_token(access_token):
|
||||
return await get_account_by_email(str(payload.get("email")))
|
||||
|
||||
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Data missing for access token.")
|
||||
except ExpiredSignatureError:
|
||||
except ExpiredSignatureError as exc:
|
||||
raise HTTPException(
|
||||
HTTPStatus.UNAUTHORIZED, "Session expired.", {"token-expired": "true"}
|
||||
)
|
||||
except JWTError as e:
|
||||
logger.debug(e)
|
||||
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid access token.")
|
||||
) from exc
|
||||
except JWTError as exc:
|
||||
logger.debug(exc)
|
||||
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid access token.") from exc
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import sys
|
||||
import traceback
|
||||
from http import HTTPStatus
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.responses import JSONResponse, RedirectResponse, Response
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.core.services import InvoiceError, PaymentError
|
||||
|
||||
from .helpers import template_renderer
|
||||
|
||||
|
||||
def register_exception_handlers(app: FastAPI):
|
||||
register_exception_handler(app)
|
||||
register_request_validation_exception_handler(app)
|
||||
register_http_exception_handler(app)
|
||||
register_payment_error_handler(app)
|
||||
register_invoice_error_handler(app)
|
||||
|
||||
|
||||
def render_html_error(request: Request, exc: Exception) -> Optional[Response]:
|
||||
# Only the browser sends "text/html" request
|
||||
# not fail proof, but everything else get's a JSON response
|
||||
if (
|
||||
request.headers
|
||||
and "accept" in request.headers
|
||||
and "text/html" in request.headers["accept"]
|
||||
):
|
||||
if (
|
||||
isinstance(exc, HTTPException)
|
||||
and exc.headers
|
||||
and "token-expired" in exc.headers
|
||||
):
|
||||
response = RedirectResponse("/")
|
||||
response.delete_cookie("cookie_access_token")
|
||||
response.delete_cookie("is_lnbits_user_authorized")
|
||||
response.set_cookie("is_access_token_expired", "true")
|
||||
return response
|
||||
|
||||
return template_renderer().TemplateResponse(
|
||||
request, "error.html", {"err": f"Error: {exc!s}"}
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def register_exception_handler(app: FastAPI):
|
||||
@app.exception_handler(Exception)
|
||||
async def exception_handler(request: Request, exc: Exception):
|
||||
etype, _, tb = sys.exc_info()
|
||||
traceback.print_exception(etype, exc, tb)
|
||||
logger.error(f"Exception: {exc!s}")
|
||||
return render_html_error(request, exc) or JSONResponse(
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
content={"detail": str(exc)},
|
||||
)
|
||||
|
||||
|
||||
def register_request_validation_exception_handler(app: FastAPI):
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def validation_exception_handler(
|
||||
request: Request, exc: RequestValidationError
|
||||
):
|
||||
logger.error(f"RequestValidationError: {exc!s}")
|
||||
return render_html_error(request, exc) or JSONResponse(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
content={"detail": str(exc)},
|
||||
)
|
||||
|
||||
|
||||
def register_http_exception_handler(app: FastAPI):
|
||||
@app.exception_handler(HTTPException)
|
||||
async def http_exception_handler(request: Request, exc: HTTPException):
|
||||
logger.error(f"HTTPException {exc.status_code}: {exc.detail}")
|
||||
return render_html_error(request, exc) or JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content={"detail": exc.detail},
|
||||
)
|
||||
|
||||
|
||||
def register_payment_error_handler(app: FastAPI):
|
||||
@app.exception_handler(PaymentError)
|
||||
async def payment_error_handler(request: Request, exc: PaymentError):
|
||||
logger.error(f"PaymentError: {exc.message}, {exc.status}")
|
||||
return JSONResponse(
|
||||
status_code=520,
|
||||
content={"detail": exc.message, "status": exc.status},
|
||||
)
|
||||
|
||||
|
||||
def register_invoice_error_handler(app: FastAPI):
|
||||
@app.exception_handler(InvoiceError)
|
||||
async def invoice_error_handler(request: Request, exc: InvoiceError):
|
||||
logger.error(f"InvoiceError: {exc.message}, Status: {exc.status}")
|
||||
return JSONResponse(
|
||||
status_code=520,
|
||||
content={"detail": exc.message, "status": exc.status},
|
||||
)
|
||||
+132
-34
@@ -1,16 +1,15 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import zipfile
|
||||
from http import HTTPStatus
|
||||
from pathlib import Path
|
||||
from typing import Any, List, NamedTuple, Optional, Tuple
|
||||
from urllib import request
|
||||
|
||||
import httpx
|
||||
from fastapi import HTTPException
|
||||
from loguru import logger
|
||||
from packaging import version
|
||||
from pydantic import BaseModel
|
||||
@@ -33,6 +32,7 @@ class ExplicitRelease(BaseModel):
|
||||
warning: Optional[str]
|
||||
info_notification: Optional[str]
|
||||
critical_notification: Optional[str]
|
||||
pay_link: Optional[str]
|
||||
|
||||
def is_version_compatible(self):
|
||||
if not self.min_lnbits_version:
|
||||
@@ -78,8 +78,15 @@ class ExtensionConfig(BaseModel):
|
||||
return version_parse(self.min_lnbits_version) <= version_parse(settings.version)
|
||||
|
||||
|
||||
class ReleasePaymentInfo(BaseModel):
|
||||
amount: Optional[int] = None
|
||||
pay_link: Optional[str] = None
|
||||
payment_hash: Optional[str] = None
|
||||
payment_request: Optional[str] = None
|
||||
|
||||
|
||||
def download_url(url, save_path):
|
||||
with request.urlopen(url) as dl_file:
|
||||
with request.urlopen(url, timeout=60) as dl_file:
|
||||
with open(save_path, "wb") as out_file:
|
||||
out_file.write(dl_file.read())
|
||||
|
||||
@@ -155,6 +162,21 @@ async def github_api_get(url: str, error_msg: Optional[str]) -> Any:
|
||||
return resp.json()
|
||||
|
||||
|
||||
async def fetch_release_payment_info(
|
||||
url: str, amount: Optional[int] = None
|
||||
) -> Optional[ReleasePaymentInfo]:
|
||||
if amount:
|
||||
url = f"{url}?amount={amount}"
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(url)
|
||||
resp.raise_for_status()
|
||||
return ReleasePaymentInfo(**resp.json())
|
||||
except Exception as e:
|
||||
logger.warning(e)
|
||||
return None
|
||||
|
||||
|
||||
def icon_to_github_url(source_repo: str, path: Optional[str]) -> str:
|
||||
if not path:
|
||||
return ""
|
||||
@@ -260,6 +282,26 @@ class ExtensionRelease(BaseModel):
|
||||
repo: Optional[str] = None
|
||||
icon: Optional[str] = None
|
||||
|
||||
pay_link: Optional[str] = None
|
||||
cost_sats: Optional[int] = None
|
||||
paid_sats: Optional[int] = 0
|
||||
payment_hash: Optional[str] = None
|
||||
|
||||
@property
|
||||
def archive_url(self) -> str:
|
||||
if not self.pay_link:
|
||||
return self.archive
|
||||
return (
|
||||
f"{self.archive}?version=v{self.version}&payment_hash={self.payment_hash}"
|
||||
)
|
||||
|
||||
async def check_payment_requirements(self):
|
||||
if not self.pay_link:
|
||||
return
|
||||
|
||||
payment_info = await fetch_release_payment_info(self.pay_link)
|
||||
self.cost_sats = payment_info.amount if payment_info else None
|
||||
|
||||
@classmethod
|
||||
def from_github_release(
|
||||
cls, source_repo: str, r: "GitHubRepoRelease"
|
||||
@@ -290,12 +332,13 @@ class ExtensionRelease(BaseModel):
|
||||
is_version_compatible=e.is_version_compatible(),
|
||||
warning=e.warning,
|
||||
html_url=e.html_url,
|
||||
pay_link=e.pay_link,
|
||||
repo=e.repo,
|
||||
icon=e.icon,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def all_releases(cls, org: str, repo: str) -> List["ExtensionRelease"]:
|
||||
async def get_github_releases(cls, org: str, repo: str) -> List["ExtensionRelease"]:
|
||||
try:
|
||||
github_releases = await fetch_github_releases(org, repo)
|
||||
return [
|
||||
@@ -318,6 +361,7 @@ class InstallableExtension(BaseModel):
|
||||
featured = False
|
||||
latest_release: Optional[ExtensionRelease] = None
|
||||
installed_release: Optional[ExtensionRelease] = None
|
||||
payments: List[ReleasePaymentInfo] = []
|
||||
archive: Optional[str] = None
|
||||
|
||||
@property
|
||||
@@ -368,30 +412,32 @@ class InstallableExtension(BaseModel):
|
||||
return self.installed_release.version
|
||||
return ""
|
||||
|
||||
def download_archive(self):
|
||||
async def download_archive(self):
|
||||
logger.info(f"Downloading extension {self.name} ({self.installed_version}).")
|
||||
ext_zip_file = self.zip_path
|
||||
if ext_zip_file.is_file():
|
||||
os.remove(ext_zip_file)
|
||||
try:
|
||||
assert self.installed_release, "installed_release is none."
|
||||
download_url(self.installed_release.archive, ext_zip_file)
|
||||
except Exception as ex:
|
||||
logger.warning(ex)
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND,
|
||||
detail="Cannot fetch extension archive file",
|
||||
|
||||
self._restore_payment_info()
|
||||
|
||||
await asyncio.to_thread(
|
||||
download_url, self.installed_release.archive_url, ext_zip_file
|
||||
)
|
||||
|
||||
self._remember_payment_info()
|
||||
|
||||
except Exception as exc:
|
||||
logger.warning(exc)
|
||||
raise AssertionError("Cannot fetch extension archive file") from exc
|
||||
|
||||
archive_hash = file_hash(ext_zip_file)
|
||||
if self.installed_release.hash and self.installed_release.hash != archive_hash:
|
||||
# remove downloaded archive
|
||||
if ext_zip_file.is_file():
|
||||
os.remove(ext_zip_file)
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND,
|
||||
detail="File hash missmatch. Will not install.",
|
||||
)
|
||||
raise AssertionError("File hash missmatch. Will not install.")
|
||||
|
||||
def extract_archive(self):
|
||||
logger.info(f"Extracting extension {self.name} ({self.installed_version}).")
|
||||
@@ -433,7 +479,7 @@ class InstallableExtension(BaseModel):
|
||||
shutil.copytree(Path(self.ext_upgrade_dir), Path(self.ext_dir))
|
||||
logger.success(f"Extension {self.name} ({self.installed_version}) installed.")
|
||||
|
||||
def nofiy_upgrade(self) -> None:
|
||||
def notify_upgrade(self) -> None:
|
||||
"""
|
||||
Update the list of upgraded extensions. The middleware will perform
|
||||
redirects based on this
|
||||
@@ -445,8 +491,9 @@ class InstallableExtension(BaseModel):
|
||||
settings.lnbits_upgraded_extensions,
|
||||
)
|
||||
)
|
||||
settings.lnbits_upgraded_extensions = clean_upgraded_exts + [
|
||||
f"{self.hash}/{self.id}"
|
||||
settings.lnbits_upgraded_extensions = [
|
||||
*clean_upgraded_exts,
|
||||
f"{self.hash}/{self.id}",
|
||||
]
|
||||
|
||||
def clean_extension_files(self):
|
||||
@@ -468,14 +515,58 @@ class InstallableExtension(BaseModel):
|
||||
if version_parse(self.latest_release.version) < version_parse(release.version):
|
||||
self.latest_release = release
|
||||
|
||||
def find_existing_payment(
|
||||
self, pay_link: Optional[str]
|
||||
) -> Optional[ReleasePaymentInfo]:
|
||||
if not pay_link:
|
||||
return None
|
||||
return next(
|
||||
(p for p in self.payments if p.pay_link == pay_link),
|
||||
None,
|
||||
)
|
||||
|
||||
def _restore_payment_info(self):
|
||||
if not self.installed_release:
|
||||
return
|
||||
if not self.installed_release.pay_link:
|
||||
return
|
||||
if self.installed_release.payment_hash:
|
||||
return
|
||||
payment_info = self.find_existing_payment(self.installed_release.pay_link)
|
||||
if payment_info:
|
||||
self.installed_release.payment_hash = payment_info.payment_hash
|
||||
|
||||
def _remember_payment_info(self):
|
||||
if not self.installed_release or not self.installed_release.pay_link:
|
||||
return
|
||||
payment_info = ReleasePaymentInfo(
|
||||
amount=self.installed_release.cost_sats,
|
||||
pay_link=self.installed_release.pay_link,
|
||||
payment_hash=self.installed_release.payment_hash,
|
||||
)
|
||||
self.payments = [
|
||||
p for p in self.payments if p.pay_link != payment_info.pay_link
|
||||
]
|
||||
self.payments.append(payment_info)
|
||||
|
||||
@classmethod
|
||||
def from_row(cls, data: dict) -> "InstallableExtension":
|
||||
meta = json.loads(data["meta"])
|
||||
ext = InstallableExtension(**data)
|
||||
if "installed_release" in meta:
|
||||
ext.installed_release = ExtensionRelease(**meta["installed_release"])
|
||||
if meta.get("payments"):
|
||||
ext.payments = [ReleasePaymentInfo(**p) for p in meta["payments"]]
|
||||
return ext
|
||||
|
||||
@classmethod
|
||||
def from_rows(
|
||||
cls, rows: Optional[List[Any]] = None
|
||||
) -> List["InstallableExtension"]:
|
||||
if rows is None:
|
||||
rows = []
|
||||
return [InstallableExtension.from_row(row) for row in rows]
|
||||
|
||||
@classmethod
|
||||
async def from_github_release(
|
||||
cls, github_release: GitHubRelease
|
||||
@@ -553,7 +644,7 @@ class InstallableExtension(BaseModel):
|
||||
extension_list += [ext]
|
||||
extension_id_list += [e.id]
|
||||
except Exception as e:
|
||||
logger.warning(f"Manifest {url} failed with '{str(e)}'")
|
||||
logger.warning(f"Manifest {url} failed with '{e!s}'")
|
||||
|
||||
return extension_list
|
||||
|
||||
@@ -565,34 +656,38 @@ class InstallableExtension(BaseModel):
|
||||
try:
|
||||
manifest = await fetch_manifest(url)
|
||||
for r in manifest.repos:
|
||||
if r.id == ext_id:
|
||||
repo_releases = await ExtensionRelease.all_releases(
|
||||
r.organisation, r.repository
|
||||
)
|
||||
extension_releases += repo_releases
|
||||
if r.id != ext_id:
|
||||
continue
|
||||
repo_releases = await ExtensionRelease.get_github_releases(
|
||||
r.organisation, r.repository
|
||||
)
|
||||
extension_releases += repo_releases
|
||||
|
||||
for e in manifest.extensions:
|
||||
if e.id == ext_id:
|
||||
extension_releases += [
|
||||
ExtensionRelease.from_explicit_release(url, e)
|
||||
]
|
||||
if e.id != ext_id:
|
||||
continue
|
||||
explicit_release = ExtensionRelease.from_explicit_release(url, e)
|
||||
await explicit_release.check_payment_requirements()
|
||||
extension_releases.append(explicit_release)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Manifest {url} failed with '{str(e)}'")
|
||||
logger.warning(f"Manifest {url} failed with '{e!s}'")
|
||||
|
||||
return extension_releases
|
||||
|
||||
@classmethod
|
||||
async def get_extension_release(
|
||||
cls, ext_id: str, source_repo: str, archive: str
|
||||
cls, ext_id: str, source_repo: str, archive: str, version: str
|
||||
) -> Optional["ExtensionRelease"]:
|
||||
all_releases: List[
|
||||
ExtensionRelease
|
||||
] = await InstallableExtension.get_extension_releases(ext_id)
|
||||
all_releases: List[ExtensionRelease] = (
|
||||
await InstallableExtension.get_extension_releases(ext_id)
|
||||
)
|
||||
selected_release = [
|
||||
r
|
||||
for r in all_releases
|
||||
if r.archive == archive and r.source_repo == source_repo
|
||||
if r.archive == archive
|
||||
and r.source_repo == source_repo
|
||||
and r.version == version
|
||||
]
|
||||
|
||||
return selected_release[0] if len(selected_release) != 0 else None
|
||||
@@ -602,6 +697,9 @@ class CreateExtension(BaseModel):
|
||||
ext_id: str
|
||||
archive: str
|
||||
source_repo: str
|
||||
version: str
|
||||
cost_sats: Optional[int] = 0
|
||||
payment_hash: Optional[str] = None
|
||||
|
||||
|
||||
def get_valid_extensions(include_deactivated: Optional[bool] = True) -> List[Extension]:
|
||||
|
||||
@@ -20,6 +20,19 @@ from .db import FilterModel
|
||||
from .extension_manager import get_valid_extensions
|
||||
|
||||
|
||||
def get_db_vendor_name():
|
||||
db_url = settings.lnbits_database_url
|
||||
return (
|
||||
"PostgreSQL"
|
||||
if db_url and db_url.startswith("postgres://")
|
||||
else (
|
||||
"CockroachDB"
|
||||
if db_url and db_url.startswith("cockroachdb://")
|
||||
else "SQLite"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def urlsafe_short_hash() -> str:
|
||||
return shortuuid.uuid()
|
||||
|
||||
@@ -58,12 +71,20 @@ def template_renderer(additional_folders: Optional[List] = None) -> Jinja2Templa
|
||||
t.env.globals["LNBITS_DENOMINATION"] = settings.lnbits_denomination
|
||||
t.env.globals["SITE_TAGLINE"] = settings.lnbits_site_tagline
|
||||
t.env.globals["SITE_DESCRIPTION"] = settings.lnbits_site_description
|
||||
t.env.globals["LNBITS_SHOW_HOME_PAGE_ELEMENTS"] = (
|
||||
settings.LNBITS_SHOW_HOME_PAGE_ELEMENTS
|
||||
)
|
||||
t.env.globals["LNBITS_CUSTOM_BADGE"] = settings.lnbits_custom_badge
|
||||
t.env.globals["LNBITS_CUSTOM_BADGE_COLOR"] = settings.lnbits_custom_badge_color
|
||||
t.env.globals["LNBITS_THEME_OPTIONS"] = settings.lnbits_theme_options
|
||||
t.env.globals["LNBITS_QR_LOGO"] = settings.lnbits_qr_logo
|
||||
t.env.globals["LNBITS_VERSION"] = settings.version
|
||||
t.env.globals["LNBITS_NEW_ACCOUNTS_ALLOWED"] = settings.new_accounts_allowed
|
||||
t.env.globals["LNBITS_AUTH_METHODS"] = settings.auth_allowed_methods
|
||||
t.env.globals["LNBITS_ADMIN_UI"] = settings.lnbits_admin_ui
|
||||
t.env.globals["LNBITS_EXTENSIONS_DEACTIVATE_ALL"] = (
|
||||
settings.lnbits_extensions_deactivate_all
|
||||
)
|
||||
t.env.globals["LNBITS_SERVICE_FEE"] = settings.lnbits_service_fee
|
||||
t.env.globals["LNBITS_SERVICE_FEE_MAX"] = settings.lnbits_service_fee_max
|
||||
t.env.globals["LNBITS_SERVICE_FEE_WALLET"] = settings.lnbits_service_fee_wallet
|
||||
@@ -92,6 +113,10 @@ def template_renderer(additional_folders: Optional[List] = None) -> Jinja2Templa
|
||||
|
||||
def get_current_extension_name() -> str:
|
||||
"""
|
||||
DEPRECATED: Use the repo name instead, will be removed in the future
|
||||
before: `register_invoice_listener(invoice_queue, get_current_extension_name())`
|
||||
after: `register_invoice_listener(invoice_queue, "my-extension")`
|
||||
|
||||
Returns the name of the extension that calls this method.
|
||||
"""
|
||||
import inspect
|
||||
|
||||
@@ -29,13 +29,13 @@ class InstalledExtensionMiddleware:
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
top_path, *rest = [p for p in full_path.split("/") if p]
|
||||
top_path, *rest = (p for p in full_path.split("/") if p)
|
||||
headers = scope.get("headers", [])
|
||||
|
||||
# block path for all users if the extension is disabled
|
||||
if top_path in settings.lnbits_deactivated_extensions:
|
||||
response = self._response_by_accepted_type(
|
||||
headers, f"Extension '{top_path}' disabled", HTTPStatus.NOT_FOUND
|
||||
scope, headers, f"Extension '{top_path}' disabled", HTTPStatus.NOT_FOUND
|
||||
)
|
||||
await response(scope, receive, send)
|
||||
return
|
||||
@@ -61,7 +61,7 @@ class InstalledExtensionMiddleware:
|
||||
await self.app(scope, receive, send)
|
||||
|
||||
def _response_by_accepted_type(
|
||||
self, headers: List[Any], msg: str, status_code: HTTPStatus
|
||||
self, scope: Scope, headers: List[Any], msg: str, status_code: HTTPStatus
|
||||
) -> Union[HTMLResponse, JSONResponse]:
|
||||
"""
|
||||
Build an HTTP response containing the `msg` as HTTP body and the `status_code`
|
||||
@@ -78,11 +78,11 @@ class InstalledExtensionMiddleware:
|
||||
"",
|
||||
)
|
||||
|
||||
if "text/html" in [a for a in accept_header.split(",")]:
|
||||
if "text/html" in accept_header.split(","):
|
||||
return HTMLResponse(
|
||||
status_code=status_code,
|
||||
content=template_renderer()
|
||||
.TemplateResponse("error.html", {"request": {}, "err": msg})
|
||||
.TemplateResponse(Request(scope), "error.html", {"err": msg})
|
||||
.body,
|
||||
)
|
||||
|
||||
@@ -179,7 +179,7 @@ class ExtensionsRedirectMiddleware:
|
||||
req_tail_path = req_path.split("/")[len(from_path) :]
|
||||
|
||||
elements = [
|
||||
e for e in ([redirect["ext_id"]] + redirect_to + req_tail_path) if e != ""
|
||||
e for e in ([redirect["ext_id"], *redirect_to, *req_tail_path]) if e != ""
|
||||
]
|
||||
|
||||
return "/" + "/".join(elements)
|
||||
|
||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING, List, Optional
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
@@ -212,7 +212,7 @@ class Node(ABC):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def get_channels(self) -> List[NodeChannel]:
|
||||
async def get_channels(self) -> list[NodeChannel]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
|
||||
+40
-33
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from http import HTTPStatus
|
||||
from typing import TYPE_CHECKING, List, Optional
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
@@ -44,11 +44,12 @@ def catch_rpc_errors(f):
|
||||
async def wrapper(*args, **kwargs):
|
||||
try:
|
||||
return await f(*args, **kwargs)
|
||||
except RpcError as e:
|
||||
if e.error["code"] == -32602:
|
||||
raise HTTPException(status_code=400, detail=e.error["message"])
|
||||
except RpcError as exc:
|
||||
msg = exc.error["message"]
|
||||
if exc.error["code"] == -32602:
|
||||
raise HTTPException(status_code=400, detail=msg) from exc
|
||||
else:
|
||||
raise HTTPException(status_code=500, detail=e.error["message"])
|
||||
raise HTTPException(status_code=500, detail=msg) from exc
|
||||
|
||||
return wrapper
|
||||
|
||||
@@ -66,9 +67,11 @@ class CoreLightningNode(Node):
|
||||
# https://docs.corelightning.org/reference/lightning-connect
|
||||
try:
|
||||
await self.ln_rpc("connect", uri)
|
||||
except RpcError as e:
|
||||
if e.error["code"] == 400:
|
||||
raise HTTPException(HTTPStatus.BAD_REQUEST, detail=e.error["message"])
|
||||
except RpcError as exc:
|
||||
if exc.error["code"] == 400:
|
||||
raise HTTPException(
|
||||
HTTPStatus.BAD_REQUEST, detail=exc.error["message"]
|
||||
) from exc
|
||||
else:
|
||||
raise
|
||||
|
||||
@@ -76,12 +79,12 @@ class CoreLightningNode(Node):
|
||||
async def disconnect_peer(self, peer_id: str):
|
||||
try:
|
||||
await self.ln_rpc("disconnect", peer_id)
|
||||
except RpcError as e:
|
||||
if e.error["code"] == -1:
|
||||
except RpcError as exc:
|
||||
if exc.error["code"] == -1:
|
||||
raise HTTPException(
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
detail=e.error["message"],
|
||||
)
|
||||
detail=exc.error["message"],
|
||||
) from exc
|
||||
else:
|
||||
raise
|
||||
|
||||
@@ -105,14 +108,14 @@ class CoreLightningNode(Node):
|
||||
funding_txid=result["txid"],
|
||||
output_index=result["outnum"],
|
||||
)
|
||||
except RpcError as e:
|
||||
message = e.error["message"]
|
||||
except RpcError as exc:
|
||||
message = exc.error["message"]
|
||||
|
||||
if "amount: should be a satoshi amount" in message:
|
||||
raise HTTPException(
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
detail="The amount is not a valid satoshi amount.",
|
||||
)
|
||||
) from exc
|
||||
|
||||
if "Unknown peer" in message:
|
||||
raise HTTPException(
|
||||
@@ -121,7 +124,7 @@ class CoreLightningNode(Node):
|
||||
"We where able to connect to the peer but CLN "
|
||||
"can't find it when opening a channel."
|
||||
),
|
||||
)
|
||||
) from exc
|
||||
|
||||
if "Owning subdaemon openingd died" in message:
|
||||
# https://github.com/ElementsProject/lightning/issues/2798#issuecomment-511205719
|
||||
@@ -131,14 +134,14 @@ class CoreLightningNode(Node):
|
||||
"Likely the peer didn't like our channel opening "
|
||||
"proposal and disconnected from us."
|
||||
),
|
||||
)
|
||||
) from exc
|
||||
|
||||
if (
|
||||
"Number of pending channels exceed maximum" in message
|
||||
or "exceeds maximum chan size of 10 BTC" in message
|
||||
or "Could not afford" in message
|
||||
):
|
||||
raise HTTPException(HTTPStatus.BAD_REQUEST, detail=message)
|
||||
raise HTTPException(HTTPStatus.BAD_REQUEST, detail=message) from exc
|
||||
raise
|
||||
|
||||
@catch_rpc_errors
|
||||
@@ -152,13 +155,13 @@ class CoreLightningNode(Node):
|
||||
raise HTTPException(status_code=400, detail="Short id required")
|
||||
try:
|
||||
await self.ln_rpc("close", short_id)
|
||||
except RpcError as e:
|
||||
message = e.error["message"]
|
||||
except RpcError as exc:
|
||||
message = exc.error["message"]
|
||||
if (
|
||||
"Short channel ID not active:" in message
|
||||
or "Short channel ID not found" in message
|
||||
):
|
||||
raise HTTPException(HTTPStatus.BAD_REQUEST, detail=message)
|
||||
raise HTTPException(HTTPStatus.BAD_REQUEST, detail=message) from exc
|
||||
else:
|
||||
raise
|
||||
|
||||
@@ -168,7 +171,7 @@ class CoreLightningNode(Node):
|
||||
return info["id"]
|
||||
|
||||
@catch_rpc_errors
|
||||
async def get_peer_ids(self) -> List[str]:
|
||||
async def get_peer_ids(self) -> list[str]:
|
||||
peers = await self.ln_rpc("listpeers")
|
||||
return [p["id"] for p in peers["peers"] if p["connected"]]
|
||||
|
||||
@@ -194,7 +197,7 @@ class CoreLightningNode(Node):
|
||||
return NodePeerInfo(id=node["nodeid"])
|
||||
|
||||
@catch_rpc_errors
|
||||
async def get_channels(self) -> List[NodeChannel]:
|
||||
async def get_channels(self) -> list[NodeChannel]:
|
||||
funds = await self.ln_rpc("listfunds")
|
||||
nodes = await self.ln_rpc("listnodes")
|
||||
nodes_by_id = {n["nodeid"]: n for n in nodes["nodes"]}
|
||||
@@ -217,17 +220,21 @@ class CoreLightningNode(Node):
|
||||
state=(
|
||||
ChannelState.ACTIVE
|
||||
if ch["state"] == "CHANNELD_NORMAL"
|
||||
else ChannelState.PENDING
|
||||
if ch["state"] in ("CHANNELD_AWAITING_LOCKIN", "OPENINGD")
|
||||
else ChannelState.CLOSED
|
||||
if ch["state"]
|
||||
in (
|
||||
"CHANNELD_CLOSING",
|
||||
"CLOSINGD_COMPLETE",
|
||||
"CLOSINGD_SIGEXCHANGE",
|
||||
"ONCHAIN",
|
||||
else (
|
||||
ChannelState.PENDING
|
||||
if ch["state"] in ("CHANNELD_AWAITING_LOCKIN", "OPENINGD")
|
||||
else (
|
||||
ChannelState.CLOSED
|
||||
if ch["state"]
|
||||
in (
|
||||
"CHANNELD_CLOSING",
|
||||
"CLOSINGD_COMPLETE",
|
||||
"CLOSINGD_SIGEXCHANGE",
|
||||
"ONCHAIN",
|
||||
)
|
||||
else ChannelState.INACTIVE
|
||||
)
|
||||
)
|
||||
else ChannelState.INACTIVE
|
||||
),
|
||||
)
|
||||
for ch in funds["channels"]
|
||||
|
||||
+28
-20
@@ -4,7 +4,7 @@ import asyncio
|
||||
import base64
|
||||
import json
|
||||
from http import HTTPStatus
|
||||
from typing import TYPE_CHECKING, List, Optional
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from fastapi import HTTPException
|
||||
from httpx import HTTPStatusError
|
||||
@@ -60,11 +60,13 @@ class LndRestNode(Node):
|
||||
)
|
||||
try:
|
||||
response.raise_for_status()
|
||||
except HTTPStatusError as e:
|
||||
json = e.response.json()
|
||||
except HTTPStatusError as exc:
|
||||
json = exc.response.json()
|
||||
if json:
|
||||
error = json.get("error") or json
|
||||
raise HTTPException(e.response.status_code, detail=error.get("message"))
|
||||
raise HTTPException(
|
||||
exc.response.status_code, detail=error.get("message")
|
||||
) from exc
|
||||
return response.json()
|
||||
|
||||
def get(self, path: str, **kwargs):
|
||||
@@ -81,8 +83,8 @@ class LndRestNode(Node):
|
||||
async def connect_peer(self, uri: str):
|
||||
try:
|
||||
pubkey, host = uri.split("@")
|
||||
except ValueError:
|
||||
raise HTTPException(400, detail="Invalid peer URI")
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, detail="Invalid peer URI") from exc
|
||||
await self.request(
|
||||
"POST",
|
||||
"/v1/peers",
|
||||
@@ -96,11 +98,11 @@ class LndRestNode(Node):
|
||||
async def disconnect_peer(self, peer_id: str):
|
||||
try:
|
||||
await self.request("DELETE", "/v1/peers/" + peer_id)
|
||||
except HTTPException as e:
|
||||
if "unable to disconnect" in e.detail:
|
||||
except HTTPException as exc:
|
||||
if "unable to disconnect" in exc.detail:
|
||||
raise HTTPException(
|
||||
HTTPStatus.BAD_REQUEST, detail="Peer is not connected"
|
||||
)
|
||||
) from exc
|
||||
raise
|
||||
|
||||
async def _get_peer_info(self, peer_id: str) -> NodePeerInfo:
|
||||
@@ -167,14 +169,16 @@ class LndRestNode(Node):
|
||||
point: Optional[ChannelPoint] = None,
|
||||
force: bool = False,
|
||||
):
|
||||
if short_id:
|
||||
logger.debug(f"Closing channel with short_id: {short_id}")
|
||||
if not point:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST, detail="Channel point required"
|
||||
)
|
||||
|
||||
asyncio.create_task(self._close_channel(point, force))
|
||||
asyncio.create_task(self._close_channel(point, force)) # noqa: RUF006
|
||||
|
||||
async def get_channels(self) -> List[NodeChannel]:
|
||||
async def get_channels(self) -> list[NodeChannel]:
|
||||
normal, pending, closed = await asyncio.gather(
|
||||
self.get("/v1/channels"),
|
||||
self.get("/v1/channels/pending"),
|
||||
@@ -237,9 +241,11 @@ class LndRestNode(Node):
|
||||
remote_msat=msat(channel["remote_balance"]),
|
||||
total_msat=msat(channel["capacity"]),
|
||||
),
|
||||
state=ChannelState.ACTIVE
|
||||
if channel["active"]
|
||||
else ChannelState.INACTIVE,
|
||||
state=(
|
||||
ChannelState.ACTIVE
|
||||
if channel["active"]
|
||||
else ChannelState.INACTIVE
|
||||
),
|
||||
# name=channel['peer_alias'],
|
||||
name=info.alias,
|
||||
color=info.color,
|
||||
@@ -318,11 +324,13 @@ class LndRestNode(Node):
|
||||
amount=payment["value_msat"],
|
||||
fee=payment["fee_msat"],
|
||||
time=payment["creation_date"],
|
||||
destination=await self.get_peer_info(
|
||||
payment["htlcs"][0]["route"]["hops"][-1]["pub_key"]
|
||||
)
|
||||
if payment["htlcs"]
|
||||
else None,
|
||||
destination=(
|
||||
await self.get_peer_info(
|
||||
payment["htlcs"][0]["route"]["hops"][-1]["pub_key"]
|
||||
)
|
||||
if payment["htlcs"]
|
||||
else None
|
||||
),
|
||||
bolt11=payment["payment_request"],
|
||||
preimage=payment["payment_preimage"],
|
||||
)
|
||||
@@ -369,7 +377,7 @@ class LndRestNode(Node):
|
||||
memo=invoice["memo"],
|
||||
pending=invoice["state"] == "OPEN",
|
||||
paid_at=invoice["settle_date"],
|
||||
expiry=invoice["creation_date"] + invoice["expiry"],
|
||||
expiry=int(invoice["creation_date"]) + int(invoice["expiry"]),
|
||||
preimage=_decode_bytes(invoice["r_preimage"]),
|
||||
bolt11=invoice["payment_request"],
|
||||
)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
# Marker file for PEP 561
|
||||
+9
-22
@@ -10,10 +10,10 @@ from lnbits.settings import set_cli_settings, settings
|
||||
|
||||
|
||||
@click.command(
|
||||
context_settings=dict(
|
||||
ignore_unknown_options=True,
|
||||
allow_extra_args=True,
|
||||
)
|
||||
context_settings={
|
||||
"ignore_unknown_options": True,
|
||||
"allow_extra_args": True,
|
||||
}
|
||||
)
|
||||
@click.option("--port", default=settings.port, help="Port to listen on")
|
||||
@click.option("--host", default=settings.host, help="Host to run LNBits on")
|
||||
@@ -24,14 +24,16 @@ from lnbits.settings import set_cli_settings, settings
|
||||
)
|
||||
@click.option("--ssl-keyfile", default=None, help="Path to SSL keyfile")
|
||||
@click.option("--ssl-certfile", default=None, help="Path to SSL certificate")
|
||||
@click.pass_context
|
||||
@click.option(
|
||||
"--reload", is_flag=True, default=False, help="Enable auto-reload for development"
|
||||
)
|
||||
def main(
|
||||
ctx,
|
||||
port: int,
|
||||
host: str,
|
||||
forwarded_allow_ips: str,
|
||||
ssl_keyfile: str,
|
||||
ssl_certfile: str,
|
||||
reload: bool,
|
||||
):
|
||||
"""Launched with `poetry run lnbits` at root level"""
|
||||
|
||||
@@ -46,21 +48,6 @@ def main(
|
||||
|
||||
set_cli_settings(host=host, port=port, forwarded_allow_ips=forwarded_allow_ips)
|
||||
|
||||
# this beautiful beast parses all command line arguments and
|
||||
# passes them to the uvicorn server
|
||||
d = dict()
|
||||
for a in ctx.args:
|
||||
item = a.split("=")
|
||||
if len(item) > 1: # argument like --key=value
|
||||
print(a, item)
|
||||
d[item[0].strip("--").replace("-", "_")] = (
|
||||
int(item[1]) # need to convert to int if it's a number
|
||||
if item[1].isdigit()
|
||||
else item[1]
|
||||
)
|
||||
else:
|
||||
d[a.strip("--")] = True # argument like --key
|
||||
|
||||
while True:
|
||||
config = uvicorn.Config(
|
||||
"lnbits.__main__:app",
|
||||
@@ -70,7 +57,7 @@ def main(
|
||||
forwarded_allow_ips=forwarded_allow_ips,
|
||||
ssl_keyfile=ssl_keyfile,
|
||||
ssl_certfile=ssl_certfile,
|
||||
**d
|
||||
reload=reload or False,
|
||||
)
|
||||
|
||||
server = uvicorn.Server(config=config)
|
||||
|
||||
+90
-41
@@ -9,7 +9,7 @@ from hashlib import sha256
|
||||
from os import path
|
||||
from sqlite3 import Row
|
||||
from time import time
|
||||
from typing import Any, List, Optional
|
||||
from typing import Any, Optional
|
||||
|
||||
import httpx
|
||||
from loguru import logger
|
||||
@@ -36,8 +36,8 @@ class LNbitsSettings(BaseModel):
|
||||
|
||||
|
||||
class UsersSettings(LNbitsSettings):
|
||||
lnbits_admin_users: List[str] = Field(default=[])
|
||||
lnbits_allowed_users: List[str] = Field(default=[])
|
||||
lnbits_admin_users: list[str] = Field(default=[])
|
||||
lnbits_allowed_users: list[str] = Field(default=[])
|
||||
lnbits_allow_new_accounts: bool = Field(default=True)
|
||||
|
||||
@property
|
||||
@@ -46,8 +46,9 @@ class UsersSettings(LNbitsSettings):
|
||||
|
||||
|
||||
class ExtensionsSettings(LNbitsSettings):
|
||||
lnbits_admin_extensions: List[str] = Field(default=[])
|
||||
lnbits_extensions_manifests: List[str] = Field(
|
||||
lnbits_admin_extensions: list[str] = Field(default=[])
|
||||
lnbits_extensions_deactivate_all: bool = Field(default=False)
|
||||
lnbits_extensions_manifests: list[str] = Field(
|
||||
default=[
|
||||
"https://raw.githubusercontent.com/lnbits/lnbits-extensions/main/extensions.json"
|
||||
]
|
||||
@@ -55,26 +56,41 @@ class ExtensionsSettings(LNbitsSettings):
|
||||
|
||||
|
||||
class ExtensionsInstallSettings(LNbitsSettings):
|
||||
lnbits_extensions_default_install: List[str] = Field(default=[])
|
||||
lnbits_extensions_default_install: list[str] = Field(default=[])
|
||||
# required due to GitHUb rate-limit
|
||||
lnbits_ext_github_token: str = Field(default="")
|
||||
|
||||
|
||||
class InstalledExtensionsSettings(LNbitsSettings):
|
||||
# installed extensions that have been deactivated
|
||||
lnbits_deactivated_extensions: List[str] = Field(default=[])
|
||||
lnbits_deactivated_extensions: list[str] = Field(default=[])
|
||||
# upgraded extensions that require API redirects
|
||||
lnbits_upgraded_extensions: List[str] = Field(default=[])
|
||||
lnbits_upgraded_extensions: list[str] = Field(default=[])
|
||||
# list of redirects that extensions want to perform
|
||||
lnbits_extensions_redirects: List[Any] = Field(default=[])
|
||||
lnbits_extensions_redirects: list[Any] = Field(default=[])
|
||||
|
||||
def extension_upgrade_path(self, ext_id: str) -> Optional[str]:
|
||||
return next(
|
||||
(e for e in self.lnbits_upgraded_extensions if e.endswith(f"/{ext_id}")),
|
||||
None,
|
||||
)
|
||||
|
||||
def extension_upgrade_hash(self, ext_id: str) -> Optional[str]:
|
||||
path = settings.extension_upgrade_path(ext_id)
|
||||
return path.split("/")[0] if path else None
|
||||
|
||||
|
||||
class ThemesSettings(LNbitsSettings):
|
||||
lnbits_site_title: str = Field(default="LNbits")
|
||||
lnbits_site_tagline: str = Field(default="free and open-source lightning wallet")
|
||||
lnbits_site_description: str = Field(default=None)
|
||||
lnbits_site_description: Optional[str] = Field(
|
||||
default="The world's most powerful suite of bitcoin tools."
|
||||
)
|
||||
LNBITS_SHOW_HOME_PAGE_ELEMENTS: bool = Field(default=True)
|
||||
lnbits_default_wallet_name: str = Field(default="LNbits wallet")
|
||||
lnbits_theme_options: List[str] = Field(
|
||||
lnbits_custom_badge: str = Field(default=None)
|
||||
lnbits_custom_badge_color: str = Field(default="warning")
|
||||
lnbits_theme_options: list[str] = Field(
|
||||
default=[
|
||||
"classic",
|
||||
"freedom",
|
||||
@@ -88,10 +104,10 @@ class ThemesSettings(LNbitsSettings):
|
||||
lnbits_custom_logo: str = Field(default=None)
|
||||
lnbits_ad_space_title: str = Field(default="Supported by")
|
||||
lnbits_ad_space: str = Field(
|
||||
default="https://shop.lnbits.com/;/static/images/lnbits-shop-light.png;/static/images/lnbits-shop-dark.png"
|
||||
default="https://shop.lnbits.com/;/static/images/bitcoin-shop-banner.png;/static/images/bitcoin-shop-banner.png,https://affil.trezor.io/aff_c?offer_id=169&aff_id=33845;/static/images/bitcoin-hardware-wallet.png;/static/images/bitcoin-hardware-wallet.png,https://opensats.org/;/static/images/open-sats.png;/static/images/open-sats.png"
|
||||
) # sneaky sneaky
|
||||
lnbits_ad_space_enabled: bool = Field(default=False)
|
||||
lnbits_allowed_currencies: List[str] = Field(default=[])
|
||||
lnbits_allowed_currencies: list[str] = Field(default=[])
|
||||
lnbits_default_accounting_currency: Optional[str] = Field(default=None)
|
||||
lnbits_qr_logo: str = Field(default="/static/images/logos/lnbits.png")
|
||||
|
||||
@@ -111,11 +127,14 @@ class OpsSettings(LNbitsSettings):
|
||||
class SecuritySettings(LNbitsSettings):
|
||||
lnbits_rate_limit_no: str = Field(default="200")
|
||||
lnbits_rate_limit_unit: str = Field(default="minute")
|
||||
lnbits_allowed_ips: List[str] = Field(default=[])
|
||||
lnbits_blocked_ips: List[str] = Field(default=[])
|
||||
lnbits_allowed_ips: list[str] = Field(default=[])
|
||||
lnbits_blocked_ips: list[str] = Field(default=[])
|
||||
lnbits_notifications: bool = Field(default=False)
|
||||
lnbits_killswitch: bool = Field(default=False)
|
||||
lnbits_killswitch_interval: int = Field(default=60)
|
||||
lnbits_wallet_limit_max_balance: int = Field(default=0)
|
||||
lnbits_wallet_limit_daily_max_withdraw: int = Field(default=0)
|
||||
lnbits_wallet_limit_secs_between_trans: int = Field(default=0)
|
||||
lnbits_watchdog: bool = Field(default=False)
|
||||
lnbits_watchdog_interval: int = Field(default=60)
|
||||
lnbits_watchdog_delta: int = Field(default=1_000_000)
|
||||
@@ -125,13 +144,20 @@ class SecuritySettings(LNbitsSettings):
|
||||
)
|
||||
)
|
||||
|
||||
def is_wallet_max_balance_exceeded(self, amount):
|
||||
return (
|
||||
self.lnbits_wallet_limit_max_balance
|
||||
and self.lnbits_wallet_limit_max_balance > 0
|
||||
and amount > self.lnbits_wallet_limit_max_balance
|
||||
)
|
||||
|
||||
|
||||
class FakeWalletFundingSource(LNbitsSettings):
|
||||
fake_wallet_secret: str = Field(default="ToTheMoon1")
|
||||
|
||||
|
||||
class LNbitsFundingSource(LNbitsSettings):
|
||||
lnbits_endpoint: str = Field(default="https://legend.lnbits.com")
|
||||
lnbits_endpoint: str = Field(default="https://demo.lnbits.com")
|
||||
lnbits_key: Optional[str] = Field(default=None)
|
||||
lnbits_admin_key: Optional[str] = Field(default=None)
|
||||
lnbits_invoice_key: Optional[str] = Field(default=None)
|
||||
@@ -143,6 +169,7 @@ class ClicheFundingSource(LNbitsSettings):
|
||||
|
||||
class CoreLightningFundingSource(LNbitsSettings):
|
||||
corelightning_rpc: Optional[str] = Field(default=None)
|
||||
corelightning_pay_command: str = Field(default="pay")
|
||||
clightning_rpc: Optional[str] = Field(default=None)
|
||||
|
||||
|
||||
@@ -162,6 +189,7 @@ class LndRestFundingSource(LNbitsSettings):
|
||||
lnd_rest_cert: Optional[str] = Field(default=None)
|
||||
lnd_rest_macaroon: Optional[str] = Field(default=None)
|
||||
lnd_rest_macaroon_encrypted: Optional[str] = Field(default=None)
|
||||
lnd_rest_route_hints: bool = Field(default=True)
|
||||
lnd_cert: Optional[str] = Field(default=None)
|
||||
lnd_admin_macaroon: Optional[str] = Field(default=None)
|
||||
lnd_invoice_macaroon: Optional[str] = Field(default=None)
|
||||
@@ -186,6 +214,16 @@ class LnPayFundingSource(LNbitsSettings):
|
||||
lnpay_admin_key: Optional[str] = Field(default=None)
|
||||
|
||||
|
||||
class ZBDFundingSource(LNbitsSettings):
|
||||
zbd_api_endpoint: Optional[str] = Field(default="https://api.zebedee.io/v0/")
|
||||
zbd_api_key: Optional[str] = Field(default=None)
|
||||
|
||||
|
||||
class PhoenixdFundingSource(LNbitsSettings):
|
||||
phoenixd_api_endpoint: Optional[str] = Field(default="http://localhost:9740/")
|
||||
phoenixd_api_password: Optional[str] = Field(default=None)
|
||||
|
||||
|
||||
class AlbyFundingSource(LNbitsSettings):
|
||||
alby_api_endpoint: Optional[str] = Field(default="https://api.getalby.com/")
|
||||
alby_access_token: Optional[str] = Field(default=None)
|
||||
@@ -225,6 +263,8 @@ class FundingSourcesSettings(
|
||||
LndGrpcFundingSource,
|
||||
LnPayFundingSource,
|
||||
AlbyFundingSource,
|
||||
ZBDFundingSource,
|
||||
PhoenixdFundingSource,
|
||||
OpenNodeFundingSource,
|
||||
SparkFundingSource,
|
||||
LnTipsFundingSource,
|
||||
@@ -252,12 +292,13 @@ class AuthMethods(Enum):
|
||||
username_and_password = "username-password"
|
||||
google_auth = "google-auth"
|
||||
github_auth = "github-auth"
|
||||
keycloak_auth = "keycloak-auth"
|
||||
|
||||
|
||||
class AuthSettings(LNbitsSettings):
|
||||
auth_token_expire_minutes: int = Field(default=525600)
|
||||
auth_all_methods = [a.value for a in AuthMethods]
|
||||
auth_allowed_methods: List[str] = Field(
|
||||
auth_allowed_methods: list[str] = Field(
|
||||
default=[
|
||||
AuthMethods.user_id_only.value,
|
||||
AuthMethods.username_and_password.value,
|
||||
@@ -272,18 +313,16 @@ class GoogleAuthSettings(LNbitsSettings):
|
||||
google_client_id: str = Field(default="")
|
||||
google_client_secret: str = Field(default="")
|
||||
|
||||
@property
|
||||
def is_google_auth_configured(self):
|
||||
return self.google_client_id != "" and self.google_client_secret != ""
|
||||
|
||||
|
||||
class GitHubAuthSettings(LNbitsSettings):
|
||||
github_client_id: str = Field(default="")
|
||||
github_client_secret: str = Field(default="")
|
||||
|
||||
@property
|
||||
def is_github_auth_configured(self):
|
||||
return self.github_client_id != "" and self.github_client_secret != ""
|
||||
|
||||
class KeycloakAuthSettings(LNbitsSettings):
|
||||
keycloak_discovery_url: str = Field(default="")
|
||||
keycloak_client_id: str = Field(default="")
|
||||
keycloak_client_secret: str = Field(default="")
|
||||
|
||||
|
||||
class EditableSettings(
|
||||
@@ -299,6 +338,7 @@ class EditableSettings(
|
||||
AuthSettings,
|
||||
GoogleAuthSettings,
|
||||
GitHubAuthSettings,
|
||||
KeycloakAuthSettings,
|
||||
):
|
||||
@validator(
|
||||
"lnbits_admin_users",
|
||||
@@ -332,6 +372,7 @@ class UpdateSettings(EditableSettings):
|
||||
|
||||
class EnvSettings(LNbitsSettings):
|
||||
debug: bool = Field(default=False)
|
||||
debug_database: bool = Field(default=False)
|
||||
bundle_assets: bool = Field(default=True)
|
||||
host: str = Field(default="127.0.0.1")
|
||||
port: int = Field(default=5000)
|
||||
@@ -347,8 +388,8 @@ class EnvSettings(LNbitsSettings):
|
||||
log_rotation: str = Field(default="100 MB")
|
||||
log_retention: str = Field(default="3 months")
|
||||
server_startup_time: int = Field(default=time())
|
||||
lnbits_extensions_deactivate_all: bool = Field(default=False)
|
||||
cleanup_wallets_days: int = Field(default=90)
|
||||
funding_source_max_retries: int = Field(default=4)
|
||||
|
||||
@property
|
||||
def has_default_extension_path(self) -> bool:
|
||||
@@ -367,20 +408,22 @@ class PersistenceSettings(LNbitsSettings):
|
||||
|
||||
|
||||
class SuperUserSettings(LNbitsSettings):
|
||||
lnbits_allowed_funding_sources: List[str] = Field(
|
||||
lnbits_allowed_funding_sources: list[str] = Field(
|
||||
default=[
|
||||
"VoidWallet",
|
||||
"FakeWallet",
|
||||
"CoreLightningWallet",
|
||||
"CoreLightningRestWallet",
|
||||
"LndRestWallet",
|
||||
"EclairWallet",
|
||||
"LndWallet",
|
||||
"LnTipsWallet",
|
||||
"LNPayWallet",
|
||||
"AlbyWallet",
|
||||
"FakeWallet",
|
||||
"CoreLightningRestWallet",
|
||||
"CoreLightningWallet",
|
||||
"EclairWallet",
|
||||
"LNbitsWallet",
|
||||
"LndRestWallet",
|
||||
"LNPayWallet",
|
||||
"LnTipsWallet",
|
||||
"LndWallet",
|
||||
"OpenNodeWallet",
|
||||
"PhoenixdWallet",
|
||||
"VoidWallet",
|
||||
"ZBDWallet",
|
||||
]
|
||||
)
|
||||
|
||||
@@ -393,6 +436,12 @@ class TransientSettings(InstalledExtensionsSettings):
|
||||
# - are cleared on server restart
|
||||
first_install: bool = Field(default=False)
|
||||
|
||||
# Indicates that the server should continue to run.
|
||||
# When set to false it indicates that the shutdown procedure is ongoing.
|
||||
# If false no new tasks, threads, etc should be started.
|
||||
# Long running while loops should use this flag instead of `while True:`
|
||||
lnbits_running: bool = Field(default=True)
|
||||
|
||||
@classmethod
|
||||
def readonly_fields(cls):
|
||||
return [f for f in inspect.signature(cls).parameters if not f.startswith("_")]
|
||||
@@ -422,7 +471,7 @@ class ReadOnlySettings(
|
||||
|
||||
class Settings(EditableSettings, ReadOnlySettings, TransientSettings, BaseSettings):
|
||||
@classmethod
|
||||
def from_row(cls, row: Row) -> "Settings":
|
||||
def from_row(cls, row: Row) -> Settings:
|
||||
data = dict(row)
|
||||
return cls(**data)
|
||||
|
||||
@@ -447,7 +496,7 @@ class SuperSettings(EditableSettings):
|
||||
|
||||
class AdminSettings(EditableSettings):
|
||||
is_super_user: bool
|
||||
lnbits_allowed_funding_sources: Optional[List[str]]
|
||||
lnbits_allowed_funding_sources: Optional[list[str]]
|
||||
|
||||
|
||||
def set_cli_settings(**kwargs):
|
||||
@@ -476,7 +525,7 @@ def send_admin_user_to_saas():
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"error sending super_user to saas:"
|
||||
f" {settings.lnbits_saas_callback}. Error: {str(e)}"
|
||||
f" {settings.lnbits_saas_callback}. Error: {e!s}"
|
||||
)
|
||||
|
||||
|
||||
@@ -502,10 +551,10 @@ if not settings.lnbits_admin_ui:
|
||||
logger.debug(f"{key}: {value}")
|
||||
|
||||
|
||||
def get_wallet_class():
|
||||
def get_funding_source():
|
||||
"""
|
||||
Backwards compatibility
|
||||
"""
|
||||
from lnbits.wallets import get_wallet_class
|
||||
from lnbits.wallets import get_funding_source
|
||||
|
||||
return get_wallet_class()
|
||||
return get_funding_source()
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+10
-10
File diff suppressed because one or more lines are too long
@@ -550,3 +550,7 @@ video {
|
||||
padding: 0.2rem;
|
||||
border-radius: 0.2rem;
|
||||
}
|
||||
|
||||
.whitespace-pre-line {
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ window.localisation.br = {
|
||||
name_your_wallet: 'Nomeie sua carteira %{name}',
|
||||
paste_invoice_label: 'Cole uma fatura, pedido de pagamento ou código lnurl *',
|
||||
lnbits_description:
|
||||
'Fácil de configurar e leve, o LNbits pode ser executado em qualquer fonte de financiamento da lightning-network, atualmente suportando LND, c-lightning, OpenNode, Alby, LNPay 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.',
|
||||
export_to_phone: 'Exportar para o telefone com código QR',
|
||||
export_to_phone_desc:
|
||||
'Este código QR contém a URL da sua carteira com acesso total. Você pode escaneá-lo do seu telefone para abrir sua carteira a partir dele.',
|
||||
@@ -61,9 +61,10 @@ window.localisation.br = {
|
||||
service_fee_tooltip:
|
||||
'Taxa de serviço cobrada pelo administrador do servidor LNbits por transação de saída',
|
||||
toggle_darkmode: 'Alternar modo escuro',
|
||||
payment_reactions: 'Reações de Pagamento',
|
||||
view_swagger_docs: 'Ver a documentação da API do LNbits Swagger',
|
||||
api_docs: 'Documentação da API',
|
||||
api_keys_api_docs: 'Chaves de API e documentação da API',
|
||||
api_keys_api_docs: 'URL do Node, chaves da API e documentação da API',
|
||||
lnbits_version: 'Versão do LNbits',
|
||||
runs_on: 'Executa em',
|
||||
credit_hint: 'Pressione Enter para creditar a conta',
|
||||
@@ -98,6 +99,7 @@ window.localisation.br = {
|
||||
'Este é um código QR de retirada do LNURL para sugar tudo desta carteira. Não compartilhe com ninguém. É compatível com balanceCheck e balanceNotify para que sua carteira possa continuar retirando os fundos continuamente daqui após a primeira retirada.',
|
||||
i_understand: 'Eu entendo',
|
||||
copy_wallet_url: 'Copiar URL da carteira',
|
||||
disclaimer_dialog_title: 'Importante!',
|
||||
disclaimer_dialog:
|
||||
'Funcionalidade de login a ser lançada em uma atualização futura, por enquanto, certifique-se de marcar esta página para acesso futuro à sua carteira! Este serviço está em BETA, e não nos responsabilizamos por pessoas que perderem o acesso aos fundos.',
|
||||
no_transactions: 'Ainda não foram feitas transações',
|
||||
@@ -190,6 +192,12 @@ window.localisation.br = {
|
||||
allow_access_hint: 'Permitir acesso por IP (substituirá os IPs bloqueados)',
|
||||
enter_ip: 'Digite o IP e pressione enter',
|
||||
rate_limiter: 'Limitador de Taxa',
|
||||
wallet_limiter: 'Limitador de Carteira',
|
||||
wallet_limit_max_withdraw_per_day:
|
||||
'Retirada máxima diária da carteira em sats (0 para desativar)',
|
||||
wallet_max_ballance: 'Saldo máximo da carteira em sats (0 para desativar)',
|
||||
wallet_limit_secs_between_trans:
|
||||
'Minutos e segundos entre transações por carteira (0 para desativar)',
|
||||
number_of_requests: 'Número de solicitações',
|
||||
time_unit: 'Unidade de tempo',
|
||||
minute: 'minuto',
|
||||
@@ -208,6 +216,7 @@ window.localisation.br = {
|
||||
account_settings: 'Configurações da Conta',
|
||||
signin_with_google: 'Entrar com o Google',
|
||||
signin_with_github: 'Entrar com GitHub',
|
||||
signin_with_keycloak: 'Entrar com Keycloak',
|
||||
username_or_email: 'Nome de usuário ou E-mail',
|
||||
password: 'Senha',
|
||||
password_config: 'Configuração de Senha',
|
||||
@@ -230,5 +239,15 @@ window.localisation.br = {
|
||||
auth_provider: 'Provedor de Autenticação',
|
||||
my_account: 'Minha Conta',
|
||||
back: 'Voltar',
|
||||
logout: 'Sair'
|
||||
logout: 'Sair',
|
||||
look_and_feel: 'Aparência',
|
||||
language: 'Idioma',
|
||||
color_scheme: 'Esquema de Cores',
|
||||
extension_cost: 'Este lançamento requer um pagamento mínimo de %{cost} sats.',
|
||||
extension_paid_sats: 'Você já pagou %{paid_sats} sats.',
|
||||
release_details_error: 'Não é possível obter os detalhes da versão.',
|
||||
pay_from_wallet: 'Pagar com a Carteira',
|
||||
show_qr: 'Exibir QR',
|
||||
retry_install: 'Repetir Instalação',
|
||||
new_payment: 'Efetuar Novo Pagamento'
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ window.localisation.cn = {
|
||||
name_your_wallet: '给你的 %{name}钱包起个名字',
|
||||
paste_invoice_label: '粘贴发票,付款请求或lnurl*',
|
||||
lnbits_description:
|
||||
'LNbits 设置简单、轻量级,可以运行在任何闪电网络的版本上,目前支持 LND、Core Lightning、OpenNode、Alby, LNPay,甚至 LNbits 本身!您可以为自己运行 LNbits,或者为他人轻松提供资金托管。每个钱包都有自己的 API 密钥,你可以创建的钱包数量没有限制。能够把资金分开管理使 LNbits 成为一款有用的资金管理和开发工具。扩展程序增加了 LNbits 的额外功能,所以你可以在闪电网络上尝试各种尖端技术。我们已经尽可能简化了开发扩展程序的过程,作为一个免费和开源的项目,我们鼓励人们开发并提交自己的扩展程序。',
|
||||
'LNbits 设置简单、轻量级,可以在任何闪电网络的资金来源上运行,甚至可以在LNbits自身上运行!您可以为自己运行LNbits,或者轻松为他人提供托管解决方案。每个钱包都有自己的 API 密钥,你可以创建的钱包数量没有限制。能够把资金分开管理使 LNbits 成为一款有用的资金管理和开发工具。扩展程序增加了 LNbits 的额外功能,所以你可以在闪电网络上尝试各种尖端技术。我们已经尽可能简化了开发扩展程序的过程,作为一个免费和开源的项目,我们鼓励人们开发并提交自己的扩展程序。',
|
||||
export_to_phone: '通过二维码导出到手机',
|
||||
export_to_phone_desc:
|
||||
'这个二维码包含您钱包的URL。您可以使用手机扫描的方式打开您的钱包。',
|
||||
@@ -57,9 +57,10 @@ window.localisation.cn = {
|
||||
service_fee_max: '服务费:%{amount}% 每笔交易(最高 %{max} sats)',
|
||||
service_fee_tooltip: 'LNbits服务器管理员每笔外发交易收取的服务费',
|
||||
toggle_darkmode: '切换暗黑模式',
|
||||
payment_reactions: '支付反应',
|
||||
view_swagger_docs: '查看 LNbits Swagger API 文档',
|
||||
api_docs: 'API文档',
|
||||
api_keys_api_docs: 'API密钥和API文档',
|
||||
api_keys_api_docs: '节点URL、API密钥和API文档',
|
||||
lnbits_version: 'LNbits版本',
|
||||
runs_on: '可运行在',
|
||||
credit_hint: '按 Enter 键充值账户',
|
||||
@@ -94,6 +95,7 @@ window.localisation.cn = {
|
||||
'这是一个 LNURL-取款的二维码,用于从该钱包中提取全部资金。请不要与他人分享。它与 balanceCheck 和 balanceNotify 兼容,因此在第一次取款后,您的钱包还可能会持续从这里提取资金',
|
||||
i_understand: '我明白',
|
||||
copy_wallet_url: '复制钱包URL',
|
||||
disclaimer_dialog_title: '重要!',
|
||||
disclaimer_dialog:
|
||||
'登录功能将在以后的更新中发布,请将此页面加为书签,以便将来访问您的钱包!此服务处于测试阶段,我们不对资金的丢失承担任何责任。',
|
||||
no_transactions: '尚未进行任何交易',
|
||||
@@ -179,6 +181,11 @@ window.localisation.cn = {
|
||||
allow_access_hint: '允许通过IP访问(将覆盖被屏蔽的IP)',
|
||||
enter_ip: '输入IP地址并按回车键',
|
||||
rate_limiter: '速率限制器',
|
||||
wallet_limiter: '钱包限制器',
|
||||
wallet_limit_max_withdraw_per_day:
|
||||
'每日钱包最大提现额度(单位:sats)(设为0则禁用)',
|
||||
wallet_max_ballance: '钱包最大余额(以sats计)(设为0则禁用)',
|
||||
wallet_limit_secs_between_trans: '每个钱包交易间最少秒数(设为0则禁用)',
|
||||
number_of_requests: '请求次数',
|
||||
time_unit: '时间单位',
|
||||
minute: '分钟',
|
||||
@@ -196,7 +203,8 @@ window.localisation.cn = {
|
||||
create_account: '创建账户',
|
||||
account_settings: '账户设置',
|
||||
signin_with_google: '使用谷歌账号登录',
|
||||
signin_with_github: '使用 GitHub 登录',
|
||||
signin_with_github: '使用GitHub登录',
|
||||
signin_with_keycloak: '使用Keycloak登录',
|
||||
username_or_email: '用户名或电子邮箱',
|
||||
password: '密码',
|
||||
password_config: '密码配置',
|
||||
@@ -219,5 +227,15 @@ window.localisation.cn = {
|
||||
auth_provider: '认证提供者',
|
||||
my_account: '我的账户',
|
||||
back: '返回',
|
||||
logout: '注销'
|
||||
logout: '注销',
|
||||
look_and_feel: '外观和感觉',
|
||||
language: '语言',
|
||||
color_scheme: '配色方案',
|
||||
extension_cost: '此版本需要支付最低 %{cost} sats。',
|
||||
extension_paid_sats: '您已经支付了%{paid_sats} sats。',
|
||||
release_details_error: '无法获取发布详情。',
|
||||
pay_from_wallet: '从钱包支付',
|
||||
show_qr: '显示QR码',
|
||||
retry_install: '重试安装',
|
||||
new_payment: '创建新支付'
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ window.localisation.cs = {
|
||||
name_your_wallet: 'Pojmenujte svou %{name} peněženku',
|
||||
paste_invoice_label: 'Vložte fakturu, platební požadavek nebo lnurl kód *',
|
||||
lnbits_description:
|
||||
'Snadno nastavitelný a lehkotonážní, LNbits může běžet na jakémkoliv zdroji financování lightning-network, v současné době podporuje LND, Core Lightning, OpenNode, Alby, LNPay 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í.',
|
||||
export_to_phone: 'Exportovat do telefonu pomocí QR kódu',
|
||||
export_to_phone_desc:
|
||||
'Tento QR kód obsahuje URL vaší peněženky s plným přístupem. Můžete jej naskenovat z telefonu a otevřít peněženku odtamtud.',
|
||||
@@ -61,9 +61,10 @@ window.localisation.cs = {
|
||||
service_fee_tooltip:
|
||||
'Servisní poplatek účtovaný správcem LNbits serveru za odchozí transakci',
|
||||
toggle_darkmode: 'Přepnout tmavý režim',
|
||||
payment_reactions: 'Reakce na platby',
|
||||
view_swagger_docs: 'Zobrazit LNbits Swagger API dokumentaci',
|
||||
api_docs: 'API dokumentace',
|
||||
api_keys_api_docs: 'API klíče a API dokumentace',
|
||||
api_keys_api_docs: 'Adresa uzlu, API klíče a API dokumentace',
|
||||
lnbits_version: 'Verze LNbits',
|
||||
runs_on: 'Běží na',
|
||||
credit_hint: 'Stiskněte Enter pro připsání na účet',
|
||||
@@ -98,6 +99,7 @@ window.localisation.cs = {
|
||||
'Toto je LNURL-withdraw QR kód pro vyčerpání všeho z této peněženky. Nesdílejte s nikým. Je kompatibilní s balanceCheck a balanceNotify, takže vaše peněženka může kontinuálně čerpat prostředky odsud po prvním výběru.',
|
||||
i_understand: 'Rozumím',
|
||||
copy_wallet_url: 'Kopírovat URL peněženky',
|
||||
disclaimer_dialog_title: 'Důležité!',
|
||||
disclaimer_dialog:
|
||||
'Funkcionalita přihlášení bude vydána v budoucí aktualizaci, zatím si ujistěte, že jste si tuto stránku uložili do záložek pro budoucí přístup k vaší peněžence! Tato služba je v BETA verzi a nepřebíráme žádnou zodpovědnost za ztrátu přístupu k prostředkům.',
|
||||
no_transactions: 'Zatím žádné transakce',
|
||||
@@ -187,6 +189,12 @@ window.localisation.cs = {
|
||||
allow_access_hint: 'Povolit přístup podle IP (přepíše blokované IP)',
|
||||
enter_ip: 'Zadejte IP a stiskněte enter',
|
||||
rate_limiter: 'Omezovač počtu požadavků',
|
||||
wallet_limiter: 'Omezení peněženky',
|
||||
wallet_limit_max_withdraw_per_day:
|
||||
'Maximální denní limit pro výběr z peněženky v sats (0 pro deaktivaci)',
|
||||
wallet_max_ballance: 'Maximální zůstatek v peněžence v sats (0 pro zakázání)',
|
||||
wallet_limit_secs_between_trans:
|
||||
'Minimální počet sekund mezi transakcemi na peněženku (0 pro vypnutí)',
|
||||
number_of_requests: 'Počet požadavků',
|
||||
time_unit: 'Časová jednotka',
|
||||
minute: 'minuta',
|
||||
@@ -205,6 +213,7 @@ window.localisation.cs = {
|
||||
account_settings: 'Nastavení účtu',
|
||||
signin_with_google: 'Přihlásit se přes Google',
|
||||
signin_with_github: 'Přihlásit se přes GitHub',
|
||||
signin_with_keycloak: 'Přihlásit se přes Keycloak',
|
||||
username_or_email: 'Uživatelské jméno nebo Email',
|
||||
password: 'Heslo',
|
||||
password_config: 'Konfigurace hesla',
|
||||
@@ -227,5 +236,15 @@ window.localisation.cs = {
|
||||
auth_provider: 'Poskytovatel ověření',
|
||||
my_account: 'Můj účet',
|
||||
back: 'Zpět',
|
||||
logout: 'Odhlásit se'
|
||||
logout: 'Odhlásit se',
|
||||
look_and_feel: 'Vzhled a chování',
|
||||
language: 'Jazyk',
|
||||
color_scheme: 'Barevné schéma',
|
||||
extension_cost: 'Toto vydání vyžaduje minimální platbu %{cost} satoshi.',
|
||||
extension_paid_sats: 'Již jste zaplatili %{paid_sats} sats.',
|
||||
release_details_error: 'Nelze získat podrobnosti o vydání.',
|
||||
pay_from_wallet: 'Platit z peněženky',
|
||||
show_qr: 'Zobrazit QR',
|
||||
retry_install: 'Zkusit znovu nainstalovat',
|
||||
new_payment: 'Vytvořit novou platbu'
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ window.localisation.de = {
|
||||
paste_invoice_label:
|
||||
'Füge eine Rechnung, Zahlungsanforderung oder LNURL ein *',
|
||||
lnbits_description:
|
||||
'Einfach zu installieren und kompakt, LNbits kann auf jeder Funding-Quelle im Lightning Netzwerk aufsetzen. Derzeit unterstützt: LND, Core Lightning, OpenNode, Alby, LNPay und sogar LNbits selbst! Du kannst LNbits für dich selbst betreiben oder anderen die Verwaltung durch dich anbieten. Jede Wallet hat ihre eigenen API-Schlüssel und die Anzahl der Wallets ist unbegrenzt. Die Möglichkeit, Gelder auf verschiedene Accounts mit unterschiedlicher Logik aufteilen zu können macht LNbits zu einem nützlichen Werkzeug für deine Buchhaltung - aber auch als Entwicklungswerkzeug. Erweiterungen bereichern LNbits Accounts um zusätzliche Funktionalität, so dass du mit einer Reihe von neuartigen Technologien auf dem Lightning-Netzwerk experimentieren kannst. Wir haben es so einfach wie möglich gemacht, Erweiterungen zu entwickeln, und als freies und Open-Source-Projekt möchten wir Menschen ermutigen, sich selbst hieran zu versuchen und gemeinsam mit uns neue Funktionalitäten zu entwickeln.',
|
||||
'Einfach zu installieren und kompakt, LNbits kann auf jeder Funding-Quelle im Lightning Netzwerk aufsetzen und sogar LNbits selbst! Du kannst LNbits für dich selbst betreiben oder anderen die Verwaltung durch dich anbieten. Jede Wallet hat ihre eigenen API-Schlüssel und die Anzahl der Wallets ist unbegrenzt. Die Möglichkeit, Gelder auf verschiedene Accounts mit unterschiedlicher Logik aufteilen zu können macht LNbits zu einem nützlichen Werkzeug für deine Buchhaltung - aber auch als Entwicklungswerkzeug. Erweiterungen bereichern LNbits Accounts um zusätzliche Funktionalität, so dass du mit einer Reihe von neuartigen Technologien auf dem Lightning-Netzwerk experimentieren kannst. Wir haben es so einfach wie möglich gemacht, Erweiterungen zu entwickeln, und als freies und Open-Source-Projekt möchten wir Menschen ermutigen, sich selbst hieran zu versuchen und gemeinsam mit uns neue Funktionalitäten zu entwickeln.',
|
||||
export_to_phone: 'Auf dem Telefon öffnen',
|
||||
export_to_phone_desc:
|
||||
'Dieser QR-Code beinhaltet vollständige Rechte auf deine Wallet. Du kannst den QR-Code mit Deinem Telefon scannen, um deine Wallet dort zu öffnen.',
|
||||
@@ -63,9 +63,10 @@ window.localisation.de = {
|
||||
service_fee_tooltip:
|
||||
'Bearbeitungsgebühr, die vom LNbits Server-Administrator pro ausgehender Transaktion berechnet wird',
|
||||
toggle_darkmode: 'Auf Dark Mode umschalten',
|
||||
payment_reactions: 'Zahlungsreaktionen',
|
||||
view_swagger_docs: 'LNbits Swagger API-Dokumentation',
|
||||
api_docs: 'API-Dokumentation',
|
||||
api_keys_api_docs: 'API-Schlüssel und API-Dokumentation',
|
||||
api_keys_api_docs: 'Knoten-URL, API-Schlüssel und API-Dokumentation',
|
||||
lnbits_version: 'LNbits-Version',
|
||||
runs_on: 'Läuft auf',
|
||||
credit_hint: 'Klicke Enter, um das Konto zu belasten',
|
||||
@@ -101,6 +102,7 @@ window.localisation.de = {
|
||||
'LNURL-withdraw QR-Code, der das Abziehen aller Geldmittel aus dieser Wallet erlaubt. Teile ihn mit niemandem! Kompatibel mit balanceCheck und balanceNotify, so dass dein Wallet die Sats nach dem ersten Abzug kontinuierlich von hier abziehen kann.',
|
||||
i_understand: 'Ich verstehe',
|
||||
copy_wallet_url: 'Wallet-URL kopieren',
|
||||
disclaimer_dialog_title: 'Wichtig!',
|
||||
disclaimer_dialog:
|
||||
'Login-Funktionalität wird in einem zukünftigen Update veröffentlicht. Bis dahin ist die Speicherung der Wallet-URL als Lesezeichen absolut notwendig, um Zugriff auf die Wallet zu erhalten! Dieser Service ist in BETA und wir übernehmen keine Verantwortung für Verluste durch verlorene Zugriffe.',
|
||||
no_transactions: 'Keine Transaktionen',
|
||||
@@ -192,6 +194,13 @@ window.localisation.de = {
|
||||
allow_access_hint: 'Zugriff durch IP erlauben (überschreibt blockierte IPs)',
|
||||
enter_ip: 'Geben Sie die IP ein und drücken Sie die Eingabetaste',
|
||||
rate_limiter: 'Ratenbegrenzer',
|
||||
wallet_limiter: 'Geldbeutel-Limiter',
|
||||
wallet_limit_max_withdraw_per_day:
|
||||
'Maximales tägliches Wallet-Auszahlungslimit in Sats (0 zum Deaktivieren)',
|
||||
wallet_max_ballance:
|
||||
'Maximales Guthaben der Wallet in Sats (0 zum Deaktivieren)',
|
||||
wallet_limit_secs_between_trans:
|
||||
'Mindestsekunden zwischen Transaktionen pro Wallet (0 zum Deaktivieren)',
|
||||
number_of_requests: 'Anzahl der Anfragen',
|
||||
time_unit: 'Zeiteinheit',
|
||||
minute: 'Minute',
|
||||
@@ -211,6 +220,7 @@ window.localisation.de = {
|
||||
account_settings: 'Kontoeinstellungen',
|
||||
signin_with_google: 'Mit Google anmelden',
|
||||
signin_with_github: 'Anmelden mit GitHub',
|
||||
signin_with_keycloak: 'Mit Keycloak anmelden',
|
||||
username_or_email: 'Benutzername oder E-Mail',
|
||||
password: 'Passwort',
|
||||
password_config: 'Passwortkonfiguration',
|
||||
@@ -233,5 +243,16 @@ window.localisation.de = {
|
||||
auth_provider: 'Anbieter für Authentifizierung',
|
||||
my_account: 'Mein Konto',
|
||||
back: 'Zurück',
|
||||
logout: 'Abmelden'
|
||||
logout: 'Abmelden',
|
||||
look_and_feel: 'Aussehen und Verhalten',
|
||||
language: 'Sprache',
|
||||
color_scheme: 'Farbschema',
|
||||
extension_cost:
|
||||
'Diese Version erfordert eine Zahlung von mindestens %{cost} Sats.',
|
||||
extension_paid_sats: 'Sie haben bereits %{paid_sats} Sats bezahlt.',
|
||||
release_details_error: 'Kann die Details zur Veröffentlichung nicht abrufen.',
|
||||
pay_from_wallet: 'Zahlen aus dem Geldbeutel',
|
||||
show_qr: 'QR anzeigen',
|
||||
retry_install: 'Installieren erneut versuchen',
|
||||
new_payment: 'Neue Zahlung vornehmen'
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ window.localisation.en = {
|
||||
transactions: 'Transactions',
|
||||
dashboard: 'Dashboard',
|
||||
node: 'Node',
|
||||
export_users: 'Export Users',
|
||||
no_users: 'No users found',
|
||||
total_capacity: 'Total Capacity',
|
||||
avg_channel_size: 'Avg. Channel Size',
|
||||
biggest_channel_size: 'Biggest Channel Size',
|
||||
@@ -33,9 +35,11 @@ window.localisation.en = {
|
||||
reset_defaults_tooltip: 'Delete all settings and reset to defaults.',
|
||||
download_backup: 'Download database backup',
|
||||
name_your_wallet: 'Name your %{name} wallet',
|
||||
wallet_topup_ok:
|
||||
'Success creating virtual funds (%{amount} sats). Payments depend on actual funds on funding source.',
|
||||
paste_invoice_label: 'Paste an invoice, payment request or lnurl code *',
|
||||
lnbits_description:
|
||||
'Easy to set up and lightweight, LNbits can run on any lightning-network funding source, currently supporting LND, Core Lightning, OpenNode, Alby, LNPay and even LNbits itself! You can run LNbits for yourself, or easily offer a custodian solution for others. Each wallet has its own API keys and there is no limit to the number of wallets you can make. Being able to partition funds makes LNbits a useful tool for money management and as a development tool. Extensions add extra functionality to LNbits so you can experiment with a range of cutting-edge technologies on the lightning network. We have made developing extensions as easy as possible, and as a free and open-source project, we encourage people to develop and submit their own.',
|
||||
'Easy to set up and lightweight, LNbits can run on any Lightning Network funding source and even LNbits itself! You can run LNbits for yourself, or easily offer a custodian solution for others. Each wallet has its own API keys and there is no limit to the number of wallets you can make. Being able to partition funds makes LNbits a useful tool for money management and as a development tool. Extensions add extra functionality to LNbits so you can experiment with a range of cutting-edge technologies on the lightning network. We have made developing extensions as easy as possible, and as a free and open-source project, we encourage people to develop and submit their own.',
|
||||
export_to_phone: 'Export to Phone with QR Code',
|
||||
export_to_phone_desc:
|
||||
'This QR code contains your wallet URL with full access. You can scan it from your phone to open your wallet from there.',
|
||||
@@ -59,9 +63,10 @@ window.localisation.en = {
|
||||
service_fee_tooltip:
|
||||
'Service fee charged by the LNbits server admin per outgoing transaction',
|
||||
toggle_darkmode: 'Toggle Dark Mode',
|
||||
payment_reactions: 'Payment Reactions',
|
||||
view_swagger_docs: 'View LNbits Swagger API docs',
|
||||
api_docs: 'API docs',
|
||||
api_keys_api_docs: 'API keys and API docs',
|
||||
api_keys_api_docs: 'Node URL, API keys and API docs',
|
||||
lnbits_version: 'LNbits version',
|
||||
runs_on: 'Runs on',
|
||||
credit_hint: 'Press Enter to credit account',
|
||||
@@ -96,8 +101,9 @@ window.localisation.en = {
|
||||
'This is an LNURL-withdraw QR code for slurping everything from this wallet. Do not share with anyone. It is compatible with balanceCheck and balanceNotify so your wallet may keep pulling the funds continuously from here after the first withdraw.',
|
||||
i_understand: 'I understand',
|
||||
copy_wallet_url: 'Copy wallet URL',
|
||||
disclaimer_dialog_title: 'Important!',
|
||||
disclaimer_dialog:
|
||||
'To ensure continuous access to your wallets, please remember to securely store your login credentials! Please visit the "My Account" page. This service is in BETA, and we hold no responsibility for people losing access to funds.',
|
||||
'You *must* save your login credentials to be able to access your wallet again. If you lose them, you will lose access to your wallet and funds.\n\nFind your login credentials on your account settings page.\n\nThis service is in BETA. LNbits holds no responsibility for loss of access to funds.',
|
||||
no_transactions: 'No transactions made yet',
|
||||
manage: 'Manage',
|
||||
extensions: 'Extensions',
|
||||
@@ -184,6 +190,12 @@ window.localisation.en = {
|
||||
allow_access_hint: 'Allow access by IP (will override blocked IPs)',
|
||||
enter_ip: 'Enter IP and hit enter',
|
||||
rate_limiter: 'Rate Limiter',
|
||||
wallet_limiter: 'Wallet Limiter',
|
||||
wallet_limit_max_withdraw_per_day:
|
||||
'Max daily wallet withdrawal in sats (0 to disable)',
|
||||
wallet_max_ballance: 'Wallet max balance in sats (0 to disable)',
|
||||
wallet_limit_secs_between_trans:
|
||||
'Min secs between transactions per wallet (0 to disable)',
|
||||
number_of_requests: 'Number of requests',
|
||||
time_unit: 'Time unit',
|
||||
minute: 'minute',
|
||||
@@ -202,6 +214,7 @@ window.localisation.en = {
|
||||
account_settings: 'Account Settings',
|
||||
signin_with_google: 'Sign in with Google',
|
||||
signin_with_github: 'Sign in with GitHub',
|
||||
signin_with_keycloak: 'Sign in with Keycloak',
|
||||
username_or_email: 'Username or Email',
|
||||
password: 'Password',
|
||||
password_config: 'Password Config',
|
||||
@@ -227,5 +240,13 @@ window.localisation.en = {
|
||||
logout: 'Logout',
|
||||
look_and_feel: 'Look and Feel',
|
||||
language: 'Language',
|
||||
color_scheme: 'Color Scheme'
|
||||
color_scheme: 'Color Scheme',
|
||||
extension_cost: 'This release requires a payment of minimum %{cost} sats.',
|
||||
extension_paid_sats: 'You have already paid %{paid_sats} sats.',
|
||||
release_details_error: 'Cannot get the release details.',
|
||||
pay_from_wallet: 'Pay from Wallet',
|
||||
show_qr: 'Show QR',
|
||||
retry_install: 'Retry Install',
|
||||
new_payment: 'Make New Payment',
|
||||
hide_empty_wallets: 'Hide empty wallets'
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ window.localisation.es = {
|
||||
name_your_wallet: 'Nombre de su billetera %{name}',
|
||||
paste_invoice_label: 'Pegue la factura aquí',
|
||||
lnbits_description:
|
||||
'Fácil de instalar y liviano, LNbits puede ejecutarse en cualquier fuente de financiación de la red Lightning, actualmente compatible con LND, Core Lightning, OpenNode, Alby, LNPay y hasta LNbits mismo! Puede ejecutar LNbits para usted mismo o ofrecer una solución competente a otros. Cada billetera tiene su propia clave API y no hay límite para la cantidad de billeteras que puede crear. La capacidad de particionar fondos hace de LNbits una herramienta útil para la administración de fondos y como herramienta de desarrollo. Las extensiones agregan funcionalidad adicional a LNbits, por lo que puede experimentar con una variedad de tecnologías de vanguardia en la red Lightning. Lo hemos hecho lo más simple posible para desarrollar extensiones y, como un proyecto gratuito y de código abierto, animamos a las personas a que se desarrollen a sí mismas y envíen sus propios contribuciones.',
|
||||
'Fácil de instalar y liviano, LNbits puede ejecutarse en cualquier fuente de financiación de la red Lightning y hasta LNbits mismo! Puede ejecutar LNbits para usted mismo o ofrecer una solución competente a otros. Cada billetera tiene su propia clave API y no hay límite para la cantidad de billeteras que puede crear. La capacidad de particionar fondos hace de LNbits una herramienta útil para la administración de fondos y como herramienta de desarrollo. Las extensiones agregan funcionalidad adicional a LNbits, por lo que puede experimentar con una variedad de tecnologías de vanguardia en la red Lightning. Lo hemos hecho lo más simple posible para desarrollar extensiones y, como un proyecto gratuito y de código abierto, animamos a las personas a que se desarrollen a sí mismas y envíen sus propios contribuciones.',
|
||||
export_to_phone: 'Exportar a teléfono con código QR',
|
||||
export_to_phone_desc:
|
||||
'Este código QR contiene su URL de billetera con acceso completo. Puede escanearlo desde su teléfono para abrir su billetera allí.',
|
||||
@@ -61,9 +61,10 @@ window.localisation.es = {
|
||||
service_fee_tooltip:
|
||||
'Comisión de servicio cobrada por el administrador del servidor LNbits por cada transacción saliente',
|
||||
toggle_darkmode: 'Cambiar modo oscuro',
|
||||
payment_reactions: 'Reacciones de Pago',
|
||||
view_swagger_docs: 'Ver documentación de API de LNbits Swagger',
|
||||
api_docs: 'Documentación de API',
|
||||
api_keys_api_docs: 'Claves de API y documentación de API',
|
||||
api_keys_api_docs: 'URL del nodo, claves de API y documentación de API',
|
||||
lnbits_version: 'Versión de LNbits',
|
||||
runs_on: 'Corre en',
|
||||
credit_hint: 'Presione Enter para cargar la cuenta',
|
||||
@@ -98,6 +99,7 @@ window.localisation.es = {
|
||||
'Este es un código QR LNURL-withdraw para drenar todos los fondos de esta billetera. No lo comparta con nadie. Es compatible con balanceCheck y balanceNotify, por lo que su billetera puede continuar drenando los fondos de aquí después del primer drenaje.',
|
||||
i_understand: 'Lo entiendo',
|
||||
copy_wallet_url: 'Copiar URL de billetera',
|
||||
disclaimer_dialog_title: '¡Importante!',
|
||||
disclaimer_dialog:
|
||||
'La funcionalidad de inicio de sesión se lanzará en una actualización futura, por ahora, asegúrese de guardar esta página como marcador para acceder a su billetera en el futuro. Este servicio está en BETA y no asumimos ninguna responsabilidad por personas que pierdan el acceso a sus fondos.',
|
||||
no_transactions: 'No hay transacciones todavía',
|
||||
@@ -190,6 +192,13 @@ window.localisation.es = {
|
||||
allow_access_hint: 'Permitir acceso por IP (anulará las IPs bloqueadas)',
|
||||
enter_ip: 'Ingrese la IP y presione enter',
|
||||
rate_limiter: 'Limitador de tasa',
|
||||
wallet_limiter: 'Limitador de Cartera',
|
||||
wallet_limit_max_withdraw_per_day:
|
||||
'Límite diario de retiro de la cartera en sats (0 para deshabilitar)',
|
||||
wallet_max_ballance:
|
||||
'Saldo máximo de la billetera en sats (0 para desactivar)',
|
||||
wallet_limit_secs_between_trans:
|
||||
'Mín. segs entre transacciones por cartera (0 para desactivar)',
|
||||
number_of_requests: 'Número de solicitudes',
|
||||
time_unit: 'Unidad de tiempo',
|
||||
minute: 'minuto',
|
||||
@@ -209,6 +218,7 @@ window.localisation.es = {
|
||||
account_settings: 'Configuración de la cuenta',
|
||||
signin_with_google: 'Inicia sesión con Google',
|
||||
signin_with_github: 'Inicia sesión con GitHub',
|
||||
signin_with_keycloak: 'Iniciar sesión con Keycloak',
|
||||
username_or_email: 'Nombre de usuario o correo electrónico',
|
||||
password: 'Contraseña',
|
||||
password_config: 'Configuración de Contraseña',
|
||||
@@ -231,5 +241,15 @@ window.localisation.es = {
|
||||
auth_provider: 'Proveedor de Autenticación',
|
||||
my_account: 'Mi cuenta',
|
||||
back: 'Atrás',
|
||||
logout: 'Cerrar sesión'
|
||||
logout: 'Cerrar sesión',
|
||||
look_and_feel: 'Apariencia',
|
||||
language: 'Idioma',
|
||||
color_scheme: 'Esquema de colores',
|
||||
extension_cost: 'Esta versión requiere un pago mínimo de %{cost} sats.',
|
||||
extension_paid_sats: 'Ya has pagado %{paid_sats} sats.',
|
||||
release_details_error: 'No se pueden obtener los detalles de la versión.',
|
||||
pay_from_wallet: 'Pagar desde la billetera',
|
||||
show_qr: 'Mostrar QR',
|
||||
retry_install: 'Reintentar Instalación',
|
||||
new_payment: 'Realizar nuevo pago'
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ window.localisation.fi = {
|
||||
paste_invoice_label:
|
||||
'Liitä lasku, maksupyyntö, lnurl-koodi tai Lightning Address *',
|
||||
lnbits_description:
|
||||
'Kevyt ja helppokäyttöinen LNbits soveltuu minkä tahansa salamaverkon rahoituslähteeksi. Se tukee rahoituslähteinä LND, Core Lightning, OpenNode, Alby, LNPay ja jopa itseään! Voi käyttää itsenäisesti sitä ja helposti tarjota säilytyspalveluita. Pystyt luomaan sillä salamaverkko lompakoita ja niiden määrää ei ole rajoitettu. Jokaiselle lompakolle saat iksilölliset AP avaimet. Varojen osittaminen tekee siitä erittäin kätevän varojen hallinnassa sekä myös ohjelmistokehityksen työkaluja. Laajennukset lisäävät LNbits:in toiminnallisuuksia. Näinpä voit helposti testailla useita erilaisia ja viimeisimpiä salamaverkon-teknologioita. Laajennuksien kehittämisen olemme pyrkineet tekemään hmahdollisimman helpoksi, ilmaisena OpenSource-projektina. Kannustamme kaikkia kehittämään ja jakelemaan omia laajennuksia!',
|
||||
'Kevyt ja helppokäyttöinen LNbits voi käyttää rahoituslähteinään erilaisia palveluita, ja jopa LNbits itseään! Voit käyttää sitä itsenäisesti ja helposti tarjota erilaisia Lightning-palveluita. Pystyt luomaan sillä salamaverkkolompakoita eikä niiden määrää ole rajoitettu. Jokaiselle lompakolle saat yksilölliset API-avaimet. Varojen osittaminen tekee siitä erittäin kätevän varojen hallinnassa sekä myös ohjelmistokehityksen työkalun. Laajennukset lisäävät LNbits:in toiminnallisuuksia. Näinpä voit helposti testailla useita erilaisia ja viimeisimpiä salamaverkon teknologioita. Laajennuksien kehittämisen olemme pyrkineet tekemään mahdollisimman helpoksi pitämällä LNbits:in ilmaisena OpenSource-projektina. Kannustamme kaikkia kehittämään ja jakelemaan omia laajennuksia!',
|
||||
export_to_phone: 'Käytä puhelimessa lukemalla QR-koodi',
|
||||
export_to_phone_desc:
|
||||
'Tämä QR-koodi sisältää URL-osoitteen, jolla saa lompakkoosi täydet valtuudet. Voi lukea sen puhelimellasi ja avata sillä lompakkosi. Voit myös lisätä lompakkosi selaimella käytettäväksi PWA-sovellukseksi puhelimen aloitusruudulle. ',
|
||||
@@ -64,9 +64,10 @@ window.localisation.fi = {
|
||||
service_fee_tooltip:
|
||||
'LNBits palvelimen ylläpitäjä veloittaa lähtevästä maksusta palvelumaksun.',
|
||||
toggle_darkmode: 'Tumma näkymä',
|
||||
payment_reactions: 'Maksureaktiot',
|
||||
view_swagger_docs: 'Näytä LNbits Swagger API-dokumentit',
|
||||
api_docs: 'API-dokumentaatio',
|
||||
api_keys_api_docs: 'API-avaimet ja -dokumentaatio',
|
||||
api_keys_api_docs: 'Solmun URL, API-avaimet ja -dokumentaatio',
|
||||
lnbits_version: 'LNbits versio',
|
||||
runs_on: 'Mukana menossa',
|
||||
credit_hint: 'Hyväksy painamalla Enter',
|
||||
@@ -101,12 +102,13 @@ window.localisation.fi = {
|
||||
'Tämä LNURL-withdraw -tyyppinen QR-koodi on tarkoitettu kaikkien varojen imurointiin lompakosta. ÄLÄ JAA SITÄ KENELLEKÄÄN! Se on balanceCheck- ja balanceNotify-toimintojen kanssa yhteensopiva, joten sitä voi käyttää lompakon tyhjentämiseen ensimmäisen käytön jälleen jatkuvasti.',
|
||||
i_understand: 'Vakuutan ymmärtäväni',
|
||||
copy_wallet_url: 'Kopioi lompakon URL',
|
||||
disclaimer_dialog_title: 'Tärkeää!',
|
||||
disclaimer_dialog:
|
||||
'Muistathan tallettaa kirjautumistietosi turvallisesta ja helposti saataville, jotta pääset jatkossakin kirjautumaan lompakkoosi! Tutustu myös Tilin asetukset -sivuun. Tämä palvelu on kokeiluvaiheessa (eli BETA), ja niinpä kukaan ei ota mitään vastuuta varojen säilymisestä tai niiden käytettävyyden takaamisesta.',
|
||||
no_transactions: 'Lompakossa ei ole yhtään tapahtumaa',
|
||||
manage: 'Hallinnoi',
|
||||
manage: 'Hallinnointi',
|
||||
extensions: 'Laajennukset',
|
||||
no_extensions: 'Yhtään laajennusta ei ole asennettuna :(',
|
||||
no_extensions: 'Laajennuksia ei ole asennettu :(',
|
||||
created: 'Luotu',
|
||||
search_extensions: 'Etsi laajennuksia',
|
||||
warning: 'Varoitus',
|
||||
@@ -190,6 +192,12 @@ window.localisation.fi = {
|
||||
allow_access_hint: 'Salli pääsy IP-osoitteen perusteella (ohittaa estot)',
|
||||
enter_ip: 'Anna IP ja paina +',
|
||||
rate_limiter: 'Toiston rajoitin',
|
||||
wallet_limiter: 'Lompakon Rajoitin',
|
||||
wallet_limit_max_withdraw_per_day:
|
||||
'Maksimi päivittäinen lompakon nosto sateissa (0 poistaa käytöstä)',
|
||||
wallet_max_ballance: 'Lompakon maksimisaldo satosheina (0 poistaa käytöstä)',
|
||||
wallet_limit_secs_between_trans:
|
||||
'Min sekuntia transaktioiden välillä lompakkoa kohden (0 poistaa käytöstä)',
|
||||
number_of_requests: 'Pyyntöjen lukumäärä',
|
||||
time_unit: 'aikayksikkö',
|
||||
minute: 'minuutti',
|
||||
@@ -208,6 +216,7 @@ window.localisation.fi = {
|
||||
account_settings: 'Tilin asetukset',
|
||||
signin_with_google: 'Kirjaudu Google-tunnuksella',
|
||||
signin_with_github: 'Kirjaudu GitHub-tunnuksella',
|
||||
signin_with_keycloak: 'Kirjaudu Keycloak-tunnuksella',
|
||||
username_or_email: 'Käyttäjänimi tai sähköposti',
|
||||
password: 'Anna uusi salasana',
|
||||
password_config: 'Salasanan määritys',
|
||||
@@ -233,5 +242,12 @@ window.localisation.fi = {
|
||||
logout: 'Poistu',
|
||||
look_and_feel: 'Kieli ja värit',
|
||||
language: 'Kieli',
|
||||
color_scheme: 'Väriteema'
|
||||
color_scheme: 'Väriteema',
|
||||
extension_cost: 'Tämä julkaisu edellyttää vähintään %{cost} satsin maksua.',
|
||||
extension_paid_sats: 'Olet jo maksanut %{paid_sats} satsia.',
|
||||
release_details_error: 'Ei voi hakea julkaisun tietoja.',
|
||||
pay_from_wallet: 'Maksa lompakosta',
|
||||
show_qr: 'Näytä QR',
|
||||
retry_install: 'Yritä asennusta uudelleen',
|
||||
new_payment: 'Tee uusi maksu'
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ window.localisation.fr = {
|
||||
paste_invoice_label:
|
||||
'Coller une facture, une demande de paiement ou un code lnurl *',
|
||||
lnbits_description:
|
||||
"Facile à installer et léger, LNbits peut fonctionner sur n'importe quelle source de financement du réseau Lightning, prenant actuellement en charge LND, Core Lightning, OpenNode, Alby, LNPay et même LNbits lui-même! Vous pouvez exécuter LNbits pour vous-même ou offrir facilement une solution de gardien pour les autres. Chaque portefeuille a ses propres clés API et il n'y a pas de limite au nombre de portefeuilles que vous pouvez créer. La capacité de partitionner les fonds rend LNbits un outil utile pour la gestion de l'argent et comme outil de développement. Les extensions ajoutent une fonctionnalité supplémentaire à LNbits afin que vous puissiez expérimenter une gamme de technologies de pointe sur le réseau Lightning. Nous avons rendu le développement d'extensions aussi simple que possible et, en tant que projet gratuit et open source, nous encourageons les gens à développer et à soumettre les leurs.",
|
||||
"Facile à installer et léger, LNbits peut fonctionner sur n'importe quelle source de financement du réseau Lightning et même LNbits lui-même! Vous pouvez exécuter LNbits pour vous-même ou offrir facilement une solution de gardien pour les autres. Chaque portefeuille a ses propres clés API et il n'y a pas de limite au nombre de portefeuilles que vous pouvez créer. La capacité de partitionner les fonds rend LNbits un outil utile pour la gestion de l'argent et comme outil de développement. Les extensions ajoutent une fonctionnalité supplémentaire à LNbits afin que vous puissiez expérimenter une gamme de technologies de pointe sur le réseau Lightning. Nous avons rendu le développement d'extensions aussi simple que possible et, en tant que projet gratuit et open source, nous encourageons les gens à développer et à soumettre les leurs.",
|
||||
export_to_phone: 'Exporter vers le téléphone avec un code QR',
|
||||
export_to_phone_desc:
|
||||
"Ce code QR contient l'URL de votre portefeuille avec un accès complet. Vous pouvez le scanner depuis votre téléphone pour ouvrir votre portefeuille depuis là-bas.",
|
||||
@@ -65,9 +65,10 @@ window.localisation.fr = {
|
||||
service_fee_tooltip:
|
||||
"Frais de service facturés par l'administrateur du serveur LNbits pour chaque transaction sortante",
|
||||
toggle_darkmode: 'Basculer le mode sombre',
|
||||
payment_reactions: 'Réactions de paiement',
|
||||
view_swagger_docs: "Voir les documentation de l'API Swagger de LNbits",
|
||||
api_docs: "Documentation de l'API",
|
||||
api_keys_api_docs: "Clés API et documentation de l'API",
|
||||
api_keys_api_docs: 'URL du nœud, clés API et documentation API',
|
||||
lnbits_version: 'Version de LNbits',
|
||||
runs_on: 'Fonctionne sur',
|
||||
credit_hint: 'Appuyez sur Entrée pour créditer le compte',
|
||||
@@ -102,6 +103,7 @@ window.localisation.fr = {
|
||||
"Il s'agit d'un code QR LNURL-withdraw pour tout aspirer de ce portefeuille. Ne le partagez avec personne. Il est compatible avec balanceCheck et balanceNotify, de sorte que votre portefeuille peut continuer à retirer les fonds continuellement à partir d'ici après le premier retrait.",
|
||||
i_understand: "J'ai compris",
|
||||
copy_wallet_url: "Copier l'URL du portefeuille",
|
||||
disclaimer_dialog_title: 'Important !',
|
||||
disclaimer_dialog:
|
||||
"La fonctionnalité de connexion sera publiée dans une future mise à jour, pour l'instant, assurez-vous de mettre cette page en favori pour accéder à votre portefeuille ultérieurement ! Ce service est en BETA, et nous ne sommes pas responsables des personnes qui perdent l'accès à leurs fonds.",
|
||||
no_transactions: 'Aucune transaction effectuée pour le moment',
|
||||
@@ -195,6 +197,13 @@ window.localisation.fr = {
|
||||
"Autoriser l'accès par IP (cela passera outre les IP bloquées)",
|
||||
enter_ip: "Entrez l'adresse IP et appuyez sur Entrée",
|
||||
rate_limiter: 'Limiteur de débit',
|
||||
wallet_limiter: 'Limiteur de portefeuille',
|
||||
wallet_limit_max_withdraw_per_day:
|
||||
'Retrait quotidien maximum du portefeuille en sats (0 pour désactiver)',
|
||||
wallet_max_ballance:
|
||||
'Solde maximum du portefeuille en sats (0 pour désactiver)',
|
||||
wallet_limit_secs_between_trans:
|
||||
'Minutes et secondes entre les transactions par portefeuille (0 pour désactiver)',
|
||||
number_of_requests: 'Nombre de requêtes',
|
||||
time_unit: 'Unité de temps',
|
||||
minute: 'minute',
|
||||
@@ -213,6 +222,7 @@ window.localisation.fr = {
|
||||
account_settings: 'Paramètres du compte',
|
||||
signin_with_google: 'Connectez-vous avec Google',
|
||||
signin_with_github: 'Connectez-vous avec GitHub',
|
||||
signin_with_keycloak: 'Connectez-vous avec Keycloak',
|
||||
username_or_email: "Nom d'utilisateur ou e-mail",
|
||||
password: 'Mot de passe',
|
||||
password_config: 'Configuration du mot de passe',
|
||||
@@ -235,5 +245,16 @@ window.localisation.fr = {
|
||||
auth_provider: "Fournisseur d'authentification",
|
||||
my_account: 'Mon compte',
|
||||
back: 'Retour',
|
||||
logout: 'Déconnexion'
|
||||
logout: 'Déconnexion',
|
||||
look_and_feel: 'Apparence',
|
||||
language: 'Langue',
|
||||
color_scheme: 'Schéma de couleurs',
|
||||
extension_cost:
|
||||
'Cette version nécessite un paiement minimum de %{cost} sats.',
|
||||
extension_paid_sats: 'Vous avez déjà payé %{paid_sats} sats.',
|
||||
release_details_error: "Impossible d'obtenir les détails de la version.",
|
||||
pay_from_wallet: 'Payer depuis le portefeuille',
|
||||
show_qr: 'Afficher le QR',
|
||||
retry_install: "Réessayer l'installation",
|
||||
new_payment: 'Effectuer un nouveau paiement'
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ window.localisation.it = {
|
||||
paste_invoice_label:
|
||||
'Incolla una fattura, una richiesta di pagamento o un codice lnurl *',
|
||||
lnbits_description:
|
||||
"Leggero e facile da configurare, LNbits può funzionare su qualsiasi fonte di finanziamento lightning-network, attualmente supporta LND, Core Lightning, OpenNode, Alby, LNPay e persino LNbits stesso! Potete gestire LNbits per conto vostro o offrire facilmente una soluzione di custodia per altri. Ogni portafoglio ha le proprie chiavi API e non c'è limite al numero di portafogli che si possono creare. La possibilità di suddividere i fondi rende LNbits uno strumento utile per la gestione del denaro e come strumento di sviluppo. Le estensioni aggiungono ulteriori funzionalità a LNbits, consentendo di sperimentare una serie di tecnologie all'avanguardia sulla rete Lightning. Abbiamo reso lo sviluppo delle estensioni il più semplice possibile e, in quanto progetto libero e open-source, incoraggiamo le persone a sviluppare e inviare le proprie",
|
||||
"Leggero e facile da configurare, LNbits può funzionare su qualsiasi fonte di finanziamento Lightning Network e persino LNbits stesso! Potete gestire LNbits per conto vostro o offrire facilmente una soluzione di custodia per altri. Ogni portafoglio ha le proprie chiavi API e non c'è limite al numero di portafogli che si possono creare. La possibilità di suddividere i fondi rende LNbits uno strumento utile per la gestione del denaro e come strumento di sviluppo. Le estensioni aggiungono ulteriori funzionalità a LNbits, consentendo di sperimentare una serie di tecnologie all'avanguardia sulla rete Lightning. Abbiamo reso lo sviluppo delle estensioni il più semplice possibile e, in quanto progetto libero e open-source, incoraggiamo le persone a sviluppare e inviare le proprie",
|
||||
export_to_phone: 'Esportazione su telefono con codice QR',
|
||||
export_to_phone_desc:
|
||||
"Questo codice QR contiene l'URL del portafoglio con accesso da amministratore. È possibile scansionarlo dal telefono per aprire il portafoglio da lì.",
|
||||
@@ -62,9 +62,10 @@ window.localisation.it = {
|
||||
service_fee_tooltip:
|
||||
"Commissione di servizio addebitata dall'amministratore del server LNbits per ogni transazione in uscita",
|
||||
toggle_darkmode: 'Attiva la modalità notturna',
|
||||
payment_reactions: 'Reazioni al Pagamento',
|
||||
view_swagger_docs: "Visualizza i documentazione dell'API Swagger di LNbits",
|
||||
api_docs: "Documentazione dell'API",
|
||||
api_keys_api_docs: "Chiavi API e documentazione dell'API",
|
||||
api_keys_api_docs: 'URL del nodo, chiavi API e documentazione API',
|
||||
lnbits_version: 'Versione di LNbits',
|
||||
runs_on: 'Esegue su',
|
||||
credit_hint: 'Premere Invio per accreditare i fondi',
|
||||
@@ -99,6 +100,7 @@ window.localisation.it = {
|
||||
'Questo è un codice QR <code>LNURL-withdraw</code> per prelevare tutti i fondi da questo portafoglio. Non condividerlo con nessuno. È compatibile con <code>balanceCheck</code> e <code>balanceNotify</code>, di conseguenza il vostro portafoglio può continuare a prelevare continuamente i fondi da qui dopo il primo prelievo',
|
||||
i_understand: 'Ho capito',
|
||||
copy_wallet_url: 'Copia URL portafoglio',
|
||||
disclaimer_dialog_title: 'Importante!',
|
||||
disclaimer_dialog:
|
||||
"La funzionalità di login sarà rilasciata in un futuro aggiornamento; per ora, assicuratevi di salvare tra i preferiti questa pagina per accedere nuovamente in futuro a questo portafoglio! Questo servizio è in fase BETA e non ci assumiamo alcuna responsabilità per la perdita all'accesso dei fondi",
|
||||
no_transactions: 'Nessuna transazione effettuata',
|
||||
@@ -191,6 +193,13 @@ window.localisation.it = {
|
||||
"Consenti l'accesso per IP (sovrascriverà gli IP bloccati)",
|
||||
enter_ip: "Inserisci l'IP e premi invio",
|
||||
rate_limiter: 'Limitatore di frequenza',
|
||||
wallet_limiter: 'Limitatore del Portafoglio',
|
||||
wallet_limit_max_withdraw_per_day:
|
||||
'Prelievo massimo giornaliero dal portafoglio in sats (0 per disabilitare)',
|
||||
wallet_max_ballance:
|
||||
'Saldo massimo del portafoglio in sats (0 per disabilitare)',
|
||||
wallet_limit_secs_between_trans:
|
||||
'Minuti e secondi tra transazioni per portafoglio (0 per disabilitare)',
|
||||
number_of_requests: 'Numero di richieste',
|
||||
time_unit: 'Unità di tempo',
|
||||
minute: 'minuto',
|
||||
@@ -210,6 +219,7 @@ window.localisation.it = {
|
||||
account_settings: "Impostazioni dell'account",
|
||||
signin_with_google: 'Accedi con Google',
|
||||
signin_with_github: 'Accedi con GitHub',
|
||||
signin_with_keycloak: 'Accedi con Keycloak',
|
||||
username_or_email: 'Nome utente o Email',
|
||||
password: 'Password',
|
||||
password_config: 'Configurazione della password',
|
||||
@@ -232,5 +242,16 @@ window.localisation.it = {
|
||||
auth_provider: 'Provider di Autenticazione',
|
||||
my_account: 'Il mio account',
|
||||
back: 'Indietro',
|
||||
logout: 'Esci'
|
||||
logout: 'Esci',
|
||||
look_and_feel: 'Aspetto e Comportamento',
|
||||
language: 'Lingua',
|
||||
color_scheme: 'Schema dei colori',
|
||||
extension_cost:
|
||||
'Questa versione richiede un pagamento minimo di %{cost} satoshi.',
|
||||
extension_paid_sats: 'Hai già pagato %{paid_sats} sats.',
|
||||
release_details_error: 'Impossibile ottenere i dettagli della versione.',
|
||||
pay_from_wallet: 'Paga dal Portafoglio',
|
||||
show_qr: 'Mostra QR',
|
||||
retry_install: 'Riprova Installazione',
|
||||
new_payment: 'Effettua Nuovo Pagamento'
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user