Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a8c5e5d685 | ||
|
|
cf4a1daba4 | ||
|
|
1947030a4e | ||
|
|
24fa921258 | ||
|
|
51279d6738 | ||
|
|
24a2719e0e | ||
|
|
e4f6b0a50c | ||
|
|
5ed3e0a175 | ||
|
|
858ff9d186 | ||
|
|
cd7fcd1c71 | ||
|
|
9f60b14745 | ||
|
|
a838777d99 | ||
|
|
356e651675 | ||
|
|
5826e2cc2b | ||
|
|
499cfa002e | ||
|
|
835f56cee6 | ||
|
|
08ea83c28e | ||
|
|
a0646142e4 | ||
|
|
42e1f932ac | ||
|
|
c3169e4dcc | ||
|
|
6c8d56e40c | ||
|
|
8aa1716e32 | ||
|
|
fbd22c1a22 | ||
|
|
e8a6870d7a | ||
|
|
937936ff33 | ||
|
|
ab3eb967b4 | ||
|
|
6341e1edaf | ||
|
|
69d518bac0 | ||
|
|
9db2429a45 | ||
|
|
dd90dec768 | ||
|
|
eb37a064ad | ||
|
|
28df100d9a | ||
|
|
6e6b387b7d | ||
|
|
209cf7fbe0 | ||
|
|
596167f443 | ||
|
|
4732c4b296 | ||
|
|
405a2f0776 | ||
|
|
304ad3035b | ||
|
|
8cffda5a55 | ||
|
|
cbe858b385 | ||
|
|
65ecca2507 | ||
|
|
1900cf9aa4 | ||
|
|
296b1dfa9a | ||
|
|
b14e0e4cc6 | ||
|
|
fb585402dd | ||
|
|
63f246908e | ||
|
|
8ac827f5a4 | ||
|
|
74d4ddd312 | ||
|
|
40ffa7dea0 | ||
|
|
ddb8fcb986 | ||
|
|
27b9e8254c | ||
|
|
daa4b92331 | ||
|
|
0015314e11 | ||
|
|
235f8a6c19 | ||
|
|
bab399f825 | ||
|
|
646a604221 | ||
|
|
7d8fad267a | ||
|
|
ce0aff206d | ||
|
|
80e7b9639d | ||
|
|
94caa2e1ba | ||
|
|
b0a66e8cf5 | ||
|
|
ffba71c0ce | ||
|
|
b41705167f | ||
|
|
0387db3b55 | ||
|
|
38ef1b0061 | ||
|
|
19c231a2f4 | ||
|
|
026c9b5155 | ||
|
|
c834929f8b | ||
|
|
97de4eda18 | ||
|
|
a844711530 | ||
|
|
e2522b358a | ||
|
|
8f761dfd0f | ||
|
|
b14d36a0aa | ||
|
|
dbb689c5c5 | ||
|
|
2167aa398f | ||
|
|
eb8d2f312f | ||
|
|
f9133760fc | ||
|
|
7298c4664b | ||
|
|
eda7e35c61 | ||
|
|
7d1e22c7de | ||
|
|
1bee84d419 | ||
|
|
a00292544f | ||
|
|
fe14c2cd83 | ||
|
|
760f11f1ce | ||
|
|
0e1090b717 | ||
|
|
b2564154cd | ||
|
|
fb17611207 | ||
|
|
7f628948c9 | ||
|
|
cbe0861439 | ||
|
|
e9d6160f4d | ||
|
|
eacdd432b2 | ||
|
|
76e8d72d0d | ||
|
|
dbacf7e8c1 | ||
|
|
b6d99b09cf | ||
|
|
5c21e7f9ed | ||
|
|
14e9c7d9dc | ||
|
|
b3368d89f4 | ||
|
|
83b89851a5 | ||
|
|
2db5a83f4e | ||
|
|
d72cf40439 | ||
|
|
7c68a02eee | ||
|
|
93965bc5b6 | ||
|
|
ae60b4517c | ||
|
|
b15596d045 | ||
|
|
07f0dc80f8 | ||
|
|
5f64c298c9 | ||
|
|
5b056ce07e | ||
|
|
44b458ebb8 | ||
|
|
d4da96597e | ||
|
|
6a0b645316 | ||
|
|
35bb3cc94b | ||
|
|
7a6c3646fb | ||
|
|
b84161c49d | ||
|
|
63ce506d29 | ||
|
|
5114bd4a47 | ||
|
|
05a244d8fd | ||
|
|
019995078c | ||
|
|
f37cb6481c | ||
|
|
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 |
@@ -3,12 +3,14 @@ data
|
||||
docker
|
||||
docs
|
||||
tests
|
||||
node_modules
|
||||
|
||||
lnbits/static/css/*
|
||||
lnbits/static/bundle.js
|
||||
lnbits/static/bundle.css
|
||||
|
||||
*.md
|
||||
!README.md
|
||||
*.log
|
||||
|
||||
.env
|
||||
|
||||
+39
-5
@@ -15,7 +15,7 @@ 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'"
|
||||
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"
|
||||
@@ -28,12 +28,15 @@ PORT=5000
|
||||
######################################
|
||||
|
||||
# which fundingsources are allowed in the admin ui
|
||||
LNBITS_ALLOWED_FUNDING_SOURCES="VoidWallet, FakeWallet, CoreLightningWallet, CoreLightningRestWallet, LndRestWallet, EclairWallet, LndWallet, LnTipsWallet, LNPayWallet, LNbitsWallet, AlbyWallet, ZBDWallet, OpenNodeWallet"
|
||||
# LNBITS_ALLOWED_FUNDING_SOURCES="VoidWallet, FakeWallet, CoreLightningWallet, CoreLightningRestWallet, LndRestWallet, EclairWallet, LndWallet, LnTipsWallet, LNPayWallet, LNbitsWallet, BlinkWallet, AlbyWallet, ZBDWallet, PhoenixdWallet, OpenNodeWallet, NWCWallet, BreezSdkWallet, BoltzWallet"
|
||||
|
||||
LNBITS_BACKEND_WALLET_CLASS=VoidWallet
|
||||
# VoidWallet is just a fallback that works without any actual Lightning capabilities,
|
||||
# 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
|
||||
|
||||
@@ -55,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
|
||||
@@ -84,10 +87,25 @@ LNPAY_WALLET_KEY=LNPAY_ADMIN_KEY
|
||||
ALBY_API_ENDPOINT=https://api.getalby.com/
|
||||
ALBY_ACCESS_TOKEN=ALBY_ACCESS_TOKEN
|
||||
|
||||
# BoltzWallet
|
||||
BOLTZ_CLIENT_ENDPOINT=127.0.0.1:9002
|
||||
BOLTZ_CLIENT_MACAROON="/home/bob/.boltz/macaroon" # or HEXSTRING
|
||||
BOLTZ_CLIENT_CERT="/home/bob/.boltz/tls.cert" # or HEXSTRING
|
||||
BOLTZ_CLIENT_WALLET="lnbits"
|
||||
|
||||
# ZBDWallet
|
||||
ZBD_API_ENDPOINT=https://api.zebedee.io/v0/
|
||||
ZBD_API_KEY=ZBD_ACCESS_TOKEN
|
||||
|
||||
# BlinkWallet
|
||||
BLINK_API_ENDPOINT=https://api.blink.sv/graphql
|
||||
BLINK_WS_ENDPOINT=wss://ws.blink.sv/graphql
|
||||
BLINK_TOKEN=BLINK_TOKEN
|
||||
|
||||
# PhoenixdWallet
|
||||
PHOENIXD_API_ENDPOINT=http://localhost:9740/
|
||||
PHOENIXD_API_PASSWORD=PHOENIXD_KEY
|
||||
|
||||
# OpenNodeWallet
|
||||
OPENNODE_API_ENDPOINT=https://api.opennode.com/
|
||||
OPENNODE_KEY=OPENNODE_ADMIN_KEY
|
||||
@@ -100,11 +118,22 @@ LNBITS_DENOMINATION=sats
|
||||
ECLAIR_URL=http://127.0.0.1:8283
|
||||
ECLAIR_PASS=eclairpw
|
||||
|
||||
# NWCWalllet
|
||||
NWC_PAIRING_URL="nostr+walletconnect://000...000?relay=example.com&secret=123"
|
||||
|
||||
# LnTipsWallet
|
||||
# Enter /api in LightningTipBot to get your key
|
||||
LNTIPS_API_KEY=LNTIPS_ADMIN_KEY
|
||||
LNTIPS_API_ENDPOINT=https://ln.tips
|
||||
|
||||
# BreezSdkWallet
|
||||
BREEZ_API_KEY=KEY
|
||||
BREEZ_GREENLIGHT_SEED=SEED
|
||||
# A Greenlight invite code or Greenlight partner certificate/key can be used
|
||||
BREEZ_GREENLIGHT_INVITE_CODE=CODE
|
||||
BREEZ_GREENLIGHT_DEVICE_KEY="/path/to/breezsdk/device.pem" # or BASE64/HEXSTRING
|
||||
BREEZ_GREENLIGHT_DEVICE_CERT="/path/to/breezsdk/device.crt" # or BASE64/HEXSTRING
|
||||
|
||||
######################################
|
||||
####### Auth Configurations ##########
|
||||
######################################
|
||||
@@ -155,6 +184,8 @@ LNBITS_ADMIN_USERS=""
|
||||
|
||||
# Extensions only admin can access
|
||||
LNBITS_ADMIN_EXTENSIONS="ngrok, admin"
|
||||
# Extensions enabled by default when a user is created
|
||||
LNBITS_USER_DEFAULT_EXTENSIONS="lnurlp"
|
||||
|
||||
# Start LNbits core only. The extensions are not loaded.
|
||||
# LNBITS_EXTENSIONS_DEACTIVATE_ALL=true
|
||||
@@ -175,6 +206,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
|
||||
@@ -192,12 +226,12 @@ LNBITS_HIDE_API=false
|
||||
LNBITS_EXTENSIONS_DEFAULT_INSTALL="tpos"
|
||||
|
||||
# Database: to use SQLite, specify LNBITS_DATA_FOLDER
|
||||
# to use PostgreSQL, specify LNBITS_DATABASE_URL=postgres://...
|
||||
# to use PostgreSQL, specify LNBITS_DATABASE_URL=postgresql+asyncpg://...
|
||||
# to use CockroachDB, specify LNBITS_DATABASE_URL=cockroachdb://...
|
||||
# for both PostgreSQL and CockroachDB, you'll need to install
|
||||
# psycopg2 as an additional dependency
|
||||
LNBITS_DATA_FOLDER="./data"
|
||||
# LNBITS_DATABASE_URL="postgres://user:password@host:port/databasename"
|
||||
# LNBITS_DATABASE_URL="postgresql+asyncpg://user:password@host:port/databasename"
|
||||
|
||||
# the service fee (in percent)
|
||||
LNBITS_SERVICE_FEE=0.0
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
custom: https://legend.lnbits.com/paywall/GAqKguK5S8f6w5VNjS9DfK
|
||||
custom: https://demo.lnbits.com/lnurlp/link/fH59GD
|
||||
|
||||
@@ -46,7 +46,10 @@ runs:
|
||||
|
||||
- name: Install the project dependencies
|
||||
shell: bash
|
||||
run: poetry install
|
||||
run: |
|
||||
poetry install
|
||||
# needed for conv tests
|
||||
poetry add psycopg2-binary
|
||||
|
||||
- name: Use Node.js ${{ inputs.node-version }}
|
||||
if: ${{ (inputs.npm == 'true') }}
|
||||
|
||||
@@ -12,14 +12,43 @@ jobs:
|
||||
lint:
|
||||
uses: ./.github/workflows/lint.yml
|
||||
|
||||
tests:
|
||||
test-api:
|
||||
needs: [ lint ]
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.9", "3.10"]
|
||||
db-url: ["", "postgres://lnbits:lnbits@0.0.0.0:5432/lnbits"]
|
||||
db-url: ["", "postgresql+asyncpg://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 }}
|
||||
|
||||
test-wallets:
|
||||
needs: [ lint ]
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.9", "3.10"]
|
||||
db-url: ["", "postgresql+asyncpg://lnbits:lnbits@0.0.0.0:5432/lnbits"]
|
||||
uses: ./.github/workflows/tests.yml
|
||||
with:
|
||||
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: ["", "postgresql+asyncpg://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:
|
||||
@@ -48,6 +77,7 @@ 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:
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
runs-on: ${{ matrix.os-version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ./.github/actions/prepare
|
||||
- uses: lnbits/lnbits/.github/actions/prepare@dev
|
||||
with:
|
||||
python-version: ${{ inputs.python-version }}
|
||||
node-version: ${{ matrix.node-version }}
|
||||
|
||||
@@ -28,11 +28,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: cachix/install-nix-action@v24
|
||||
- uses: cachix/install-nix-action@v27
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-23.11
|
||||
- uses: cachix/cachix-action@v13
|
||||
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/cachix'
|
||||
nix_path: nixpkgs=channel:nixos-24.05
|
||||
- uses: cachix/cachix-action@v15
|
||||
with:
|
||||
name: lnbits
|
||||
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
||||
|
||||
@@ -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"
|
||||
@@ -26,19 +27,10 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
- name: docker build
|
||||
if: ${{ inputs.backend-wallet-class == 'LNbitsWallet' }}
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build and push
|
||||
if: ${{ inputs.backend-wallet-class == 'LNbitsWallet' }}
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: false
|
||||
tags: lnbits/lnbits:latest
|
||||
cache-from: type=registry,ref=lnbits/lnbits:latest
|
||||
cache-to: type=inline
|
||||
run: |
|
||||
docker build -t lnbits/lnbits .
|
||||
|
||||
- name: Setup Regtest
|
||||
run: |
|
||||
@@ -52,10 +44,6 @@ jobs:
|
||||
with:
|
||||
python-version: ${{ inputs.python-version }}
|
||||
|
||||
- name: Create fake admin
|
||||
if: ${{ inputs.backend-wallet-class == 'LNbitsWallet' }}
|
||||
run: docker exec lnbits-lnbits-1 poetry run python tools/create_fake_admin.py
|
||||
|
||||
- name: Run pytest
|
||||
uses: pavelzw/pytest-action@v2
|
||||
env:
|
||||
@@ -76,15 +64,14 @@ jobs:
|
||||
LNBITS_KEY: "d08a3313322a4514af75d488bcc27eee"
|
||||
ECLAIR_URL: http://127.0.0.1:8082
|
||||
ECLAIR_PASS: lnbits
|
||||
LNBITS_DATA_FOLDER: "./tests/data"
|
||||
PYTHONUNBUFFERED: 1
|
||||
DEBUG: true
|
||||
with:
|
||||
verbose: false
|
||||
verbose: true
|
||||
job-summary: true
|
||||
emoji: false
|
||||
click-to-expand: false
|
||||
custom-pytest: poetry run pytest
|
||||
click-to-expand: true
|
||||
custom-pytest: ${{ inputs.custom-pytest }}
|
||||
report-title: "regtest (${{ inputs.python-version }}, ${{ inputs.backend-wallet-class }}"
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
@@ -93,3 +80,8 @@ jobs:
|
||||
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
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
name: release-rc
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "*-rc[0-9]"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Create github release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
tag: ${{ github.ref_name }}
|
||||
run: |
|
||||
gh release create "$tag" --generate-notes --prerelease
|
||||
|
||||
docker:
|
||||
needs: [ release ]
|
||||
uses: ./.github/workflows/docker.yml
|
||||
with:
|
||||
tag: ${{ github.ref_name }}
|
||||
secrets:
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
||||
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
pypi:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Install dependencies for building secp256k1
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y build-essential automake libtool libffi-dev libgmp-dev
|
||||
- uses: actions/checkout@v4
|
||||
- name: Build and publish to pypi
|
||||
uses: JRubics/poetry-publish@v1.15
|
||||
with:
|
||||
pypi_token: ${{ secrets.PYPI_API_KEY }}
|
||||
@@ -10,6 +10,7 @@ permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -29,3 +30,25 @@ jobs:
|
||||
secrets:
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
||||
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
docker-latest:
|
||||
needs: [ release ]
|
||||
uses: ./.github/workflows/docker.yml
|
||||
with:
|
||||
tag: latest
|
||||
secrets:
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
||||
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
pypi:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Install dependencies for building secp256k1
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y build-essential automake libtool libffi-dev libgmp-dev
|
||||
- uses: actions/checkout@v4
|
||||
- name: Build and publish to pypi
|
||||
uses: JRubics/poetry-publish@v1.15
|
||||
with:
|
||||
pypi_token: ${{ secrets.PYPI_API_KEY }}
|
||||
|
||||
@@ -3,6 +3,10 @@ name: tests
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
custom-pytest:
|
||||
description: "Custom pytest arguments"
|
||||
required: true
|
||||
type: string
|
||||
python-version:
|
||||
default: "3.9"
|
||||
type: string
|
||||
@@ -50,16 +54,14 @@ jobs:
|
||||
env:
|
||||
LNBITS_DATABASE_URL: ${{ inputs.db-url }}
|
||||
LNBITS_BACKEND_WALLET_CLASS: FakeWallet
|
||||
FAKE_WALLET_SECRET: "ToTheMoon1"
|
||||
LNBITS_DATA_FOLDER: "./tests/data"
|
||||
PYTHONUNBUFFERED: 1
|
||||
DEBUG: true
|
||||
with:
|
||||
verbose: false
|
||||
verbose: true
|
||||
job-summary: true
|
||||
emoji: false
|
||||
click-to-expand: false
|
||||
custom-pytest: poetry run pytest
|
||||
click-to-expand: true
|
||||
custom-pytest: ${{ inputs.custom-pytest }}
|
||||
report-title: "test (${{ inputs.python-version }}, ${{ inputs.db-url }})"
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
|
||||
@@ -51,3 +51,6 @@ lnbits-backup.zip
|
||||
# Ignore extensions (post installable extension PR)
|
||||
extensions
|
||||
upgrades/
|
||||
|
||||
# builded python package
|
||||
dist
|
||||
|
||||
@@ -23,7 +23,7 @@ repos:
|
||||
- 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
|
||||
|
||||
+34
-8
@@ -1,4 +1,4 @@
|
||||
FROM python:3.10-slim-bullseye
|
||||
FROM python:3.10-slim-bookworm AS builder
|
||||
|
||||
RUN apt-get clean
|
||||
RUN apt-get update
|
||||
@@ -7,18 +7,44 @@ RUN apt-get install -y curl pkg-config build-essential libnss-myhostname
|
||||
RUN curl -sSL https://install.python-poetry.org | python3 -
|
||||
ENV PATH="/root/.local/bin:$PATH"
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Only copy the files required to install the dependencies
|
||||
COPY pyproject.toml poetry.lock ./
|
||||
|
||||
RUN mkdir data
|
||||
|
||||
ENV POETRY_NO_INTERACTION=1 \
|
||||
POETRY_VIRTUALENVS_IN_PROJECT=1 \
|
||||
POETRY_VIRTUALENVS_CREATE=1 \
|
||||
POETRY_CACHE_DIR=/tmp/poetry_cache
|
||||
|
||||
RUN poetry install --only main
|
||||
|
||||
FROM python:3.10-slim-bookworm
|
||||
|
||||
# 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-get update
|
||||
RUN apt-get install -y postgresql-client-14
|
||||
RUN apt-get update && apt-get -y upgrade && \
|
||||
apt-get -y install gnupg2 curl lsb-release && \
|
||||
sh -c 'echo "deb http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list' && \
|
||||
curl -s https://www.postgresql.org/media/keys/ACCC4CF8.asc | apt-key add - && \
|
||||
apt-get update && \
|
||||
apt-get -y install postgresql-client-14 postgresql-client-common && \
|
||||
apt-get clean all && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN curl -sSL https://install.python-poetry.org | python3 -
|
||||
ENV PATH="/root/.local/bin:$PATH"
|
||||
|
||||
ENV POETRY_NO_INTERACTION=1 \
|
||||
POETRY_VIRTUALENVS_IN_PROJECT=1 \
|
||||
POETRY_VIRTUALENVS_CREATE=1 \
|
||||
VIRTUAL_ENV=/app/.venv \
|
||||
PATH="/app/.venv/bin:$PATH"
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN mkdir data
|
||||
COPY --from=builder /app/.venv .venv
|
||||
|
||||
RUN poetry install --only main
|
||||
|
||||
|
||||
@@ -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,33 +38,46 @@ checkeditorconfig:
|
||||
dev:
|
||||
poetry run lnbits --reload
|
||||
|
||||
test:
|
||||
test-wallets:
|
||||
LNBITS_DATA_FOLDER="./tests/data" \
|
||||
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:
|
||||
test-unit:
|
||||
LNBITS_DATA_FOLDER="./tests/data" \
|
||||
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
DEBUG=true \
|
||||
poetry run pytest tests/unit
|
||||
|
||||
test-api:
|
||||
LNBITS_DATA_FOLDER="./tests/data" \
|
||||
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
DEBUG=true \
|
||||
poetry run pytest tests/api
|
||||
|
||||
test-regtest:
|
||||
LNBITS_DATA_FOLDER="./tests/data" \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
DEBUG=true \
|
||||
poetry run pytest
|
||||
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" \
|
||||
timeout 5s poetry run lnbits --host 0.0.0.0 --port 5002 || code=$?; if [[ $code -ne 124 && $code -ne 0 ]]; then exit $code; fi
|
||||
HOST=0.0.0.0 \
|
||||
PORT=5002 \
|
||||
LNBITS_DATABASE_URL="postgres://lnbits:lnbits@localhost:5432/migration" \
|
||||
LNBITS_DATABASE_URL="postgresql+asyncpg://lnbits:lnbits@localhost:5432/migration" \
|
||||
timeout 5s poetry run lnbits --host 0.0.0.0 --port 5002 || code=$?; if [[ $code -ne 124 && $code -ne 0 ]]; then exit $code; fi
|
||||
LNBITS_DATA_FOLDER="./tests/data" \
|
||||
LNBITS_DATABASE_URL="postgres://lnbits:lnbits@localhost:5432/migration" \
|
||||
LNBITS_DATABASE_URL="postgresql+asyncpg://lnbits:lnbits@localhost:5432/migration" \
|
||||
poetry run python tools/conv.py
|
||||
|
||||
migration:
|
||||
|
||||
@@ -1,22 +1,16 @@
|
||||
|
||||
<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>
|
||||
|
||||
<b>(BETA)</b>
|
||||
|
||||
[![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.
|
||||
|
||||
## Run for yourself, for others, or as part of a stack.
|
||||
## 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 an admin in [https://t.me/lnbits](https://t.me/lnbits)
|
||||
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:
|
||||
|
||||
@@ -36,7 +30,7 @@ LNbits is inspired by all the great work of [opennode.com](https://www.opennode.
|
||||
|
||||
## Running LNbits
|
||||
|
||||
Test on our demo server [legend.lnbits.com](https://legend.lnbits.com), or on [lnbits.com](https://lnbits.com) software as a service, where you can spin up an LNbits instance for 21sats per hr.
|
||||
Test on our demo server [demo.lnbits.com](https://demo.lnbits.com), or on [lnbits.com](https://lnbits.com) software as a service, where you can spin up an LNbits instance for 21sats per hr.
|
||||
|
||||
See the [install guide](https://github.com/lnbits/lnbits/blob/main/docs/guide/installation.md) for details on installation and setup.
|
||||
|
||||
@@ -50,7 +44,7 @@ LNbits is packaged with tools to help manage funds, such as a table of transacti
|
||||
|
||||
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.
|
||||
All non-core features are installed as extensions, reducing your code base and making your LNbits unique to you. Extend your LNbits install in any direction, and even create and share your own extensions.
|
||||
|
||||
<img src="https://i.imgur.com/aEBpwJF.png" style="width:800px">
|
||||
|
||||
@@ -74,7 +68,7 @@ As well as working great in a browser, LNbits has native IoS and Android apps as
|
||||
|
||||
## Tip us
|
||||
|
||||
If you like this project [send some tip love](https://legend.lnbits.com/paywall/GAqKguK5S8f6w5VNjS9DfK)!
|
||||
If you like this project [send some tip love](https://demo.lnbits.com/lnurlp/link/fH59GD)!
|
||||
|
||||
[docs]: https://github.com/lnbits/lnbits/wiki
|
||||
[docs-badge]: https://img.shields.io/badge/docs-lnbits.org-673ab7.svg
|
||||
|
||||
+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)
|
||||
|
||||
+24
-11
@@ -5,30 +5,39 @@ nav_order: 4
|
||||
has_children: true
|
||||
---
|
||||
|
||||
|
||||
For developers
|
||||
==============
|
||||
# For developers
|
||||
|
||||
Thanks for contributing :)
|
||||
|
||||
# Run
|
||||
|
||||
Run
|
||||
=====
|
||||
Follow the [Basic installation: Option 1 (recommended): poetry](https://docs.lnbits.org/guide/installation.html#option-1-recommended-poetry)
|
||||
guide to install poetry and other dependencies.
|
||||
|
||||
Then you can start LNbits uvicorn server with:
|
||||
|
||||
This starts the lnbits uvicorn server
|
||||
```bash
|
||||
poetry run lnbits
|
||||
```
|
||||
|
||||
This starts the lnbits uvicorn with hot reloading.
|
||||
Or you can use the following to start uvicorn with hot reloading enabled:
|
||||
|
||||
```bash
|
||||
make dev
|
||||
# or
|
||||
poetry run lnbits --reload
|
||||
```
|
||||
|
||||
Precommit hooks
|
||||
=====
|
||||
You might need the following extra dependencies on clean installation of Debian:
|
||||
|
||||
```
|
||||
sudo apt install nodejs
|
||||
sudo apt install npm
|
||||
npm install
|
||||
sudo apt-get install autoconf libtool libpg-dev
|
||||
```
|
||||
|
||||
# Precommit hooks
|
||||
|
||||
This ensures that all commits adhere to the formatting and linting rules.
|
||||
|
||||
@@ -36,31 +45,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
|
||||
```
|
||||
|
||||
+19
-14
@@ -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,17 +30,15 @@ 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 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.
|
||||
|
||||
@@ -53,9 +51,7 @@ $ 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` 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:
|
||||
|
||||
|
||||
+14
-12
@@ -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 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:
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
+6
-3
@@ -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>
|
||||
@@ -155,7 +156,6 @@ allow-self-payment=1
|
||||
</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
|
||||
|
||||
+31
-11
@@ -6,9 +6,9 @@ nav_order: 2
|
||||
|
||||
# Basic installation
|
||||
|
||||
You can choose between four package managers, `poetry` and `nix`
|
||||
The following sections explain how to install LNbits using varions package managers: `poetry`, `nix`, `Docker` and `Fly.io`.
|
||||
|
||||
By default, LNbits will use SQLite as its database. You can also use PostgreSQL which is recommended for applications with a high load (see guide below).
|
||||
Note that by default LNbits uses SQLite as its database, which is simple and effective but you can configure it to use PostgreSQL instead which is also described in a section below.
|
||||
|
||||
## Option 1 (recommended): poetry
|
||||
|
||||
@@ -16,6 +16,7 @@ Mininum poetry version has is ^1.2, but it is recommended to use latest poetry.
|
||||
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
|
||||
@@ -25,6 +26,7 @@ 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
|
||||
@@ -42,7 +44,6 @@ git checkout main
|
||||
poetry env use python3.9
|
||||
poetry install --only main
|
||||
|
||||
mkdir data
|
||||
cp .env.example .env
|
||||
# set funding source amongst other options
|
||||
nano .env
|
||||
@@ -56,6 +57,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
|
||||
|
||||
```
|
||||
@@ -97,7 +99,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
|
||||
|
||||
@@ -108,13 +110,16 @@ SUPER_USER=be54db7f245346c8833eaa430e1e0405 LNBITS_ADMIN_UI=true ./result/bin/ln
|
||||
## Option 3: Docker
|
||||
|
||||
use latest version from docker hub
|
||||
|
||||
```sh
|
||||
docker pull lnbits/lnbits
|
||||
wget https://raw.githubusercontent.com/lnbits/lnbits/main/.env.example -O .env
|
||||
mkdir data
|
||||
docker run --detach --publish 5000:5000 --name lnbits --volume ${PWD}/.env:/app/.env --volume ${PWD}/data/:/app/data lnbits/lnbits
|
||||
```
|
||||
|
||||
build the image yourself
|
||||
|
||||
```sh
|
||||
git clone https://github.com/lnbits/lnbits.git
|
||||
cd lnbits
|
||||
@@ -157,7 +162,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>`.
|
||||
|
||||
@@ -254,11 +259,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:
|
||||
@@ -280,7 +284,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:
|
||||
@@ -440,6 +443,15 @@ server {
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_pass_request_headers on;
|
||||
|
||||
# WebSocket support
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "Upgrade";
|
||||
|
||||
listen [::]:443 ssl;
|
||||
listen 443 ssl;
|
||||
ssl_certificate /etc/letsencrypt/live/lnbits.org/fullchain.pem;
|
||||
@@ -457,28 +469,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
|
||||
@@ -490,7 +510,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.
|
||||
@@ -502,7 +521,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
|
||||
@@ -514,6 +533,7 @@ cp <lnbits_repo>/.env.example .env
|
||||
and change the configuration in `.env` as required.
|
||||
|
||||
Then create the data directory
|
||||
|
||||
```
|
||||
mkdir data
|
||||
```
|
||||
@@ -521,7 +541,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.
|
||||
|
||||
+51
-4
@@ -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.
|
||||
|
||||
A backend wallet can be configured using the following LNbits environment variables:
|
||||
|
||||
|
||||
### CoreLightning
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: **CoreLightningWallet**
|
||||
@@ -79,6 +76,15 @@ For the invoice to work you must have a publicly accessible URL in your LNbits.
|
||||
- `OPENNODE_API_ENDPOINT`: https://api.opennode.com/
|
||||
- `OPENNODE_KEY`: opennodeAdminApiKey
|
||||
|
||||
### Blink
|
||||
|
||||
For the invoice to work you must have a publicly accessible URL in your LNbits. No manual webhook setting is necessary. You can generate a Blink API key after logging in or creating a new Blink account at: https://dashboard.blink.sv. For more info visit: https://dev.blink.sv/api/auth#create-an-api-key```
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: **BlinkWallet**
|
||||
- `BLINK_API_ENDPOINT`: https://api.blink.sv/graphql
|
||||
- `BLINK_WS_ENDPOINT`: wss://ws.blink.sv/graphql
|
||||
- `BLINK_TOKEN`: BlinkToken
|
||||
|
||||
### Alby
|
||||
|
||||
For the invoice to work you must have a publicly accessible URL in your LNbits. No manual webhook setting is necessary. You can generate an alby access token here: https://getalby.com/developer/access_tokens/new
|
||||
@@ -87,6 +93,18 @@ For the invoice to work you must have a publicly accessible URL in your LNbits.
|
||||
- `ALBY_API_ENDPOINT`: https://api.getalby.com/
|
||||
- `ALBY_ACCESS_TOKEN`: AlbyAccessToken
|
||||
|
||||
### Boltz
|
||||
|
||||
This funding source connects to a running [boltz-client](https://docs.boltz.exchange/v/boltz-client) and handles all lightning payments through submarine swaps on the liquid network.
|
||||
You can configure the daemon to run in standalone mode by `standalone = True` in the config file or using the cli flag (`boltzd --standalone`).
|
||||
Once running, you can create a liquid wallet using `boltzcli wallet create lnbits lbtc`.
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: **BoltzWallet**
|
||||
- `BOLTZ_CLIENT_ENDPOINT`: 127.0.0.1:9002
|
||||
- `BOLTZ_CLIENT_MACAROON`: /home/bob/.boltz/macaroons/admin.macaroon or Base64/Hex
|
||||
- `BOLTZ_CLIENT_CERT`: /home/bob/.boltz/tls.cert or Base64/Hex
|
||||
- `BOLTZ_CLIENT_WALLET`: lnbits
|
||||
|
||||
### ZBD
|
||||
|
||||
For the invoice to work you must have a publicly accessible URL in your LNbits. No manual webhook setting is necessary. You can generate an ZBD API Key here: https://zbd.dev/docs/dashboard/projects/api
|
||||
@@ -95,6 +113,35 @@ For the invoice to work you must have a publicly accessible URL in your LNbits.
|
||||
- `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
|
||||
|
||||
### Breez SDK
|
||||
|
||||
A Greenlight invite code or Greenlight partner certificate/key can be used to register a new node with Greenlight. If the Greenlight node already exists, neither are required.
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: **BreezSdkWallet**
|
||||
- `BREEZ_API_KEY`: ...
|
||||
- `BREEZ_GREENLIGHT_SEED`: ...
|
||||
- `BREEZ_GREENLIGHT_INVITE_CODE`: ...
|
||||
- `BREEZ_GREENLIGHT_DEVICE_KEY`: /path/to/breezsdk/device.pem or Base64/Hex
|
||||
- `BREEZ_GREENLIGHT_DEVICE_CERT`: /path/to/breezsdk/device.crt or Base64/Hex
|
||||
|
||||
### Cliche Wallet
|
||||
|
||||
- `CLICHE_ENDPOINT`: ws://127.0.0.1:12000
|
||||
|
||||
### Nostr Wallet Connect (NWC)
|
||||
|
||||
To use NWC as funding source in LNbits you'll need a pairing URL (also known as pairing secret) from a NWC service provider. You can find a list of providers [here](https://github.com/getAlby/awesome-nwc?tab=readme-ov-file#nwc-wallets).
|
||||
|
||||
You can configure Nostr Wallet Connect in the admin ui or using the following environment variables:
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: **NWCWallet**
|
||||
- `NWC_PAIRING_URL`: **nostr+walletconnect://...your...pairing...secret...**
|
||||
|
||||
+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...
|
||||
|
||||
Generated
+16
-16
@@ -5,11 +5,11 @@
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1694529238,
|
||||
"narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=",
|
||||
"lastModified": 1710146030,
|
||||
"narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "ff7b65b44d01cf9ba6a71320833626af21126384",
|
||||
"rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -26,11 +26,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1698974481,
|
||||
"narHash": "sha256-yPncV9Ohdz1zPZxYHQf47S8S0VrnhV7nNhCawY46hDA=",
|
||||
"lastModified": 1703863825,
|
||||
"narHash": "sha256-rXwqjtwiGKJheXB43ybM8NwWB8rO2dSRrEqes0S7F5Y=",
|
||||
"owner": "nix-community",
|
||||
"repo": "nix-github-actions",
|
||||
"rev": "4bb5e752616262457bc7ca5882192a564c0472d2",
|
||||
"rev": "5163432afc817cf8bd1f031418d1869e4c9d5547",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -41,16 +41,16 @@
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1702233072,
|
||||
"narHash": "sha256-H5G2wgbim2Ku6G6w+NSaQaauv6B6DlPhY9fMvArKqRo=",
|
||||
"lastModified": 1723938990,
|
||||
"narHash": "sha256-9tUadhnZQbWIiYVXH8ncfGXGvkNq3Hag4RCBEMUk7MI=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "781e2a9797ecf0f146e81425c822dca69fe4a348",
|
||||
"rev": "c42fcfbdfeae23e68fc520f9182dde9f38ad1890",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nixos",
|
||||
"ref": "nixos-23.11",
|
||||
"ref": "nixos-24.05",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
@@ -66,11 +66,11 @@
|
||||
"treefmt-nix": "treefmt-nix"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1702334837,
|
||||
"narHash": "sha256-QZG6+zFshyY+L8m2tlOTm75U5m9y7z01g0josVK+8Os=",
|
||||
"lastModified": 1724134185,
|
||||
"narHash": "sha256-nDqpGjz7cq3ThdC98BPe1ANCNlsJds/LLZ3/MdIXjA0=",
|
||||
"owner": "nix-community",
|
||||
"repo": "poetry2nix",
|
||||
"rev": "1f4bcbf1be73abc232a972a77102a3e820485a99",
|
||||
"rev": "5ee730a8752264e463c0eaf06cc060fd07f6dae9",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -122,11 +122,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1699786194,
|
||||
"narHash": "sha256-3h3EH1FXQkIeAuzaWB+nK0XK54uSD46pp+dMD3gAcB4=",
|
||||
"lastModified": 1719749022,
|
||||
"narHash": "sha256-ddPKHcqaKCIFSFc/cvxS14goUhCOAwsM1PbMr0ZtHMg=",
|
||||
"owner": "numtide",
|
||||
"repo": "treefmt-nix",
|
||||
"rev": "e82f32aa7f06bbbd56d7b12186d555223dc399d1",
|
||||
"rev": "8df5ff62195d4e67e2264df0b7f5e8c9995fd0bd",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
description = "LNbits, free and open-source Lightning wallet and accounts system";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:nixos/nixpkgs/nixos-23.11";
|
||||
nixpkgs.url = "github:nixos/nixpkgs/nixos-24.05";
|
||||
poetry2nix = {
|
||||
url = "github:nix-community/poetry2nix";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
@@ -33,13 +33,6 @@
|
||||
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 ]; }
|
||||
);
|
||||
});
|
||||
};
|
||||
});
|
||||
|
||||
+55
-240
@@ -1,35 +1,32 @@
|
||||
import asyncio
|
||||
import glob
|
||||
import importlib
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import traceback
|
||||
from contextlib import asynccontextmanager
|
||||
from hashlib import sha256
|
||||
from http import HTTPStatus
|
||||
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_dbversions, get_installed_extensions
|
||||
from lnbits.core.crud import (
|
||||
get_dbversions,
|
||||
get_installed_extensions,
|
||||
update_installed_extension_state,
|
||||
)
|
||||
from lnbits.core.helpers import migrate_extension_database
|
||||
from lnbits.core.services import websocketUpdater
|
||||
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,
|
||||
@@ -37,21 +34,24 @@ from lnbits.tasks import (
|
||||
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 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.extension_api import add_installed_extension
|
||||
from .core.views.generic import update_installed_extension_state
|
||||
from .extension_manager import (
|
||||
Extension,
|
||||
InstallableExtension,
|
||||
get_valid_extensions,
|
||||
version_parse,
|
||||
)
|
||||
from .helpers import template_renderer
|
||||
from .middleware import (
|
||||
CustomGZipMiddleware,
|
||||
ExtensionsRedirectMiddleware,
|
||||
@@ -63,12 +63,14 @@ from .middleware import (
|
||||
from .requestvars import g
|
||||
from .tasks import (
|
||||
check_pending_payments,
|
||||
create_task,
|
||||
internal_invoice_listener,
|
||||
invoice_listener,
|
||||
)
|
||||
|
||||
|
||||
async def startup(app: FastAPI):
|
||||
settings.lnbits_running = True
|
||||
|
||||
# wait till migration is done
|
||||
await migrate_databases()
|
||||
@@ -81,7 +83,7 @@ async def startup(app: FastAPI):
|
||||
|
||||
# initialize WALLET
|
||||
try:
|
||||
set_wallet_class()
|
||||
set_funding_source()
|
||||
except Exception as e:
|
||||
logger.error(f"Error initializing {settings.lnbits_backend_wallet_class}: {e}")
|
||||
set_void_wallet_class()
|
||||
@@ -92,26 +94,21 @@ async def startup(app: FastAPI):
|
||||
# register core routes
|
||||
init_core_routers(app)
|
||||
|
||||
# check extensions after restart
|
||||
if not settings.lnbits_extensions_deactivate_all:
|
||||
await check_installed_extensions(app)
|
||||
register_all_ext_routes(app)
|
||||
|
||||
if settings.lnbits_admin_ui:
|
||||
initialize_server_logger()
|
||||
|
||||
# initialize tasks
|
||||
register_async_tasks()
|
||||
register_async_tasks(app)
|
||||
|
||||
|
||||
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)
|
||||
WALLET = get_wallet_class()
|
||||
await WALLET.cleanup()
|
||||
funding_source = get_funding_source()
|
||||
await funding_source.cleanup()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -138,8 +135,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")
|
||||
@@ -177,38 +174,39 @@ def create_app() -> FastAPI:
|
||||
|
||||
|
||||
async def check_funding_source() -> None:
|
||||
funding_source = get_funding_source()
|
||||
|
||||
WALLET = get_wallet_class()
|
||||
|
||||
sleep_time = 5
|
||||
max_retries = 5
|
||||
max_retries = settings.funding_source_max_retries
|
||||
retry_counter = 0
|
||||
|
||||
while True:
|
||||
while settings.lnbits_running:
|
||||
try:
|
||||
logger.info(f"Connecting to backend {WALLET.__class__.__name__}...")
|
||||
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 {WALLET.__class__.__name__} connected "
|
||||
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}")
|
||||
logger.error(
|
||||
f"Error connecting to {funding_source.__class__.__name__}: {e}"
|
||||
)
|
||||
|
||||
if retry_counter == max_retries:
|
||||
if retry_counter >= max_retries:
|
||||
set_void_wallet_class()
|
||||
WALLET = get_wallet_class()
|
||||
funding_source = get_funding_source()
|
||||
break
|
||||
|
||||
retry_counter += 1
|
||||
sleep_time = min(0.25 * (2**retry_counter), 60)
|
||||
logger.warning(
|
||||
f"Retrying connection to backend in {sleep_time} seconds... "
|
||||
f"({retry_counter}/{max_retries})"
|
||||
@@ -221,7 +219,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):
|
||||
@@ -262,10 +260,10 @@ async def build_all_installed_extensions_list(
|
||||
MUST be installed by default (see LNBITS_EXTENSIONS_DEFAULT_INSTALL).
|
||||
"""
|
||||
installed_extensions = await get_installed_extensions()
|
||||
settings.lnbits_all_extensions_ids = {e.id for e in installed_extensions}
|
||||
|
||||
installed_extensions_ids = [e.id for e in installed_extensions]
|
||||
for ext_id in settings.lnbits_extensions_default_install:
|
||||
if ext_id in installed_extensions_ids:
|
||||
if ext_id in settings.lnbits_all_extensions_ids:
|
||||
continue
|
||||
|
||||
ext_releases = await InstallableExtension.get_extension_releases(ext_id)
|
||||
@@ -300,7 +298,7 @@ async def check_installed_extension_files(ext: InstallableExtension) -> bool:
|
||||
|
||||
zip_files = glob.glob(os.path.join(settings.lnbits_data_folder, "zips", "*.zip"))
|
||||
|
||||
if f"./{str(ext.zip_path)}" not in zip_files:
|
||||
if f"./{ext.zip_path!s}" not in zip_files:
|
||||
await ext.download_archive()
|
||||
ext.extract_archive()
|
||||
|
||||
@@ -319,8 +317,7 @@ async def restore_installed_extension(app: FastAPI, ext: InstallableExtension):
|
||||
|
||||
# mount routes for the new version
|
||||
core_app_extra.register_new_ext_routes(extension)
|
||||
if extension.upgrade_hash:
|
||||
ext.notify_upgrade()
|
||||
ext.notify_upgrade(extension.upgrade_hash)
|
||||
|
||||
|
||||
def register_custom_extensions_path():
|
||||
@@ -398,69 +395,28 @@ def register_ext_routes(app: FastAPI, ext: Extension) -> None:
|
||||
app.include_router(router=ext_route, prefix=prefix)
|
||||
|
||||
|
||||
def register_all_ext_routes(app: FastAPI):
|
||||
async def check_and_register_extensions(app: FastAPI):
|
||||
await check_installed_extensions(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)}")
|
||||
except Exception as exc:
|
||||
logger.error(f"Could not load extension `{ext.code}`: {exc!s}")
|
||||
|
||||
|
||||
def initialize_server_logger():
|
||||
super_user_hash = sha256(settings.super_user.encode("utf-8")).hexdigest()
|
||||
def register_async_tasks(app: FastAPI):
|
||||
|
||||
serverlog_queue = asyncio.Queue()
|
||||
# check extensions after restart
|
||||
if not settings.lnbits_extensions_deactivate_all:
|
||||
create_task(check_and_register_extensions(app))
|
||||
|
||||
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():
|
||||
create_permanent_task(check_pending_payments)
|
||||
create_permanent_task(invoice_listener)
|
||||
create_permanent_task(internal_invoice_listener)
|
||||
create_permanent_task(cache.invalidate_forever)
|
||||
|
||||
# core invoice listener
|
||||
invoice_queue = asyncio.Queue(5)
|
||||
invoice_queue: asyncio.Queue = asyncio.Queue(5)
|
||||
register_invoice_listener(invoice_queue, "core")
|
||||
create_permanent_task(lambda: wait_for_paid_invoices(invoice_queue))
|
||||
|
||||
@@ -468,148 +424,7 @@ def register_async_tasks():
|
||||
# create_permanent_task(watchdog_task)
|
||||
create_permanent_task(killswitch_task)
|
||||
|
||||
|
||||
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(
|
||||
request, "error.html", {"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(
|
||||
request,
|
||||
"error.html",
|
||||
{"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")
|
||||
return response
|
||||
|
||||
return template_renderer().TemplateResponse(
|
||||
request,
|
||||
"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
|
||||
|
||||
logging.getLogger("sqlalchemy").handlers = [InterceptHandler()]
|
||||
logging.getLogger("sqlalchemy.engine.base").handlers = [InterceptHandler()]
|
||||
logging.getLogger("sqlalchemy.engine.base").propagate = False
|
||||
logging.getLogger("sqlalchemy.engine.base.Engine").handlers = [InterceptHandler()]
|
||||
logging.getLogger("sqlalchemy.engine.base.Engine").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)
|
||||
|
||||
+24
-73
@@ -12,38 +12,33 @@ from fastapi.exceptions import HTTPException
|
||||
from loguru import logger
|
||||
from packaging import version
|
||||
|
||||
from lnbits.core.models import Payment, User
|
||||
from lnbits.core.services import check_admin_settings
|
||||
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 (
|
||||
from lnbits.core import db as core_db
|
||||
from lnbits.core.crud import (
|
||||
delete_accounts_no_wallets,
|
||||
delete_unused_wallets,
|
||||
delete_wallet_by_id,
|
||||
delete_wallet_payment,
|
||||
get_dbversions,
|
||||
get_inactive_extensions,
|
||||
get_installed_extension,
|
||||
get_installed_extensions,
|
||||
get_payments,
|
||||
remove_deleted_wallets,
|
||||
update_payment_status,
|
||||
)
|
||||
from .core.helpers import migrate_extension_database, run_migration
|
||||
from .db import COCKROACH, POSTGRES, SQLITE
|
||||
from .extension_manager import (
|
||||
from lnbits.core.helpers import migrate_databases
|
||||
from lnbits.core.models import Payment, PaymentState, User
|
||||
from lnbits.core.services import check_admin_settings
|
||||
from lnbits.core.views.extension_api import (
|
||||
api_install_extension,
|
||||
api_uninstall_extension,
|
||||
)
|
||||
from lnbits.extension_manager import (
|
||||
CreateExtension,
|
||||
ExtensionRelease,
|
||||
InstallableExtension,
|
||||
get_valid_extensions,
|
||||
)
|
||||
from lnbits.settings import settings
|
||||
from lnbits.wallets.base import Wallet
|
||||
|
||||
|
||||
def coro(f):
|
||||
@@ -83,7 +78,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()
|
||||
|
||||
|
||||
@@ -123,46 +118,6 @@ def database_migrate():
|
||||
loop.run_until_complete(migrate_databases())
|
||||
|
||||
|
||||
async def db_migrate():
|
||||
asyncio.create_task(migrate_databases())
|
||||
|
||||
|
||||
async def migrate_databases():
|
||||
"""Creates the necessary databases if they don't exist already; or migrates them."""
|
||||
|
||||
async with core_db.connect() as conn:
|
||||
exists = False
|
||||
if conn.type == SQLITE:
|
||||
exists = await conn.fetchone(
|
||||
"SELECT * FROM sqlite_master WHERE type='table' AND name='dbversions'"
|
||||
)
|
||||
elif conn.type in {POSTGRES, COCKROACH}:
|
||||
exists = await conn.fetchone(
|
||||
"SELECT * FROM information_schema.tables WHERE table_schema = 'public'"
|
||||
" AND table_name = 'dbversions'"
|
||||
)
|
||||
|
||||
if not exists:
|
||||
await core_migrations.m000_create_migrations_table(conn)
|
||||
|
||||
current_versions = await get_dbversions(conn)
|
||||
core_version = current_versions.get("core", 0)
|
||||
await run_migration(conn, core_migrations, "core", core_version)
|
||||
|
||||
# here is the first place we can be sure that the
|
||||
# `installed_extensions` table has been created
|
||||
await load_disabled_extension_list()
|
||||
|
||||
for ext in get_valid_extensions(False):
|
||||
current_version = current_versions.get(ext.code, 0)
|
||||
try:
|
||||
await migrate_extension_database(ext, current_version)
|
||||
except Exception as e:
|
||||
logger.exception(f"Error migrating extension {ext.code}: {e}")
|
||||
|
||||
logger.info("✔️ All migrations done.")
|
||||
|
||||
|
||||
@db.command("versions")
|
||||
@coro
|
||||
async def db_versions():
|
||||
@@ -205,7 +160,7 @@ async def database_delete_wallet(wallet: str):
|
||||
@click.option("-c", "--checking-id", required=True, help="Payment checking Id.")
|
||||
@coro
|
||||
async def database_delete_wallet_payment(wallet: str, checking_id: str):
|
||||
"""Mark wallet as deleted"""
|
||||
"""Delete wallet payment"""
|
||||
async with core_db.connect() as conn:
|
||||
await delete_wallet_payment(
|
||||
wallet_id=wallet, checking_id=checking_id, conn=conn
|
||||
@@ -215,10 +170,12 @@ async def database_delete_wallet_payment(wallet: str, checking_id: str):
|
||||
@db.command("mark-payment-pending")
|
||||
@click.option("-c", "--checking-id", required=True, help="Payment checking Id.")
|
||||
@coro
|
||||
async def database_revert_payment(checking_id: str, pending: bool = True):
|
||||
"""Mark wallet as deleted"""
|
||||
async def database_revert_payment(checking_id: str):
|
||||
"""Mark payment as pending"""
|
||||
async with core_db.connect() as conn:
|
||||
await update_payment_status(pending=pending, checking_id=checking_id, conn=conn)
|
||||
await update_payment_status(
|
||||
status=PaymentState.PENDING, checking_id=checking_id, conn=conn
|
||||
)
|
||||
|
||||
|
||||
@db.command("cleanup-accounts")
|
||||
@@ -312,12 +269,6 @@ async def check_invalid_payments(
|
||||
click.echo(" ".join([w, str(data[0]), str(data[1] / 1000).ljust(10)]))
|
||||
|
||||
|
||||
async def load_disabled_extension_list() -> None:
|
||||
"""Update list of extensions that have been explicitly disabled"""
|
||||
inactive_extensions = await get_inactive_extensions()
|
||||
settings.lnbits_deactivated_extensions += inactive_extensions
|
||||
|
||||
|
||||
@extensions.command("list")
|
||||
@coro
|
||||
async def extensions_list():
|
||||
@@ -492,7 +443,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)
|
||||
|
||||
|
||||
@@ -530,7 +481,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)
|
||||
|
||||
|
||||
@@ -582,7 +533,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)
|
||||
|
||||
|
||||
@@ -605,7 +556,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()
|
||||
@@ -693,7 +644,7 @@ async def _can_run_operation(url) -> bool:
|
||||
elif url:
|
||||
click.echo(
|
||||
"The option '--url' has been provided,"
|
||||
+ f" but no server found runnint at '{url}'"
|
||||
f" but no server found running at '{url}'"
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
@@ -7,11 +7,12 @@ from .views.auth_api import auth_router
|
||||
from .views.extension_api import extension_router
|
||||
|
||||
# this compat is needed for usermanager extension
|
||||
from .views.generic import generic_router, update_user_extension
|
||||
from .views.generic import generic_router
|
||||
from .views.node_api import node_router, public_node_router, super_node_router
|
||||
from .views.payment_api import payment_router
|
||||
from .views.public_api import public_router
|
||||
from .views.tinyurl_api import tinyurl_router
|
||||
from .views.user_api import users_router
|
||||
from .views.wallet_api import wallet_router
|
||||
from .views.webpush_api import webpush_router
|
||||
from .views.websocket_api import websocket_router
|
||||
@@ -36,3 +37,7 @@ def init_core_routers(app: FastAPI):
|
||||
app.include_router(websocket_router)
|
||||
app.include_router(tinyurl_router)
|
||||
app.include_router(webpush_router)
|
||||
app.include_router(users_router)
|
||||
|
||||
|
||||
__all__ = ["core_app", "core_app_extra", "db"]
|
||||
|
||||
+424
-391
File diff suppressed because it is too large
Load Diff
+62
-10
@@ -6,23 +6,30 @@ from uuid import UUID
|
||||
import httpx
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.core import migrations as core_migrations
|
||||
from lnbits.core.crud import (
|
||||
get_dbversions,
|
||||
get_installed_extensions,
|
||||
update_migration_version,
|
||||
)
|
||||
from lnbits.core.db import db as core_db
|
||||
from lnbits.db import Connection
|
||||
from lnbits.extension_manager import Extension
|
||||
from lnbits.db import COCKROACH, POSTGRES, SQLITE, Connection
|
||||
from lnbits.extension_manager import (
|
||||
Extension,
|
||||
get_valid_extensions,
|
||||
)
|
||||
from lnbits.settings import settings
|
||||
|
||||
from .crud import update_migration_version
|
||||
|
||||
|
||||
async def migrate_extension_database(ext: Extension, current_version):
|
||||
try:
|
||||
ext_migrations = importlib.import_module(f"{ext.module_name}.migrations")
|
||||
ext_db = importlib.import_module(ext.module_name).db
|
||||
except ImportError as e:
|
||||
logger.error(e)
|
||||
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)
|
||||
@@ -77,7 +84,9 @@ async def _stop_extension_background_work(ext_id) -> bool:
|
||||
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)()
|
||||
stop_fn = getattr(old_module, stop_fn_name)
|
||||
if stop_fn:
|
||||
await stop_fn()
|
||||
|
||||
logger.info(f"Stopped background work for extension '{ext.module_name}'.")
|
||||
except Exception as ex:
|
||||
@@ -113,7 +122,50 @@ 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)
|
||||
|
||||
|
||||
async def load_disabled_extension_list() -> None:
|
||||
"""Update list of extensions that have been explicitly disabled"""
|
||||
inactive_extensions = await get_installed_extensions(active=False)
|
||||
settings.lnbits_deactivated_extensions.update([e.id for e in inactive_extensions])
|
||||
|
||||
|
||||
async def migrate_databases():
|
||||
"""Creates the necessary databases if they don't exist already; or migrates them."""
|
||||
|
||||
async with core_db.connect() as conn:
|
||||
exists = False
|
||||
if conn.type == SQLITE:
|
||||
exists = await conn.fetchone(
|
||||
"SELECT * FROM sqlite_master WHERE type='table' AND name='dbversions'"
|
||||
)
|
||||
elif conn.type in {POSTGRES, COCKROACH}:
|
||||
exists = await conn.fetchone(
|
||||
"SELECT * FROM information_schema.tables WHERE table_schema = 'public'"
|
||||
" AND table_name = 'dbversions'"
|
||||
)
|
||||
|
||||
if not exists:
|
||||
await core_migrations.m000_create_migrations_table(conn)
|
||||
|
||||
current_versions = await get_dbversions(conn)
|
||||
core_version = current_versions.get("core", 0)
|
||||
await run_migration(conn, core_migrations, "core", core_version)
|
||||
|
||||
# here is the first place we can be sure that the
|
||||
# `installed_extensions` table has been created
|
||||
await load_disabled_extension_list()
|
||||
|
||||
# todo: revisit, use installed extensions
|
||||
for ext in get_valid_extensions(False):
|
||||
current_version = current_versions.get(ext.code, 0)
|
||||
try:
|
||||
await migrate_extension_database(ext, current_version)
|
||||
except Exception as e:
|
||||
logger.exception(f"Error migrating extension {ext.code}: {e}")
|
||||
|
||||
logger.info("✔️ All migrations done.")
|
||||
|
||||
+123
-51
@@ -1,4 +1,3 @@
|
||||
import datetime
|
||||
from time import time
|
||||
|
||||
from loguru import logger
|
||||
@@ -102,7 +101,7 @@ async def m002_add_fields_to_apipayments(db):
|
||||
|
||||
import json
|
||||
|
||||
rows = await (await db.execute("SELECT * FROM apipayments")).fetchall()
|
||||
rows = await db.fetchall("SELECT * FROM apipayments")
|
||||
for row in rows:
|
||||
if not row["memo"] or not row["memo"].startswith("#"):
|
||||
continue
|
||||
@@ -113,15 +112,15 @@ async def m002_add_fields_to_apipayments(db):
|
||||
new = row["memo"][len(prefix) :]
|
||||
await db.execute(
|
||||
"""
|
||||
UPDATE apipayments SET extra = ?, memo = ?
|
||||
WHERE checking_id = ? AND memo = ?
|
||||
UPDATE apipayments SET extra = :extra, memo = :memo1
|
||||
WHERE checking_id = :checking_id AND memo = :memo2
|
||||
""",
|
||||
(
|
||||
json.dumps({"tag": ext}),
|
||||
new,
|
||||
row["checking_id"],
|
||||
row["memo"],
|
||||
),
|
||||
{
|
||||
"extra": json.dumps({"tag": ext}),
|
||||
"memo1": new,
|
||||
"checking_id": row["checking_id"],
|
||||
"memo2": row["memo"],
|
||||
},
|
||||
)
|
||||
break
|
||||
except OperationalError:
|
||||
@@ -212,19 +211,17 @@ async def m007_set_invoice_expiries(db):
|
||||
Precomputes invoice expiry for existing pending incoming payments.
|
||||
"""
|
||||
try:
|
||||
rows = await (
|
||||
await db.execute(
|
||||
f"""
|
||||
SELECT bolt11, checking_id
|
||||
FROM apipayments
|
||||
WHERE pending = true
|
||||
AND amount > 0
|
||||
AND bolt11 IS NOT NULL
|
||||
AND expiry IS NULL
|
||||
AND time < {db.timestamp_now}
|
||||
"""
|
||||
)
|
||||
).fetchall()
|
||||
rows = await db.fetchall(
|
||||
f"""
|
||||
SELECT bolt11, checking_id
|
||||
FROM apipayments
|
||||
WHERE pending = true
|
||||
AND amount > 0
|
||||
AND bolt11 IS NOT NULL
|
||||
AND expiry IS NULL
|
||||
AND time < {db.timestamp_now}
|
||||
"""
|
||||
)
|
||||
if len(rows):
|
||||
logger.info(f"Migration: Checking expiry of {len(rows)} invoices")
|
||||
for i, (
|
||||
@@ -236,22 +233,17 @@ async def m007_set_invoice_expiries(db):
|
||||
if invoice.expiry is None:
|
||||
continue
|
||||
|
||||
expiration_date = datetime.datetime.fromtimestamp(
|
||||
invoice.date + invoice.expiry
|
||||
)
|
||||
expiration_date = invoice.date + invoice.expiry
|
||||
logger.info(
|
||||
f"Migration: {i+1}/{len(rows)} setting expiry of invoice"
|
||||
f" {invoice.payment_hash} to {expiration_date}"
|
||||
)
|
||||
await db.execute(
|
||||
"""
|
||||
UPDATE apipayments SET expiry = ?
|
||||
WHERE checking_id = ? AND amount > 0
|
||||
f"""
|
||||
UPDATE apipayments SET expiry = {db.timestamp_placeholder('expiry')}
|
||||
WHERE checking_id = :checking_id AND amount > 0
|
||||
""",
|
||||
(
|
||||
db.datetime_to_timestamp(expiration_date),
|
||||
checking_id,
|
||||
),
|
||||
{"expiry": expiration_date, "checking_id": checking_id},
|
||||
)
|
||||
except Exception:
|
||||
continue
|
||||
@@ -347,17 +339,15 @@ async def m014_set_deleted_wallets(db):
|
||||
Sets deleted column to wallets.
|
||||
"""
|
||||
try:
|
||||
rows = await (
|
||||
await db.execute(
|
||||
"""
|
||||
SELECT *
|
||||
FROM wallets
|
||||
WHERE user LIKE 'del:%'
|
||||
AND adminkey LIKE 'del:%'
|
||||
AND inkey LIKE 'del:%'
|
||||
"""
|
||||
)
|
||||
).fetchall()
|
||||
rows = await db.fetchall(
|
||||
"""
|
||||
SELECT *
|
||||
FROM wallets
|
||||
WHERE user LIKE 'del:%'
|
||||
AND adminkey LIKE 'del:%'
|
||||
AND inkey LIKE 'del:%'
|
||||
"""
|
||||
)
|
||||
|
||||
for row in rows:
|
||||
try:
|
||||
@@ -366,10 +356,16 @@ async def m014_set_deleted_wallets(db):
|
||||
inkey = row[4].split(":")[1]
|
||||
await db.execute(
|
||||
"""
|
||||
UPDATE wallets SET user = ?, adminkey = ?, inkey = ?, deleted = true
|
||||
WHERE id = ?
|
||||
UPDATE wallets SET
|
||||
"user" = :user, adminkey = :adminkey, inkey = :inkey, deleted = true
|
||||
WHERE id = :wallet
|
||||
""",
|
||||
(user, adminkey, inkey, row[0]),
|
||||
{
|
||||
"user": user,
|
||||
"adminkey": adminkey,
|
||||
"inkey": inkey,
|
||||
"wallet": row.get("id"),
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
continue
|
||||
@@ -455,19 +451,95 @@ async def m017_add_timestamp_columns_to_accounts_and_wallets(db):
|
||||
now = int(time())
|
||||
await db.execute(
|
||||
f"""
|
||||
UPDATE wallets SET created_at = {db.timestamp_placeholder}
|
||||
UPDATE wallets SET created_at = {db.timestamp_placeholder('now')}
|
||||
WHERE created_at IS NULL
|
||||
""",
|
||||
(now,),
|
||||
{"now": now},
|
||||
)
|
||||
await db.execute(
|
||||
f"""
|
||||
UPDATE accounts SET created_at = {db.timestamp_placeholder}
|
||||
UPDATE accounts SET created_at = {db.timestamp_placeholder('now')}
|
||||
WHERE created_at IS NULL
|
||||
""",
|
||||
(now,),
|
||||
{"now": now},
|
||||
)
|
||||
|
||||
except OperationalError as exc:
|
||||
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
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
async def m020_add_column_column_to_user_extensions(db):
|
||||
"""
|
||||
Adds extra column to user extensions.
|
||||
"""
|
||||
await db.execute("ALTER TABLE extensions ADD COLUMN extra TEXT")
|
||||
|
||||
|
||||
async def m021_add_success_failed_to_apipayments(db):
|
||||
"""
|
||||
Adds success and failed columns to apipayments.
|
||||
"""
|
||||
await db.execute("ALTER TABLE apipayments ADD COLUMN status TEXT DEFAULT 'pending'")
|
||||
# set all not pending to success true, failed payments were deleted until now
|
||||
await db.execute("UPDATE apipayments SET status = 'success' WHERE NOT pending")
|
||||
|
||||
await db.execute("DROP VIEW balances")
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE VIEW balances AS
|
||||
SELECT apipayments.wallet,
|
||||
SUM(apipayments.amount - ABS(apipayments.fee)) AS balance
|
||||
FROM wallets
|
||||
LEFT JOIN apipayments ON apipayments.wallet = wallets.id
|
||||
WHERE (wallets.deleted = false OR wallets.deleted is NULL)
|
||||
AND (
|
||||
(apipayments.status = 'success' AND apipayments.amount > 0)
|
||||
OR (apipayments.status IN ('success', 'pending') AND apipayments.amount < 0)
|
||||
)
|
||||
GROUP BY apipayments.wallet
|
||||
"""
|
||||
)
|
||||
# TODO: drop column in next release
|
||||
# await db.execute("ALTER TABLE apipayments DROP COLUMN pending")
|
||||
|
||||
+104
-100
@@ -1,3 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import hashlib
|
||||
import hmac
|
||||
@@ -5,20 +7,22 @@ import json
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from sqlite3 import Row
|
||||
from typing import Callable, Dict, List, Optional
|
||||
from typing import Callable, Optional
|
||||
|
||||
from ecdsa import SECP256k1, SigningKey
|
||||
from fastapi import Query
|
||||
from loguru import logger
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, validator
|
||||
|
||||
from lnbits.db import Connection, FilterModel, FromRowModel
|
||||
from lnbits.db import 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 PaymentPendingStatus, PaymentStatus
|
||||
from lnbits.utils.exchange_rates import allowed_currencies
|
||||
from lnbits.wallets import get_funding_source
|
||||
from lnbits.wallets.base import (
|
||||
PaymentPendingStatus,
|
||||
PaymentStatus,
|
||||
)
|
||||
|
||||
|
||||
class BaseWallet(BaseModel):
|
||||
@@ -62,13 +66,13 @@ class Wallet(BaseWallet):
|
||||
linking_key, curve=SECP256k1, hashfunc=hashlib.sha256
|
||||
)
|
||||
|
||||
async def get_payment(self, payment_hash: str) -> Optional["Payment"]:
|
||||
async def get_payment(self, payment_hash: str) -> Optional[Payment]:
|
||||
from .crud import get_standalone_payment
|
||||
|
||||
return await get_standalone_payment(payment_hash)
|
||||
|
||||
|
||||
class WalletType(Enum):
|
||||
class KeyType(Enum):
|
||||
admin = 0
|
||||
invoice = 1
|
||||
invalid = 2
|
||||
@@ -80,7 +84,7 @@ class WalletType(Enum):
|
||||
|
||||
@dataclass
|
||||
class WalletTypeInfo:
|
||||
wallet_type: WalletType
|
||||
key_type: KeyType
|
||||
wallet: Wallet
|
||||
|
||||
|
||||
@@ -97,12 +101,43 @@ 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
|
||||
username: Optional[str] = None
|
||||
extensions: List[str] = []
|
||||
wallets: List[Wallet] = []
|
||||
extensions: list[str] = []
|
||||
wallets: list[Wallet] = []
|
||||
admin: bool = False
|
||||
super_user: bool = False
|
||||
has_password: bool = False
|
||||
@@ -111,10 +146,10 @@ class User(BaseModel):
|
||||
updated_at: Optional[int] = None
|
||||
|
||||
@property
|
||||
def wallet_ids(self) -> List[str]:
|
||||
def wallet_ids(self) -> list[str]:
|
||||
return [wallet.id for wallet in self.wallets]
|
||||
|
||||
def get_wallet(self, wallet_id: str) -> Optional["Wallet"]:
|
||||
def get_wallet(self, wallet_id: str) -> Optional[Wallet]:
|
||||
w = [wallet for wallet in self.wallets if wallet.id == wallet_id]
|
||||
return w[0] if w else None
|
||||
|
||||
@@ -166,9 +201,20 @@ class LoginUsernamePassword(BaseModel):
|
||||
password: str
|
||||
|
||||
|
||||
class PaymentState(str, Enum):
|
||||
PENDING = "pending"
|
||||
SUCCESS = "success"
|
||||
FAILED = "failed"
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.value
|
||||
|
||||
|
||||
class Payment(FromRowModel):
|
||||
checking_id: str
|
||||
status: str
|
||||
# TODO should be removed in the future, backward compatibility
|
||||
pending: bool
|
||||
checking_id: str
|
||||
amount: int
|
||||
fee: int
|
||||
memo: Optional[str]
|
||||
@@ -177,20 +223,30 @@ class Payment(FromRowModel):
|
||||
preimage: str
|
||||
payment_hash: str
|
||||
expiry: Optional[float]
|
||||
extra: Dict = {}
|
||||
extra: dict = {}
|
||||
wallet_id: str
|
||||
webhook: Optional[str]
|
||||
webhook_status: Optional[int]
|
||||
|
||||
@property
|
||||
def success(self) -> bool:
|
||||
return self.status == PaymentState.SUCCESS.value
|
||||
|
||||
@property
|
||||
def failed(self) -> bool:
|
||||
return self.status == PaymentState.FAILED.value
|
||||
|
||||
@classmethod
|
||||
def from_row(cls, row: Row):
|
||||
def from_row(cls, row: dict):
|
||||
return cls(
|
||||
checking_id=row["checking_id"],
|
||||
payment_hash=row["hash"] or "0" * 64,
|
||||
bolt11=row["bolt11"] or "",
|
||||
preimage=row["preimage"] or "0" * 64,
|
||||
extra=json.loads(row["extra"] or "{}"),
|
||||
pending=row["pending"],
|
||||
status=row["status"],
|
||||
# TODO should be removed in the future, backward compatibility
|
||||
pending=row["status"] == PaymentState.PENDING.value,
|
||||
amount=row["amount"],
|
||||
fee=row["fee"],
|
||||
memo=row["memo"],
|
||||
@@ -231,80 +287,16 @@ class Payment(FromRowModel):
|
||||
def is_uncheckable(self) -> bool:
|
||||
return self.checking_id.startswith("internal_")
|
||||
|
||||
async def update_status(
|
||||
self,
|
||||
status: PaymentStatus,
|
||||
conn: Optional[Connection] = None,
|
||||
) -> None:
|
||||
from .crud import update_payment_details
|
||||
|
||||
await update_payment_details(
|
||||
checking_id=self.checking_id,
|
||||
pending=status.pending,
|
||||
fee=status.fee_msat,
|
||||
preimage=status.preimage,
|
||||
conn=conn,
|
||||
)
|
||||
|
||||
async def set_pending(self, pending: bool) -> None:
|
||||
from .crud import update_payment_status
|
||||
|
||||
self.pending = pending
|
||||
|
||||
await update_payment_status(self.checking_id, pending)
|
||||
|
||||
async def check_status(
|
||||
self,
|
||||
conn: Optional[Connection] = None,
|
||||
) -> PaymentStatus:
|
||||
async def check_status(self) -> PaymentStatus:
|
||||
if self.is_uncheckable:
|
||||
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)
|
||||
|
||||
logger.debug(f"Status: {status}")
|
||||
|
||||
if self.is_in and status.pending and self.is_expired and self.expiry:
|
||||
expiration_date = datetime.datetime.fromtimestamp(self.expiry)
|
||||
logger.debug(
|
||||
f"Deleting expired incoming pending payment {self.checking_id}: "
|
||||
f"expired {expiration_date}"
|
||||
)
|
||||
await self.delete(conn)
|
||||
# wait at least 15 minutes before deleting failed outgoing payments
|
||||
elif self.is_out and status.failed:
|
||||
if self.time + 900 < int(time.time()):
|
||||
logger.warning(
|
||||
f"Deleting outgoing failed payment {self.checking_id}: {status}"
|
||||
)
|
||||
await self.delete(conn)
|
||||
else:
|
||||
logger.warning(
|
||||
f"Tried to delete outgoing payment {self.checking_id}: "
|
||||
"skipping because it's not old enough"
|
||||
)
|
||||
elif not status.pending:
|
||||
logger.info(
|
||||
f"Marking '{'in' if self.is_in else 'out'}' "
|
||||
f"{self.checking_id} as not pending anymore: {status}"
|
||||
)
|
||||
await self.update_status(status, conn=conn)
|
||||
status = await funding_source.get_invoice_status(self.checking_id)
|
||||
return status
|
||||
|
||||
async def delete(self, conn: Optional[Connection] = None) -> None:
|
||||
from .crud import delete_wallet_payment
|
||||
|
||||
await delete_wallet_payment(self.checking_id, self.wallet_id, conn=conn)
|
||||
|
||||
|
||||
class PaymentFilters(FilterModel):
|
||||
__search_fields__ = ["memo", "amount"]
|
||||
@@ -318,7 +310,7 @@ class PaymentFilters(FilterModel):
|
||||
preimage: str
|
||||
payment_hash: str
|
||||
expiry: Optional[datetime.datetime]
|
||||
extra: Dict = {}
|
||||
extra: dict = {}
|
||||
wallet_id: str
|
||||
webhook: Optional[str]
|
||||
webhook_status: Optional[int]
|
||||
@@ -331,16 +323,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
|
||||
|
||||
@@ -358,7 +340,7 @@ class TinyURL(BaseModel):
|
||||
time: float
|
||||
|
||||
@classmethod
|
||||
def from_row(cls, row: Row):
|
||||
def from_row(cls, row: dict):
|
||||
return cls(**dict(row))
|
||||
|
||||
|
||||
@@ -374,6 +356,7 @@ class Callback(BaseModel):
|
||||
|
||||
class DecodePayment(BaseModel):
|
||||
data: str
|
||||
filter_fields: Optional[list[str]] = []
|
||||
|
||||
|
||||
class CreateLnurl(BaseModel):
|
||||
@@ -394,11 +377,18 @@ 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
|
||||
|
||||
@validator("unit")
|
||||
@classmethod
|
||||
def unit_is_from_allowed_currencies(cls, v):
|
||||
if v != "sat" and v not in allowed_currencies():
|
||||
raise ValueError("The provided unit is not supported")
|
||||
|
||||
return v
|
||||
|
||||
|
||||
class CreateTopup(BaseModel):
|
||||
@@ -424,3 +414,17 @@ 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
|
||||
|
||||
|
||||
class SimpleStatus(BaseModel):
|
||||
success: bool
|
||||
message: str
|
||||
|
||||
+222
-100
@@ -6,19 +6,26 @@ from io import BytesIO
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Tuple, TypedDict
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import httpx
|
||||
from bolt11 import Bolt11
|
||||
from bolt11 import MilliSatoshi
|
||||
from bolt11 import decode as bolt11_decode
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from fastapi import Depends, WebSocket
|
||||
from loguru import logger
|
||||
from passlib.context import CryptContext
|
||||
from py_vapid import Vapid
|
||||
from py_vapid.utils import b64urlencode
|
||||
|
||||
from lnbits.core.db import db
|
||||
from lnbits.db import Connection
|
||||
from lnbits.decorators import WalletTypeInfo, require_admin_key
|
||||
from lnbits.decorators import (
|
||||
WalletTypeInfo,
|
||||
check_user_extension_access,
|
||||
require_admin_key,
|
||||
)
|
||||
from lnbits.exceptions import InvoiceError, PaymentError
|
||||
from lnbits.helpers import url_for
|
||||
from lnbits.lnurl import LnurlErrorResponse
|
||||
from lnbits.lnurl import decode as decode_lnurl
|
||||
@@ -30,7 +37,7 @@ 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 import fake_wallet, get_funding_source, set_funding_source
|
||||
from lnbits.wallets.base import (
|
||||
PaymentPendingStatus,
|
||||
PaymentResponse,
|
||||
@@ -45,8 +52,9 @@ from .crud import (
|
||||
create_admin_settings,
|
||||
create_payment,
|
||||
create_wallet,
|
||||
delete_wallet_payment,
|
||||
get_account,
|
||||
get_account_by_email,
|
||||
get_account_by_username,
|
||||
get_payments,
|
||||
get_standalone_payment,
|
||||
get_super_settings,
|
||||
@@ -57,17 +65,10 @@ from .crud import (
|
||||
update_payment_details,
|
||||
update_payment_status,
|
||||
update_super_user,
|
||||
update_user_extension,
|
||||
)
|
||||
from .helpers import to_valid_user_id
|
||||
from .models import Payment, UserConfig, Wallet
|
||||
|
||||
|
||||
class PaymentFailure(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class InvoiceFailure(Exception):
|
||||
pass
|
||||
from .models import BalanceDelta, Payment, PaymentState, User, UserConfig, Wallet
|
||||
|
||||
|
||||
async def calculate_fiat_amounts(
|
||||
@@ -123,16 +124,16 @@ 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")
|
||||
|
||||
user_wallet = await get_wallet(wallet_id, conn=conn)
|
||||
if not user_wallet:
|
||||
raise InvoiceFailure(f"Could not fetch wallet '{wallet_id}'.")
|
||||
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
|
||||
@@ -141,12 +142,18 @@ async def create_invoice(
|
||||
if settings.is_wallet_max_balance_exceeded(
|
||||
user_wallet.balance_msat / 1000 + amount_sat
|
||||
):
|
||||
raise InvoiceFailure(
|
||||
f"Wallet balance cannot exceed "
|
||||
f"{settings.lnbits_wallet_limit_max_balance} sats."
|
||||
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 wallet.create_invoice(
|
||||
(
|
||||
ok,
|
||||
checking_id,
|
||||
payment_request,
|
||||
error_message,
|
||||
) = await funding_source.create_invoice(
|
||||
amount=amount_sat,
|
||||
memo=invoice_memo,
|
||||
description_hash=description_hash,
|
||||
@@ -154,7 +161,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)
|
||||
|
||||
@@ -165,7 +174,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,
|
||||
@@ -196,13 +205,13 @@ async def pay_invoice(
|
||||
"""
|
||||
try:
|
||||
invoice = bolt11_decode(payment_request)
|
||||
except Exception:
|
||||
raise InvoiceFailure("Bolt11 decoding failed.")
|
||||
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 InvoiceFailure("Amountless invoices not supported.")
|
||||
raise PaymentError("Amountless invoices not supported.", status="failed")
|
||||
if max_sat and invoice.amount_msat > max_sat * 1000:
|
||||
raise InvoiceFailure("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)
|
||||
|
||||
@@ -229,7 +238,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,
|
||||
)
|
||||
@@ -237,7 +246,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)
|
||||
@@ -256,50 +265,33 @@ 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
|
||||
new_payment = await create_payment(
|
||||
checking_id=internal_id,
|
||||
fee=0 + abs(fee_reserve_total_msat),
|
||||
pending=False,
|
||||
status=PaymentState.SUCCESS,
|
||||
conn=conn,
|
||||
**payment_kwargs,
|
||||
)
|
||||
else:
|
||||
fee_reserve_total_msat = fee_reserve_total(
|
||||
invoice.amount_msat, internal=False
|
||||
new_payment = await _create_external_payment(
|
||||
temp_id, invoice.amount_msat, conn=conn, **payment_kwargs
|
||||
)
|
||||
logger.debug(f"creating temporary payment with id {temp_id}")
|
||||
# create a temporary payment here so we can check if
|
||||
# the balance is enough in the next step
|
||||
try:
|
||||
new_payment = await create_payment(
|
||||
checking_id=temp_id,
|
||||
fee=-abs(fee_reserve_total_msat),
|
||||
conn=conn,
|
||||
**payment_kwargs,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"could not create temporary payment: {e}")
|
||||
# happens if the same wallet tries to pay an invoice twice
|
||||
raise PaymentFailure("Could not make payment.")
|
||||
|
||||
# do the balance check
|
||||
wallet = await get_wallet(wallet_id, conn=conn)
|
||||
assert wallet, "Wallet for balancecheck could not be fetched"
|
||||
if wallet.balance_msat < 0:
|
||||
logger.debug("balance is too low, deleting temporary payment")
|
||||
if (
|
||||
not internal_checking_id
|
||||
and wallet.balance_msat > -fee_reserve_total_msat
|
||||
):
|
||||
raise PaymentFailure(
|
||||
f"You must reserve at least ({round(fee_reserve_total_msat/1000)}"
|
||||
" sat) to cover potential routing fees."
|
||||
)
|
||||
raise PermissionError("Insufficient balance.")
|
||||
fee_reserve_total_msat = fee_reserve_total(invoice.amount_msat, internal=False)
|
||||
_check_wallet_balance(wallet, fee_reserve_total_msat, internal_checking_id)
|
||||
|
||||
if extra and "tag" in extra:
|
||||
# check if the payment is made for an extension that the user disabled
|
||||
status = await check_user_extension_access(wallet.user, extra["tag"])
|
||||
if not status.success:
|
||||
raise PaymentError(status.message)
|
||||
|
||||
if internal_checking_id:
|
||||
service_fee_msat = service_fee(invoice.amount_msat, internal=True)
|
||||
@@ -309,7 +301,9 @@ async def pay_invoice(
|
||||
# the payer has enough to deduct from
|
||||
async with db.connect() as conn:
|
||||
await update_payment_status(
|
||||
checking_id=internal_checking_id, pending=False, conn=conn
|
||||
checking_id=internal_checking_id,
|
||||
status=PaymentState.SUCCESS,
|
||||
conn=conn,
|
||||
)
|
||||
await send_payment_notification(wallet, new_payment)
|
||||
|
||||
@@ -323,8 +317,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
|
||||
)
|
||||
|
||||
@@ -334,14 +328,18 @@ async def pay_invoice(
|
||||
f" {payment.checking_id})"
|
||||
)
|
||||
|
||||
logger.debug(f"backend: pay_invoice finished {temp_id}")
|
||||
logger.debug(f"backend: pay_invoice finished {temp_id}, {payment}")
|
||||
if payment.checking_id and payment.ok is not False:
|
||||
# payment.ok can be True (paid) or None (pending)!
|
||||
logger.debug(f"updating payment {temp_id}")
|
||||
async with db.connect() as conn:
|
||||
await update_payment_details(
|
||||
checking_id=temp_id,
|
||||
pending=payment.ok is not True,
|
||||
status=(
|
||||
PaymentState.SUCCESS
|
||||
if payment.ok is True
|
||||
else PaymentState.PENDING
|
||||
),
|
||||
fee=-(
|
||||
abs(payment.fee_msat if payment.fee_msat else 0)
|
||||
+ abs(service_fee_msat)
|
||||
@@ -356,16 +354,20 @@ async def pay_invoice(
|
||||
)
|
||||
if wallet and updated:
|
||||
await send_payment_notification(wallet, updated)
|
||||
logger.debug(f"payment successful {payment.checking_id}")
|
||||
logger.success(f"payment successful {payment.checking_id}")
|
||||
elif payment.checking_id is None and payment.ok is False:
|
||||
# payment failed
|
||||
logger.warning("backend sent payment failure")
|
||||
logger.debug(f"payment failed {temp_id}, {payment.error_message}")
|
||||
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(
|
||||
await update_payment_status(
|
||||
checking_id=temp_id,
|
||||
status=PaymentState.FAILED,
|
||||
conn=conn,
|
||||
)
|
||||
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(
|
||||
@@ -383,11 +385,78 @@ async def pay_invoice(
|
||||
checking_id="service_fee" + temp_id,
|
||||
payment_request=payment_request,
|
||||
payment_hash=invoice.payment_hash,
|
||||
pending=False,
|
||||
status=PaymentState.SUCCESS,
|
||||
)
|
||||
return invoice.payment_hash
|
||||
|
||||
|
||||
async def _create_external_payment(
|
||||
temp_id: str,
|
||||
amount_msat: MilliSatoshi,
|
||||
conn: Optional[Connection],
|
||||
**payment_kwargs,
|
||||
) -> Payment:
|
||||
fee_reserve_total_msat = fee_reserve_total(amount_msat, internal=False)
|
||||
|
||||
# check if there is already a payment with the same checking_id
|
||||
old_payment = await get_standalone_payment(temp_id, conn=conn)
|
||||
if old_payment:
|
||||
# fail on pending payments
|
||||
if old_payment.pending:
|
||||
raise PaymentError("Payment is still pending.", status="pending")
|
||||
if old_payment.success:
|
||||
raise PaymentError("Payment already paid.", status="success")
|
||||
if old_payment.failed:
|
||||
status = await old_payment.check_status()
|
||||
if status.success:
|
||||
# payment was successful on the fundingsource
|
||||
await update_payment_status(
|
||||
checking_id=temp_id, status=PaymentState.SUCCESS, conn=conn
|
||||
)
|
||||
raise PaymentError(
|
||||
"Failed payment was already paid on the fundingsource.",
|
||||
status="success",
|
||||
)
|
||||
if status.failed:
|
||||
raise PaymentError(
|
||||
"Payment is failed node, retrying is not possible.", status="failed"
|
||||
)
|
||||
# status.pending fall through and try again
|
||||
return old_payment
|
||||
|
||||
logger.debug(f"creating temporary payment with id {temp_id}")
|
||||
# create a temporary payment here so we can check if
|
||||
# the balance is enough in the next step
|
||||
try:
|
||||
new_payment = await create_payment(
|
||||
checking_id=temp_id,
|
||||
fee=-abs(fee_reserve_total_msat),
|
||||
conn=conn,
|
||||
**payment_kwargs,
|
||||
)
|
||||
return new_payment
|
||||
except Exception as exc:
|
||||
logger.error(f"could not create temporary payment: {exc}")
|
||||
# happens if the same wallet tries to pay an invoice twice
|
||||
raise PaymentError("Could not make payment", status="failed") from exc
|
||||
|
||||
|
||||
def _check_wallet_balance(
|
||||
wallet: Wallet,
|
||||
fee_reserve_total_msat: int,
|
||||
internal_checking_id: Optional[str] = None,
|
||||
):
|
||||
if wallet.balance_msat < 0:
|
||||
logger.debug("balance is too low, deleting temporary payment")
|
||||
if not internal_checking_id and wallet.balance_msat > -fee_reserve_total_msat:
|
||||
raise PaymentError(
|
||||
f"You must reserve at least ({round(fee_reserve_total_msat/1000)}"
|
||||
" sat) to cover potential routing fees.",
|
||||
status="failed",
|
||||
)
|
||||
raise PaymentError("Insufficient balance.", status="failed")
|
||||
|
||||
|
||||
async def check_wallet_limits(wallet_id, conn, amount_msat):
|
||||
await check_time_limit_between_transactions(conn, wallet_id)
|
||||
await check_wallet_daily_withdraw_limit(conn, wallet_id, amount_msat)
|
||||
@@ -408,8 +477,9 @@ async def check_time_limit_between_transactions(conn, wallet_id):
|
||||
if len(payments) == 0:
|
||||
return
|
||||
|
||||
raise ValueError(
|
||||
f"The time limit of {limit} seconds between payments has been reached."
|
||||
raise PaymentError(
|
||||
status="failed",
|
||||
message=f"The time limit of {limit} seconds between payments has been reached.",
|
||||
)
|
||||
|
||||
|
||||
@@ -499,7 +569,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)
|
||||
|
||||
@@ -583,16 +652,15 @@ async def check_transaction_status(
|
||||
)
|
||||
if not payment:
|
||||
return PaymentPendingStatus()
|
||||
if not payment.pending:
|
||||
# note: before, we still checked the status of the payment again
|
||||
|
||||
if payment.status == PaymentState.SUCCESS.value:
|
||||
return PaymentSuccessStatus(fee_msat=payment.fee)
|
||||
|
||||
status: PaymentStatus = await payment.check_status()
|
||||
return status
|
||||
return await payment.check_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
|
||||
@@ -621,8 +689,8 @@ def fee_reserve_total(amount_msat: int, internal: bool = False) -> int:
|
||||
|
||||
|
||||
async def send_payment_notification(wallet: Wallet, payment: Payment):
|
||||
await websocketUpdater(
|
||||
wallet.id,
|
||||
await websocket_updater(
|
||||
wallet.inkey,
|
||||
json.dumps(
|
||||
{
|
||||
"wallet_balance": wallet.balance,
|
||||
@@ -631,6 +699,10 @@ async def send_payment_notification(wallet: Wallet, payment: Payment):
|
||||
),
|
||||
)
|
||||
|
||||
await websocket_updater(
|
||||
payment.payment_hash, json.dumps({"pending": payment.pending})
|
||||
)
|
||||
|
||||
|
||||
async def update_wallet_balance(wallet_id: str, amount: int):
|
||||
payment_hash, _ = await create_invoice(
|
||||
@@ -642,7 +714,9 @@ async def update_wallet_balance(wallet_id: str, amount: int):
|
||||
async with db.connect() as conn:
|
||||
checking_id = await check_internal(payment_hash, conn=conn)
|
||||
assert checking_id, "newly created checking_id cannot be retrieved"
|
||||
await update_payment_status(checking_id=checking_id, pending=False, conn=conn)
|
||||
await update_payment_status(
|
||||
checking_id=checking_id, status=PaymentState.SUCCESS, conn=conn
|
||||
)
|
||||
# notify receiver asynchronously
|
||||
from lnbits.tasks import internal_invoice_queue
|
||||
|
||||
@@ -723,7 +797,7 @@ def update_cached_settings(sets_dict: dict):
|
||||
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:
|
||||
@@ -742,6 +816,41 @@ async def init_admin_settings(super_user: Optional[str] = None) -> SuperSettings
|
||||
return await create_admin_settings(account.id, editable_settings.dict())
|
||||
|
||||
|
||||
async def create_user_account(
|
||||
user_id: Optional[str] = None,
|
||||
email: Optional[str] = None,
|
||||
username: Optional[str] = None,
|
||||
password: Optional[str] = None,
|
||||
wallet_name: Optional[str] = None,
|
||||
user_config: Optional[UserConfig] = None,
|
||||
) -> User:
|
||||
if not settings.new_accounts_allowed:
|
||||
raise ValueError("Account creation is disabled.")
|
||||
if username and await get_account_by_username(username):
|
||||
raise ValueError("Username already exists.")
|
||||
|
||||
if email and await get_account_by_email(email):
|
||||
raise ValueError("Email already exists.")
|
||||
|
||||
if user_id:
|
||||
user_uuid4 = UUID(hex=user_id, version=4)
|
||||
assert user_uuid4.hex == user_id, "User ID is not valid UUID4 hex string"
|
||||
else:
|
||||
user_id = uuid4().hex
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
password = pwd_context.hash(password) if password else None
|
||||
|
||||
account = await create_account(user_id, username, email, password, user_config)
|
||||
wallet = await create_wallet(user_id=account.id, wallet_name=wallet_name)
|
||||
account.wallets = [wallet]
|
||||
|
||||
for ext_id in settings.lnbits_user_default_extensions:
|
||||
await update_user_extension(user_id=account.id, extension=ext_id, active=True)
|
||||
|
||||
return account
|
||||
|
||||
|
||||
class WebsocketConnectionManager:
|
||||
def __init__(self) -> None:
|
||||
self.active_connections: List[WebSocket] = []
|
||||
@@ -760,33 +869,46 @@ 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
|
||||
async def get_balance_delta() -> BalanceDelta:
|
||||
funding_source = get_funding_source()
|
||||
status = await funding_source.status()
|
||||
lnbits_balance = await get_total_balance()
|
||||
return BalanceDelta(
|
||||
lnbits_balance_msats=lnbits_balance,
|
||||
node_balance_msats=status.balance_msat,
|
||||
)
|
||||
|
||||
|
||||
def get_bolt11_expiry(invoice: Bolt11) -> datetime.datetime:
|
||||
if invoice.expiry:
|
||||
return datetime.datetime.fromtimestamp(invoice.date + invoice.expiry)
|
||||
else:
|
||||
# assume maximum bolt11 expiry of 31 days to be on the safe side
|
||||
return datetime.datetime.now() + datetime.timedelta(days=31)
|
||||
async def update_pending_payments(wallet_id: str):
|
||||
pending_payments = await get_payments(
|
||||
wallet_id=wallet_id,
|
||||
pending=True,
|
||||
exclude_uncheckable=True,
|
||||
)
|
||||
for payment in pending_payments:
|
||||
status = await payment.check_status()
|
||||
if status.failed:
|
||||
await update_payment_status(
|
||||
checking_id=payment.checking_id,
|
||||
status=PaymentState.FAILED,
|
||||
)
|
||||
elif status.success:
|
||||
await update_payment_status(
|
||||
checking_id=payment.checking_id,
|
||||
status=PaymentState.SUCCESS,
|
||||
)
|
||||
|
||||
+17
-34
@@ -5,7 +5,6 @@ import httpx
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.core.crud import (
|
||||
get_balance_notify,
|
||||
get_wallet,
|
||||
get_webpush_subscriptions_for_user,
|
||||
mark_webhook_sent,
|
||||
@@ -16,7 +15,7 @@ from lnbits.core.services import (
|
||||
send_payment_notification,
|
||||
switch_to_voidwallet,
|
||||
)
|
||||
from lnbits.settings import get_wallet_class, settings
|
||||
from lnbits.settings import get_funding_source, settings
|
||||
from lnbits.tasks import send_push_notification
|
||||
|
||||
api_invoice_listeners: Dict[str, asyncio.Queue] = {}
|
||||
@@ -27,9 +26,12 @@ async def killswitch_task():
|
||||
killswitch will check lnbits-status repository for a signal from
|
||||
LNbits and will switch to VoidWallet if the killswitch is triggered.
|
||||
"""
|
||||
while True:
|
||||
WALLET = get_wallet_class()
|
||||
if settings.lnbits_killswitch and WALLET.__class__.__name__ != "VoidWallet":
|
||||
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,11 +56,15 @@ 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.
|
||||
"""
|
||||
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}")
|
||||
@@ -70,10 +76,9 @@ async def watchdog_task():
|
||||
|
||||
async def wait_for_paid_invoices(invoice_paid_queue: asyncio.Queue):
|
||||
"""
|
||||
This task dispatches events to all api_invoice_listeners,
|
||||
webhooks, push notifications and balance notifications.
|
||||
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")
|
||||
# dispatch api_invoice_listeners
|
||||
@@ -85,28 +90,6 @@ async def wait_for_paid_invoices(invoice_paid_queue: asyncio.Queue):
|
||||
# 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)
|
||||
r.raise_for_status()
|
||||
await mark_webhook_sent(payment.payment_hash, r.status_code)
|
||||
except httpx.HTTPStatusError as exc:
|
||||
status_code = exc.response.status_code
|
||||
await mark_webhook_sent(payment.payment_hash, status_code)
|
||||
logger.warning(
|
||||
f"balance_notify returned a bad status_code: {status_code} "
|
||||
f"while requesting {exc.request.url!r}."
|
||||
)
|
||||
logger.warning(exc)
|
||||
except httpx.RequestError as exc:
|
||||
await mark_webhook_sent(payment.payment_hash, -1)
|
||||
logger.warning(f"Could not send balance_notify to {url}")
|
||||
logger.warning(exc)
|
||||
|
||||
# dispatch push notification
|
||||
await send_payment_push_notification(payment)
|
||||
|
||||
|
||||
@@ -173,7 +173,7 @@
|
||||
@remove="removeBlockedIPs(blocked_ip)"
|
||||
color="primary"
|
||||
text-color="white"
|
||||
v-text="blocked_ip"
|
||||
:label="blocked_ip"
|
||||
></q-chip>
|
||||
</div>
|
||||
<br />
|
||||
@@ -202,7 +202,7 @@
|
||||
@remove="removeAllowedIPs(allowed_ip)"
|
||||
color="primary"
|
||||
text-color="white"
|
||||
v-text="allowed_ip"
|
||||
:label="allowed_ip"
|
||||
></q-chip>
|
||||
</div>
|
||||
<br />
|
||||
|
||||
@@ -1,24 +1,19 @@
|
||||
<q-tab-panel name="server">
|
||||
<q-card-section class="q-pa-none">
|
||||
<h6 class="q-my-none">Server Management</h6>
|
||||
<br />
|
||||
<div>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<p>Server Info</p>
|
||||
<ul>
|
||||
<li
|
||||
v-if="settings.lnbits_data_folder"
|
||||
v-text="'SQlite: ' + settings.lnbits_data_folder"
|
||||
></li>
|
||||
<li
|
||||
v-if="settings.lnbits_database_url"
|
||||
v-text="'Postgres: ' + settings.lnbits_database_url"
|
||||
></li>
|
||||
</ul>
|
||||
<div class="col-md-6">
|
||||
<p>Base URL</p>
|
||||
<q-input
|
||||
filled
|
||||
v-model.number="formData.lnbits_baseurl"
|
||||
label="Static/Base url for the server"
|
||||
></q-input>
|
||||
<br />
|
||||
</div>
|
||||
</div>
|
||||
<h6 class="q-my-none">Currency Settings</h6>
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 col-md-6">
|
||||
<p>Allowed currencies</p>
|
||||
@@ -45,56 +40,7 @@
|
||||
<br />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 col-md-6">
|
||||
<p>Admin Extensions</p>
|
||||
<q-select
|
||||
filled
|
||||
v-model="formData.lnbits_admin_extensions"
|
||||
multiple
|
||||
hint="Extensions only user with admin privileges can use"
|
||||
label="Admin extensions"
|
||||
:options="g.extensions.map(e => e.code)"
|
||||
></q-select>
|
||||
<br />
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<p>Miscellaneous</p>
|
||||
<q-item tag="label" v-ripple>
|
||||
<q-item-section>
|
||||
<q-item-label>Disable Extensions</q-item-label>
|
||||
<q-item-label caption>Disables all extensions</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section avatar>
|
||||
<q-toggle
|
||||
size="md"
|
||||
v-model="formData.lnbits_extensions_deactivate_all"
|
||||
checked-icon="check"
|
||||
color="green"
|
||||
unchecked-icon="clear"
|
||||
/>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item tag="label" v-ripple>
|
||||
<q-item-section>
|
||||
<q-item-label>Hide API</q-item-label>
|
||||
<q-item-label caption
|
||||
>Hides wallet api, extensions can choose to honor</q-item-label
|
||||
>
|
||||
</q-item-section>
|
||||
<q-item-section avatar>
|
||||
<q-toggle
|
||||
size="md"
|
||||
v-model="formData.lnbits_hide_api"
|
||||
checked-icon="check"
|
||||
color="green"
|
||||
unchecked-icon="clear"
|
||||
/>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<br />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<br />
|
||||
<h6 class="q-my-none">Service Fee</h6>
|
||||
<div class="row q-col-gutter-md">
|
||||
@@ -154,32 +100,94 @@
|
||||
<br />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<q-separator></q-separator>
|
||||
<h6 class="q-my-none">Extensions</h6>
|
||||
<div>
|
||||
<p>Extension Sources</p>
|
||||
<q-input
|
||||
filled
|
||||
v-model="formAddExtensionsManifest"
|
||||
@keydown.enter="addExtensionsManifest"
|
||||
type="text"
|
||||
label="Source URL (only use the official LNbits extension source, and sources you can trust)"
|
||||
hint="Repositories from where the extensions can be downloaded"
|
||||
>
|
||||
<q-btn @click="addExtensionsManifest" dense flat icon="add"></q-btn>
|
||||
</q-input>
|
||||
<div>
|
||||
<q-chip
|
||||
v-for="manifestUrl in formData.lnbits_extensions_manifests"
|
||||
:key="manifestUrl"
|
||||
removable
|
||||
@remove="removeExtensionsManifest(manifestUrl)"
|
||||
color="primary"
|
||||
text-color="white"
|
||||
><span v-text="manifestUrl"></span
|
||||
></q-chip>
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12">
|
||||
<p>Extension Sources</p>
|
||||
<q-input
|
||||
filled
|
||||
v-model="formAddExtensionsManifest"
|
||||
@keydown.enter="addExtensionsManifest"
|
||||
type="text"
|
||||
label="Source URL (only use the official LNbits extension source, and sources you can trust)"
|
||||
hint="Repositories from where the extensions can be downloaded"
|
||||
>
|
||||
<q-btn @click="addExtensionsManifest" dense flat icon="add"></q-btn>
|
||||
</q-input>
|
||||
<div>
|
||||
<q-chip
|
||||
v-for="manifestUrl in formData.lnbits_extensions_manifests"
|
||||
:key="manifestUrl"
|
||||
removable
|
||||
@remove="removeExtensionsManifest(manifestUrl)"
|
||||
color="primary"
|
||||
text-color="white"
|
||||
><span v-text="manifestUrl"></span
|
||||
></q-chip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 col-md-6">
|
||||
<p>Admin Extensions</p>
|
||||
<q-select
|
||||
filled
|
||||
v-model="formData.lnbits_admin_extensions"
|
||||
multiple
|
||||
hint="Extensions only user with admin privileges can use"
|
||||
label="Admin extensions"
|
||||
:options="g.extensions.map(e => e.code)"
|
||||
></q-select>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-6">
|
||||
<p>User Default Extensions</p>
|
||||
<q-select
|
||||
filled
|
||||
v-model="formData.lnbits_user_default_extensions"
|
||||
multiple
|
||||
hint="Extensions that will be enabled by default for the users."
|
||||
label="User extensions"
|
||||
:options="g.extensions.map(e => e.code)"
|
||||
></q-select>
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<p>Miscellaneous</p>
|
||||
<q-item tag="label" v-ripple>
|
||||
<q-item-section>
|
||||
<q-item-label>Disable Extensions</q-item-label>
|
||||
<q-item-label caption>Disables all extensions</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section avatar>
|
||||
<q-toggle
|
||||
size="md"
|
||||
v-model="formData.lnbits_extensions_deactivate_all"
|
||||
checked-icon="check"
|
||||
color="green"
|
||||
unchecked-icon="clear"
|
||||
/>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item tag="label" v-ripple>
|
||||
<q-item-section>
|
||||
<q-item-label>Hide API</q-item-label>
|
||||
<q-item-label caption
|
||||
>Hides wallet api, extensions can choose to honor</q-item-label
|
||||
>
|
||||
</q-item-section>
|
||||
<q-item-section avatar>
|
||||
<q-toggle
|
||||
size="md"
|
||||
v-model="formData.lnbits_hide_api"
|
||||
checked-icon="check"
|
||||
color="green"
|
||||
unchecked-icon="clear"
|
||||
/>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<br />
|
||||
</div>
|
||||
<br />
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -23,8 +23,8 @@
|
||||
@remove="removeAdminUser(user)"
|
||||
color="primary"
|
||||
text-color="white"
|
||||
:label="user"
|
||||
>
|
||||
<span v-text="user"></span>
|
||||
</q-chip>
|
||||
</div>
|
||||
<br />
|
||||
@@ -49,8 +49,8 @@
|
||||
@remove="removeAllowedUser(user)"
|
||||
color="primary"
|
||||
text-color="white"
|
||||
:label="user"
|
||||
>
|
||||
<span v-text="user" />
|
||||
</q-chip>
|
||||
</div>
|
||||
<br />
|
||||
|
||||
@@ -40,17 +40,6 @@
|
||||
/>
|
||||
</q-btn>
|
||||
|
||||
<q-btn
|
||||
v-if="isSuperUser"
|
||||
:label="$t('topup')"
|
||||
color="primary"
|
||||
@click="topUpDialog.show = true"
|
||||
>
|
||||
<q-tooltip>
|
||||
<span v-text="$t('add_funds_tooltip')"></span>
|
||||
</q-tooltip>
|
||||
</q-btn>
|
||||
|
||||
<q-btn :label="$t('download_backup')" flat @click="downloadBackup"></q-btn>
|
||||
|
||||
<q-btn
|
||||
@@ -119,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,10 +5,77 @@
|
||||
:content-inset-level="0.5"
|
||||
>
|
||||
<q-card-section>
|
||||
<strong>Node URL: </strong><em v-text="origin"></em><br />
|
||||
<strong>Wallet ID: </strong><em>{{ wallet.id }}</em><br />
|
||||
<strong>Admin key: </strong><em>{{ wallet.adminkey }}</em><br />
|
||||
<strong>Invoice/read key: </strong><em>{{ wallet.inkey }}</em>
|
||||
<q-list>
|
||||
<q-item dense class="q-pa-none">
|
||||
<q-item-section>
|
||||
<q-item-label>
|
||||
<strong>Node URL: </strong><em v-text="origin"></em>
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item dense class="q-pa-none">
|
||||
<q-item-section>
|
||||
<q-item-label>
|
||||
<strong>Wallet ID: </strong><em>{{ wallet.id }}</em>
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section side>
|
||||
<q-icon
|
||||
name="content_copy"
|
||||
class="cursor-pointer"
|
||||
@click="copyText('{{ wallet.id }}')"
|
||||
></q-icon>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item dense class="q-pa-none">
|
||||
<q-item-section>
|
||||
<q-item-label>
|
||||
<strong>Admin key: </strong
|
||||
><em
|
||||
v-text="adminkeyHidden ? '****************' : `{{ wallet.adminkey }}`"
|
||||
></em>
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section side>
|
||||
<div>
|
||||
<q-icon
|
||||
:name="adminkeyHidden ? 'visibility_off' : 'visibility'"
|
||||
class="cursor-pointer"
|
||||
@click="adminkeyHidden = !adminkeyHidden"
|
||||
></q-icon>
|
||||
<q-icon
|
||||
name="content_copy"
|
||||
class="cursor-pointer q-ml-sm"
|
||||
@click="copyText('{{ wallet.adminkey }}')"
|
||||
></q-icon>
|
||||
</div>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item dense class="q-pa-none">
|
||||
<q-item-section>
|
||||
<q-item-label>
|
||||
<strong>Invoice/read key: </strong
|
||||
><em
|
||||
v-text="inkeyHidden ? '****************' : `{{ wallet.inkey }}`"
|
||||
></em>
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section side>
|
||||
<div>
|
||||
<q-icon
|
||||
:name="inkeyHidden ? 'visibility_off' : 'visibility'"
|
||||
class="cursor-pointer"
|
||||
@click="inkeyHidden = !inkeyHidden"
|
||||
></q-icon>
|
||||
<q-icon
|
||||
name="content_copy"
|
||||
class="cursor-pointer q-ml-sm"
|
||||
@click="copyText('{{ wallet.inkey }}')"
|
||||
></q-icon>
|
||||
</div>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-card-section>
|
||||
<q-expansion-item
|
||||
group="api"
|
||||
@@ -109,18 +176,16 @@
|
||||
><span class="text-light-green">POST</span>
|
||||
/api/v1/payments/decode</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": "<i>{{ wallet.inkey }}</i>"}</code><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||
<code>{"invoice": <string>}</code>
|
||||
<code>{"data": <string>}</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 200 (application/json)
|
||||
</h5>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X POST {{ request.base_url }}api/v1/payments/decode -d
|
||||
'{"data": <bolt11/lnurl, string>}' -H "X-Api-Key:
|
||||
<i>{{ wallet.inkey }}</i>" -H "Content-type: application/json"</code
|
||||
'{"data": <bolt11/lnurl, string>}' -H "Content-type:
|
||||
application/json"</code
|
||||
>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
@@ -373,6 +373,27 @@
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-mb-md">
|
||||
<div class="col-4">
|
||||
<span v-text="$t('gradient_background')"></span>
|
||||
</div>
|
||||
<div class="col-8">
|
||||
<q-btn
|
||||
dense
|
||||
flat
|
||||
round
|
||||
@click="toggleGradient"
|
||||
icon="gradient"
|
||||
size="sm"
|
||||
v-model="gradientChoice"
|
||||
>
|
||||
<q-tooltip
|
||||
><span v-text="$t('toggle_gradient')"></span
|
||||
></q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row q-mb-md">
|
||||
<div class="col-4">
|
||||
<span v-text="$t('toggle_darkmode')"></span>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,12 +8,19 @@
|
||||
></div>
|
||||
<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" v-if="'{{SITE_TITLE}}' == 'LNbits'">
|
||||
<h3
|
||||
class="q-my-none"
|
||||
v-if="'{{LNBITS_SHOW_HOME_PAGE_ELEMENTS}}' == 'True'"
|
||||
>
|
||||
{{SITE_TITLE}}
|
||||
</h3>
|
||||
<h5 class="q-my-md" v-if="'{{SITE_TITLE}}' == 'LNbits'">
|
||||
<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)%}
|
||||
@@ -26,7 +33,7 @@
|
||||
color="primary"
|
||||
@click="processing"
|
||||
type="a"
|
||||
href="{{ url_for('core.lnurlwallet') }}?lightning={{ lnurl }}"
|
||||
href="/lnurlwallet?lightning={{ lnurl }}"
|
||||
v-text="$t('press_to_claim')"
|
||||
class="full-width"
|
||||
></q-btn>
|
||||
@@ -306,7 +313,7 @@
|
||||
</div>
|
||||
|
||||
<div
|
||||
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>
|
||||
@@ -331,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')"
|
||||
@@ -477,6 +484,32 @@
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<a
|
||||
href="https://breez.technology/sdk/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/breez.png') }}' : '{{ static_url_for('static', 'images/breezl.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col q-pl-md">
|
||||
<a
|
||||
href="https://blockstream.com/lightning/greenlight/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/greenlight.png') }}' : '{{ static_url_for('static', 'images/greenlightl.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<a
|
||||
@@ -499,23 +532,62 @@
|
||||
</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">
|
||||
<a
|
||||
href="https://boltz.exchange/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/boltz.svg') }}' : '{{ static_url_for('static', 'images/boltz.svg') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% 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
|
||||
>
|
||||
</div>
|
||||
{% if AD_SPACE %}
|
||||
<div class="row justify-center">
|
||||
<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%" />
|
||||
{% 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 %} {% endif %}
|
||||
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div v-if="'{{SITE_TITLE}}' == 'LNbits'" class="row gt-sm q-mt-xl">
|
||||
{% 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">
|
||||
@@ -371,9 +156,17 @@
|
||||
<p v-text="$t('export_to_phone_desc')"></p>
|
||||
<qrcode
|
||||
:value="'{{request.base_url}}wallet?usr={{user.id}}&wal={{wallet.id}}'"
|
||||
:options="{width:240}"
|
||||
:options="{ width: 256 }"
|
||||
></qrcode>
|
||||
</q-card-section>
|
||||
<q-card-actions class="flex-center q-pb-md">
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
:label="$t('copy_wallet_url')"
|
||||
@click="copyText('{{request.base_url}}wallet?usr={{user.id}}&wal={{wallet.id}}')"
|
||||
></q-btn>
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-separator></q-separator>
|
||||
@@ -459,19 +252,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 +278,10 @@
|
||||
v-else
|
||||
src="{{ AD[2] }}"
|
||||
/>
|
||||
</a> </q-card-section></q-card
|
||||
>{% endfor %} {% endif %}
|
||||
</a> </q-card-section
|
||||
>{% endfor %}
|
||||
</q-card>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -522,13 +319,11 @@
|
||||
<q-input
|
||||
ref="setAmount"
|
||||
filled
|
||||
:pattern="receive.unit === 'sat' ? '\\d*' : '\\d*\\.?\\d*'"
|
||||
inputmode="numeric"
|
||||
dense
|
||||
v-model.number="receive.data.amount"
|
||||
:label="$t('amount') + ' (' + receive.unit + ') *'"
|
||||
:mask="receive.unit != 'sat' ? '#.##' : '#'"
|
||||
fill-mask="0"
|
||||
reverse-fill-mask
|
||||
:step="receive.unit != 'sat' ? '0.01' : '1'"
|
||||
:min="receive.minMax[0]"
|
||||
:max="receive.minMax[1]"
|
||||
:readonly="receive.lnurl && receive.lnurl.fixed"
|
||||
@@ -873,27 +668,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"
|
||||
|
||||
@@ -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,14 +8,11 @@ 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
|
||||
@@ -36,18 +33,7 @@ admin_router = APIRouter(tags=["Admin UI"], prefix="/admin")
|
||||
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(
|
||||
@@ -104,30 +90,6 @@ async def api_restart_server() -> dict[str, str]:
|
||||
return {"status": "Success"}
|
||||
|
||||
|
||||
@admin_router.put(
|
||||
"/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(
|
||||
"/api/v1/backup",
|
||||
status_code=HTTPStatus.OK,
|
||||
|
||||
+20
-15
@@ -25,22 +25,19 @@ from lnbits.core.models import (
|
||||
from lnbits.decorators import (
|
||||
WalletTypeInfo,
|
||||
check_user_exists,
|
||||
get_key_type,
|
||||
require_admin_key,
|
||||
require_invoice_key,
|
||||
)
|
||||
from lnbits.lnurl import decode as lnurl_decode
|
||||
from lnbits.settings import settings
|
||||
from lnbits.utils.exchange_rates import (
|
||||
allowed_currencies,
|
||||
fiat_amount_as_satoshis,
|
||||
get_fiat_rate_satoshis,
|
||||
satoshis_amount_as_fiat,
|
||||
)
|
||||
|
||||
from ..crud import (
|
||||
create_account,
|
||||
create_wallet,
|
||||
)
|
||||
from ..services import perform_lnurlauth
|
||||
from ..services import create_user_account, perform_lnurlauth
|
||||
|
||||
# backwards compatibility for extension
|
||||
# TODO: remove api_payment and pay_invoice imports from extensions
|
||||
@@ -67,19 +64,21 @@ async def api_wallets(user: User = Depends(check_user_exists)) -> List[BaseWalle
|
||||
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)
|
||||
account = await create_user_account(wallet_name=data.name)
|
||||
return account.wallets[0]
|
||||
|
||||
|
||||
@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:
|
||||
@@ -94,7 +93,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}
|
||||
@@ -119,14 +118,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")
|
||||
@@ -185,7 +184,7 @@ 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
|
||||
|
||||
@@ -202,6 +201,12 @@ async def api_perform_lnurlauth(
|
||||
return ""
|
||||
|
||||
|
||||
@api_router.get("/api/v1/rate/{currency}")
|
||||
async def api_check_fiat_rate(currency: str) -> Dict[str, float]:
|
||||
rate = await get_fiat_rate_satoshis(currency)
|
||||
return {"rate": rate}
|
||||
|
||||
|
||||
@api_router.get("/api/v1/currencies")
|
||||
async def api_list_currencies_available() -> List[str]:
|
||||
return allowed_currencies()
|
||||
|
||||
@@ -12,6 +12,7 @@ from starlette.status import (
|
||||
HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
from lnbits.core.services import create_user_account
|
||||
from lnbits.decorators import check_user_exists
|
||||
from lnbits.helpers import (
|
||||
create_access_token,
|
||||
@@ -23,8 +24,6 @@ from lnbits.helpers import (
|
||||
from lnbits.settings import AuthMethods, settings
|
||||
|
||||
from ..crud import (
|
||||
create_account,
|
||||
create_user,
|
||||
get_account,
|
||||
get_account_by_email,
|
||||
get_account_by_username_or_email,
|
||||
@@ -68,11 +67,11 @@ 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("/usr", description="Login via the User ID")
|
||||
@@ -86,11 +85,11 @@ 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("/{provider}", description="SSO Provider")
|
||||
@@ -124,16 +123,16 @@ async def handle_oauth_token(request: Request, provider: str) -> RedirectRespons
|
||||
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,
|
||||
f"Cannot authenticate user with {provider} Auth.",
|
||||
)
|
||||
) from exc
|
||||
|
||||
|
||||
@auth_router.post("/logout")
|
||||
@@ -166,14 +165,18 @@ async def register(data: CreateUser) -> JSONResponse:
|
||||
raise HTTPException(HTTP_400_BAD_REQUEST, "Invalid email.")
|
||||
|
||||
try:
|
||||
user = await create_user(data)
|
||||
user = await create_user_account(
|
||||
email=data.email, username=data.username, password=data.password
|
||||
)
|
||||
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("/password")
|
||||
@@ -189,13 +192,13 @@ 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("/update")
|
||||
@@ -211,11 +214,13 @@ 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("/first_install")
|
||||
@@ -237,13 +242,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):
|
||||
@@ -270,7 +275,7 @@ async def _handle_sso_login(userinfo: OpenID, verified_user_id: Optional[str] =
|
||||
else:
|
||||
if not settings.new_accounts_allowed:
|
||||
raise HTTPException(HTTP_400_BAD_REQUEST, "Account creation is disabled.")
|
||||
user = await create_account(email=email, user_config=user_config)
|
||||
user = await create_user_account(email=email, user_config=user_config)
|
||||
|
||||
if not user:
|
||||
raise HTTPException(HTTP_401_UNAUTHORIZED, "User not found.")
|
||||
@@ -316,16 +321,16 @@ def _new_sso(provider: str) -> Optional[SSOBase]:
|
||||
logger.warning(f"{provider} auth allowed but not configured.")
|
||||
return None
|
||||
|
||||
SSOProviderClass = _find_auth_provider_class(provider)
|
||||
ssoProvider = SSOProviderClass(
|
||||
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(ssoProvider, "discovery_url", discovery_url) != discovery_url
|
||||
and getattr(sso_provider, "discovery_url", discovery_url) != discovery_url
|
||||
):
|
||||
ssoProvider.discovery_url = discovery_url
|
||||
return ssoProvider
|
||||
sso_provider.discovery_url = discovery_url
|
||||
return sso_provider
|
||||
except Exception as e:
|
||||
logger.warning(e)
|
||||
|
||||
@@ -337,9 +342,9 @@ def _find_auth_provider_class(provider: str) -> Callable:
|
||||
for module in sso_modules:
|
||||
try:
|
||||
provider_module = importlib.import_module(f"{module}.{provider}")
|
||||
ProviderClass = getattr(provider_module, f"{provider.title()}SSO")
|
||||
if ProviderClass:
|
||||
return ProviderClass
|
||||
provider_class = getattr(provider_module, f"{provider.title()}SSO")
|
||||
if provider_class:
|
||||
return provider_class
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import sys
|
||||
from http import HTTPStatus
|
||||
from typing import (
|
||||
List,
|
||||
Optional,
|
||||
@@ -9,9 +11,6 @@ from fastapi import (
|
||||
Depends,
|
||||
HTTPException,
|
||||
)
|
||||
from fastapi import (
|
||||
status as HTTPStatus,
|
||||
)
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.core.db import core_app_extra
|
||||
@@ -20,18 +19,25 @@ from lnbits.core.helpers import (
|
||||
stop_extension_background_work,
|
||||
)
|
||||
from lnbits.core.models import (
|
||||
SimpleStatus,
|
||||
User,
|
||||
)
|
||||
from lnbits.core.services import check_transaction_status, create_invoice
|
||||
from lnbits.decorators import (
|
||||
check_access_token,
|
||||
check_admin,
|
||||
check_user_exists,
|
||||
)
|
||||
from lnbits.extension_manager import (
|
||||
CreateExtension,
|
||||
Extension,
|
||||
ExtensionRelease,
|
||||
InstallableExtension,
|
||||
PayToEnableInfo,
|
||||
ReleasePaymentInfo,
|
||||
UserExtensionInfo,
|
||||
fetch_github_release_config,
|
||||
fetch_release_details,
|
||||
fetch_release_payment_info,
|
||||
get_valid_extensions,
|
||||
)
|
||||
@@ -44,6 +50,12 @@ from ..crud import (
|
||||
drop_extension_db,
|
||||
get_dbversions,
|
||||
get_installed_extension,
|
||||
get_installed_extensions,
|
||||
get_user_extension,
|
||||
update_extension_pay_to_enable,
|
||||
update_installed_extension_state,
|
||||
update_user_extension,
|
||||
update_user_extension_extra,
|
||||
)
|
||||
|
||||
extension_router = APIRouter(
|
||||
@@ -89,26 +101,24 @@ async def api_install_extension(
|
||||
db_version = (await get_dbversions()).get(data.ext_id, 0)
|
||||
await migrate_extension_database(extension, db_version)
|
||||
|
||||
ext_info.active = True
|
||||
await add_installed_extension(ext_info)
|
||||
|
||||
if extension.is_upgrade_extension:
|
||||
# call stop while the old routes are still active
|
||||
await stop_extension_background_work(data.ext_id, user.id, access_token)
|
||||
|
||||
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()
|
||||
ext_info.notify_upgrade(extension.upgrade_hash)
|
||||
settings.lnbits_deactivated_extensions.discard(data.ext_id)
|
||||
|
||||
return extension
|
||||
except AssertionError as e:
|
||||
raise HTTPException(HTTPStatus.BAD_REQUEST, str(e))
|
||||
except Exception as ex:
|
||||
logger.warning(ex)
|
||||
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,
|
||||
@@ -116,7 +126,189 @@ async def api_install_extension(
|
||||
f"Failed to install extension {ext_info.id} "
|
||||
f"({ext_info.installed_version})."
|
||||
),
|
||||
) from exc
|
||||
|
||||
|
||||
@extension_router.get("/{ext_id}/details", dependencies=[Depends(check_user_exists)])
|
||||
async def api_extension_details(
|
||||
ext_id: str,
|
||||
details_link: str,
|
||||
):
|
||||
|
||||
try:
|
||||
all_releases = await InstallableExtension.get_extension_releases(ext_id)
|
||||
|
||||
release = next(
|
||||
(r for r in all_releases if r.details_link == details_link), None
|
||||
)
|
||||
assert release, "Details not found for release"
|
||||
|
||||
release_details = await fetch_release_details(details_link)
|
||||
assert release_details, "Cannot fetch details for release"
|
||||
release_details["icon"] = release.icon
|
||||
release_details["repo"] = release.repo
|
||||
return release_details
|
||||
except AssertionError as exc:
|
||||
raise HTTPException(HTTPStatus.BAD_REQUEST, str(exc)) from exc
|
||||
except Exception as exc:
|
||||
logger.warning(exc)
|
||||
raise HTTPException(
|
||||
HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
f"Failed to get details for extension {ext_id}.",
|
||||
) from exc
|
||||
|
||||
|
||||
@extension_router.put("/{ext_id}/sell")
|
||||
async def api_update_pay_to_enable(
|
||||
ext_id: str,
|
||||
data: PayToEnableInfo,
|
||||
user: User = Depends(check_admin),
|
||||
) -> SimpleStatus:
|
||||
try:
|
||||
assert (
|
||||
data.wallet in user.wallet_ids
|
||||
), "Wallet does not belong to this admin user."
|
||||
await update_extension_pay_to_enable(ext_id, data)
|
||||
return SimpleStatus(
|
||||
success=True, message=f"Payment info updated for '{ext_id}' extension."
|
||||
)
|
||||
except AssertionError as exc:
|
||||
raise HTTPException(HTTPStatus.BAD_REQUEST, str(exc)) from exc
|
||||
except Exception as exc:
|
||||
logger.warning(exc)
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
detail=(f"Failed to update pay to install data for extension '{ext_id}' "),
|
||||
) from exc
|
||||
|
||||
|
||||
@extension_router.put("/{ext_id}/enable")
|
||||
async def api_enable_extension(
|
||||
ext_id: str, user: User = Depends(check_user_exists)
|
||||
) -> SimpleStatus:
|
||||
if ext_id not in [e.code for e in get_valid_extensions()]:
|
||||
raise HTTPException(
|
||||
HTTPStatus.NOT_FOUND, f"Extension '{ext_id}' doesn't exist."
|
||||
)
|
||||
try:
|
||||
logger.info(f"Enabling extension: {ext_id}.")
|
||||
ext = await get_installed_extension(ext_id)
|
||||
assert ext, f"Extension '{ext_id}' is not installed."
|
||||
assert ext.active, f"Extension '{ext_id}' is not activated."
|
||||
|
||||
if user.admin or not ext.requires_payment:
|
||||
await update_user_extension(user_id=user.id, extension=ext_id, active=True)
|
||||
return SimpleStatus(success=True, message=f"Extension '{ext_id}' enabled.")
|
||||
|
||||
user_ext = await get_user_extension(user.id, ext_id)
|
||||
if not (user_ext and user_ext.extra and user_ext.extra.payment_hash_to_enable):
|
||||
raise HTTPException(
|
||||
HTTPStatus.PAYMENT_REQUIRED, f"Extension '{ext_id}' requires payment."
|
||||
)
|
||||
|
||||
if user_ext.is_paid:
|
||||
await update_user_extension(user_id=user.id, extension=ext_id, active=True)
|
||||
return SimpleStatus(
|
||||
success=True, message=f"Paid extension '{ext_id}' enabled."
|
||||
)
|
||||
|
||||
assert (
|
||||
ext.pay_to_enable and ext.pay_to_enable.wallet
|
||||
), f"Extension '{ext_id}' is missing payment wallet."
|
||||
|
||||
payment_status = await check_transaction_status(
|
||||
wallet_id=ext.pay_to_enable.wallet,
|
||||
payment_hash=user_ext.extra.payment_hash_to_enable,
|
||||
)
|
||||
|
||||
if not payment_status.paid:
|
||||
raise HTTPException(
|
||||
HTTPStatus.PAYMENT_REQUIRED,
|
||||
f"Invoice generated but not paid for enabeling extension '{ext_id}'.",
|
||||
)
|
||||
|
||||
user_ext.extra.paid_to_enable = True
|
||||
await update_user_extension_extra(user.id, ext_id, user_ext.extra)
|
||||
|
||||
await update_user_extension(user_id=user.id, extension=ext_id, active=True)
|
||||
return SimpleStatus(success=True, message=f"Paid extension '{ext_id}' enabled.")
|
||||
|
||||
except AssertionError as exc:
|
||||
raise HTTPException(HTTPStatus.BAD_REQUEST, str(exc)) from exc
|
||||
except HTTPException as exc:
|
||||
raise exc from exc
|
||||
except Exception as exc:
|
||||
logger.warning(exc)
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
detail=(f"Failed to enable '{ext_id}' "),
|
||||
) from exc
|
||||
|
||||
|
||||
@extension_router.put("/{ext_id}/disable")
|
||||
async def api_disable_extension(
|
||||
ext_id: str, user: User = Depends(check_user_exists)
|
||||
) -> SimpleStatus:
|
||||
if ext_id not in [e.code for e in get_valid_extensions()]:
|
||||
raise HTTPException(
|
||||
HTTPStatus.BAD_REQUEST, f"Extension '{ext_id}' doesn't exist."
|
||||
)
|
||||
try:
|
||||
logger.info(f"Disabeling extension: {ext_id}.")
|
||||
await update_user_extension(user_id=user.id, extension=ext_id, active=False)
|
||||
return SimpleStatus(success=True, message=f"Extension '{ext_id}' disabled.")
|
||||
except Exception as exc:
|
||||
logger.warning(exc)
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
detail=(f"Failed to disable '{ext_id}'."),
|
||||
) from exc
|
||||
|
||||
|
||||
@extension_router.put("/{ext_id}/activate", dependencies=[Depends(check_admin)])
|
||||
async def api_activate_extension(ext_id: str) -> SimpleStatus:
|
||||
try:
|
||||
logger.info(f"Activating extension: '{ext_id}'.")
|
||||
|
||||
all_extensions = get_valid_extensions()
|
||||
ext = next((e for e in all_extensions if e.code == ext_id), None)
|
||||
assert ext, f"Extension '{ext_id}' doesn't exist."
|
||||
# if extension never loaded (was deactivated on server startup)
|
||||
if ext_id not in sys.modules.keys():
|
||||
# run extension start-up routine
|
||||
core_app_extra.register_new_ext_routes(ext)
|
||||
|
||||
settings.lnbits_deactivated_extensions.discard(ext_id)
|
||||
|
||||
await update_installed_extension_state(ext_id=ext_id, active=True)
|
||||
return SimpleStatus(success=True, message=f"Extension '{ext_id}' activated.")
|
||||
except Exception as exc:
|
||||
logger.warning(exc)
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
detail=(f"Failed to activate '{ext_id}'."),
|
||||
) from exc
|
||||
|
||||
|
||||
@extension_router.put("/{ext_id}/deactivate", dependencies=[Depends(check_admin)])
|
||||
async def api_deactivate_extension(ext_id: str) -> SimpleStatus:
|
||||
try:
|
||||
logger.info(f"Deactivating extension: '{ext_id}'.")
|
||||
|
||||
all_extensions = get_valid_extensions()
|
||||
ext = next((e for e in all_extensions if e.code == ext_id), None)
|
||||
assert ext, f"Extension '{ext_id}' doesn't exist."
|
||||
|
||||
settings.lnbits_deactivated_extensions.add(ext_id)
|
||||
|
||||
await update_installed_extension_state(ext_id=ext_id, active=False)
|
||||
return SimpleStatus(success=True, message=f"Extension '{ext_id}' deactivated.")
|
||||
except Exception as exc:
|
||||
logger.warning(exc)
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
detail=(f"Failed to deactivate '{ext_id}'."),
|
||||
) from exc
|
||||
|
||||
|
||||
@extension_router.delete("/{ext_id}")
|
||||
@@ -124,20 +316,20 @@ 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()
|
||||
) -> SimpleStatus:
|
||||
installed_extensions = await get_installed_extensions()
|
||||
|
||||
extensions = [e for e in installable_extensions if e.id == ext_id]
|
||||
extensions = [e for e in installed_extensions if e.id == ext_id]
|
||||
if len(extensions) == 0:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
status_code=HTTPStatus.NOT_FOUND,
|
||||
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 installable_extensions if ext.id == valid_ext_id), None
|
||||
(ext for ext in installed_extensions if ext.id == valid_ext_id), None
|
||||
)
|
||||
if installed_ext and ext_id in installed_ext.dependencies:
|
||||
raise HTTPException(
|
||||
@@ -152,22 +344,22 @@ async def api_uninstall_extension(
|
||||
# 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]
|
||||
settings.lnbits_deactivated_extensions.add(ext_id)
|
||||
|
||||
for ext_info in extensions:
|
||||
ext_info.clean_extension_files()
|
||||
await delete_installed_extension(ext_id=ext_info.id)
|
||||
|
||||
logger.success(f"Extension '{ext_id}' uninstalled.")
|
||||
except Exception as ex:
|
||||
return SimpleStatus(success=True, message=f"Extension '{ext_id}' uninstalled.")
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(ex)
|
||||
)
|
||||
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):
|
||||
async def get_extension_releases(ext_id: str) -> List[ExtensionRelease]:
|
||||
try:
|
||||
extension_releases: List[ExtensionRelease] = (
|
||||
await InstallableExtension.get_extension_releases(ext_id)
|
||||
@@ -184,44 +376,96 @@ async def get_extension_releases(ext_id: str):
|
||||
|
||||
return extension_releases
|
||||
|
||||
except Exception as ex:
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(ex)
|
||||
)
|
||||
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):
|
||||
@extension_router.put("/{ext_id}/invoice/install", dependencies=[Depends(check_admin)])
|
||||
async def get_pay_to_install_invoice(
|
||||
ext_id: str, data: CreateExtension
|
||||
) -> ReleasePaymentInfo:
|
||||
try:
|
||||
assert data.cost_sats, "A non-zero amount must be specified"
|
||||
assert (
|
||||
ext_id == data.ext_id
|
||||
), f"Wrong extension id. Expected {ext_id}, but got {data.ext_id}"
|
||||
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"
|
||||
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"
|
||||
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"
|
||||
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"
|
||||
), "Wrong invoice payment hash."
|
||||
|
||||
return payment_info
|
||||
|
||||
except AssertionError as e:
|
||||
raise HTTPException(HTTPStatus.BAD_REQUEST, str(e))
|
||||
except Exception as ex:
|
||||
logger.warning(ex)
|
||||
raise HTTPException(HTTPStatus.INTERNAL_SERVER_ERROR, "Cannot request invoice")
|
||||
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.put("/{ext_id}/invoice/enable")
|
||||
async def get_pay_to_enable_invoice(
|
||||
ext_id: str, data: PayToEnableInfo, user: User = Depends(check_user_exists)
|
||||
):
|
||||
try:
|
||||
assert data.amount and data.amount > 0, "A non-zero amount must be specified."
|
||||
|
||||
ext = await get_installed_extension(ext_id)
|
||||
assert ext, f"Extension '{ext_id}' not found."
|
||||
assert ext.pay_to_enable, f"Payment Info not found for extension '{ext_id}'."
|
||||
assert (
|
||||
ext.pay_to_enable.required
|
||||
), f"Payment not required for extension '{ext_id}'."
|
||||
assert ext.pay_to_enable.wallet and ext.pay_to_enable.amount, (
|
||||
f"Payment wallet or amount missing for extension '{ext_id}'."
|
||||
"Please contact the administrator."
|
||||
)
|
||||
assert (
|
||||
data.amount >= ext.pay_to_enable.amount
|
||||
), f"Minimum amount is {ext.pay_to_enable.amount} sats."
|
||||
|
||||
payment_hash, payment_request = await create_invoice(
|
||||
wallet_id=ext.pay_to_enable.wallet,
|
||||
amount=data.amount,
|
||||
memo=f"Enable '{ext.name}' extension.",
|
||||
)
|
||||
|
||||
user_ext = await get_user_extension(user.id, ext_id)
|
||||
user_ext_info = (
|
||||
user_ext.extra if user_ext and user_ext.extra else UserExtensionInfo()
|
||||
)
|
||||
user_ext_info.payment_hash_to_enable = payment_hash
|
||||
await update_user_extension_extra(user.id, ext_id, user_ext_info)
|
||||
|
||||
return {"payment_hash": payment_hash, "payment_request": payment_request}
|
||||
|
||||
except AssertionError as exc:
|
||||
raise HTTPException(HTTPStatus.BAD_REQUEST, str(exc)) from exc
|
||||
except Exception as exc:
|
||||
logger.warning(exc)
|
||||
raise HTTPException(
|
||||
HTTPStatus.INTERNAL_SERVER_ERROR, "Cannot request invoice."
|
||||
) from exc
|
||||
|
||||
|
||||
@extension_router.get(
|
||||
@@ -239,10 +483,10 @@ async def get_extension_release(org: str, repo: str, tag_name: str):
|
||||
"is_version_compatible": config.is_version_compatible(),
|
||||
"warning": config.warning,
|
||||
}
|
||||
except Exception as ex:
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(ex)
|
||||
)
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(exc)
|
||||
) from exc
|
||||
|
||||
|
||||
@extension_router.delete(
|
||||
@@ -260,12 +504,15 @@ async def delete_extension_db(ext_id: str):
|
||||
await drop_extension_db(ext_id=ext_id)
|
||||
await delete_dbversion(ext_id=ext_id)
|
||||
logger.success(f"Database removed for extension '{ext_id}'")
|
||||
return SimpleStatus(
|
||||
success=True, message=f"DB deleted for '{ext_id}' extension."
|
||||
)
|
||||
except HTTPException as ex:
|
||||
logger.error(ex)
|
||||
raise ex
|
||||
except Exception as ex:
|
||||
logger.error(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
|
||||
|
||||
+100
-195
@@ -1,40 +1,34 @@
|
||||
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 urllib.parse import urlencode, urlparse
|
||||
|
||||
from fastapi import Cookie, Depends, Query, Request, status
|
||||
import httpx
|
||||
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 lnurl import decode as lnurl_decode
|
||||
from loguru import logger
|
||||
from pydantic.types import UUID4
|
||||
|
||||
from lnbits.core.db import core_app_extra, db
|
||||
from lnbits.core.helpers import to_valid_user_id
|
||||
from lnbits.core.models import User
|
||||
from lnbits.core.services import create_invoice
|
||||
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 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
|
||||
@@ -78,21 +72,10 @@ async def robots():
|
||||
return HTMLResponse(content=data, media_type="text/plain")
|
||||
|
||||
|
||||
@generic_router.get(
|
||||
"/extensions", name="install.extensions", response_class=HTMLResponse
|
||||
)
|
||||
async def extensions_install(
|
||||
request: Request,
|
||||
user: User = Depends(check_user_exists),
|
||||
activate: str = Query(None),
|
||||
deactivate: str = Query(None),
|
||||
enable: str = Query(None),
|
||||
disable: str = Query(None),
|
||||
):
|
||||
await toggle_extension(enable, disable, user.id)
|
||||
|
||||
@generic_router.get("/extensions", name="extensions", response_class=HTMLResponse)
|
||||
async def extensions(request: Request, user: User = Depends(check_user_exists)):
|
||||
try:
|
||||
installed_exts: List["InstallableExtension"] = await get_installed_extensions()
|
||||
installed_exts: List[InstallableExtension] = await get_installed_extensions()
|
||||
installed_exts_ids = [e.id for e in installed_exts]
|
||||
|
||||
installable_exts = await InstallableExtension.get_installable_extensions()
|
||||
@@ -105,6 +88,11 @@ async def extensions_install(
|
||||
installed_ext = next((ie for ie in installed_exts if e.id == ie.id), None)
|
||||
if installed_ext:
|
||||
e.installed_release = installed_ext.installed_release
|
||||
if installed_ext.pay_to_enable and not user.admin:
|
||||
# not a security leak, but better not to share the wallet id
|
||||
installed_ext.pay_to_enable.wallet = None
|
||||
e.pay_to_enable = installed_ext.pay_to_enable
|
||||
|
||||
# use the installed extension values
|
||||
e.name = installed_ext.name
|
||||
e.short_description = installed_ext.short_description
|
||||
@@ -116,30 +104,10 @@ async def extensions_install(
|
||||
installed_exts_ids = []
|
||||
|
||||
try:
|
||||
ext_id = activate or deactivate
|
||||
all_extensions = get_valid_extensions()
|
||||
ext = next((e for e in all_extensions if e.code == ext_id), None)
|
||||
if ext_id and user.admin:
|
||||
if deactivate and deactivate not in settings.lnbits_deactivated_extensions:
|
||||
settings.lnbits_deactivated_extensions += [deactivate]
|
||||
elif activate:
|
||||
# if extension never loaded (was deactivated on server startup)
|
||||
if ext_id not in sys.modules.keys():
|
||||
# run extension start-up routine
|
||||
core_app_extra.register_new_ext_routes(ext)
|
||||
|
||||
settings.lnbits_deactivated_extensions = list(
|
||||
filter(
|
||||
lambda e: e != activate, settings.lnbits_deactivated_extensions
|
||||
)
|
||||
)
|
||||
|
||||
await update_installed_extension_state(
|
||||
ext_id=ext_id, active=activate is not None
|
||||
)
|
||||
|
||||
all_ext_ids = [ext.code for ext in all_extensions]
|
||||
inactive_extensions = await get_inactive_extensions()
|
||||
all_ext_ids = [ext.code for ext in get_valid_extensions()]
|
||||
inactive_extensions = [
|
||||
e.id for e in await get_installed_extensions(active=False)
|
||||
]
|
||||
db_version = await get_dbversions()
|
||||
extensions = [
|
||||
{
|
||||
@@ -161,6 +129,8 @@ async def extensions_install(
|
||||
"installedRelease": (
|
||||
dict(ext.installed_release) if ext.installed_release else None
|
||||
),
|
||||
"payToEnable": (dict(ext.pay_to_enable) if ext.pay_to_enable else {}),
|
||||
"isPaymentRequired": ext.requires_payment,
|
||||
}
|
||||
for ext in installable_exts
|
||||
]
|
||||
@@ -176,9 +146,11 @@ async def extensions_install(
|
||||
"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(
|
||||
@@ -206,7 +178,7 @@ async def wallet(
|
||||
user_wallet = user.get_wallet(wallet_id)
|
||||
if not user_wallet or user_wallet.deleted:
|
||||
return template_renderer().TemplateResponse(
|
||||
request, "error.html", {"err": "Wallet not found"}
|
||||
request, "error.html", {"err": "Wallet not found"}, HTTPStatus.NOT_FOUND
|
||||
)
|
||||
|
||||
resp = template_renderer().TemplateResponse(
|
||||
@@ -243,115 +215,6 @@ async def account(
|
||||
)
|
||||
|
||||
|
||||
@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
|
||||
)
|
||||
)
|
||||
|
||||
return RedirectResponse(
|
||||
f"/wallet?usr={user.id}&wal={wallet.id}",
|
||||
status_code=status.HTTP_307_TEMPORARY_REDIRECT,
|
||||
)
|
||||
|
||||
|
||||
@generic_router.get("/service-worker.js")
|
||||
async def service_worker(request: Request):
|
||||
return template_renderer().TemplateResponse(
|
||||
@@ -452,8 +315,8 @@ 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,
|
||||
@@ -472,8 +335,8 @@ 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,
|
||||
@@ -486,12 +349,12 @@ 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,
|
||||
@@ -505,36 +368,78 @@ async def index(request: Request, user: User = Depends(check_admin)):
|
||||
)
|
||||
|
||||
|
||||
@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(),
|
||||
"currencies": list(currencies.keys()),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@generic_router.get("/uuidv4/{hex_value}")
|
||||
async def hex_to_uuid4(hex_value: str):
|
||||
try:
|
||||
user_id = to_valid_user_id(hex_value).hex
|
||||
return RedirectResponse(url=f"/wallet?usr={user_id}")
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e))
|
||||
|
||||
|
||||
async def toggle_extension(extension_to_enable, extension_to_disable, user_id):
|
||||
if extension_to_enable and extension_to_disable:
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
HTTPStatus.BAD_REQUEST, "You can either `enable` or `disable` an extension."
|
||||
)
|
||||
status_code=HTTPStatus.BAD_REQUEST, detail=str(exc)
|
||||
) from exc
|
||||
|
||||
# check if extension exists
|
||||
if extension_to_enable or extension_to_disable:
|
||||
ext = extension_to_enable or extension_to_disable
|
||||
if ext not in [e.code for e in get_valid_extensions()]:
|
||||
|
||||
@generic_router.get("/lnurlwallet", response_class=RedirectResponse)
|
||||
async def lnurlwallet(request: Request):
|
||||
"""
|
||||
If a user doesn't have a Lightning Network wallet and scans the LNURLw QR code with
|
||||
their smartphone camera, or a QR scanner app, they can follow the link provided to
|
||||
claim their satoshis and get an instant LNbits wallet! lnbits/withdraw docs
|
||||
"""
|
||||
|
||||
lightning_param = request.query_params.get("lightning")
|
||||
if not lightning_param:
|
||||
return {"status": "ERROR", "reason": "lightning parameter not provided."}
|
||||
if not settings.lnbits_allow_new_accounts:
|
||||
return {"status": "ERROR", "reason": "New accounts are not allowed."}
|
||||
|
||||
lnurl = lnurl_decode(lightning_param)
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
|
||||
res1 = await client.get(lnurl, timeout=2)
|
||||
res1.raise_for_status()
|
||||
data1 = res1.json()
|
||||
if data1.get("tag") != "withdrawRequest":
|
||||
raise HTTPException(
|
||||
HTTPStatus.BAD_REQUEST, f"Extension '{ext}' doesn't exist."
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail="Invalid lnurl. Expected tag=withdrawRequest",
|
||||
)
|
||||
if not data1.get("maxWithdrawable"):
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail="Invalid lnurl. Expected maxWithdrawable",
|
||||
)
|
||||
account = await create_account()
|
||||
wallet = await create_wallet(user_id=account.id)
|
||||
_, payment_request = await create_invoice(
|
||||
wallet_id=wallet.id,
|
||||
amount=data1.get("maxWithdrawable") / 1000,
|
||||
memo=data1.get("defaultDescription", "lnurl wallet withdraw"),
|
||||
)
|
||||
url = data1.get("callback")
|
||||
params = {"k1": data1.get("k1"), "pr": payment_request}
|
||||
callback = url + ("&" if urlparse(url).query else "?") + urlencode(params)
|
||||
|
||||
if extension_to_enable:
|
||||
logger.info(f"Enabling extension: {extension_to_enable} for user {user_id}")
|
||||
await update_user_extension(
|
||||
user_id=user_id, extension=extension_to_enable, active=True
|
||||
)
|
||||
elif extension_to_disable:
|
||||
logger.info(f"Disabling extension: {extension_to_disable} for user {user_id}")
|
||||
await update_user_extension(
|
||||
user_id=user_id, extension=extension_to_disable, active=False
|
||||
)
|
||||
res2 = await client.get(callback, timeout=2)
|
||||
res2.raise_for_status()
|
||||
|
||||
return RedirectResponse(
|
||||
f"/wallet?usr={account.id}&wal={wallet.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():
|
||||
@@ -195,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
|
||||
|
||||
@@ -13,6 +13,7 @@ from fastapi import (
|
||||
Depends,
|
||||
Header,
|
||||
HTTPException,
|
||||
Query,
|
||||
Request,
|
||||
)
|
||||
from fastapi.responses import JSONResponse
|
||||
@@ -25,12 +26,11 @@ from lnbits.core.models import (
|
||||
CreateInvoice,
|
||||
CreateLnurl,
|
||||
DecodePayment,
|
||||
KeyType,
|
||||
Payment,
|
||||
PaymentFilters,
|
||||
PaymentHistoryPoint,
|
||||
Query,
|
||||
Wallet,
|
||||
WalletType,
|
||||
)
|
||||
from lnbits.db import Filters, Page
|
||||
from lnbits.decorators import (
|
||||
@@ -40,7 +40,7 @@ from lnbits.decorators import (
|
||||
require_admin_key,
|
||||
require_invoice_key,
|
||||
)
|
||||
from lnbits.helpers import generate_filter_params_openapi, url_for
|
||||
from lnbits.helpers import filter_dict_keys, 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
|
||||
@@ -52,16 +52,12 @@ from ..crud import (
|
||||
get_payments_paginated,
|
||||
get_standalone_payment,
|
||||
get_wallet_for_key,
|
||||
save_balance_check,
|
||||
update_pending_payments,
|
||||
)
|
||||
from ..services import (
|
||||
InvoiceFailure,
|
||||
PaymentFailure,
|
||||
check_transaction_status,
|
||||
create_invoice,
|
||||
fee_reserve_total,
|
||||
pay_invoice,
|
||||
update_pending_payments,
|
||||
)
|
||||
from ..tasks import api_invoice_listeners
|
||||
|
||||
@@ -134,55 +130,47 @@ async def api_payments_create_invoice(data: CreateInvoice, wallet: Wallet):
|
||||
if data.description_hash:
|
||||
try:
|
||||
description_hash = bytes.fromhex(data.description_hash)
|
||||
except ValueError:
|
||||
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:
|
||||
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:
|
||||
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
|
||||
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:
|
||||
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:
|
||||
@@ -190,11 +178,6 @@ async def api_payments_create_invoice(data: CreateInvoice, wallet: Wallet):
|
||||
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,
|
||||
)
|
||||
@@ -213,32 +196,9 @@ async def api_payments_create_invoice(data: CreateInvoice, wallet: Wallet):
|
||||
return {
|
||||
"payment_hash": invoice.payment_hash,
|
||||
"payment_request": payment_request,
|
||||
"lnurl_response": lnurl_response,
|
||||
# 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,
|
||||
}
|
||||
|
||||
|
||||
@@ -254,23 +214,37 @@ async def api_payments_pay_invoice(
|
||||
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),
|
||||
invoiceData: CreateInvoice = Body(...),
|
||||
invoice_data: CreateInvoice = Body(...),
|
||||
):
|
||||
if invoiceData.out is True and wallet.wallet_type == WalletType.admin:
|
||||
if not invoiceData.bolt11:
|
||||
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",
|
||||
)
|
||||
return await api_payments_pay_invoice(
|
||||
invoiceData.bolt11, wallet.wallet, invoiceData.extra
|
||||
) # admin key
|
||||
elif not invoiceData.out:
|
||||
|
||||
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(invoiceData, wallet.wallet)
|
||||
return await api_payments_create_invoice(invoice_data, wallet.wallet)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.UNAUTHORIZED,
|
||||
@@ -316,11 +290,11 @@ async def api_payments_pay_lnurl(
|
||||
if r.is_error:
|
||||
raise httpx.ConnectError("LNURL callback connection error")
|
||||
r.raise_for_status()
|
||||
except (httpx.ConnectError, httpx.RequestError):
|
||||
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":
|
||||
@@ -386,7 +360,7 @@ async def subscribe_wallet_invoices(request: Request, wallet: Wallet):
|
||||
api_invoice_listeners[uid] = payment_queue
|
||||
|
||||
try:
|
||||
while True:
|
||||
while settings.lnbits_running:
|
||||
if await request.is_disconnected():
|
||||
await request.close()
|
||||
break
|
||||
@@ -415,10 +389,10 @@ async def api_payments_sse(
|
||||
|
||||
# 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)):
|
||||
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 = 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
|
||||
@@ -427,21 +401,14 @@ async def api_payment(payment_hash, X_Api_Key: Optional[str] = Header(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 payment.success:
|
||||
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()
|
||||
status = await payment.check_status()
|
||||
except Exception:
|
||||
if wallet and wallet.id == payment.wallet_id:
|
||||
return {"paid": False, "details": payment}
|
||||
@@ -449,11 +416,12 @@ async def api_payment(payment_hash, X_Api_Key: Optional[str] = Header(None)):
|
||||
|
||||
if wallet and wallet.id == payment.wallet_id:
|
||||
return {
|
||||
"paid": not payment.pending,
|
||||
"paid": payment.success,
|
||||
"status": f"{status!s}",
|
||||
"preimage": payment.preimage,
|
||||
"details": payment,
|
||||
}
|
||||
return {"paid": not payment.pending, "preimage": payment.preimage}
|
||||
return {"paid": payment.success, "preimage": payment.preimage}
|
||||
|
||||
|
||||
@payment_router.post("/decode", status_code=HTTPStatus.OK)
|
||||
@@ -465,9 +433,10 @@ async def api_payments_decode(data: DecodePayment) -> JSONResponse:
|
||||
return JSONResponse({"domain": url})
|
||||
else:
|
||||
invoice = bolt11.decode(payment_str)
|
||||
return JSONResponse(invoice.data)
|
||||
filtered_data = filter_dict_keys(invoice.data, data.filter_fields)
|
||||
return JSONResponse(filtered_data)
|
||||
except Exception as exc:
|
||||
return JSONResponse(
|
||||
{"message": f"Failed to decode: {str(exc)}"},
|
||||
{"message": f"Failed to decode: {exc!s}"},
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
)
|
||||
|
||||
@@ -20,17 +20,18 @@ async def api_public_payment_longpolling(payment_hash):
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Payment does not exist."
|
||||
)
|
||||
elif not payment.pending:
|
||||
# TODO: refactor to use PaymentState
|
||||
if payment.success:
|
||||
return {"status": "paid"}
|
||||
|
||||
try:
|
||||
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 +51,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
|
||||
|
||||
@@ -38,10 +38,10 @@ async def api_create_tinyurl(
|
||||
if tinyurl.wallet == wallet.wallet.id:
|
||||
return tinyurl
|
||||
return await create_tinyurl(url, endless, wallet.wallet.id)
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST, detail="Unable to create tinyurl"
|
||||
)
|
||||
) from exc
|
||||
|
||||
|
||||
@tinyurl_router.get(
|
||||
@@ -60,10 +60,10 @@ async def api_get_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(
|
||||
@@ -83,10 +83,10 @@ async def api_delete_tinyurl(
|
||||
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
|
||||
@@ -8,8 +8,8 @@ from fastapi import (
|
||||
|
||||
from lnbits.core.models import (
|
||||
CreateWallet,
|
||||
KeyType,
|
||||
Wallet,
|
||||
WalletType,
|
||||
)
|
||||
from lnbits.decorators import (
|
||||
WalletTypeInfo,
|
||||
@@ -28,7 +28,7 @@ wallet_router = APIRouter(prefix="/api/v1/wallet", tags=["Wallet"])
|
||||
|
||||
@wallet_router.get("")
|
||||
async def api_wallet(wallet: WalletTypeInfo = Depends(get_key_type)):
|
||||
if wallet.wallet_type == WalletType.admin:
|
||||
if wallet.key_type == KeyType.admin:
|
||||
return {
|
||||
"id": wallet.wallet.id,
|
||||
"name": wallet.wallet.name,
|
||||
|
||||
@@ -6,8 +6,10 @@ from urllib.parse import unquote, urlparse
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
Depends,
|
||||
HTTPException,
|
||||
Request,
|
||||
)
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.core.models import (
|
||||
CreateWebPushSubscription,
|
||||
@@ -33,20 +35,27 @@ async def api_create_webpush_subscription(
|
||||
data: CreateWebPushSubscription,
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||
) -> WebPushSubscription:
|
||||
subscription = json.loads(data.subscription)
|
||||
endpoint = subscription["endpoint"]
|
||||
host = urlparse(str(request.url)).netloc
|
||||
try:
|
||||
subscription = json.loads(data.subscription)
|
||||
endpoint = subscription["endpoint"]
|
||||
host = urlparse(str(request.url)).netloc
|
||||
|
||||
subscription = await get_webpush_subscription(endpoint, wallet.wallet.user)
|
||||
if subscription:
|
||||
return subscription
|
||||
else:
|
||||
return await create_webpush_subscription(
|
||||
endpoint,
|
||||
wallet.wallet.user,
|
||||
data.subscription,
|
||||
host,
|
||||
)
|
||||
subscription = await get_webpush_subscription(endpoint, wallet.wallet.user)
|
||||
if subscription:
|
||||
return subscription
|
||||
else:
|
||||
return await create_webpush_subscription(
|
||||
endpoint,
|
||||
wallet.wallet.user,
|
||||
data.subscription,
|
||||
host,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug(exc)
|
||||
raise HTTPException(
|
||||
HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
"Cannot create webpush notification",
|
||||
) from exc
|
||||
|
||||
|
||||
@webpush_router.delete("", status_code=HTTPStatus.OK)
|
||||
@@ -54,7 +63,15 @@ async def api_delete_webpush_subscription(
|
||||
request: Request,
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||
):
|
||||
endpoint = unquote(
|
||||
base64.b64decode(str(request.query_params.get("endpoint"))).decode("utf-8")
|
||||
)
|
||||
await delete_webpush_subscription(endpoint, wallet.wallet.user)
|
||||
try:
|
||||
endpoint = unquote(
|
||||
base64.b64decode(str(request.query_params.get("endpoint"))).decode("utf-8")
|
||||
)
|
||||
count = await delete_webpush_subscription(endpoint, wallet.wallet.user)
|
||||
return {"count": count}
|
||||
except Exception as exc:
|
||||
logger.debug(exc)
|
||||
raise HTTPException(
|
||||
HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
"Cannot delete webpush notification",
|
||||
) from exc
|
||||
|
||||
@@ -4,9 +4,11 @@ from fastapi import (
|
||||
WebSocketDisconnect,
|
||||
)
|
||||
|
||||
from lnbits.settings import settings
|
||||
|
||||
from ..services import (
|
||||
websocketManager,
|
||||
websocketUpdater,
|
||||
websocket_manager,
|
||||
websocket_updater,
|
||||
)
|
||||
|
||||
websocket_router = APIRouter(prefix="/api/v1/ws", tags=["Websocket"])
|
||||
@@ -14,18 +16,18 @@ websocket_router = APIRouter(prefix="/api/v1/ws", tags=["Websocket"])
|
||||
|
||||
@websocket_router.websocket("/{item_id}")
|
||||
async def websocket_connect(websocket: WebSocket, item_id: str):
|
||||
await websocketManager.connect(websocket, item_id)
|
||||
await websocket_manager.connect(websocket, item_id)
|
||||
try:
|
||||
while True:
|
||||
while settings.lnbits_running:
|
||||
await websocket.receive_text()
|
||||
except WebSocketDisconnect:
|
||||
websocketManager.disconnect(websocket)
|
||||
websocket_manager.disconnect(websocket)
|
||||
|
||||
|
||||
@websocket_router.post("/{item_id}")
|
||||
async def websocket_update_post(item_id: str, data: str):
|
||||
try:
|
||||
await websocketUpdater(item_id, data)
|
||||
await websocket_updater(item_id, data)
|
||||
return {"sent": True, "data": data}
|
||||
except Exception:
|
||||
return {"sent": False, "data": data}
|
||||
@@ -34,7 +36,7 @@ async def websocket_update_post(item_id: str, data: str):
|
||||
@websocket_router.get("/{item_id}/{data}")
|
||||
async def websocket_update_get(item_id: str, data: str):
|
||||
try:
|
||||
await websocketUpdater(item_id, data)
|
||||
await websocket_updater(item_id, data)
|
||||
return {"sent": True, "data": data}
|
||||
except Exception:
|
||||
return {"sent": False, "data": data}
|
||||
|
||||
+142
-132
@@ -7,14 +7,13 @@ import re
|
||||
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
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy_aio.base import AsyncConnection
|
||||
from sqlalchemy_aio.strategy import ASYNCIO_STRATEGY
|
||||
from sqlalchemy import event
|
||||
from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine, create_async_engine
|
||||
from sqlalchemy.sql import text
|
||||
|
||||
from lnbits.settings import settings
|
||||
|
||||
@@ -24,31 +23,16 @@ SQLITE = "SQLITE"
|
||||
|
||||
if settings.lnbits_database_url:
|
||||
database_uri = settings.lnbits_database_url
|
||||
|
||||
if database_uri.startswith("cockroachdb://"):
|
||||
DB_TYPE = COCKROACH
|
||||
else:
|
||||
if not database_uri.startswith("postgresql+asyncpg://"):
|
||||
raise ValueError(
|
||||
"Please use the 'postgresql+asyncpg://...' "
|
||||
"format for the database URL."
|
||||
)
|
||||
DB_TYPE = POSTGRES
|
||||
|
||||
from psycopg2.extensions import DECIMAL, new_type, register_type
|
||||
|
||||
def _parse_timestamp(value, _):
|
||||
if value is None:
|
||||
return None
|
||||
f = "%Y-%m-%d %H:%M:%S.%f"
|
||||
if "." not in value:
|
||||
f = "%Y-%m-%d %H:%M:%S"
|
||||
return time.mktime(datetime.datetime.strptime(value, f).timetuple())
|
||||
|
||||
register_type(
|
||||
new_type(
|
||||
DECIMAL.values,
|
||||
"DEC2FLOAT",
|
||||
lambda value, curs: float(value) if value is not None else None,
|
||||
)
|
||||
)
|
||||
|
||||
register_type(new_type((1184, 1114), "TIMESTAMP2INT", _parse_timestamp))
|
||||
else:
|
||||
if not os.path.isdir(settings.lnbits_data_folder):
|
||||
os.mkdir(settings.lnbits_data_folder)
|
||||
@@ -56,13 +40,21 @@ else:
|
||||
DB_TYPE = SQLITE
|
||||
|
||||
|
||||
def compat_timestamp_placeholder():
|
||||
def compat_timestamp_placeholder(key: str):
|
||||
if DB_TYPE == POSTGRES:
|
||||
return "to_timestamp(?)"
|
||||
return f"to_timestamp(:{key})"
|
||||
elif DB_TYPE == COCKROACH:
|
||||
return "cast(? AS timestamp)"
|
||||
return f"cast(:{key} AS timestamp)"
|
||||
else:
|
||||
return "?"
|
||||
return f":{key}"
|
||||
|
||||
|
||||
def get_placeholder(model: Any, field: str) -> str:
|
||||
type_ = model.__fields__[field].type_
|
||||
if type_ == datetime.datetime:
|
||||
return compat_timestamp_placeholder(field)
|
||||
else:
|
||||
return f":{field}"
|
||||
|
||||
|
||||
class Compat:
|
||||
@@ -119,15 +111,13 @@ class Compat:
|
||||
return "BIGINT"
|
||||
return "INT"
|
||||
|
||||
@property
|
||||
def timestamp_placeholder(self) -> str:
|
||||
return compat_timestamp_placeholder()
|
||||
def timestamp_placeholder(self, key: str) -> str:
|
||||
return compat_timestamp_placeholder(key)
|
||||
|
||||
|
||||
class Connection(Compat):
|
||||
def __init__(self, conn: AsyncConnection, txn, typ, name, schema):
|
||||
def __init__(self, conn: AsyncConnection, typ, name, schema):
|
||||
self.conn = conn
|
||||
self.txn = txn
|
||||
self.type = typ
|
||||
self.name = name
|
||||
self.schema = schema
|
||||
@@ -138,48 +128,45 @@ class Connection(Compat):
|
||||
query = query.replace("?", "%s")
|
||||
return query
|
||||
|
||||
def rewrite_values(self, values):
|
||||
def rewrite_values(self, values: dict) -> dict:
|
||||
# strip html
|
||||
CLEANR = 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:
|
||||
clean_regex = re.compile("<.*?>|&([a-z0-9]+|#[0-9]{1,6}|#x[0-9a-f]{1,6});")
|
||||
clean_values: dict = {}
|
||||
for key, raw_value in values.items():
|
||||
if isinstance(raw_value, str):
|
||||
values.append(re.sub(CLEANR, "", raw_value))
|
||||
clean_values[key] = re.sub(clean_regex, "", raw_value)
|
||||
elif isinstance(raw_value, datetime.datetime):
|
||||
ts = raw_value.timestamp()
|
||||
if self.type == SQLITE:
|
||||
values.append(int(ts))
|
||||
clean_values[key] = int(ts)
|
||||
else:
|
||||
values.append(ts)
|
||||
clean_values[key] = ts
|
||||
else:
|
||||
values.append(raw_value)
|
||||
return tuple(values)
|
||||
clean_values[key] = raw_value
|
||||
return clean_values
|
||||
|
||||
async def fetchall(self, query: str, values: tuple = ()) -> list:
|
||||
result = await self.conn.execute(
|
||||
self.rewrite_query(query), self.rewrite_values(values)
|
||||
)
|
||||
return await result.fetchall()
|
||||
async def fetchall(self, query: str, values: Optional[dict] = None) -> list[dict]:
|
||||
params = self.rewrite_values(values) if values else {}
|
||||
result = await self.conn.execute(text(self.rewrite_query(query)), params)
|
||||
row = result.mappings().all()
|
||||
result.close()
|
||||
return row
|
||||
|
||||
async def fetchone(self, query: str, values: tuple = ()):
|
||||
result = await self.conn.execute(
|
||||
self.rewrite_query(query), self.rewrite_values(values)
|
||||
)
|
||||
row = await result.fetchone()
|
||||
await result.close()
|
||||
async def fetchone(self, query: str, values: Optional[dict] = None) -> dict:
|
||||
params = self.rewrite_values(values) if values else {}
|
||||
result = await self.conn.execute(text(self.rewrite_query(query)), params)
|
||||
row = result.mappings().first()
|
||||
result.close()
|
||||
return row
|
||||
|
||||
async def fetch_page(
|
||||
self,
|
||||
query: str,
|
||||
where: Optional[List[str]] = None,
|
||||
values: Optional[List[str]] = None,
|
||||
where: Optional[list[str]] = None,
|
||||
values: Optional[dict] = None,
|
||||
filters: Optional[Filters] = None,
|
||||
model: Optional[Type[TRowModel]] = None,
|
||||
group_by: Optional[List[str]] = None,
|
||||
model: Optional[type[TRowModel]] = None,
|
||||
group_by: Optional[list[str]] = None,
|
||||
) -> Page[TRowModel]:
|
||||
if not filters:
|
||||
filters = Filters()
|
||||
@@ -203,14 +190,14 @@ class Connection(Compat):
|
||||
{filters.order_by()}
|
||||
{filters.pagination()}
|
||||
""",
|
||||
parsed_values,
|
||||
self.rewrite_values(parsed_values),
|
||||
)
|
||||
if rows:
|
||||
# no need for extra query if no pagination is specified
|
||||
if filters.offset or filters.limit:
|
||||
count = await self.fetchone(
|
||||
result = await self.fetchone(
|
||||
f"""
|
||||
SELECT COUNT(*) FROM (
|
||||
SELECT COUNT(*) as count FROM (
|
||||
{query}
|
||||
{clause}
|
||||
{group_by_string}
|
||||
@@ -218,21 +205,22 @@ class Connection(Compat):
|
||||
""",
|
||||
parsed_values,
|
||||
)
|
||||
count = int(count[0])
|
||||
count = int(result.get("count", 0))
|
||||
else:
|
||||
count = len(rows)
|
||||
else:
|
||||
count = 0
|
||||
|
||||
return Page(
|
||||
data=[model.from_row(row) for row in rows] if model else rows,
|
||||
data=[model.from_row(row) for row in rows] if model else [],
|
||||
total=count,
|
||||
)
|
||||
|
||||
async def execute(self, query: str, values: tuple = ()):
|
||||
return await self.conn.execute(
|
||||
self.rewrite_query(query), self.rewrite_values(values)
|
||||
)
|
||||
async def execute(self, query: str, values: Optional[dict] = None):
|
||||
params = self.rewrite_values(values) if values else {}
|
||||
result = await self.conn.execute(text(self.rewrite_query(query)), params)
|
||||
await self.conn.commit()
|
||||
return result
|
||||
|
||||
|
||||
class Database(Compat):
|
||||
@@ -245,7 +233,7 @@ class Database(Compat):
|
||||
self.path = os.path.join(
|
||||
settings.lnbits_data_folder, f"{self.name}.sqlite3"
|
||||
)
|
||||
database_uri = f"sqlite:///{self.path}"
|
||||
database_uri = f"sqlite+aiosqlite:///{self.path}"
|
||||
else:
|
||||
database_uri = settings.lnbits_database_url
|
||||
|
||||
@@ -254,9 +242,33 @@ class Database(Compat):
|
||||
else:
|
||||
self.schema = None
|
||||
|
||||
self.engine = create_engine(
|
||||
database_uri, strategy=ASYNCIO_STRATEGY, echo=settings.debug_database
|
||||
self.engine: AsyncEngine = create_async_engine(
|
||||
database_uri, echo=settings.debug_database
|
||||
)
|
||||
|
||||
if self.type in {POSTGRES, COCKROACH}:
|
||||
|
||||
@event.listens_for(self.engine.sync_engine, "connect")
|
||||
def register_custom_types(dbapi_connection, *_):
|
||||
def _parse_timestamp(value):
|
||||
if value is None:
|
||||
return None
|
||||
f = "%Y-%m-%d %H:%M:%S.%f"
|
||||
if "." not in value:
|
||||
f = "%Y-%m-%d %H:%M:%S"
|
||||
return int(
|
||||
time.mktime(datetime.datetime.strptime(value, f).timetuple())
|
||||
)
|
||||
|
||||
dbapi_connection.run_async(
|
||||
lambda connection: connection.set_type_codec(
|
||||
"TIMESTAMP",
|
||||
encoder=datetime.datetime,
|
||||
decoder=_parse_timestamp,
|
||||
schema="pg_catalog",
|
||||
)
|
||||
)
|
||||
|
||||
self.lock = asyncio.Lock()
|
||||
|
||||
logger.trace(f"database {self.type} added for {self.name}")
|
||||
@@ -265,49 +277,45 @@ class Database(Compat):
|
||||
async def connect(self):
|
||||
await self.lock.acquire()
|
||||
try:
|
||||
async with self.engine.connect() as conn: # type: ignore
|
||||
async with conn.begin() as txn:
|
||||
wconn = Connection(conn, txn, self.type, self.name, self.schema)
|
||||
async with self.engine.connect() as conn:
|
||||
if not conn:
|
||||
raise Exception("Could not connect to the database")
|
||||
|
||||
if self.schema:
|
||||
if self.type in {POSTGRES, COCKROACH}:
|
||||
await wconn.execute(
|
||||
f"CREATE SCHEMA IF NOT EXISTS {self.schema}"
|
||||
)
|
||||
elif self.type == SQLITE:
|
||||
await wconn.execute(
|
||||
f"ATTACH '{self.path}' AS {self.schema}"
|
||||
)
|
||||
wconn = Connection(conn, self.type, self.name, self.schema)
|
||||
|
||||
yield wconn
|
||||
if self.schema:
|
||||
if self.type in {POSTGRES, COCKROACH}:
|
||||
await wconn.execute(
|
||||
f"CREATE SCHEMA IF NOT EXISTS {self.schema}"
|
||||
)
|
||||
elif self.type == SQLITE:
|
||||
await wconn.execute(f"ATTACH '{self.path}' AS {self.schema}")
|
||||
|
||||
yield wconn
|
||||
finally:
|
||||
self.lock.release()
|
||||
|
||||
async def fetchall(self, query: str, values: tuple = ()) -> list:
|
||||
async def fetchall(self, query: str, values: Optional[dict] = None) -> list[dict]:
|
||||
async with self.connect() as conn:
|
||||
result = await conn.execute(query, values)
|
||||
return await result.fetchall()
|
||||
return await conn.fetchall(query, values)
|
||||
|
||||
async def fetchone(self, query: str, values: tuple = ()):
|
||||
async def fetchone(self, query: str, values: Optional[dict] = None) -> dict:
|
||||
async with self.connect() as conn:
|
||||
result = await conn.execute(query, values)
|
||||
row = await result.fetchone()
|
||||
await result.close()
|
||||
return row
|
||||
return await conn.fetchone(query, values)
|
||||
|
||||
async def fetch_page(
|
||||
self,
|
||||
query: str,
|
||||
where: Optional[List[str]] = None,
|
||||
values: Optional[List[str]] = None,
|
||||
where: Optional[list[str]] = None,
|
||||
values: Optional[dict] = None,
|
||||
filters: Optional[Filters] = None,
|
||||
model: Optional[Type[TRowModel]] = None,
|
||||
group_by: Optional[List[str]] = 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, group_by)
|
||||
|
||||
async def execute(self, query: str, values: tuple = ()):
|
||||
async def execute(self, query: str, values: Optional[dict] = None):
|
||||
async with self.connect() as conn:
|
||||
return await conn.execute(query, values)
|
||||
|
||||
@@ -365,13 +373,13 @@ class Operator(Enum):
|
||||
|
||||
class FromRowModel(BaseModel):
|
||||
@classmethod
|
||||
def from_row(cls, row: Row):
|
||||
return cls(**dict(row))
|
||||
def from_row(cls, row: dict):
|
||||
return cls(**row)
|
||||
|
||||
|
||||
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")
|
||||
@@ -388,12 +396,13 @@ class Page(BaseModel, Generic[T]):
|
||||
class Filter(BaseModel, Generic[TFilterModel]):
|
||||
field: str
|
||||
op: Operator = Operator.EQ
|
||||
values: list[Any]
|
||||
|
||||
model: Optional[Type[TFilterModel]]
|
||||
model: Optional[type[TFilterModel]]
|
||||
values: Optional[dict] = None
|
||||
|
||||
@classmethod
|
||||
def parse_query(cls, key: str, raw_values: list[Any], model: Type[TFilterModel]):
|
||||
def parse_query(
|
||||
cls, key: str, raw_values: list[Any], model: type[TFilterModel], i: int = 0
|
||||
):
|
||||
# Key format:
|
||||
# key[operator]
|
||||
# e.g. name[eq]
|
||||
@@ -409,12 +418,12 @@ class Filter(BaseModel, Generic[TFilterModel]):
|
||||
|
||||
if field in model.__fields__:
|
||||
compare_field = model.__fields__[field]
|
||||
values = []
|
||||
values: dict = {}
|
||||
for raw_value in raw_values:
|
||||
validated, errors = compare_field.validate(raw_value, {}, loc="none")
|
||||
if errors:
|
||||
raise ValidationError(errors=[errors], model=model)
|
||||
values.append(validated)
|
||||
values[f"{field}__{i}"] = validated
|
||||
else:
|
||||
raise ValueError("Unknown filter field")
|
||||
|
||||
@@ -422,15 +431,17 @@ class Filter(BaseModel, Generic[TFilterModel]):
|
||||
|
||||
@property
|
||||
def statement(self):
|
||||
if self.model and self.model.__fields__[self.field].type_ == datetime.datetime:
|
||||
placeholder = compat_timestamp_placeholder()
|
||||
else:
|
||||
placeholder = "?"
|
||||
if self.op in (Operator.INCLUDE, Operator.EXCLUDE):
|
||||
placeholders = ", ".join([placeholder] * len(self.values))
|
||||
stmt = [f"{self.field} {self.op.as_sql} ({placeholders})"]
|
||||
else:
|
||||
stmt = [f"{self.field} {self.op.as_sql} {placeholder}"] * len(self.values)
|
||||
stmt = []
|
||||
for key in self.values.keys() if self.values else []:
|
||||
clean_key = key.split("__")[0]
|
||||
if (
|
||||
self.model
|
||||
and self.model.__fields__[clean_key].type_ == datetime.datetime
|
||||
):
|
||||
placeholder = compat_timestamp_placeholder(key)
|
||||
else:
|
||||
placeholder = f":{key}"
|
||||
stmt.append(f"{clean_key} {self.op.as_sql} {placeholder}")
|
||||
return " OR ".join(stmt)
|
||||
|
||||
|
||||
@@ -443,7 +454,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
|
||||
@@ -452,7 +463,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):
|
||||
@@ -474,21 +485,18 @@ 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 page_filter in self.filters:
|
||||
where_stmts.append(page_filter.statement)
|
||||
if self.search and self.model:
|
||||
fields = self.model.__search_fields__
|
||||
if DB_TYPE == POSTGRES:
|
||||
where_stmts.append(
|
||||
f"lower(concat({', '.join(self.model.__search_fields__)})) LIKE ?"
|
||||
)
|
||||
where_stmts.append(f"lower(concat({', '.join(fields)})) LIKE :search")
|
||||
elif DB_TYPE == SQLITE:
|
||||
where_stmts.append(
|
||||
f"lower({'||'.join(self.model.__search_fields__)}) LIKE ?"
|
||||
)
|
||||
where_stmts.append(f"lower({'||'.join(fields)}) LIKE :search")
|
||||
if where_stmts:
|
||||
return "WHERE " + " AND ".join(where_stmts)
|
||||
return ""
|
||||
@@ -498,12 +506,14 @@ 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[dict] = None) -> dict:
|
||||
if not values:
|
||||
values = []
|
||||
values = {}
|
||||
if self.filters:
|
||||
for page_filter in self.filters:
|
||||
values.extend(page_filter.values)
|
||||
if page_filter.values:
|
||||
for key, value in page_filter.values.items():
|
||||
values[key] = value
|
||||
if self.search and self.model:
|
||||
values.append(f"%{self.search}%")
|
||||
return tuple(values)
|
||||
values["search"] = f"%{self.search}%"
|
||||
return values
|
||||
|
||||
+112
-171
@@ -1,12 +1,12 @@
|
||||
from http import HTTPStatus
|
||||
from typing import Annotated, Literal, Optional, Type, Union
|
||||
|
||||
import jwt
|
||||
from fastapi import Cookie, Depends, Query, Request, Security
|
||||
from fastapi.exceptions import HTTPException
|
||||
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
|
||||
from loguru import logger
|
||||
from pydantic.types import UUID4
|
||||
|
||||
@@ -15,25 +15,35 @@ from lnbits.core.crud import (
|
||||
get_account_by_email,
|
||||
get_account_by_username,
|
||||
get_user,
|
||||
get_user_active_extensions_ids,
|
||||
get_wallet_for_key,
|
||||
)
|
||||
from lnbits.core.models import User, Wallet, WalletType, WalletTypeInfo
|
||||
from lnbits.core.models import KeyType, SimpleStatus, 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)
|
||||
|
||||
api_key_header = APIKeyHeader(
|
||||
name="X-API-KEY",
|
||||
auto_error=False,
|
||||
description="Admin or Invoice key for wallet API's",
|
||||
)
|
||||
api_key_query = APIKeyQuery(
|
||||
name="api-key",
|
||||
auto_error=False,
|
||||
description="Admin or Invoice key for wallet API's",
|
||||
)
|
||||
|
||||
|
||||
class KeyChecker(SecurityBase):
|
||||
def __init__(
|
||||
self,
|
||||
scheme_name: Optional[str] = None,
|
||||
auto_error: bool = True,
|
||||
api_key: Optional[str] = None,
|
||||
expected_key_type: Optional[KeyType] = None,
|
||||
):
|
||||
self.scheme_name = scheme_name or self.__class__.__name__
|
||||
self.auto_error: bool = auto_error
|
||||
self._key_type: WalletType = WalletType.invoice
|
||||
self.auto_error: bool = True
|
||||
self.expected_key_type = expected_key_type
|
||||
self._api_key = api_key
|
||||
if api_key:
|
||||
openapi_model = APIKey(
|
||||
@@ -49,185 +59,73 @@ class KeyChecker(SecurityBase):
|
||||
name="X-API-KEY",
|
||||
description="Wallet API Key - HEADER",
|
||||
)
|
||||
self.wallet: Optional[Wallet] = None
|
||||
self.model: APIKey = openapi_model
|
||||
|
||||
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:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.UNAUTHORIZED,
|
||||
detail="Invalid key or wallet.",
|
||||
)
|
||||
self.wallet = wallet
|
||||
except KeyError:
|
||||
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.BAD_REQUEST, detail="`X-API-KEY` header missing."
|
||||
status_code=HTTPStatus.UNAUTHORIZED,
|
||||
detail="No Api Key provided.",
|
||||
)
|
||||
|
||||
wallet = await get_wallet_for_key(key_value)
|
||||
|
||||
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`.
|
||||
if not wallet:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND,
|
||||
detail="Wallet not found.",
|
||||
)
|
||||
|
||||
The checker will raise an HTTPException when the key is wrong in some ways.
|
||||
"""
|
||||
if self.expected_key_type is KeyType.admin and wallet.adminkey != key_value:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.UNAUTHORIZED,
|
||||
detail="Invalid adminkey.",
|
||||
)
|
||||
|
||||
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
|
||||
await _check_user_extension_access(wallet.user, request["path"])
|
||||
|
||||
|
||||
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,
|
||||
description="Admin or Invoice key for wallet API's",
|
||||
)
|
||||
api_key_query = APIKeyQuery(
|
||||
name="api-key",
|
||||
auto_error=False,
|
||||
description="Admin or Invoice key for wallet API's",
|
||||
)
|
||||
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(
|
||||
@@ -255,12 +153,24 @@ 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.")
|
||||
await _check_user_extension_access(user.id, r["path"])
|
||||
|
||||
return user
|
||||
|
||||
|
||||
async def optional_user_id(
|
||||
access_token: Annotated[Optional[str], Depends(check_access_token)],
|
||||
usr: Optional[UUID4] = None,
|
||||
) -> Optional[str]:
|
||||
if usr and settings.is_auth_method_allowed(AuthMethods.user_id_only):
|
||||
return usr.hex
|
||||
if access_token:
|
||||
account = await _get_account_from_token(access_token)
|
||||
return account.id if account else None
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def check_admin(user: Annotated[User, Depends(check_user_exists)]) -> User:
|
||||
if user.id != settings.super_user and user.id not in settings.lnbits_admin_users:
|
||||
raise HTTPException(
|
||||
@@ -294,9 +204,9 @@ def parse_filters(model: Type[TFilterModel]):
|
||||
):
|
||||
params = request.query_params
|
||||
filters = []
|
||||
for key in params.keys():
|
||||
for i, key in enumerate(params.keys()):
|
||||
try:
|
||||
filters.append(Filter.parse_query(key, params.getlist(key), model))
|
||||
filters.append(Filter.parse_query(key, params.getlist(key), model, i))
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
@@ -313,9 +223,40 @@ def parse_filters(model: Type[TFilterModel]):
|
||||
return dependency
|
||||
|
||||
|
||||
async def check_user_extension_access(user_id: str, ext_id: str) -> SimpleStatus:
|
||||
"""
|
||||
Check if the user has access to a particular extension.
|
||||
Raises HTTP Forbidden if the user is not allowed.
|
||||
"""
|
||||
if settings.is_admin_extension(ext_id) and not settings.is_admin_user(user_id):
|
||||
return SimpleStatus(
|
||||
success=False, message=f"User not authorized for extension '{ext_id}'."
|
||||
)
|
||||
|
||||
if settings.is_extension_id(ext_id):
|
||||
ext_ids = await get_user_active_extensions_ids(user_id)
|
||||
if ext_id not in ext_ids:
|
||||
return SimpleStatus(
|
||||
success=False, message=f"User extension '{ext_id}' not enabled."
|
||||
)
|
||||
|
||||
return SimpleStatus(success=True, message="OK")
|
||||
|
||||
|
||||
async def _check_user_extension_access(user_id: str, current_path: str):
|
||||
path = current_path.split("/")
|
||||
ext_id = path[3] if path[1] == "upgrades" else path[1]
|
||||
status = await check_user_extension_access(user_id, ext_id)
|
||||
if not status.success:
|
||||
raise HTTPException(
|
||||
HTTPStatus.FORBIDDEN,
|
||||
status.message,
|
||||
)
|
||||
|
||||
|
||||
async def _get_account_from_token(access_token):
|
||||
try:
|
||||
payload = jwt.decode(access_token, settings.auth_secret_key, "HS256")
|
||||
payload = jwt.decode(access_token, settings.auth_secret_key, ["HS256"])
|
||||
if "sub" in payload and payload.get("sub"):
|
||||
return await get_account_by_username(str(payload.get("sub")))
|
||||
if "usr" in payload and payload.get("usr"):
|
||||
@@ -324,10 +265,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 jwt.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 jwt.PyJWTError as exc:
|
||||
logger.debug(exc)
|
||||
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid access token.") from exc
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
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 .helpers import template_renderer
|
||||
|
||||
|
||||
class PaymentError(Exception):
|
||||
def __init__(self, message: str, status: str = "pending"):
|
||||
self.message = message
|
||||
self.status = status
|
||||
|
||||
|
||||
class InvoiceError(Exception):
|
||||
def __init__(self, message: str, status: str = "pending"):
|
||||
self.message = message
|
||||
self.status = status
|
||||
|
||||
|
||||
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
|
||||
|
||||
status_code: int = (
|
||||
exc.status_code
|
||||
if isinstance(exc, HTTPException)
|
||||
else HTTPStatus.INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
return template_renderer().TemplateResponse(
|
||||
request, "error.html", {"err": f"Error: {exc!s}"}, status_code
|
||||
)
|
||||
|
||||
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"{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"{exc.message}, Status: {exc.status}")
|
||||
return JSONResponse(
|
||||
status_code=520,
|
||||
content={"detail": exc.message, "status": exc.status},
|
||||
)
|
||||
+93
-19
@@ -32,6 +32,7 @@ class ExplicitRelease(BaseModel):
|
||||
warning: Optional[str]
|
||||
info_notification: Optional[str]
|
||||
critical_notification: Optional[str]
|
||||
details_link: Optional[str]
|
||||
pay_link: Optional[str]
|
||||
|
||||
def is_version_compatible(self):
|
||||
@@ -58,6 +59,9 @@ class GitHubRepoRelease(BaseModel):
|
||||
zipball_url: str
|
||||
html_url: str
|
||||
|
||||
def details_link(self, source_repo: str) -> str:
|
||||
return f"https://raw.githubusercontent.com/{source_repo}/{self.tag_name}/config.json"
|
||||
|
||||
|
||||
class GitHubRepo(BaseModel):
|
||||
stargazers_count: str
|
||||
@@ -85,6 +89,39 @@ class ReleasePaymentInfo(BaseModel):
|
||||
payment_request: Optional[str] = None
|
||||
|
||||
|
||||
class PayToEnableInfo(BaseModel):
|
||||
required: Optional[bool] = False
|
||||
amount: Optional[int] = None
|
||||
wallet: Optional[str] = None
|
||||
|
||||
|
||||
class UserExtensionInfo(BaseModel):
|
||||
paid_to_enable: Optional[bool] = False
|
||||
payment_hash_to_enable: Optional[str] = None
|
||||
|
||||
|
||||
class UserExtension(BaseModel):
|
||||
extension: str
|
||||
active: bool
|
||||
extra: Optional[UserExtensionInfo] = None
|
||||
|
||||
@property
|
||||
def is_paid(self) -> bool:
|
||||
if not self.extra:
|
||||
return False
|
||||
return self.extra.paid_to_enable is True
|
||||
|
||||
@classmethod
|
||||
def from_row(cls, data: dict) -> "UserExtension":
|
||||
ext = UserExtension(**data)
|
||||
ext.extra = (
|
||||
UserExtensionInfo(**json.loads(data["_extra"] or "{}"))
|
||||
if "_extra" in data
|
||||
else None
|
||||
)
|
||||
return ext
|
||||
|
||||
|
||||
def download_url(url, save_path):
|
||||
with request.urlopen(url, timeout=60) as dl_file:
|
||||
with open(save_path, "wb") as out_file:
|
||||
@@ -177,6 +214,24 @@ async def fetch_release_payment_info(
|
||||
return None
|
||||
|
||||
|
||||
async def fetch_release_details(details_link: str) -> Optional[dict]:
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(details_link)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
if "description_md" in data:
|
||||
resp = await client.get(data["description_md"])
|
||||
if not resp.is_error:
|
||||
data["description_md"] = resp.text
|
||||
|
||||
return data
|
||||
except Exception as e:
|
||||
logger.warning(e)
|
||||
return None
|
||||
|
||||
|
||||
def icon_to_github_url(source_repo: str, path: Optional[str]) -> str:
|
||||
if not path:
|
||||
return ""
|
||||
@@ -235,6 +290,7 @@ class ExtensionManager:
|
||||
|
||||
@property
|
||||
def extensions(self) -> List[Extension]:
|
||||
# todo: remove this property somehow, it is too expensive
|
||||
output: List[Extension] = []
|
||||
|
||||
for extension_folder in self._extension_folders:
|
||||
@@ -281,6 +337,7 @@ class ExtensionRelease(BaseModel):
|
||||
warning: Optional[str] = None
|
||||
repo: Optional[str] = None
|
||||
icon: Optional[str] = None
|
||||
details_link: Optional[str] = None
|
||||
|
||||
pay_link: Optional[str] = None
|
||||
cost_sats: Optional[int] = None
|
||||
@@ -313,6 +370,7 @@ class ExtensionRelease(BaseModel):
|
||||
archive=r.zipball_url,
|
||||
source_repo=source_repo,
|
||||
is_github_release=True,
|
||||
details_link=r.details_link(source_repo),
|
||||
repo=f"https://github.com/{source_repo}",
|
||||
html_url=r.html_url,
|
||||
)
|
||||
@@ -332,6 +390,7 @@ class ExtensionRelease(BaseModel):
|
||||
is_version_compatible=e.is_version_compatible(),
|
||||
warning=e.warning,
|
||||
html_url=e.html_url,
|
||||
details_link=e.details_link,
|
||||
pay_link=e.pay_link,
|
||||
repo=e.repo,
|
||||
icon=e.icon,
|
||||
@@ -353,6 +412,7 @@ class ExtensionRelease(BaseModel):
|
||||
class InstallableExtension(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
active: Optional[bool] = False
|
||||
short_description: Optional[str] = None
|
||||
icon: Optional[str] = None
|
||||
dependencies: List[str] = []
|
||||
@@ -362,6 +422,7 @@ class InstallableExtension(BaseModel):
|
||||
latest_release: Optional[ExtensionRelease] = None
|
||||
installed_release: Optional[ExtensionRelease] = None
|
||||
payments: List[ReleasePaymentInfo] = []
|
||||
pay_to_enable: Optional[PayToEnableInfo] = None
|
||||
archive: Optional[str] = None
|
||||
|
||||
@property
|
||||
@@ -412,6 +473,12 @@ class InstallableExtension(BaseModel):
|
||||
return self.installed_release.version
|
||||
return ""
|
||||
|
||||
@property
|
||||
def requires_payment(self) -> bool:
|
||||
if not self.pay_to_enable:
|
||||
return False
|
||||
return self.pay_to_enable.required is True
|
||||
|
||||
async def download_archive(self):
|
||||
logger.info(f"Downloading extension {self.name} ({self.installed_version}).")
|
||||
ext_zip_file = self.zip_path
|
||||
@@ -428,9 +495,9 @@ class InstallableExtension(BaseModel):
|
||||
|
||||
self._remember_payment_info()
|
||||
|
||||
except Exception as ex:
|
||||
logger.warning(ex)
|
||||
raise AssertionError("Cannot fetch extension archive file")
|
||||
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:
|
||||
@@ -479,21 +546,15 @@ 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 notify_upgrade(self) -> None:
|
||||
def notify_upgrade(self, upgrade_hash: Optional[str]) -> None:
|
||||
"""
|
||||
Update the list of upgraded extensions. The middleware will perform
|
||||
redirects based on this
|
||||
"""
|
||||
if upgrade_hash:
|
||||
settings.lnbits_upgraded_extensions.add(f"{self.hash}/{self.id}")
|
||||
|
||||
clean_upgraded_exts = list(
|
||||
filter(
|
||||
lambda old_ext: not old_ext.endswith(f"/{self.id}"),
|
||||
settings.lnbits_upgraded_extensions,
|
||||
)
|
||||
)
|
||||
settings.lnbits_upgraded_extensions = clean_upgraded_exts + [
|
||||
f"{self.hash}/{self.id}"
|
||||
]
|
||||
settings.lnbits_all_extensions_ids.add(self.id)
|
||||
|
||||
def clean_extension_files(self):
|
||||
# remove downloaded archive
|
||||
@@ -554,12 +615,19 @@ class InstallableExtension(BaseModel):
|
||||
ext = InstallableExtension(**data)
|
||||
if "installed_release" in meta:
|
||||
ext.installed_release = ExtensionRelease(**meta["installed_release"])
|
||||
if meta.get("pay_to_enable"):
|
||||
ext.pay_to_enable = PayToEnableInfo(**meta["pay_to_enable"])
|
||||
if meta.get("payments"):
|
||||
ext.payments = [ReleasePaymentInfo(**p) for p in meta["payments"]]
|
||||
|
||||
return ext
|
||||
|
||||
@classmethod
|
||||
def from_rows(cls, rows: List[Any] = []) -> List["InstallableExtension"]:
|
||||
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
|
||||
@@ -570,18 +638,18 @@ class InstallableExtension(BaseModel):
|
||||
repo, latest_release, config = await fetch_github_repo_info(
|
||||
github_release.organisation, github_release.repository
|
||||
)
|
||||
|
||||
source_repo = f"{github_release.organisation}/{github_release.repository}"
|
||||
return InstallableExtension(
|
||||
id=github_release.id,
|
||||
name=config.name,
|
||||
short_description=config.short_description,
|
||||
stars=int(repo.stargazers_count),
|
||||
icon=icon_to_github_url(
|
||||
f"{github_release.organisation}/{github_release.repository}",
|
||||
source_repo,
|
||||
config.tile,
|
||||
),
|
||||
latest_release=ExtensionRelease.from_github_release(
|
||||
repo.html_url, latest_release
|
||||
source_repo, latest_release
|
||||
),
|
||||
)
|
||||
except Exception as e:
|
||||
@@ -639,7 +707,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
|
||||
|
||||
@@ -666,7 +734,7 @@ class InstallableExtension(BaseModel):
|
||||
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
|
||||
|
||||
@@ -697,6 +765,12 @@ class CreateExtension(BaseModel):
|
||||
payment_hash: Optional[str] = None
|
||||
|
||||
|
||||
class ExtensionDetailsRequest(BaseModel):
|
||||
ext_id: str
|
||||
source_repo: str
|
||||
version: str
|
||||
|
||||
|
||||
def get_valid_extensions(include_deactivated: Optional[bool] = True) -> List[Extension]:
|
||||
valid_extensions = [
|
||||
extension for extension in ExtensionManager().extensions if extension.is_valid
|
||||
|
||||
+41
-6
@@ -5,11 +5,12 @@ from pathlib import Path
|
||||
from typing import Any, List, Optional, Type
|
||||
|
||||
import jinja2
|
||||
import jwt
|
||||
import shortuuid
|
||||
from jose import jwt
|
||||
from pydantic import BaseModel
|
||||
from pydantic.schema import field_schema
|
||||
|
||||
from lnbits.db import get_placeholder
|
||||
from lnbits.jinja2_templating import Jinja2Templates
|
||||
from lnbits.nodes import get_node_class
|
||||
from lnbits.requestvars import g
|
||||
@@ -20,6 +21,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,6 +72,11 @@ 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
|
||||
@@ -160,19 +179,28 @@ def insert_query(table_name: str, model: BaseModel) -> str:
|
||||
:param table_name: Name of the table
|
||||
:param model: Pydantic model
|
||||
"""
|
||||
placeholders = ", ".join(["?"] * len(model.dict().keys()))
|
||||
placeholders = []
|
||||
for field in model.dict().keys():
|
||||
placeholders.append(get_placeholder(model, field))
|
||||
fields = ", ".join(model.dict().keys())
|
||||
return f"INSERT INTO {table_name} ({fields}) VALUES ({placeholders})"
|
||||
values = ", ".join(placeholders)
|
||||
return f"INSERT INTO {table_name} ({fields}) VALUES ({values})"
|
||||
|
||||
|
||||
def update_query(table_name: str, model: BaseModel, where: str = "WHERE id = ?") -> str:
|
||||
def update_query(
|
||||
table_name: str, model: BaseModel, where: str = "WHERE id = :id"
|
||||
) -> str:
|
||||
"""
|
||||
Generate an update query with placeholders for a given table and model
|
||||
:param table_name: Name of the table
|
||||
:param model: Pydantic model
|
||||
:param where: Where string, default to `WHERE id = ?`
|
||||
:param where: Where string, default to `WHERE id = :id`
|
||||
"""
|
||||
query = ", ".join([f"{field} = ?" for field in model.dict().keys()])
|
||||
fields = []
|
||||
for field in model.dict().keys():
|
||||
placeholder = get_placeholder(model, field)
|
||||
fields.append(f"{field} = {placeholder}")
|
||||
query = ", ".join(fields)
|
||||
return f"UPDATE {table_name} SET {query} {where}"
|
||||
|
||||
|
||||
@@ -205,3 +233,10 @@ def decrypt_internal_message(m: Optional[str] = None) -> Optional[str]:
|
||||
if not m:
|
||||
return None
|
||||
return AESCipher(key=settings.auth_secret_key).decrypt(m)
|
||||
|
||||
|
||||
def filter_dict_keys(data: dict, filter_keys: Optional[list[str]]) -> dict:
|
||||
if not filter_keys:
|
||||
# return shallow clone of the dict even if there are no filters
|
||||
return {**data}
|
||||
return {key: data[key] for key in filter_keys if key in data}
|
||||
|
||||
+65
-1
@@ -1 +1,65 @@
|
||||
from lnurl import LnurlErrorResponse, decode, encode, handle # noqa: F401
|
||||
from typing import Callable
|
||||
|
||||
from fastapi import HTTPException, Request, Response
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.routing import APIRoute
|
||||
from lnurl import LnurlErrorResponse, decode, encode, handle
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.exceptions import InvoiceError, PaymentError
|
||||
|
||||
|
||||
class LnurlErrorResponseHandler(APIRoute):
|
||||
"""
|
||||
Custom APIRoute class to handle LNURL errors.
|
||||
LNURL errors always return with status 200 and
|
||||
a JSON response with `status="ERROR"` and a `reason` key.
|
||||
Helps to catch HTTPException and return a valid lnurl error response
|
||||
|
||||
Example:
|
||||
withdraw_lnurl_router = APIRouter(prefix="/api/v1/lnurl")
|
||||
withdraw_lnurl_router.route_class = LnurlErrorResponseHandler
|
||||
"""
|
||||
|
||||
def get_route_handler(self) -> Callable:
|
||||
original_route_handler = super().get_route_handler()
|
||||
|
||||
async def lnurl_route_handler(request: Request) -> Response:
|
||||
try:
|
||||
response = await original_route_handler(request)
|
||||
return response
|
||||
except (InvoiceError, PaymentError) as exc:
|
||||
logger.debug(f"Wallet Error: {exc}")
|
||||
response = JSONResponse(
|
||||
status_code=200,
|
||||
content={"status": "ERROR", "reason": f"{exc.message}"},
|
||||
)
|
||||
return response
|
||||
except HTTPException as exc:
|
||||
logger.debug(f"HTTPException: {exc}")
|
||||
response = JSONResponse(
|
||||
status_code=200,
|
||||
content={"status": "ERROR", "reason": f"{exc.detail}"},
|
||||
)
|
||||
return response
|
||||
except Exception as exc:
|
||||
logger.error("Unknown Error:", exc)
|
||||
response = JSONResponse(
|
||||
status_code=200,
|
||||
content={
|
||||
"status": "ERROR",
|
||||
"reason": f"UNKNOWN ERROR: {exc!s}",
|
||||
},
|
||||
)
|
||||
return response
|
||||
|
||||
return lnurl_route_handler
|
||||
|
||||
|
||||
__all__ = [
|
||||
"decode",
|
||||
"encode",
|
||||
"handle",
|
||||
"LnurlErrorResponse",
|
||||
"LnurlErrorResponseHandler",
|
||||
]
|
||||
|
||||
@@ -29,7 +29,7 @@ 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
|
||||
@@ -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)
|
||||
@@ -214,8 +214,6 @@ def add_ip_block_middleware(app: FastAPI):
|
||||
)
|
||||
return await call_next(request)
|
||||
|
||||
app.middleware("http")(block_allow_ip_middleware)
|
||||
|
||||
|
||||
def add_first_install_middleware(app: FastAPI):
|
||||
@app.middleware("http")
|
||||
@@ -228,5 +226,3 @@ def add_first_install_middleware(app: FastAPI):
|
||||
):
|
||||
return RedirectResponse("/first_install")
|
||||
return await call_next(request)
|
||||
|
||||
app.middleware("http")(first_install_middleware)
|
||||
|
||||
@@ -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
|
||||
|
||||
+26
-23
@@ -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"]}
|
||||
|
||||
+13
-11
@@ -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:
|
||||
@@ -174,9 +176,9 @@ class LndRestNode(Node):
|
||||
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"),
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
# Marker file for PEP 561
|
||||
+6
-19
@@ -16,7 +16,7 @@ from lnbits.settings import set_cli_settings, settings
|
||||
}
|
||||
)
|
||||
@click.option("--port", default=settings.port, help="Port to listen on")
|
||||
@click.option("--host", default=settings.host, help="Host to run LNBits on")
|
||||
@click.option("--host", default=settings.host, help="Host to run LNbits on")
|
||||
@click.option(
|
||||
"--forwarded-allow-ips",
|
||||
default=settings.forwarded_allow_ips,
|
||||
@@ -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 = {}
|
||||
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)
|
||||
|
||||
+105
-39
@@ -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,9 +46,10 @@ class UsersSettings(LNbitsSettings):
|
||||
|
||||
|
||||
class ExtensionsSettings(LNbitsSettings):
|
||||
lnbits_admin_extensions: List[str] = Field(default=[])
|
||||
lnbits_admin_extensions: list[str] = Field(default=[])
|
||||
lnbits_user_default_extensions: list[str] = Field(default=[])
|
||||
lnbits_extensions_deactivate_all: bool = Field(default=False)
|
||||
lnbits_extensions_manifests: List[str] = Field(
|
||||
lnbits_extensions_manifests: list[str] = Field(
|
||||
default=[
|
||||
"https://raw.githubusercontent.com/lnbits/lnbits-extensions/main/extensions.json"
|
||||
]
|
||||
@@ -56,18 +57,21 @@ 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: set[str] = Field(default=[])
|
||||
# upgraded extensions that require API redirects
|
||||
lnbits_upgraded_extensions: List[str] = Field(default=[])
|
||||
lnbits_upgraded_extensions: set[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=[])
|
||||
|
||||
# list of all extension ids
|
||||
lnbits_all_extensions_ids: set[Any] = Field(default=[])
|
||||
|
||||
def extension_upgrade_path(self, ext_id: str) -> Optional[str]:
|
||||
return next(
|
||||
@@ -83,9 +87,14 @@ class InstalledExtensionsSettings(LNbitsSettings):
|
||||
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: Optional[str] = Field(default=None)
|
||||
lnbits_custom_badge_color: str = Field(default="warning")
|
||||
lnbits_theme_options: list[str] = Field(
|
||||
default=[
|
||||
"classic",
|
||||
"freedom",
|
||||
@@ -96,13 +105,13 @@ class ThemesSettings(LNbitsSettings):
|
||||
"cyber",
|
||||
]
|
||||
)
|
||||
lnbits_custom_logo: str = Field(default=None)
|
||||
lnbits_custom_logo: Optional[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")
|
||||
|
||||
@@ -114,7 +123,7 @@ class OpsSettings(LNbitsSettings):
|
||||
lnbits_service_fee: float = Field(default=0)
|
||||
lnbits_service_fee_ignore_internal: bool = Field(default=True)
|
||||
lnbits_service_fee_max: int = Field(default=0)
|
||||
lnbits_service_fee_wallet: str = Field(default=None)
|
||||
lnbits_service_fee_wallet: Optional[str] = Field(default=None)
|
||||
lnbits_hide_api: bool = Field(default=False)
|
||||
lnbits_denomination: str = Field(default="sats")
|
||||
|
||||
@@ -122,8 +131,8 @@ 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)
|
||||
@@ -152,7 +161,7 @@ class FakeWalletFundingSource(LNbitsSettings):
|
||||
|
||||
|
||||
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)
|
||||
@@ -164,6 +173,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)
|
||||
|
||||
|
||||
@@ -208,11 +218,22 @@ class LnPayFundingSource(LNbitsSettings):
|
||||
lnpay_admin_key: Optional[str] = Field(default=None)
|
||||
|
||||
|
||||
class BlinkFundingSource(LNbitsSettings):
|
||||
blink_api_endpoint: Optional[str] = Field(default="https://api.blink.sv/graphql")
|
||||
blink_ws_endpoint: Optional[str] = Field(default="wss://ws.blink.sv/graphql")
|
||||
blink_token: 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)
|
||||
@@ -237,6 +258,25 @@ class LnTipsFundingSource(LNbitsSettings):
|
||||
lntips_invoice_key: Optional[str] = Field(default=None)
|
||||
|
||||
|
||||
class NWCFundingSource(LNbitsSettings):
|
||||
nwc_pairing_url: Optional[str] = Field(default=None)
|
||||
|
||||
|
||||
class BreezSdkFundingSource(LNbitsSettings):
|
||||
breez_api_key: Optional[str] = Field(default=None)
|
||||
breez_greenlight_seed: Optional[str] = Field(default=None)
|
||||
breez_greenlight_invite_code: Optional[str] = Field(default=None)
|
||||
breez_greenlight_device_key: Optional[str] = Field(default=None)
|
||||
breez_greenlight_device_cert: Optional[str] = Field(default=None)
|
||||
|
||||
|
||||
class BoltzFundingSource(LNbitsSettings):
|
||||
boltz_client_endpoint: Optional[str] = Field(default="127.0.0.1:9002")
|
||||
boltz_client_macaroon: Optional[str] = Field(default=None)
|
||||
boltz_client_wallet: Optional[str] = Field(default="lnbits")
|
||||
boltz_client_cert: Optional[str] = Field(default=None)
|
||||
|
||||
|
||||
class LightningSettings(LNbitsSettings):
|
||||
lightning_invoice_expiry: int = Field(default=3600)
|
||||
|
||||
@@ -251,18 +291,23 @@ class FundingSourcesSettings(
|
||||
LndRestFundingSource,
|
||||
LndGrpcFundingSource,
|
||||
LnPayFundingSource,
|
||||
BlinkFundingSource,
|
||||
AlbyFundingSource,
|
||||
BoltzFundingSource,
|
||||
ZBDFundingSource,
|
||||
PhoenixdFundingSource,
|
||||
OpenNodeFundingSource,
|
||||
SparkFundingSource,
|
||||
LnTipsFundingSource,
|
||||
NWCFundingSource,
|
||||
BreezSdkFundingSource,
|
||||
):
|
||||
lnbits_backend_wallet_class: str = Field(default="VoidWallet")
|
||||
|
||||
|
||||
class WebPushSettings(LNbitsSettings):
|
||||
lnbits_webpush_pubkey: str = Field(default=None)
|
||||
lnbits_webpush_privkey: str = Field(default=None)
|
||||
lnbits_webpush_pubkey: Optional[str] = Field(default=None)
|
||||
lnbits_webpush_privkey: Optional[str] = Field(default=None)
|
||||
|
||||
|
||||
class NodeUISettings(LNbitsSettings):
|
||||
@@ -286,7 +331,7 @@ class AuthMethods(Enum):
|
||||
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,
|
||||
@@ -377,6 +422,7 @@ class EnvSettings(LNbitsSettings):
|
||||
log_retention: str = Field(default="3 months")
|
||||
server_startup_time: int = Field(default=time())
|
||||
cleanup_wallets_days: int = Field(default=90)
|
||||
funding_source_max_retries: int = Field(default=4)
|
||||
|
||||
@property
|
||||
def has_default_extension_path(self) -> bool:
|
||||
@@ -395,21 +441,26 @@ 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",
|
||||
"ZBDWallet",
|
||||
"BoltzWallet",
|
||||
"BlinkWallet",
|
||||
"BreezSdkWallet",
|
||||
"CoreLightningRestWallet",
|
||||
"CoreLightningWallet",
|
||||
"EclairWallet",
|
||||
"FakeWallet",
|
||||
"LNPayWallet",
|
||||
"LNbitsWallet",
|
||||
"LnTipsWallet",
|
||||
"LndRestWallet",
|
||||
"LndWallet",
|
||||
"OpenNodeWallet",
|
||||
"PhoenixdWallet",
|
||||
"VoidWallet",
|
||||
"ZBDWallet",
|
||||
"NWCWallet",
|
||||
]
|
||||
)
|
||||
|
||||
@@ -422,6 +473,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("_")]
|
||||
@@ -451,7 +508,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)
|
||||
|
||||
@@ -461,7 +518,7 @@ class Settings(EditableSettings, ReadOnlySettings, TransientSettings, BaseSettin
|
||||
case_sensitive = False
|
||||
json_loads = list_parse_fallback
|
||||
|
||||
def is_user_allowed(self, user_id: str):
|
||||
def is_user_allowed(self, user_id: str) -> bool:
|
||||
return (
|
||||
len(self.lnbits_allowed_users) == 0
|
||||
or user_id in self.lnbits_allowed_users
|
||||
@@ -469,6 +526,15 @@ class Settings(EditableSettings, ReadOnlySettings, TransientSettings, BaseSettin
|
||||
or user_id == self.super_user
|
||||
)
|
||||
|
||||
def is_admin_user(self, user_id: str) -> bool:
|
||||
return user_id in self.lnbits_admin_users or user_id == self.super_user
|
||||
|
||||
def is_admin_extension(self, ext_id: str) -> bool:
|
||||
return ext_id in self.lnbits_admin_extensions
|
||||
|
||||
def is_extension_id(self, ext_id: str) -> bool:
|
||||
return ext_id in self.lnbits_all_extensions_ids
|
||||
|
||||
|
||||
class SuperSettings(EditableSettings):
|
||||
super_user: str
|
||||
@@ -476,7 +542,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):
|
||||
@@ -505,7 +571,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}"
|
||||
)
|
||||
|
||||
|
||||
@@ -531,10 +597,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
+11
-11
File diff suppressed because one or more lines are too long
@@ -554,3 +554,12 @@ video {
|
||||
.whitespace-pre-line {
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
.q-carousel__slide {
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
.q-dialog__inner--minimized {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ window.localisation.br = {
|
||||
transactions: 'Transações',
|
||||
dashboard: 'Painel de Controle',
|
||||
node: 'Nó',
|
||||
export_users: 'Exportar Usuários',
|
||||
no_users: 'Nenhum usuário encontrado',
|
||||
total_capacity: 'Capacidade Total',
|
||||
avg_channel_size: 'Tamanho médio do canal',
|
||||
biggest_channel_size: 'Maior Tamanho de Canal',
|
||||
@@ -34,6 +36,8 @@ window.localisation.br = {
|
||||
'Apagar todas as configurações e redefinir para os padrões.',
|
||||
download_backup: 'Fazer backup do banco de dados',
|
||||
name_your_wallet: 'Nomeie sua carteira %{name}',
|
||||
wallet_topup_ok:
|
||||
'Sucesso ao criar fundos virtuais (%{amount} sats). Pagamentos dependem dos fundos reais na fonte de financiamento.',
|
||||
paste_invoice_label: 'Cole uma fatura, pedido de pagamento ou código lnurl *',
|
||||
lnbits_description:
|
||||
'Fácil de configurar e leve, o LNbits pode ser executado em qualquer fonte de financiamento da Lightning Network e até mesmo o LNbits em si! Você pode executar o LNbits para si mesmo ou oferecer facilmente uma solução de custódia para outros. Cada carteira tem suas próprias chaves de API e não há limite para o número de carteiras que você pode criar. Ser capaz de particionar fundos torna o LNbits uma ferramenta útil para gerenciamento de dinheiro e como uma ferramenta de desenvolvimento. As extensões adicionam funcionalidades extras ao LNbits para que você possa experimentar uma série de tecnologias de ponta na rede lightning. Nós tornamos o desenvolvimento de extensões o mais fácil possível e, como um projeto gratuito e de código aberto, incentivamos as pessoas a desenvolver e enviar as suas próprias.',
|
||||
@@ -99,6 +103,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',
|
||||
@@ -164,7 +169,7 @@ window.localisation.br = {
|
||||
'Se ativado, mudará sua fonte de fundos para VoidWallet automaticamente se o LNbits enviar um sinal de desativação. Você precisará ativar manualmente após uma atualização.',
|
||||
killswitch_interval: 'Intervalo do Killswitch',
|
||||
killswitch_interval_desc:
|
||||
'Com que frequência a tarefa de fundo deve verificar o sinal de desativação do LNBits proveniente da fonte de status (em minutos).',
|
||||
'Com que frequência a tarefa de fundo deve verificar o sinal de desativação do LNbits proveniente da fonte de status (em minutos).',
|
||||
enable_watchdog: 'Ativar Watchdog',
|
||||
enable_watchdog_desc:
|
||||
'Se ativado, ele mudará automaticamente sua fonte de financiamento para VoidWallet se o seu saldo for inferior ao saldo do LNbits. Você precisará ativar manualmente após uma atualização.',
|
||||
@@ -241,5 +246,13 @@ window.localisation.br = {
|
||||
logout: 'Sair',
|
||||
look_and_feel: 'Aparência',
|
||||
language: 'Idioma',
|
||||
color_scheme: 'Esquema de Cores'
|
||||
color_scheme: 'Esquema de Cores',
|
||||
extension_cost: 'Este lançamento requer um pagamento mínimo de %{cost} sats.',
|
||||
extension_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',
|
||||
hide_empty_wallets: 'Ocultar carteiras vazias'
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ window.localisation.cn = {
|
||||
transactions: '交易记录',
|
||||
dashboard: '控制面板',
|
||||
node: '节点',
|
||||
export_users: '导出用户',
|
||||
no_users: '未找到用户',
|
||||
total_capacity: '总容量',
|
||||
avg_channel_size: '平均频道大小',
|
||||
biggest_channel_size: '最大通道大小',
|
||||
@@ -33,6 +35,8 @@ window.localisation.cn = {
|
||||
reset_defaults_tooltip: '删除所有设置并重置为默认设置',
|
||||
download_backup: '下载数据库备份',
|
||||
name_your_wallet: '给你的 %{name}钱包起个名字',
|
||||
wallet_topup_ok:
|
||||
'成功创建虚拟资金(%{amount} sats)。付款取决于资金来源的实际资金。',
|
||||
paste_invoice_label: '粘贴发票,付款请求或lnurl*',
|
||||
lnbits_description:
|
||||
'LNbits 设置简单、轻量级,可以在任何闪电网络的资金来源上运行,甚至可以在LNbits自身上运行!您可以为自己运行LNbits,或者轻松为他人提供托管解决方案。每个钱包都有自己的 API 密钥,你可以创建的钱包数量没有限制。能够把资金分开管理使 LNbits 成为一款有用的资金管理和开发工具。扩展程序增加了 LNbits 的额外功能,所以你可以在闪电网络上尝试各种尖端技术。我们已经尽可能简化了开发扩展程序的过程,作为一个免费和开源的项目,我们鼓励人们开发并提交自己的扩展程序。',
|
||||
@@ -95,6 +99,7 @@ window.localisation.cn = {
|
||||
'这是一个 LNURL-取款的二维码,用于从该钱包中提取全部资金。请不要与他人分享。它与 balanceCheck 和 balanceNotify 兼容,因此在第一次取款后,您的钱包还可能会持续从这里提取资金',
|
||||
i_understand: '我明白',
|
||||
copy_wallet_url: '复制钱包URL',
|
||||
disclaimer_dialog_title: '重要!',
|
||||
disclaimer_dialog:
|
||||
'登录功能将在以后的更新中发布,请将此页面加为书签,以便将来访问您的钱包!此服务处于测试阶段,我们不对资金的丢失承担任何责任。',
|
||||
no_transactions: '尚未进行任何交易',
|
||||
@@ -154,7 +159,7 @@ window.localisation.cn = {
|
||||
'如果启用,当LNbits发送终止信号时,系统将自动将您的资金来源更改为VoidWallet。更新后,您将需要手动启用。',
|
||||
killswitch_interval: 'Killswitch 间隔',
|
||||
killswitch_interval_desc:
|
||||
'后台任务应该多久检查一次来自状态源的LNBits断路信号(以分钟为单位)。',
|
||||
'后台任务应该多久检查一次来自状态源的LNbits断路信号(以分钟为单位)。',
|
||||
enable_watchdog: '启用看门狗',
|
||||
enable_watchdog_desc:
|
||||
'如果启用,当您的余额低于LNbits余额时,系统将自动将您的资金来源更改为VoidWallet。更新后您将需要手动启用。',
|
||||
@@ -229,5 +234,13 @@ window.localisation.cn = {
|
||||
logout: '注销',
|
||||
look_and_feel: '外观和感觉',
|
||||
language: '语言',
|
||||
color_scheme: '配色方案'
|
||||
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: '创建新支付',
|
||||
hide_empty_wallets: '隐藏空钱包'
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ window.localisation.cs = {
|
||||
transactions: 'Transakce',
|
||||
dashboard: 'Přehled',
|
||||
node: 'Uzel',
|
||||
export_users: 'Exportovat uživatele',
|
||||
no_users: 'Nebyli nalezeni žádní uživatelé',
|
||||
total_capacity: 'Celková kapacita',
|
||||
avg_channel_size: 'Průmerná velikost kanálu',
|
||||
biggest_channel_size: 'Největší velikost kanálu',
|
||||
@@ -33,6 +35,8 @@ window.localisation.cs = {
|
||||
reset_defaults_tooltip: 'Smazat všechna nastavení a obnovit výchozí.',
|
||||
download_backup: 'Stáhnout zálohu databáze',
|
||||
name_your_wallet: 'Pojmenujte svou %{name} peněženku',
|
||||
wallet_topup_ok:
|
||||
'Úspěšně vytvořeny virtuální prostředky (%{amount} sats). Platby závisí na skutečných prostředcích na zdrojovém účtu.',
|
||||
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 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í.',
|
||||
@@ -99,6 +103,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',
|
||||
@@ -161,7 +166,7 @@ window.localisation.cs = {
|
||||
'Pokud je povoleno, automaticky změní zdroj financování na VoidWallet pokud LNbits odešle signál killswitch. Po aktualizaci budete muset povolit ručně.',
|
||||
killswitch_interval: 'Interval Killswitch',
|
||||
killswitch_interval_desc:
|
||||
'Jak často by měl úkol na pozadí kontrolovat signál killswitch od LNBits ze zdroje stavu (v minutách).',
|
||||
'Jak často by měl úkol na pozadí kontrolovat signál killswitch od LNbits ze zdroje stavu (v minutách).',
|
||||
enable_watchdog: 'Povolit Watchdog',
|
||||
enable_watchdog_desc:
|
||||
'Pokud je povoleno, automaticky změní zdroj financování na VoidWallet pokud je váš zůstatek nižší než zůstatek LNbits. Po aktualizaci budete muset povolit ručně.',
|
||||
@@ -238,5 +243,13 @@ window.localisation.cs = {
|
||||
logout: 'Odhlásit se',
|
||||
look_and_feel: 'Vzhled a chování',
|
||||
language: 'Jazyk',
|
||||
color_scheme: 'Barevné schéma'
|
||||
color_scheme: 'Barevné schéma',
|
||||
extension_cost: 'Toto vydání vyžaduje minimální platbu %{cost} satoshi.',
|
||||
extension_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',
|
||||
hide_empty_wallets: 'Skrýt prázdné peněženky'
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ window.localisation.de = {
|
||||
transactions: 'Transaktionen',
|
||||
dashboard: 'Armaturenbrett',
|
||||
node: 'Knoten',
|
||||
export_users: 'Benutzer exportieren',
|
||||
no_users: 'Keine Benutzer gefunden',
|
||||
total_capacity: 'Gesamtkapazität',
|
||||
avg_channel_size: 'Durchschn. Kanalgröße',
|
||||
biggest_channel_size: 'Größte Kanalgröße',
|
||||
@@ -34,6 +36,8 @@ window.localisation.de = {
|
||||
'Alle Einstellungen auf die Standardeinstellungen zurücksetzen.',
|
||||
download_backup: 'Datenbank-Backup herunterladen',
|
||||
name_your_wallet: 'Vergib deiner %{name} Wallet einen Namen',
|
||||
wallet_topup_ok:
|
||||
'Erfolg beim Erstellen von virtuellen Mitteln (%{amount} Satoshis). Zahlungen hängen von den tatsächlichen Mitteln der Finanzierungsquelle ab.',
|
||||
paste_invoice_label:
|
||||
'Füge eine Rechnung, Zahlungsanforderung oder LNURL ein *',
|
||||
lnbits_description:
|
||||
@@ -102,6 +106,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',
|
||||
@@ -166,7 +171,7 @@ window.localisation.de = {
|
||||
'Falls aktiviert, wird Ihre Zahlungsquelle automatisch auf VoidWallet umgestellt, wenn LNbits ein Killswitch-Signal sendet. Nach einem Update müssen Sie dies manuell wieder aktivieren.',
|
||||
killswitch_interval: 'Intervall für den Notausschalter',
|
||||
killswitch_interval_desc:
|
||||
'Wie oft die Hintergrundaufgabe nach dem LNBits-Killswitch-Signal aus der Statusquelle suchen soll (in Minuten).',
|
||||
'Wie oft die Hintergrundaufgabe nach dem LNbits-Killswitch-Signal aus der Statusquelle suchen soll (in Minuten).',
|
||||
enable_watchdog: 'Aktiviere Watchdog',
|
||||
enable_watchdog_desc:
|
||||
'Wenn aktiviert, wird Ihre Zahlungsquelle automatisch auf VoidWallet umgestellt, wenn Ihr Guthaben niedriger als das LNbits-Guthaben ist. Nach einem Update müssen Sie dies manuell aktivieren.',
|
||||
@@ -245,5 +250,14 @@ window.localisation.de = {
|
||||
logout: 'Abmelden',
|
||||
look_and_feel: 'Aussehen und Verhalten',
|
||||
language: 'Sprache',
|
||||
color_scheme: 'Farbschema'
|
||||
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',
|
||||
hide_empty_wallets: 'Leere Geldbörsen verbergen'
|
||||
}
|
||||
|
||||
@@ -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,6 +35,8 @@ 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 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.',
|
||||
@@ -98,11 +102,8 @@ window.localisation.en = {
|
||||
i_understand: 'I understand',
|
||||
copy_wallet_url: 'Copy wallet URL',
|
||||
disclaimer_dialog_title: 'Important!',
|
||||
disclaimer_dialog: `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.
|
||||
|
||||
Find your login credentials on your account settings page.
|
||||
|
||||
This service is in BETA. LNbits holds no responsibility for loss of access to funds.`,
|
||||
disclaimer_dialog:
|
||||
'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',
|
||||
@@ -117,6 +118,7 @@ This service is in BETA. LNbits holds no responsibility for loss of access to fu
|
||||
uninstall: 'Uninstall',
|
||||
drop_db: 'Remove Data',
|
||||
enable: 'Enable',
|
||||
pay_to_enable: 'Pay To Enable',
|
||||
enable_extension_details: 'Enable extension for current user',
|
||||
disable: 'Disable',
|
||||
installed: 'Installed',
|
||||
@@ -143,6 +145,7 @@ This service is in BETA. LNbits holds no responsibility for loss of access to fu
|
||||
payment_hash: 'Payment Hash',
|
||||
fee: 'Fee',
|
||||
amount: 'Amount',
|
||||
amount_sats: 'Amount (sats)',
|
||||
tag: 'Tag',
|
||||
unit: 'Unit',
|
||||
description: 'Description',
|
||||
@@ -162,7 +165,7 @@ This service is in BETA. LNbits holds no responsibility for loss of access to fu
|
||||
'If enabled it will change your funding source to VoidWallet automatically if LNbits sends out a killswitch signal. You will need to enable manually after an update.',
|
||||
killswitch_interval: 'Killswitch Interval',
|
||||
killswitch_interval_desc:
|
||||
'How often the background task should check for the LNBits killswitch signal from the status source (in minutes).',
|
||||
'How often the background task should check for the LNbits killswitch signal from the status source (in minutes).',
|
||||
enable_watchdog: 'Enable Watchdog',
|
||||
enable_watchdog_desc:
|
||||
'If enabled it will change your funding source to VoidWallet automatically if your balance is lower than the LNbits balance. You will need to enable manually after an update.',
|
||||
@@ -238,13 +241,26 @@ This service is in BETA. LNbits holds no responsibility for loss of access to fu
|
||||
back: 'Back',
|
||||
logout: 'Logout',
|
||||
look_and_feel: 'Look and Feel',
|
||||
toggle_gradient: 'Toggle Gradient',
|
||||
gradient_background: 'Gradient Background',
|
||||
language: 'Language',
|
||||
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',
|
||||
wallet_required: 'Wallet *',
|
||||
show_qr: 'Show QR',
|
||||
retry_install: 'Retry Install',
|
||||
new_payment: 'Make New Payment'
|
||||
new_payment: 'Make New Payment',
|
||||
update_payment: 'Update Payment',
|
||||
already_paid_question: 'Have you already paid?',
|
||||
sell: 'Sell',
|
||||
sell_require: 'Ask payment to enable extension',
|
||||
sell_info:
|
||||
'The %{name} extension requires a payment of minimum %{amount} sats to enable.',
|
||||
hide_empty_wallets: 'Hide empty wallets',
|
||||
recheck: 'Recheck',
|
||||
contributors: 'Contributors',
|
||||
license: 'License'
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ window.localisation.es = {
|
||||
transactions: 'Transacciones',
|
||||
dashboard: 'Tablero de instrumentos',
|
||||
node: 'Nodo',
|
||||
export_users: 'Exportar Usuarios',
|
||||
no_users: 'No se encontraron usuarios',
|
||||
total_capacity: 'Capacidad Total',
|
||||
avg_channel_size: 'Tamaño Medio del Canal',
|
||||
biggest_channel_size: 'Tamaño del Canal Más Grande',
|
||||
@@ -34,6 +36,8 @@ window.localisation.es = {
|
||||
'Borrar todas las configuraciones y restablecer a los valores predeterminados.',
|
||||
download_backup: 'Descargar copia de seguridad de la base de datos',
|
||||
name_your_wallet: 'Nombre de su billetera %{name}',
|
||||
wallet_topup_ok:
|
||||
'Éxito creando fondos virtuales (%{amount} sats). Los pagos dependen de los fondos reales en la fuente de financiación.',
|
||||
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 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.',
|
||||
@@ -99,6 +103,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',
|
||||
@@ -164,7 +169,7 @@ window.localisation.es = {
|
||||
'Si está activado, cambiará automáticamente su fuente de financiamiento a VoidWallet si LNbits envía una señal de parada de emergencia. Necesitará activarlo manualmente después de una actualización.',
|
||||
killswitch_interval: 'Intervalo de Killswitch',
|
||||
killswitch_interval_desc:
|
||||
'Con qué frecuencia la tarea en segundo plano debe verificar la señal de interruptor de emergencia de LNBits desde la fuente de estado (en minutos).',
|
||||
'Con qué frecuencia la tarea en segundo plano debe verificar la señal de interruptor de emergencia de LNbits desde la fuente de estado (en minutos).',
|
||||
enable_watchdog: 'Activar Watchdog',
|
||||
enable_watchdog_desc:
|
||||
'Si está activado, cambiará automáticamente su fuente de financiamiento a VoidWallet si su saldo es inferior al saldo de LNbits. Tendrá que activarlo manualmente después de una actualización.',
|
||||
@@ -243,5 +248,13 @@ window.localisation.es = {
|
||||
logout: 'Cerrar sesión',
|
||||
look_and_feel: 'Apariencia',
|
||||
language: 'Idioma',
|
||||
color_scheme: 'Esquema de colores'
|
||||
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',
|
||||
hide_empty_wallets: 'Ocultar billeteras vacías'
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ window.localisation.fi = {
|
||||
transactions: 'Tapahtumat',
|
||||
dashboard: 'Ohjauspaneeli',
|
||||
node: 'Solmu',
|
||||
export_users: 'Vie käyttäjät',
|
||||
no_users: 'Käyttäjiä ei löytynyt',
|
||||
total_capacity: 'Kokonaiskapasiteetti',
|
||||
avg_channel_size: 'Keskimääräisen kanavan kapasiteetti',
|
||||
biggest_channel_size: 'Suurimman kanavan kapasiteetti',
|
||||
@@ -34,6 +36,8 @@ window.localisation.fi = {
|
||||
'Poista kaikki asetusten muutokset ja palauta järjestelmän oletusasetukset.',
|
||||
download_backup: 'Lataa tietokannan varmuuskopio',
|
||||
name_your_wallet: 'Anna %{name}-lompakollesi nimi',
|
||||
wallet_topup_ok:
|
||||
'Virtuaalisten varojen luominen onnistui (%{amount} sats). Maksut riippuvat rahoituslähteen todellisista varoista.',
|
||||
paste_invoice_label:
|
||||
'Liitä lasku, maksupyyntö, lnurl-koodi tai Lightning Address *',
|
||||
lnbits_description:
|
||||
@@ -62,9 +66,9 @@ window.localisation.fi = {
|
||||
service_fee_max:
|
||||
'Palvelumaksu: %{amount} % tapahtumasta (enintään %{max} sat)',
|
||||
service_fee_tooltip:
|
||||
'LNBits palvelimen ylläpitäjä veloittaa lähtevästä maksusta palvelumaksun.',
|
||||
'LNbits palvelimen ylläpitäjä veloittaa lähtevästä maksusta palvelumaksun.',
|
||||
toggle_darkmode: 'Tumma näkymä',
|
||||
toggle_reactions: 'Käytä tapahtuma efektejä',
|
||||
payment_reactions: 'Maksureaktiot',
|
||||
view_swagger_docs: 'Näytä LNbits Swagger API-dokumentit',
|
||||
api_docs: 'API-dokumentaatio',
|
||||
api_keys_api_docs: 'Solmun URL, API-avaimet ja -dokumentaatio',
|
||||
@@ -102,6 +106,7 @@ 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',
|
||||
@@ -241,5 +246,13 @@ 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',
|
||||
hide_empty_wallets: 'Piilota tyhjät lompakot'
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ window.localisation.fr = {
|
||||
transactions: 'Transactions',
|
||||
dashboard: 'Tableau de bord',
|
||||
node: 'Noeud',
|
||||
export_users: 'Exporter les utilisateurs',
|
||||
no_users: 'Aucun utilisateur trouvé',
|
||||
total_capacity: 'Capacité totale',
|
||||
avg_channel_size: 'Taille moyenne du canal',
|
||||
biggest_channel_size: 'Taille de canal maximale',
|
||||
@@ -36,6 +38,8 @@ window.localisation.fr = {
|
||||
'Supprimer tous les paramètres et les réinitialiser aux valeurs par défaut.',
|
||||
download_backup: 'Télécharger la sauvegarde de la base de données',
|
||||
name_your_wallet: 'Nommez votre portefeuille %{name}',
|
||||
wallet_topup_ok:
|
||||
'Succès de la création de fonds virtuels (%{amount} sats). Les paiements dépendent des fonds réels sur la source de financement.',
|
||||
paste_invoice_label:
|
||||
'Coller une facture, une demande de paiement ou un code lnurl *',
|
||||
lnbits_description:
|
||||
@@ -103,6 +107,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',
|
||||
@@ -168,7 +173,7 @@ window.localisation.fr = {
|
||||
'Si activé, il changera automatiquement votre source de financement en VoidWallet si LNbits envoie un signal de coupure. Vous devrez activer manuellement après une mise à jour.',
|
||||
killswitch_interval: 'Intervalle du Killswitch',
|
||||
killswitch_interval_desc:
|
||||
"À quelle fréquence la tâche de fond doit-elle vérifier le signal d'arrêt d'urgence LNBits provenant de la source de statut (en minutes).",
|
||||
"À quelle fréquence la tâche de fond doit-elle vérifier le signal d'arrêt d'urgence LNbits provenant de la source de statut (en minutes).",
|
||||
enable_watchdog: 'Activer le Watchdog',
|
||||
enable_watchdog_desc:
|
||||
'Si elle est activée, elle changera automatiquement votre source de financement en VoidWallet si votre solde est inférieur au solde LNbits. Vous devrez activer manuellement après une mise à jour.',
|
||||
@@ -247,5 +252,14 @@ window.localisation.fr = {
|
||||
logout: 'Déconnexion',
|
||||
look_and_feel: 'Apparence',
|
||||
language: 'Langue',
|
||||
color_scheme: 'Schéma de couleurs'
|
||||
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',
|
||||
hide_empty_wallets: 'Masquer les portefeuilles vides'
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ window.localisation.it = {
|
||||
transactions: 'Transazioni',
|
||||
dashboard: 'Pannello di controllo',
|
||||
node: 'Interruttore',
|
||||
export_users: 'Esporta utenti',
|
||||
no_users: 'Nessun utente trovato',
|
||||
total_capacity: 'Capacità Totale',
|
||||
avg_channel_size: 'Dimensione media del canale',
|
||||
biggest_channel_size: 'Dimensione del canale più grande',
|
||||
@@ -34,6 +36,8 @@ window.localisation.it = {
|
||||
'Cancella tutte le impostazioni e ripristina i valori predefiniti',
|
||||
download_backup: 'Scarica il backup del database',
|
||||
name_your_wallet: 'Dai un nome al tuo portafoglio %{name}',
|
||||
wallet_topup_ok:
|
||||
'Operazione riuscita nella creazione di fondi virtuali (%{amount} sats). I pagamenti dipendono dai fondi effettivi sulla fonte di finanziamento.',
|
||||
paste_invoice_label:
|
||||
'Incolla una fattura, una richiesta di pagamento o un codice lnurl *',
|
||||
lnbits_description:
|
||||
@@ -100,6 +104,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',
|
||||
@@ -164,7 +169,7 @@ window.localisation.it = {
|
||||
'Se attivato, cambierà automaticamente la tua fonte di finanziamento in VoidWallet se LNbits invia un segnale di killswitch. Dovrai attivare manualmente dopo un aggiornamento.',
|
||||
killswitch_interval: 'Intervallo Killswitch',
|
||||
killswitch_interval_desc:
|
||||
'Quanto spesso il compito in background dovrebbe controllare il segnale di killswitch LNBits dalla fonte di stato (in minuti).',
|
||||
'Quanto spesso il compito in background dovrebbe controllare il segnale di killswitch LNbits dalla fonte di stato (in minuti).',
|
||||
enable_watchdog: 'Attiva Watchdog',
|
||||
enable_watchdog_desc:
|
||||
'Se abilitato, cambierà automaticamente la tua fonte di finanziamento in VoidWallet se il tuo saldo è inferiore al saldo LNbits. Dovrai abilitarlo manualmente dopo un aggiornamento.',
|
||||
@@ -244,5 +249,14 @@ window.localisation.it = {
|
||||
logout: 'Esci',
|
||||
look_and_feel: 'Aspetto e Comportamento',
|
||||
language: 'Lingua',
|
||||
color_scheme: 'Schema dei colori'
|
||||
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',
|
||||
hide_empty_wallets: 'Nascondi portafogli vuoti'
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ window.localisation.jp = {
|
||||
transactions: 'トランザクション',
|
||||
dashboard: 'ダッシュボード',
|
||||
node: 'ノード',
|
||||
export_users: 'ユーザーのエクスポート',
|
||||
no_users: 'ユーザーが見つかりません',
|
||||
total_capacity: '合計容量',
|
||||
avg_channel_size: '平均チャンネルサイズ',
|
||||
biggest_channel_size: '最大チャネルサイズ',
|
||||
@@ -33,6 +35,8 @@ window.localisation.jp = {
|
||||
reset_defaults_tooltip: 'すべての設定を削除してデフォルトに戻します。',
|
||||
download_backup: 'データベースのバックアップをダウンロードする',
|
||||
name_your_wallet: 'あなたのウォレットの名前 %{name}',
|
||||
wallet_topup_ok:
|
||||
'仮想資金の作成に成功しました(%{amount} sats)。支払いは資金ソースの実際の資金に依存します。',
|
||||
paste_invoice_label: '請求書を貼り付けてください',
|
||||
lnbits_description:
|
||||
'簡単にインストールでき、軽量なLNbitsは、あらゆるライトニングネットワークの資金源と、LNbits自身でさえも実行できます!LNbitsを個人で実行することも、他人に対してカストディアンソリューションをで実行できます! LNbitsを自分で実行することも、他の人に優れたソリューションを提供することもできます。各ウォレットには独自のAPIキーがあり、作成できるウォレットの数に制限はありません。資金を分割する機能は、LNbitsを資金管理ツールとして使用したり、開発ツールとして使用したりするための便利なツールです。拡張機能は、LNbitsに追加の機能を追加します。そのため、LNbitsは最先端の技術をネットワークLightningで試すことができます。拡張機能を開発するのは簡単で、無料でオープンソースのプロジェクトであるため、人々が自分で開発し、自分の貢献を送信することを奨励しています。',
|
||||
@@ -97,6 +101,7 @@ window.localisation.jp = {
|
||||
drain_funds_desc: 'ウォレットの残高をすべて他のウォレットに送金します',
|
||||
i_understand: '理解した',
|
||||
copy_wallet_url: 'ウォレットURLをコピー',
|
||||
disclaimer_dialog_title: '重要!',
|
||||
disclaimer_dialog:
|
||||
'ウォレットを削除すると、ウォレットの秘密鍵が削除され、ウォレットを復元することはできません。ウォレットを削除する前に、ウォレットをエクスポートしてください。',
|
||||
no_transactions: 'トランザクションはありません',
|
||||
@@ -161,7 +166,7 @@ window.localisation.jp = {
|
||||
'有効にすると、LNbitsからキルスイッチ信号が送信された場合に自動的に資金源をVoidWalletに切り替えます。更新後には手動で有効にする必要があります。',
|
||||
killswitch_interval: 'キルスイッチ間隔',
|
||||
killswitch_interval_desc:
|
||||
'バックグラウンドタスクがステータスソースからLNBitsキルスイッチ信号を確認する頻度(分単位)。',
|
||||
'バックグラウンドタスクがステータスソースからLNbitsキルスイッチ信号を確認する頻度(分単位)。',
|
||||
enable_watchdog: 'ウォッチドッグを有効にする',
|
||||
enable_watchdog_desc:
|
||||
'有効にすると、残高がLNbitsの残高より少ない場合に、資金源を自動的にVoidWalletに変更します。アップデート後は手動で有効にする必要があります。',
|
||||
@@ -240,5 +245,13 @@ window.localisation.jp = {
|
||||
logout: 'ログアウト',
|
||||
look_and_feel: 'ルック・アンド・フィール',
|
||||
language: '言語',
|
||||
color_scheme: 'カラースキーム'
|
||||
color_scheme: 'カラースキーム',
|
||||
extension_cost: 'このリリースには最低 %{cost} サトシの支払いが必要です。',
|
||||
extension_paid_sats: 'すでに%{paid_sats} satsを支払いました。',
|
||||
release_details_error: 'リリースの詳細を取得できません。',
|
||||
pay_from_wallet: 'ウォレットから支払う',
|
||||
show_qr: 'QRを表示',
|
||||
retry_install: '再試行インストール',
|
||||
new_payment: '新しい支払いを作成する',
|
||||
hide_empty_wallets: '空のウォレットを非表示にする'
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ window.localisation.kr = {
|
||||
transactions: '거래 내역',
|
||||
dashboard: '현황판',
|
||||
node: '노드',
|
||||
export_users: '사용자 내보내기',
|
||||
no_users: '사용자가 없습니다',
|
||||
total_capacity: '총 용량',
|
||||
avg_channel_size: '평균 채널 용량',
|
||||
biggest_channel_size: '가장 큰 채널 용량',
|
||||
@@ -34,9 +36,11 @@ window.localisation.kr = {
|
||||
'설정했던 내용들을 모두 지우고, 기본 설정으로 돌아갑니다.',
|
||||
download_backup: '데이터베이스 백업 다운로드',
|
||||
name_your_wallet: '사용할 %{name}지갑의 이름을 정하세요',
|
||||
wallet_topup_ok:
|
||||
'성공적으로 가상 자금을 생성했습니다 (%{amount} sats). 지급은 자금 원천의 실제 자금에 따라 달라집니다.',
|
||||
paste_invoice_label: '인보이스, 결제 요청, 혹은 lnurl 코드를 붙여넣으세요 *',
|
||||
lnbits_description:
|
||||
'설정이 쉽고 가벼운 LNBits는 어떤 라이트닝 네트워크의 예산 자원 위에서든 돌아갈 수 있습니다, 그리고 다른 LNBits 지갑들입니다. 스스로 사용하기 위해, 또는 다른 사람들에게 수탁형 솔루션을 제공하기 위해 LNBits를 운영할 수 있습니다. 각 지갑들은 자신만의 API key를 가지며, 생성 가능한 지갑의 수에는 제한이 없습니다. 자금을 분할할 수 있는 기능으로 인해, LNBits는 자금 운영 도구로써뿐만 아니라 개발 도구로써도 유용합니다. 확장 기능들은 LNBits에 여러분들이 라이트닝 네트워크의 다양한 최신 기술들을 수행해볼 수 있게 하는 추가 기능을 제공합니다. LNBits 개발진들은 확장 기능들의 개발 또한 가능한 쉽게 만들었으며, 무료 오픈 소스 프로젝트답게 사람들이 자신만의 확장 기능들을 개발하고 제출하기를 응원합니다.',
|
||||
'설정이 쉽고 가벼운 LNbits는 어떤 라이트닝 네트워크의 예산 자원 위에서든 돌아갈 수 있습니다, 그리고 다른 LNbits 지갑들입니다. 스스로 사용하기 위해, 또는 다른 사람들에게 수탁형 솔루션을 제공하기 위해 LNbits를 운영할 수 있습니다. 각 지갑들은 자신만의 API key를 가지며, 생성 가능한 지갑의 수에는 제한이 없습니다. 자금을 분할할 수 있는 기능으로 인해, LNbits는 자금 운영 도구로써뿐만 아니라 개발 도구로써도 유용합니다. 확장 기능들은 LNbits에 여러분들이 라이트닝 네트워크의 다양한 최신 기술들을 수행해볼 수 있게 하는 추가 기능을 제공합니다. LNbits 개발진들은 확장 기능들의 개발 또한 가능한 쉽게 만들었으며, 무료 오픈 소스 프로젝트답게 사람들이 자신만의 확장 기능들을 개발하고 제출하기를 응원합니다.',
|
||||
export_to_phone: 'QR 코드를 이용해 모바일 기기로 내보내기',
|
||||
export_to_phone_desc:
|
||||
'이 QR 코드는 선택된 지갑의 최대 접근 권한을 가진 전체 URL을 담고 있습니다. 스캔 후, 모바일 기기에서 지갑을 열 수 있습니다.',
|
||||
@@ -58,7 +62,7 @@ window.localisation.kr = {
|
||||
service_fee: '서비스 수수료: 거래액의 %{amount} %',
|
||||
service_fee_max: '서비스 수수료: 거래액의 %{amount} % (최대 %{max} sats)',
|
||||
service_fee_tooltip:
|
||||
'지불 결제 시마다 LNBits 서버 관리자에게 납부되는 서비스 수수료',
|
||||
'지불 결제 시마다 LNbits 서버 관리자에게 납부되는 서비스 수수료',
|
||||
toggle_darkmode: '다크 모드 전환',
|
||||
payment_reactions: '결제 반응',
|
||||
view_swagger_docs: 'LNbits Swagger API 문서를 봅니다',
|
||||
@@ -98,6 +102,7 @@ window.localisation.kr = {
|
||||
'이는 선택된 지갑으로부터 모든 자금을 인출하는 LNURL-withdraw QR 코드입니다. 그 누구와도 공유하지 마세요. balanceCheck 및 balanceNotify 기능과 호환되며, 당신의 지갑은 첫 출금 이후로도 계속 자금을 끌어당기고 있을 수 있습니다.',
|
||||
i_understand: '이해하였습니다',
|
||||
copy_wallet_url: '지갑 URL 복사하기',
|
||||
disclaimer_dialog_title: '중요!',
|
||||
disclaimer_dialog:
|
||||
'로그인 기능은 향후 업데이트를 통해 지원될 계획이지만, 현재로써는 이 페이지에 향후 다시 접속하기 위해 북마크 설정하는 것을 잊지 마세요! 이 서비스는 아직 BETA 과정에 있고, LNbits 개발자들은 자금 손실에 대해 전혀 책임을 지지 않습니다.',
|
||||
no_transactions: '아직 아무런 거래도 이루어지지 않았습니다',
|
||||
@@ -237,5 +242,13 @@ window.localisation.kr = {
|
||||
logout: '로그아웃',
|
||||
look_and_feel: '외관과 느낌',
|
||||
language: '언어',
|
||||
color_scheme: '색상 구성'
|
||||
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: '새로운 결제하기',
|
||||
hide_empty_wallets: '빈 지갑 숨기기'
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ window.localisation.nl = {
|
||||
transactions: 'Transacties',
|
||||
dashboard: 'Dashboard',
|
||||
node: 'Knooppunt',
|
||||
export_users: 'Gebruikers exporteren',
|
||||
no_users: 'Geen gebruikers gevonden',
|
||||
total_capacity: 'Totale capaciteit',
|
||||
avg_channel_size: 'Gem. Kanaalgrootte',
|
||||
biggest_channel_size: 'Grootste Kanaalgrootte',
|
||||
@@ -35,6 +37,8 @@ window.localisation.nl = {
|
||||
'Wis alle instellingen en herstel de standaardinstellingen.',
|
||||
download_backup: 'Databaseback-up downloaden',
|
||||
name_your_wallet: 'Geef je %{name} portemonnee een naam',
|
||||
wallet_topup_ok:
|
||||
'Succes met het aanmaken van virtuele fondsen (%{amount} sats). Betalingen zijn afhankelijk van de werkelijke fondsen op de financieringsbron.',
|
||||
paste_invoice_label: 'Plak een factuur, betalingsverzoek of lnurl-code*',
|
||||
lnbits_description:
|
||||
'Gemakkelijk in te stellen en lichtgewicht, LNbits kan op elke lightning-netwerkfinancieringsbron draaien en zelfs LNbits zelf! U kunt LNbits voor uzelf laten draaien of gemakkelijk een bewaardersoplossing voor anderen bieden. Elke portemonnee heeft zijn eigen API-sleutels en er is geen limiet aan het aantal portemonnees dat u kunt maken. Het kunnen partitioneren van fondsen maakt LNbits een nuttige tool voor geldbeheer en als ontwikkelingstool. Extensies voegen extra functionaliteit toe aan LNbits, zodat u kunt experimenteren met een reeks toonaangevende technologieën op het bliksemschichtnetwerk. We hebben het ontwikkelen van extensies zo eenvoudig mogelijk gemaakt en als een gratis en opensource-project moedigen we mensen aan om hun eigen ontwikkelingen in te dienen.',
|
||||
@@ -101,6 +105,7 @@ window.localisation.nl = {
|
||||
'Dit is een LNURL-withdraw QR-code om alles uit deze portemonnee te halen. Deel deze code niet met anderen. Het is compatibel met balanceCheck en balanceNotify zodat jouw portemonnee continu geld kan blijven opnemen vanaf hier na de eerste opname.',
|
||||
i_understand: 'Ik begrijp het',
|
||||
copy_wallet_url: 'Kopieer portemonnee-URL',
|
||||
disclaimer_dialog_title: 'Belangrijk!',
|
||||
disclaimer_dialog:
|
||||
'Inlogfunctionaliteit wordt uitgebracht in een toekomstige update. Zorg er nu voor dat je deze pagina als favoriet markeert om in de toekomst toegang te krijgen tot je portemonnee! Deze service is in BETA en we zijn niet verantwoordelijk voor mensen die de toegang tot hun fondsen verliezen.',
|
||||
no_transactions: 'Er zijn nog geen transacties gedaan',
|
||||
@@ -164,7 +169,7 @@ window.localisation.nl = {
|
||||
'Indien ingeschakeld, zal het uw financieringsbron automatisch wijzigen naar VoidWallet als LNbits een killswitch-signaal verzendt. U zult het na een update handmatig moeten inschakelen.',
|
||||
killswitch_interval: 'Uitschakelschakelaar-interval',
|
||||
killswitch_interval_desc:
|
||||
'Hoe vaak de achtergrondtaak moet controleren op het LNBits killswitch signaal van de statusbron (in minuten).',
|
||||
'Hoe vaak de achtergrondtaak moet controleren op het LNbits killswitch signaal van de statusbron (in minuten).',
|
||||
enable_watchdog: 'Inschakelen Watchdog',
|
||||
enable_watchdog_desc:
|
||||
'Indien ingeschakeld, wordt uw betaalbron automatisch gewijzigd naar VoidWallet als uw saldo lager is dan het saldo van LNbits. U zult dit na een update handmatig moeten inschakelen.',
|
||||
@@ -243,5 +248,14 @@ window.localisation.nl = {
|
||||
logout: 'Afmelden',
|
||||
look_and_feel: 'Uiterlijk en gedrag',
|
||||
language: 'Taal',
|
||||
color_scheme: 'Kleurenschema'
|
||||
color_scheme: 'Kleurenschema',
|
||||
extension_cost:
|
||||
'Deze release vereist een betaling van minimaal %{cost} sats.',
|
||||
extension_paid_sats: 'U heeft al %{paid_sats} sats betaald.',
|
||||
release_details_error: 'Kan de gegevens van de release niet ophalen.',
|
||||
pay_from_wallet: 'Betalen vanuit Portemonnee',
|
||||
show_qr: 'Toon QR',
|
||||
retry_install: 'Opnieuw installeren',
|
||||
new_payment: 'Nieuwe betaling maken',
|
||||
hide_empty_wallets: 'Verberg lege portemonnees'
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ window.localisation.pi = {
|
||||
transactions: 'Pirate Transactions and loot',
|
||||
dashboard: 'Arrr-board',
|
||||
node: 'Node',
|
||||
export_users: 'Export Mateys',
|
||||
no_users: 'No swabbies found',
|
||||
total_capacity: 'Total Capacity',
|
||||
avg_channel_size: 'Avg. Channel Size',
|
||||
biggest_channel_size: 'Largest Bilge Size',
|
||||
@@ -34,6 +36,8 @@ window.localisation.pi = {
|
||||
'Scuttle all settings and reset to Davy Jones Locker. Aye, start anew!',
|
||||
download_backup: 'Download database booty',
|
||||
name_your_wallet: 'Name yer %{name} treasure chest',
|
||||
wallet_topup_ok:
|
||||
"Success creatin' virtual funds (%{amount} sats). Payments depend on actual funds on funding source.",
|
||||
paste_invoice_label: 'Paste a booty, payment request or lnurl code, matey!',
|
||||
lnbits_description:
|
||||
'Arr, easy to set up and lightweight, LNbits can run on any Lightning Network funding source and even LNbits itself! Ye can run LNbits for yourself, or easily offer a custodian solution for others. Each chest has its own API keys and there be no limit to the number of chests ye can make. Being able to partition booty makes LNbits a useful tool for money management and as a development tool. Arr, extensions add extra functionality to LNbits so ye 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 scallywags to develop and submit their own.',
|
||||
@@ -100,6 +104,7 @@ window.localisation.pi = {
|
||||
'This be an LNURL-withdraw QR code for slurpin everything from this wallet. Don`t share with anyone. It be compatible with balanceCheck and balanceNotify so yer wallet may keep pullin` the funds continuously from here after the first withdraw.',
|
||||
i_understand: 'I understand, yo ho ho and a bottle of rum!',
|
||||
copy_wallet_url: 'Copy wallet URL like a map, savvy',
|
||||
disclaimer_dialog_title: 'Avast!',
|
||||
disclaimer_dialog:
|
||||
'Login functionality to be released in a future update, for now, make sure ye bookmark this page for future access to your booty! This service be in BETA, and we hold no responsibility for people losing access to doubloons.',
|
||||
no_transactions: 'No transactions made yet, me hearties. Belay that!',
|
||||
@@ -163,7 +168,7 @@ window.localisation.pi = {
|
||||
"If enabled it'll be changin' yer fundin' source to VoidWallet automatically if LNbits sends out a killswitch signal, ye will. Ye'll be needin' t' enable manually after an update, arr.",
|
||||
killswitch_interval: 'Killswitch Interval',
|
||||
killswitch_interval_desc:
|
||||
"How oft th' background task should be checkin' fer th' LNBits killswitch signal from th' status source (in minutes).",
|
||||
"How oft th' background task should be checkin' fer th' LNbits killswitch signal from th' status source (in minutes).",
|
||||
enable_watchdog: 'Enable Seadog',
|
||||
enable_watchdog_desc:
|
||||
"If enabled, it will swap yer treasure source t' VoidWallet on its own if yer balance be lower than th' LNbits balance. Ye'll need t' enable by hand after an update.",
|
||||
@@ -240,5 +245,14 @@ window.localisation.pi = {
|
||||
logout: 'Log out yer session',
|
||||
look_and_feel: 'Look and Feel',
|
||||
language: 'Langwidge',
|
||||
color_scheme: 'Colour Scheme'
|
||||
color_scheme: 'Colour Scheme',
|
||||
extension_cost:
|
||||
"This release be needin' a payment o' minimum %{cost} sats, arr.",
|
||||
extension_paid_sats: 'Ye have already paid %{paid_sats} sats.',
|
||||
release_details_error: "Cannot get th' release details.",
|
||||
pay_from_wallet: 'Pay from ye Wallet',
|
||||
show_qr: 'Show QR',
|
||||
retry_install: "Try 'nstallin' Again",
|
||||
new_payment: 'Make New Payment',
|
||||
hide_empty_wallets: 'Stow empty wallets'
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ window.localisation.pl = {
|
||||
transactions: 'Transakcje',
|
||||
dashboard: 'Panel kontrolny',
|
||||
node: 'Węzeł',
|
||||
export_users: 'Eksportuj użytkowników',
|
||||
no_users: 'Nie znaleziono użytkowników',
|
||||
total_capacity: 'Całkowita Pojemność',
|
||||
avg_channel_size: 'Średni rozmiar kanału',
|
||||
biggest_channel_size: 'Największy Rozmiar Kanału',
|
||||
@@ -33,6 +35,8 @@ window.localisation.pl = {
|
||||
reset_defaults_tooltip: 'Wymaż wszystkie ustawienia i ustaw domyślne.',
|
||||
download_backup: 'Pobierz kopię zapasową bazy danych',
|
||||
name_your_wallet: 'Nazwij swój portfel %{name}',
|
||||
wallet_topup_ok:
|
||||
'Sukces w tworzeniu wirtualnych środków (%{amount} sats). Płatności zależą od rzeczywistych środków na źródle finansowania.',
|
||||
paste_invoice_label: 'Wklej fakturę, żądanie zapłaty lub kod lnurl *',
|
||||
lnbits_description:
|
||||
'Łatwy i lekki w konfiguracji, LNbits może działać w oparciu o dowolne źródło finansowania w sieci lightning czy nawet inną instancję LNbits! Możesz uruchomić instancję LNbits dla siebie lub dla innych. Każdy portfel ma swoje klucze API i nie ma ograniczeń jeśli chodzi o ilość portfeli. LNbits umożliwia dzielenie środków w celu zarządzania nimi, jest również dobrym narzędziem deweloperskim. Rozszerzenia zwiększają funkcjonalność LNbits co umożliwia eksperymentowanie z nowym technologiami w sieci lightning. Tworzenie rozszerzeń jest proste dlatego zachęcamy innych deweloperów do tworzenia dodatkowych funkcjonalności i wysyłanie do nas PR',
|
||||
@@ -98,6 +102,7 @@ window.localisation.pl = {
|
||||
'To jest kod QR służący do opróżnienia portfela (LNURL-withdraw). Nie udostępniaj go nikomu. Ten kod jest kompatybilny z funkcjami, które umożliwiają wielokrotne żądania aż do zupełnego opróżnienia portfela.',
|
||||
i_understand: 'Rozumiem',
|
||||
copy_wallet_url: 'Skopiuj URL portfela',
|
||||
disclaimer_dialog_title: 'Ważne!',
|
||||
disclaimer_dialog:
|
||||
'Funkcja logowania zostanie uruchomiona w przyszłości. Póki co upewnij się, że zapisałeś adres URL tej strony aby mieć dostęp do tego portfela. Nie udostępniaj adresu tej strony nikomu, kto nie ma mieć do tego portfela dostępu! Ta usługa działa w wersji BETA, nie odpowiadamy za utratę dostępu do środków przez osoby używające LNbits.',
|
||||
no_transactions: 'Brak transakcji',
|
||||
@@ -161,7 +166,7 @@ window.localisation.pl = {
|
||||
'Jeśli zostanie włączone, automatycznie zmieni źródło finansowania na VoidWallet, jeśli LNbits wyśle sygnał wyłączający. Po aktualizacji będziesz musiał włączyć to ręcznie.',
|
||||
killswitch_interval: 'Interwał wyłącznika awaryjnego',
|
||||
killswitch_interval_desc:
|
||||
'Jak często zadanie w tle powinno sprawdzać sygnał wyłącznika awaryjnego LNBits ze źródła statusu (w minutach).',
|
||||
'Jak często zadanie w tle powinno sprawdzać sygnał wyłącznika awaryjnego LNbits ze źródła statusu (w minutach).',
|
||||
enable_watchdog: 'Włącz Watchdog',
|
||||
enable_watchdog_desc:
|
||||
'Jeśli zostanie włączone, automatycznie zmieni źródło finansowania na VoidWallet, jeśli saldo jest niższe niż saldo LNbits. Po aktualizacji trzeba będzie włączyć ręcznie.',
|
||||
@@ -239,5 +244,14 @@ window.localisation.pl = {
|
||||
logout: 'Wyloguj',
|
||||
look_and_feel: 'Wygląd i zachowanie',
|
||||
language: 'Język',
|
||||
color_scheme: 'Schemat kolorów'
|
||||
color_scheme: 'Schemat kolorów',
|
||||
extension_cost:
|
||||
'To niniejsze wydanie wymaga zapłaty minimalnej %{cost} satów.',
|
||||
extension_paid_sats: 'Już zapłaciłeś %{paid_sats} satów.',
|
||||
release_details_error: 'Nie można uzyskać szczegółów wydania.',
|
||||
pay_from_wallet: 'Zapłać z portfela',
|
||||
show_qr: 'Pokaż kod QR',
|
||||
retry_install: 'Ponów instalację',
|
||||
new_payment: 'Dokonaj nowej płatności',
|
||||
hide_empty_wallets: 'Ukryj puste portfele'
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ window.localisation.pt = {
|
||||
transactions: 'Transações',
|
||||
dashboard: 'Painel de Controle',
|
||||
node: 'Nó',
|
||||
export_users: 'Exportar Usuários',
|
||||
no_users: 'Nenhum usuário encontrado',
|
||||
total_capacity: 'Capacidade Total',
|
||||
avg_channel_size: 'Tamanho Médio do Canal',
|
||||
biggest_channel_size: 'Maior Tamanho do Canal',
|
||||
@@ -34,6 +36,8 @@ window.localisation.pt = {
|
||||
'Apagar todas as configurações e redefinir para os padrões.',
|
||||
download_backup: 'Fazer backup da base de dados',
|
||||
name_your_wallet: 'Nomeie sua carteira %{name}',
|
||||
wallet_topup_ok:
|
||||
'Sucesso ao criar fundos virtuais (%{amount} sats). Os pagamentos dependem dos fundos reais na fonte de financiamento.',
|
||||
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 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.',
|
||||
@@ -99,6 +103,7 @@ window.localisation.pt = {
|
||||
'Este é um código QR de saque LNURL para sacar tudo desta carteira. Não o partilhe com ninguém. É compatível com balanceCheck e balanceNotify para que a sua carteira possa continuar levantando os fundos continuamente daqui após o primeiro saque.',
|
||||
i_understand: 'Eu entendo',
|
||||
copy_wallet_url: 'Copiar URL da carteira',
|
||||
disclaimer_dialog_title: 'Importante!',
|
||||
disclaimer_dialog:
|
||||
'Funcionalidade de login a ser lançada numa atualização futura, por enquanto, certifique-se que marca 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',
|
||||
@@ -163,7 +168,7 @@ window.localisation.pt = {
|
||||
'Se ativado, ele mudará sua fonte de financiamento para VoidWallet automaticamente se o LNbits enviar um sinal de desativação. Você precisará ativar manualmente após uma atualização.',
|
||||
killswitch_interval: 'Intervalo do Killswitch',
|
||||
killswitch_interval_desc:
|
||||
'Com que frequência a tarefa de fundo deve verificar o sinal de desativação do LNBits proveniente da fonte de status (em minutos).',
|
||||
'Com que frequência a tarefa de fundo deve verificar o sinal de desativação do LNbits proveniente da fonte de status (em minutos).',
|
||||
enable_watchdog: 'Ativar Watchdog',
|
||||
enable_watchdog_desc:
|
||||
'Se ativado, mudará automaticamente a sua fonte de financiamento para VoidWallet caso o seu saldo seja inferior ao saldo LNbits. Você precisará ativar manualmente após uma atualização.',
|
||||
@@ -240,5 +245,13 @@ window.localisation.pt = {
|
||||
logout: 'Sair',
|
||||
look_and_feel: 'Aparência e Sensação',
|
||||
language: 'Idioma',
|
||||
color_scheme: 'Esquema de Cores'
|
||||
color_scheme: 'Esquema de Cores',
|
||||
extension_cost: 'Este lançamento requer um pagamento mínimo de %{cost} sats.',
|
||||
extension_paid_sats: 'Você já pagou %{paid_sats} sats.',
|
||||
release_details_error: 'Não é possível obter os detalhes da versão.',
|
||||
pay_from_wallet: 'Pague da Carteira',
|
||||
show_qr: 'Exibir QR',
|
||||
retry_install: 'Reinstalar Tente Novamente',
|
||||
new_payment: 'Realizar Novo Pagamento',
|
||||
hide_empty_wallets: 'Ocultar carteiras vazias'
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ window.localisation.sk = {
|
||||
transactions: 'Transakcie',
|
||||
dashboard: 'Prehľad',
|
||||
node: 'Uzol',
|
||||
export_users: 'Exportovať používateľov',
|
||||
no_users: 'Nenašli sa žiadni používatelia',
|
||||
total_capacity: 'Celková kapacita',
|
||||
avg_channel_size: 'Priemerná veľkosť kanálu',
|
||||
biggest_channel_size: 'Najväčší kanál',
|
||||
@@ -33,6 +35,8 @@ window.localisation.sk = {
|
||||
reset_defaults_tooltip: 'Odstrániť všetky nastavenia a obnoviť predvolené.',
|
||||
download_backup: 'Stiahnuť zálohu databázy',
|
||||
name_your_wallet: 'Pomenujte vašu %{name} peňaženku',
|
||||
wallet_topup_ok:
|
||||
'Úspešne vytvorené virtuálne prostriedky (%{amount} sats). Platby závisia od skutočných prostriedkov v zdroji financovania.',
|
||||
paste_invoice_label: 'Vložte faktúru, platobnú požiadavku alebo lnurl kód *',
|
||||
lnbits_description:
|
||||
'Ľahko nastaviteľný a ľahkotonážny, LNbits môže bežať na akomkoľvek zdroji financovania Lightning Network a dokonca LNbits samotný! LNbits môžete používať pre seba, alebo ľahko ponúknuť správcovské riešenie pre iných. Každá peňaženka má svoje vlastné API kľúče a nie je limit na počet peňaženiek, ktoré môžete vytvoriť. Schopnosť rozdeľovať finančné prostriedky robí z LNbits užitočný nástroj pre správu peňazí a ako vývojový nástroj. Rozšírenia pridávajú extra funkčnosť do LNbits, takže môžete experimentovať s radou najnovších technológií na lightning sieti. Vývoj rozšírení sme urobili čo najjednoduchší a ako voľný a open-source projekt, podporujeme ľudí vývoj a odovzdávanie vlastných rozšírení.',
|
||||
@@ -98,6 +102,7 @@ window.localisation.sk = {
|
||||
'Toto je LNURL-withdraw QR kód pre vyprázdnienie všetkého z tejto peňaženky. S nikým ho nezdieľajte. Je kompatibilný s balanceCheck a balanceNotify, takže vaša peňaženka môže naďalej kontinuálne vyťahovať prostriedky odtiaľto po prvom výbere.',
|
||||
i_understand: 'Rozumiem',
|
||||
copy_wallet_url: 'Kopírovať URL peňaženky',
|
||||
disclaimer_dialog_title: 'Dôležité!',
|
||||
disclaimer_dialog:
|
||||
'Funkcionalita prihlásenia bude vydaná v budúcej aktualizácii, zatiaľ si uistite, že ste si túto stránku pridali medzi záložky pre budúci prístup k vašej peňaženke! Táto služba je v BETA verzii a nenesieme zodpovednosť za stratu prístupu k prostriedkom.',
|
||||
no_transactions: 'Zatiaľ žiadne transakcie',
|
||||
@@ -239,5 +244,13 @@ window.localisation.sk = {
|
||||
logout: 'Odhlásiť sa',
|
||||
look_and_feel: 'Vzhľad a dojem',
|
||||
language: 'Jazyk',
|
||||
color_scheme: 'Farebná schéma'
|
||||
color_scheme: 'Farebná schéma',
|
||||
extension_cost: 'Táto verzia vyžaduje minimálnu platbu %{cost} satoshi.',
|
||||
extension_paid_sats: 'Už ste zaplatili %{paid_sats} sats.',
|
||||
release_details_error: 'Nepodarilo sa získať podrobnosti o vydaní.',
|
||||
pay_from_wallet: 'Zaplatiť z peňaženky',
|
||||
show_qr: 'Zobraziť QR',
|
||||
retry_install: 'Skúste inštaláciu znova',
|
||||
new_payment: 'Vytvoriť novú platbu',
|
||||
hide_empty_wallets: 'Skryť prázdne peňaženky'
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user