Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
+3 |
bc81dc5a28 | ||
|
|
fb98576431 | ||
|
|
8f0c1f6a80 | ||
|
|
576e20d0cd | ||
|
|
6773a0f533 | ||
|
|
2c53445b5e | ||
|
|
62435b3723 | ||
|
|
6efe1a156b | ||
|
|
85b7591f6e | ||
|
|
172c277c2d | ||
|
|
2623e9247a | ||
|
|
7a37e72915 | ||
|
|
48f25488df | ||
|
|
6077edad8f | ||
|
|
e50a7fb2d1 | ||
|
|
7343d1e0a0 | ||
|
|
1efec9bb80 | ||
|
|
c54f48ee73 | ||
|
|
ac0ef07a77 | ||
|
|
39d717e34c | ||
|
|
4e6f229db2 | ||
|
|
2ab18544c3 | ||
|
|
fe88320f08 | ||
|
|
b0ad47b69b | ||
|
|
88c07acc2c | ||
|
|
c4da1dfdce | ||
|
|
3a653630f1 | ||
|
|
bb5033d225 | ||
|
|
eb0b06f98c | ||
|
|
642f9a4c00 | ||
|
|
8a6e411a0d | ||
|
|
d4de78f1e8 | ||
|
|
1fd4d9d514 | ||
|
|
65db43ace4 | ||
|
|
59acd3a2ef | ||
|
|
905afc1f5c | ||
|
|
c7cf743852 | ||
|
|
b43e7efa0b | ||
|
|
c8414829d7 | ||
|
|
5e03f8eed8 | ||
|
|
511a0fd895 | ||
|
|
2434518d21 | ||
|
|
26a0633d49 | ||
|
|
f0a66e41fb | ||
|
|
355806608b | ||
|
|
3aef1605be | ||
|
|
f096b51f70 | ||
|
|
03acf5e5ec | ||
|
|
772ae2a1ab | ||
|
|
2577ce7f81 | ||
|
|
9bc8a9db55 | ||
|
|
9f40b3cdbd | ||
|
|
1ab81f6d7e | ||
|
|
67d3a4f359 | ||
|
|
7f0c7138af | ||
|
|
dda6c1b3c1 | ||
|
|
cf0a87582c | ||
|
|
4b25714b31 | ||
|
|
0ff0abf442 | ||
|
|
6ce0387e1c | ||
|
|
427f155c76 | ||
|
|
a81a2b5a08 | ||
|
|
11fec7a889 | ||
|
|
f478c1848a | ||
|
|
2d4c9f349b | ||
|
|
befb288900 | ||
|
|
963fd5de50 | ||
|
|
a3aafe4b6f | ||
|
|
f384c5c366 | ||
|
|
153e22fb54 | ||
|
|
213be79aaa | ||
|
|
f7cbe74d77 | ||
|
|
d0d5c4d033 | ||
|
|
0adaf03de5 | ||
|
|
83a04d8f74 | ||
|
|
e13426a56b | ||
|
|
6aaa46e5a0 | ||
|
|
b9db3eafe0 | ||
|
|
ae9eb314cc | ||
|
|
fe393b83ab | ||
|
|
bc55d52ea2 | ||
|
|
9a0878de19 | ||
|
|
be7c589722 | ||
|
|
28a55b2d41 | ||
|
|
d66c83c91a | ||
|
|
7e1f43933d | ||
|
|
758a4ecaf6 | ||
|
|
339645a912 | ||
|
|
9014e73529 | ||
|
|
48fc73d9d4 | ||
|
|
150a33009d | ||
|
|
e6499104c0 | ||
|
|
49411e58cc | ||
|
|
8c0e7725de | ||
|
|
95281eba8c | ||
|
|
cd17b59a3a | ||
|
|
ec623f4861 | ||
|
|
7c479a3ebf | ||
|
|
9017cee491 | ||
|
|
2940716807 | ||
|
|
c48e2329c5 | ||
|
|
8ed2422ade | ||
|
|
af660fec14 | ||
|
|
6bbebe0cee | ||
|
|
f863a043cb | ||
|
|
2216649412 | ||
|
|
9d8c78c927 | ||
|
|
02bf2f9819 |
@@ -0,0 +1,14 @@
|
||||
# Top-most EditorConfig file
|
||||
root = true
|
||||
|
||||
# Unix-style newlines with a newline ending every file, utf-8 charset
|
||||
[*]
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
charset = utf-8
|
||||
|
||||
[/lnbits/static/vendor/*]
|
||||
end_of_line = unset
|
||||
insert_final_newline = unset
|
||||
trim_trailing_whitespace = unset
|
||||
@@ -8,7 +8,14 @@ PORT=5000
|
||||
|
||||
DEBUG=false
|
||||
|
||||
# Server security, rate limiting ips, blocked ips, allowed ips
|
||||
LNBITS_RATE_LIMIT_NO="200"
|
||||
LNBITS_RATE_LIMIT_UNIT="minute"
|
||||
LNBITS_ALLOWED_IPS=""
|
||||
LNBITS_BLOCKED_IPS=""
|
||||
|
||||
# Allow users and admins by user IDs (comma separated list)
|
||||
# if set new users will not be able to create accounts
|
||||
LNBITS_ALLOWED_USERS=""
|
||||
LNBITS_ADMIN_USERS=""
|
||||
# Extensions only admin can access
|
||||
@@ -34,6 +41,11 @@ LNBITS_HIDE_API=false
|
||||
# GitHub has rate-limits for its APIs. The limit can be increased specifying a GITHUB_TOKEN
|
||||
# LNBITS_EXT_GITHUB_TOKEN=github_pat_xxxxxxxxxxxxxxxxxx
|
||||
|
||||
# Path where extensions will be installed (defaults to `./lnbits/`).
|
||||
# Inside this directory the `extensions` and `upgrades` sub-directories will be created.
|
||||
# LNBITS_EXTENSIONS_PATH="/path/to/some/dir"
|
||||
|
||||
|
||||
# Extensions to be installed by default. If an extension from this list is uninstalled then it will be re-installed on the next restart.
|
||||
# The extension must be removed from this list in order to not be re-installed.
|
||||
LNBITS_EXTENSIONS_DEFAULT_INSTALL="tpos"
|
||||
@@ -52,6 +64,9 @@ LNBITS_RESERVE_FEE_MIN=2000
|
||||
# value in percent
|
||||
LNBITS_RESERVE_FEE_PERCENT=1.0
|
||||
|
||||
# Limit fiat currencies allowed to see in UI
|
||||
# LNBITS_ALLOWED_CURRENCIES="EUR, USD"
|
||||
|
||||
# Change theme
|
||||
LNBITS_SITE_TITLE="LNbits"
|
||||
LNBITS_SITE_TAGLINE="free and open-source lightning wallet"
|
||||
@@ -60,15 +75,15 @@ LNBITS_SITE_DESCRIPTION="Some description about your service, will display if ti
|
||||
LNBITS_THEME_OPTIONS="classic, bitcoin, flamingo, freedom, mint, autumn, monochrome, salvador, cyber"
|
||||
# LNBITS_CUSTOM_LOGO="https://lnbits.com/assets/images/logo/logo.svg"
|
||||
|
||||
# Choose from LNPayWallet, OpenNodeWallet, ClicheWallet,
|
||||
# LndWallet, LndRestWallet, CoreLightningWallet, EclairWallet,
|
||||
# LnTipsWallet, LNbitsWallet, SparkWallet, FakeWallet,
|
||||
# which fundingsources are allowed in the admin ui
|
||||
LNBITS_ALLOWED_FUNDING_SOURCES="VoidWallet, FakeWallet, CoreLightningWallet, CoreLightningRestWallet, LndRestWallet, EclairWallet, LndWallet, LnTipsWallet, LNPayWallet, LNbitsWallet, OpenNodeWallet"
|
||||
|
||||
LNBITS_BACKEND_WALLET_CLASS=VoidWallet
|
||||
# VoidWallet is just a fallback that works without any actual Lightning capabilities,
|
||||
# just so you can see the UI before dealing with this file.
|
||||
|
||||
# Invoice expiry for LND, CLN, Eclair, LNbits funding sources
|
||||
LIGHTNING_INVOICE_EXPIRY=600
|
||||
LIGHTNING_INVOICE_EXPIRY=3600
|
||||
|
||||
# Set one of these blocks depending on the wallet kind you chose above:
|
||||
|
||||
@@ -82,6 +97,12 @@ SPARK_TOKEN=myaccesstoken
|
||||
# CoreLightningWallet
|
||||
CORELIGHTNING_RPC="/home/bob/.lightning/bitcoin/lightning-rpc"
|
||||
|
||||
# CoreLightningRestWallet
|
||||
CORELIGHTNING_REST_URL=http://127.0.0.1:8185/
|
||||
# Path or BASE64/HEX STRING
|
||||
CORELIGHTNING_REST_MACAROON="/path/to/clnrest/access.macaroon"
|
||||
CORELIGHTNING_REST_CERT="/path/to/clnrest/tls.cert"
|
||||
|
||||
# LnbitsWallet
|
||||
LNBITS_ENDPOINT=https://legend.lnbits.com
|
||||
LNBITS_KEY=LNBITS_ADMIN_KEY
|
||||
@@ -124,8 +145,3 @@ ECLAIR_PASS=eclairpw
|
||||
# Enter /api in LightningTipBot to get your key
|
||||
LNTIPS_API_KEY=LNTIPS_ADMIN_KEY
|
||||
LNTIPS_API_ENDPOINT=https://ln.tips
|
||||
|
||||
# Cashu Mint
|
||||
# Use a long-enough random (!) private key.
|
||||
# Once set, you cannot change this key as for now.
|
||||
CASHU_PRIVATE_KEY="SuperSecretPrivateKey"
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
[flake8]
|
||||
max-line-length = 150
|
||||
exclude = lnbits/wallets/lnd_grpc_files/, lnbits/extensions/
|
||||
ignore =
|
||||
# E203 whitespace before ':' black does not like it
|
||||
E203
|
||||
# E402: module level import not at top of file
|
||||
E402,
|
||||
# W503: line break before binary operator
|
||||
W503,
|
||||
# F821: undefined name - should be addressed in future PR
|
||||
F821,
|
||||
# E722 do not use bare 'except' - should be addressed in future PR
|
||||
E722,
|
||||
# flake8-requirements import checks
|
||||
I
|
||||
@@ -0,0 +1,67 @@
|
||||
name: prepare
|
||||
|
||||
inputs:
|
||||
python-version:
|
||||
description: "Python Version"
|
||||
required: true
|
||||
default: "3.9"
|
||||
poetry-version:
|
||||
description: "Poetry Version"
|
||||
default: "1.5.1"
|
||||
node-version:
|
||||
description: "Node Version"
|
||||
default: "18.x"
|
||||
npm:
|
||||
description: "use npm"
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Set up Python ${{ inputs.python-version }}
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: ${{ inputs.python-version }}
|
||||
# cache poetry install via pip
|
||||
cache: "pip"
|
||||
|
||||
- name: Set up Poetry ${{ inputs.poetry-version }}
|
||||
uses: abatilo/actions-poetry@v2
|
||||
with:
|
||||
poetry-version: ${{ inputs.poetry-version }}
|
||||
|
||||
- name: Setup a local virtual environment (if no poetry.toml file)
|
||||
shell: bash
|
||||
run: |
|
||||
poetry config virtualenvs.create true --local
|
||||
poetry config virtualenvs.in-project true --local
|
||||
|
||||
- uses: actions/cache@v3
|
||||
name: Define a cache for the virtual environment based on the dependencies lock file
|
||||
with:
|
||||
path: ./.venv
|
||||
key: venv-${{ hashFiles('poetry.lock') }}
|
||||
|
||||
- name: Install the project dependencies
|
||||
shell: bash
|
||||
run: poetry install
|
||||
|
||||
- name: Use Node.js ${{ inputs.node-version }}
|
||||
if: ${{ (inputs.npm == 'true') }}
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: ${{ inputs.node-version }}
|
||||
|
||||
- uses: actions/cache@v3
|
||||
if: ${{ (inputs.npm == 'true') }}
|
||||
name: Define a cache for the npm based on the dependencies lock file
|
||||
with:
|
||||
path: ./node_modules
|
||||
key: npm-${{ hashFiles('package-lock.json') }}
|
||||
|
||||
- name: Install npm packages
|
||||
if: ${{ (inputs.npm == 'true') }}
|
||||
shell: bash
|
||||
run: npm install
|
||||
@@ -0,0 +1,47 @@
|
||||
name: LNbits CI
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
|
||||
lint:
|
||||
uses: ./.github/workflows/lint.yml
|
||||
|
||||
tests:
|
||||
needs: [ lint ]
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.9", "3.10"]
|
||||
db-url: ["", "postgres://lnbits:lnbits@0.0.0.0:5432/lnbits"]
|
||||
uses: ./.github/workflows/tests.yml
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
migrations:
|
||||
needs: [ lint ]
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.9"]
|
||||
uses: ./.github/workflows/tests.yml
|
||||
with:
|
||||
make: test-migration
|
||||
db-name: migration
|
||||
|
||||
openapi:
|
||||
needs: [ lint ]
|
||||
uses: ./.github/workflows/make.yml
|
||||
with:
|
||||
make: openapi
|
||||
|
||||
# docker:
|
||||
# uses: ./.github/workflows/docker.yml
|
||||
|
||||
regtest:
|
||||
needs: [ lint ]
|
||||
uses: ./.github/workflows/regtest.yml
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.9"]
|
||||
backend-wallet-class: ["LndRestWallet", "LndWallet", "CoreLightningWallet", "CoreLightningRestWallet", "LNbitsWallet", "EclairWallet"]
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
backend-wallet-class: ${{ matrix.backend-wallet-class }}
|
||||
@@ -2,9 +2,9 @@ name: codeql
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, ]
|
||||
branches: [main, dev]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
branches: [main, dev]
|
||||
schedule:
|
||||
- cron: '0 12 * * 5'
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
name: docker
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
docker:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
push: false
|
||||
tags: lnbitsdocker/lnbits-legend:latest
|
||||
cache-from: type=registry,ref=lnbitsdocker/lnbits-legend:latest
|
||||
cache-to: type=inline
|
||||
@@ -1,27 +0,0 @@
|
||||
name: flake8
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.9"]
|
||||
poetry-version: ["1.3.1"]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Set up Poetry ${{ matrix.poetry-version }}
|
||||
uses: abatilo/actions-poetry@v2
|
||||
with:
|
||||
poetry-version: ${{ matrix.poetry-version }}
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: "poetry"
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
poetry install
|
||||
- name: Run tests
|
||||
run: make flake8
|
||||
@@ -1,33 +0,0 @@
|
||||
name: formatting
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
checks:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.9"]
|
||||
poetry-version: ["1.3.1"]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Set up Poetry ${{ matrix.poetry-version }}
|
||||
uses: abatilo/actions-poetry@v2
|
||||
with:
|
||||
poetry-version: ${{ matrix.poetry-version }}
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: "poetry"
|
||||
- name: Install packages
|
||||
run: |
|
||||
poetry install
|
||||
npm install prettier
|
||||
- name: Check black
|
||||
run: make checkblack
|
||||
- name: Check isort
|
||||
run: make checkisort
|
||||
- uses: actions/setup-node@v3
|
||||
- name: Check prettier
|
||||
run: make checkprettier
|
||||
@@ -0,0 +1,27 @@
|
||||
name: lint
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
black:
|
||||
uses: ./.github/workflows/make.yml
|
||||
with:
|
||||
make: checkblack
|
||||
ruff:
|
||||
uses: ./.github/workflows/make.yml
|
||||
with:
|
||||
make: checkruff
|
||||
mypy:
|
||||
uses: ./.github/workflows/make.yml
|
||||
with:
|
||||
make: mypy
|
||||
pyright:
|
||||
uses: ./.github/workflows/make.yml
|
||||
with:
|
||||
make: pyright
|
||||
npm: true
|
||||
prettier:
|
||||
uses: ./.github/workflows/make.yml
|
||||
with:
|
||||
make: prettier
|
||||
npm: true
|
||||
@@ -0,0 +1,31 @@
|
||||
name: make
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
make:
|
||||
description: "make command that is run"
|
||||
required: true
|
||||
type: string
|
||||
npm:
|
||||
description: "use npm install"
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
jobs:
|
||||
make:
|
||||
name: ${{ inputs.make }} (${{ matrix.python-version }})
|
||||
strategy:
|
||||
matrix:
|
||||
os-version: ["ubuntu-latest"]
|
||||
python-version: ["3.9", "3.10"]
|
||||
node-version: ["18.x"]
|
||||
runs-on: ${{ matrix.os-version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: ./.github/actions/prepare
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
node-version: ${{ matrix.node-version }}
|
||||
npm: ${{ inputs.npm }}
|
||||
- run: make ${{ inputs.make }}
|
||||
@@ -1,43 +0,0 @@
|
||||
name: migrations
|
||||
|
||||
on: [pull_request]
|
||||
|
||||
jobs:
|
||||
sqlite-to-postgres:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:latest
|
||||
env:
|
||||
POSTGRES_USER: lnbits
|
||||
POSTGRES_PASSWORD: lnbits
|
||||
POSTGRES_DB: migration
|
||||
ports:
|
||||
# maps tcp port 5432 on service container to the host
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.9"]
|
||||
poetry-version: ["1.3.1"]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Set up Poetry ${{ matrix.poetry-version }}
|
||||
uses: abatilo/actions-poetry@v2
|
||||
with:
|
||||
poetry-version: ${{ matrix.poetry-version }}
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
poetry install
|
||||
sudo apt install unzip
|
||||
- name: Run migrations
|
||||
run: |
|
||||
make test-migration
|
||||
@@ -1,27 +0,0 @@
|
||||
name: mypy
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.9"]
|
||||
poetry-version: ["1.3.1"]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Set up Poetry ${{ matrix.poetry-version }}
|
||||
uses: abatilo/actions-poetry@v2
|
||||
with:
|
||||
poetry-version: ${{ matrix.poetry-version }}
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: "poetry"
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
poetry install
|
||||
- name: Run tests
|
||||
run: make mypy
|
||||
@@ -1,27 +0,0 @@
|
||||
name: pylint
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.9"]
|
||||
poetry-version: ["1.3.1"]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Set up Poetry ${{ matrix.poetry-version }}
|
||||
uses: abatilo/actions-poetry@v2
|
||||
with:
|
||||
poetry-version: ${{ matrix.poetry-version }}
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: "poetry"
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
poetry install
|
||||
- name: Run tests
|
||||
run: make pylint
|
||||
@@ -1,28 +0,0 @@
|
||||
name: pyright
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.9"]
|
||||
poetry-version: ["1.3.1"]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Set up Poetry ${{ matrix.poetry-version }}
|
||||
uses: abatilo/actions-poetry@v2
|
||||
with:
|
||||
poetry-version: ${{ matrix.poetry-version }}
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: "poetry"
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
poetry install
|
||||
npm install
|
||||
- name: Run tests
|
||||
run: make pyright
|
||||
@@ -1,223 +1,81 @@
|
||||
name: regtest
|
||||
|
||||
on: [push, pull_request]
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
make:
|
||||
default: test
|
||||
type: string
|
||||
python-version:
|
||||
default: "3.9"
|
||||
type: string
|
||||
os-version:
|
||||
default: "ubuntu-latest"
|
||||
type: string
|
||||
backend-wallet-class:
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
LndRestWallet:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.9"]
|
||||
poetry-version: ["1.3.1"]
|
||||
regtest:
|
||||
runs-on: ${{ inputs.os-version }}
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Set up Poetry ${{ matrix.poetry-version }}
|
||||
uses: abatilo/actions-poetry@v2
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
if: ${{ inputs.backend-wallet-class == 'LNbitsWallet' }}
|
||||
uses: docker/setup-buildx-action@v2
|
||||
|
||||
- name: Build and push
|
||||
if: ${{ inputs.backend-wallet-class == 'LNbitsWallet' }}
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
poetry-version: ${{ matrix.poetry-version }}
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: "poetry"
|
||||
context: .
|
||||
push: false
|
||||
tags: lnbitsdocker/lnbits-legend:latest
|
||||
cache-from: type=registry,ref=lnbitsdocker/lnbits-legend:latest
|
||||
cache-to: type=inline
|
||||
|
||||
- name: Setup Regtest
|
||||
run: |
|
||||
docker build -t lnbitsdocker/lnbits-legend .
|
||||
git clone https://github.com/lnbits/legend-regtest-enviroment.git docker
|
||||
cd docker
|
||||
chmod +x ./tests
|
||||
./tests
|
||||
sudo chmod -R a+rwx .
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
poetry install
|
||||
- name: Run tests
|
||||
|
||||
- uses: ./.github/actions/prepare
|
||||
with:
|
||||
python-version: ${{ inputs.python-version }}
|
||||
|
||||
- name: Create fake admin
|
||||
if: ${{ inputs.backend-wallet-class == 'LNbitsWallet' }}
|
||||
run: docker exec lnbits-legend-lnbits-1 poetry run python tools/create_fake_admin.py
|
||||
|
||||
- name: Run Tests
|
||||
env:
|
||||
PYTHONUNBUFFERED: 1
|
||||
PORT: 5123
|
||||
LNBITS_DATA_FOLDER: ./data
|
||||
LNBITS_BACKEND_WALLET_CLASS: LndRestWallet
|
||||
LNBITS_DATABASE_URL: ${{ inputs.db-url }}
|
||||
LNBITS_BACKEND_WALLET_CLASS: ${{ inputs.backend-wallet-class }}
|
||||
LND_REST_ENDPOINT: https://localhost:8081/
|
||||
LND_REST_CERT: ./docker/data/lnd-1/tls.cert
|
||||
LND_REST_MACAROON: ./docker/data/lnd-1/data/chain/bitcoin/regtest/admin.macaroon
|
||||
run: |
|
||||
sudo chmod -R a+rwx . && rm -rf ./data && mkdir -p ./data
|
||||
make test-real-wallet
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v3
|
||||
with:
|
||||
file: ./coverage.xml
|
||||
LndWallet:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.9"]
|
||||
poetry-version: ["1.3.1"]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Set up Poetry ${{ matrix.poetry-version }}
|
||||
uses: abatilo/actions-poetry@v2
|
||||
with:
|
||||
poetry-version: ${{ matrix.poetry-version }}
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: "poetry"
|
||||
- name: Setup Regtest
|
||||
run: |
|
||||
docker build -t lnbitsdocker/lnbits-legend .
|
||||
git clone https://github.com/lnbits/legend-regtest-enviroment.git docker
|
||||
cd docker
|
||||
chmod +x ./tests
|
||||
./tests
|
||||
sudo chmod -R a+rwx .
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
poetry install
|
||||
- name: Run tests
|
||||
env:
|
||||
PYTHONUNBUFFERED: 1
|
||||
PORT: 5123
|
||||
LNBITS_DATA_FOLDER: ./data
|
||||
LNBITS_BACKEND_WALLET_CLASS: LndWallet
|
||||
LND_REST_CERT: ./docker/data/lnd-3/tls.cert
|
||||
LND_REST_MACAROON: ./docker/data/lnd-3/data/chain/bitcoin/regtest/admin.macaroon
|
||||
LND_GRPC_ENDPOINT: localhost
|
||||
LND_GRPC_PORT: 10009
|
||||
LND_GRPC_CERT: docker/data/lnd-1/tls.cert
|
||||
LND_GRPC_MACAROON: docker/data/lnd-1/data/chain/bitcoin/regtest/admin.macaroon
|
||||
run: |
|
||||
sudo chmod -R a+rwx . && rm -rf ./data && mkdir -p ./data
|
||||
make test-real-wallet
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v3
|
||||
with:
|
||||
file: ./coverage.xml
|
||||
CoreLightningWallet:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.9"]
|
||||
poetry-version: ["1.3.1"]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Set up Poetry ${{ matrix.poetry-version }}
|
||||
uses: abatilo/actions-poetry@v2
|
||||
with:
|
||||
poetry-version: ${{ matrix.poetry-version }}
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: "poetry"
|
||||
- name: Setup Regtest
|
||||
run: |
|
||||
docker build -t lnbitsdocker/lnbits-legend .
|
||||
git clone https://github.com/lnbits/legend-regtest-enviroment.git docker
|
||||
cd docker
|
||||
chmod +x ./tests
|
||||
./tests
|
||||
sudo chmod -R a+rwx .
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
poetry install
|
||||
- name: Run tests
|
||||
env:
|
||||
PYTHONUNBUFFERED: 1
|
||||
PORT: 5123
|
||||
LNBITS_DATA_FOLDER: ./data
|
||||
LNBITS_BACKEND_WALLET_CLASS: CoreLightningWallet
|
||||
LND_GRPC_CERT: docker/data/lnd-3/tls.cert
|
||||
LND_GRPC_MACAROON: docker/data/lnd-3/data/chain/bitcoin/regtest/admin.macaroon
|
||||
CORELIGHTNING_RPC: ./docker/data/clightning-1/regtest/lightning-rpc
|
||||
run: |
|
||||
sudo chmod -R a+rwx . && rm -rf ./data && mkdir -p ./data
|
||||
make test-real-wallet
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v3
|
||||
with:
|
||||
file: ./coverage.xml
|
||||
LNbitsWallet:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.9"]
|
||||
poetry-version: ["1.3.1"]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Set up Poetry ${{ matrix.poetry-version }}
|
||||
uses: abatilo/actions-poetry@v2
|
||||
with:
|
||||
poetry-version: ${{ matrix.poetry-version }}
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: "poetry"
|
||||
- name: Setup Regtest
|
||||
run: |
|
||||
docker build -t lnbitsdocker/lnbits-legend .
|
||||
git clone https://github.com/lnbits/legend-regtest-enviroment.git docker
|
||||
cd docker
|
||||
chmod +x ./tests
|
||||
./tests
|
||||
sudo chmod -R a+rwx .
|
||||
docker exec lnbits-legend-lnbits-1 /bin/bash -c "poetry run python tools/create_fake_admin.py"
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
poetry install
|
||||
- name: Run tests
|
||||
env:
|
||||
PYTHONUNBUFFERED: 1
|
||||
PORT: 5123
|
||||
LNBITS_DATA_FOLDER: ./data
|
||||
LNBITS_BACKEND_WALLET_CLASS: LNbitsWallet
|
||||
CORELIGHTNING_REST_URL: https://localhost:3001
|
||||
CORELIGHTNING_REST_MACAROON: ./docker/data/clightning-2-rest/access.macaroon
|
||||
CORELIGHTNING_REST_CERT: ./docker/data/clightning-2-rest/certificate.pem
|
||||
LNBITS_ENDPOINT: http://localhost:5001
|
||||
LNBITS_KEY: "d08a3313322a4514af75d488bcc27eee"
|
||||
run: |
|
||||
sudo chmod -R a+rwx . && rm -rf ./data && mkdir -p ./data
|
||||
make test-real-wallet
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v3
|
||||
with:
|
||||
file: ./coverage.xml
|
||||
EclairWallet:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.9"]
|
||||
poetry-version: ["1.3.1"]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Set up Poetry ${{ matrix.poetry-version }}
|
||||
uses: abatilo/actions-poetry@v2
|
||||
with:
|
||||
poetry-version: ${{ matrix.poetry-version }}
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: "poetry"
|
||||
- name: Setup Regtest
|
||||
run: |
|
||||
docker build -t lnbitsdocker/lnbits-legend .
|
||||
git clone https://github.com/lnbits/legend-regtest-enviroment.git docker
|
||||
cd docker
|
||||
chmod +x ./tests
|
||||
./tests
|
||||
sudo chmod -R a+rwx .
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
poetry install
|
||||
- name: Run tests
|
||||
env:
|
||||
PYTHONUNBUFFERED: 1
|
||||
PORT: 5123
|
||||
LNBITS_DATA_FOLDER: ./data
|
||||
LNBITS_BACKEND_WALLET_CLASS: EclairWallet
|
||||
ECLAIR_URL: http://127.0.0.1:8082
|
||||
ECLAIR_PASS: lnbits
|
||||
run: |
|
||||
sudo chmod -R a+rwx . && rm -rf ./data && mkdir -p ./data
|
||||
make test-real-wallet
|
||||
run: make test-real-wallet
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v3
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
file: ./coverage.xml
|
||||
|
||||
@@ -1,70 +1,58 @@
|
||||
name: tests
|
||||
|
||||
on: [push, pull_request]
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
make:
|
||||
default: test
|
||||
type: string
|
||||
python-version:
|
||||
default: "3.9"
|
||||
type: string
|
||||
os-version:
|
||||
default: "ubuntu-latest"
|
||||
type: string
|
||||
db-url:
|
||||
default: ""
|
||||
type: string
|
||||
db-name:
|
||||
default: "lnbits"
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
sqlite:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.9"]
|
||||
poetry-version: ["1.3.1"]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Set up Poetry ${{ matrix.poetry-version }}
|
||||
uses: abatilo/actions-poetry@v2
|
||||
with:
|
||||
poetry-version: ${{ matrix.poetry-version }}
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: "poetry"
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
poetry install
|
||||
- name: Run tests
|
||||
run: make test
|
||||
postgres:
|
||||
runs-on: ubuntu-latest
|
||||
tests:
|
||||
runs-on: ${{ inputs.os-version }}
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:latest
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: postgres
|
||||
POSTGRES_USER: lnbits
|
||||
POSTGRES_PASSWORD: lnbits
|
||||
POSTGRES_DB: ${{ inputs.db-name }}
|
||||
ports:
|
||||
# maps tcp port 5432 on service container to the host
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.9"]
|
||||
poetry-version: ["1.3.1"]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Set up Poetry ${{ matrix.poetry-version }}
|
||||
uses: abatilo/actions-poetry@v2
|
||||
|
||||
- uses: ./.github/actions/prepare
|
||||
with:
|
||||
poetry-version: ${{ matrix.poetry-version }}
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: "poetry"
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
poetry install
|
||||
- name: Run tests
|
||||
python-version: ${{ inputs.python-version }}
|
||||
|
||||
- name: Run Tests
|
||||
env:
|
||||
LNBITS_DATABASE_URL: postgres://postgres:postgres@0.0.0.0:5432/postgres
|
||||
run: make test
|
||||
LNBITS_DATABASE_URL: ${{ inputs.db-url }}
|
||||
LNBITS_BACKEND_WALLET_CLASS: FakeWallet
|
||||
run: make ${{ inputs.make }}
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v3
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
file: ./coverage.xml
|
||||
|
||||
@@ -23,7 +23,7 @@ tests/data/*.sqlite3
|
||||
*.pyc
|
||||
*.env
|
||||
.env
|
||||
.super_user
|
||||
.pre-commit-config.yaml
|
||||
|
||||
data
|
||||
*.sqlite3
|
||||
@@ -46,5 +46,5 @@ fly.toml
|
||||
lnbits-backup.zip
|
||||
|
||||
# Ignore extensions (post installable extension PR)
|
||||
extensions/
|
||||
extensions
|
||||
upgrades/
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
exclude: '^lnbits/static/bundle.*|^docs/.*|^lnbits/static/vendor/.*|^lnbits/extensions/.*|^lnbits/wallets/lnd_grpc_files/.*|^package-lock.json$'
|
||||
exclude: '^lnbits/static/bundle.*|^docs/.*|^lnbits/static/vendor/.*|^lnbits/extensions/.*|^lnbits/upgrades/.*|^lnbits/wallets/lnd_grpc_files/.*|^package-lock.json$'
|
||||
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
@@ -14,21 +14,17 @@ repos:
|
||||
- id: mixed-line-ending
|
||||
- id: check-case-conflict
|
||||
- repo: https://github.com/psf/black
|
||||
rev: 22.6.0
|
||||
rev: 23.7.0
|
||||
hooks:
|
||||
- id: black
|
||||
- repo: https://github.com/pycqa/isort
|
||||
rev: 5.12.0
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.0.283
|
||||
hooks:
|
||||
- id: isort
|
||||
args: ['--profile', 'black']
|
||||
- id: ruff
|
||||
args: [ --fix, --exit-non-zero-on-fix ]
|
||||
- repo: https://github.com/pre-commit/mirrors-prettier
|
||||
rev: '50c5478ed9e10bf360335449280cf2a67f4edb7a'
|
||||
hooks:
|
||||
- id: prettier
|
||||
types_or: [css, javascript, html, json]
|
||||
args: ['lnbits']
|
||||
- repo: https://github.com/pycqa/flake8
|
||||
rev: 6.0.0
|
||||
hooks:
|
||||
- id: flake8
|
||||
entry: poetry run flake8
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
*.yml
|
||||
|
||||
**/lnbits/extensions/*
|
||||
**/lnbits/upgrades/*
|
||||
|
||||
**/lnbits/static/vendor
|
||||
**/lnbits/static/bundle.*
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM python:3.10-slim
|
||||
FROM python:3.10-slim-bullseye
|
||||
|
||||
RUN apt-get clean
|
||||
RUN apt-get update
|
||||
@@ -8,7 +8,7 @@ RUN curl -sSL https://install.python-poetry.org | python3 -
|
||||
ENV PATH="/root/.local/bin:$PATH"
|
||||
|
||||
# needed for backups postgresql-client version 14 (pg_dump)
|
||||
RUN apt-get install -y wget
|
||||
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
|
||||
@@ -20,7 +20,6 @@ COPY . .
|
||||
|
||||
RUN mkdir data
|
||||
|
||||
RUN poetry config virtualenvs.create false
|
||||
RUN poetry install --only main
|
||||
|
||||
ENV LNBITS_PORT="5000"
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
all: format check
|
||||
|
||||
format: prettier isort black
|
||||
format: prettier black ruff
|
||||
|
||||
check: mypy pyright pylint flake8 checkisort checkblack checkprettier
|
||||
check: mypy pyright checkblack checkruff checkprettier
|
||||
|
||||
prettier:
|
||||
poetry run ./node_modules/.bin/prettier --write lnbits
|
||||
@@ -12,20 +12,17 @@ prettier:
|
||||
pyright:
|
||||
poetry run ./node_modules/.bin/pyright
|
||||
|
||||
black:
|
||||
poetry run black .
|
||||
|
||||
flake8:
|
||||
poetry run flake8
|
||||
|
||||
mypy:
|
||||
poetry run mypy
|
||||
|
||||
isort:
|
||||
poetry run isort .
|
||||
black:
|
||||
poetry run black .
|
||||
|
||||
pylint:
|
||||
poetry run pylint *.py lnbits/ tools/ tests/
|
||||
ruff:
|
||||
poetry run ruff check . --fix
|
||||
|
||||
checkruff:
|
||||
poetry run ruff check .
|
||||
|
||||
checkprettier:
|
||||
poetry run ./node_modules/.bin/prettier --check lnbits
|
||||
@@ -33,8 +30,11 @@ checkprettier:
|
||||
checkblack:
|
||||
poetry run black --check .
|
||||
|
||||
checkisort:
|
||||
poetry run isort --check-only .
|
||||
checkeditorconfig:
|
||||
editorconfig-checker
|
||||
|
||||
dev:
|
||||
poetry run lnbits --reload
|
||||
|
||||
test:
|
||||
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
|
||||
@@ -51,24 +51,34 @@ test-real-wallet:
|
||||
poetry run pytest
|
||||
|
||||
test-migration:
|
||||
rm -rf ./migration-data
|
||||
mkdir -p ./migration-data
|
||||
unzip tests/data/mock_data.zip -d ./migration-data
|
||||
LNBITS_ADMIN_UI=True \
|
||||
make test
|
||||
HOST=0.0.0.0 \
|
||||
PORT=5002 \
|
||||
LNBITS_DATA_FOLDER="./migration-data" \
|
||||
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" \
|
||||
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="./migration-data" \
|
||||
LNBITS_DATA_FOLDER="./tests/data" \
|
||||
LNBITS_DATABASE_URL="postgres://lnbits:lnbits@localhost:5432/migration" \
|
||||
poetry run python tools/conv.py
|
||||
|
||||
migration:
|
||||
poetry run python tools/conv.py
|
||||
|
||||
openapi:
|
||||
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
|
||||
LNBITS_DATA_FOLDER="./tests/data" \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
HOST=0.0.0.0 \
|
||||
PORT=5003 \
|
||||
poetry run lnbits &
|
||||
sleep 15
|
||||
curl -s http://0.0.0.0:5003/openapi.json | poetry run openapi-spec-validator --errors=all -
|
||||
# kill -9 %1
|
||||
|
||||
bak:
|
||||
# LNBITS_DATABASE_URL=postgres://postgres:postgres@0.0.0.0:5432/postgres
|
||||
#
|
||||
|
||||
@@ -31,7 +31,7 @@ LNbits is inspired by all the great work of [opennode.com](https://www.opennode.
|
||||
|
||||
## Running LNbits
|
||||
|
||||
See the [install guide](https://github.com/lnbits/lnbits/wiki/LNbits-Documentation#install-options) for details on installation and setup.
|
||||
See the [install guide](https://github.com/lnbits/lnbits/blob/main/docs/guide/installation.md) for details on installation and setup.
|
||||
|
||||
## LNbits as an account system
|
||||
|
||||
@@ -52,7 +52,7 @@ LNURL has a fallback scheme, so if scanned by a regular QR code reader it can de
|
||||

|
||||
|
||||
Using **lnbits.com/?lightning="LNURL-withdraw"** will trigger a withdraw that builds an LNbits wallet.
|
||||
Example use would be an ATM, which utilises LNURL, if the user scans the QR with a regular QR code scanner app, they will still be able to access the funds.
|
||||
Example use would be an ATM, which utilizes LNURL, if the user scans the QR with a regular QR code scanner app, they will still be able to access the funds.
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
docs.lnbits.org
|
||||
docs.lnbits.org
|
||||
|
||||
@@ -12,6 +12,30 @@ For developers
|
||||
Thanks for contributing :)
|
||||
|
||||
|
||||
Run
|
||||
=====
|
||||
|
||||
This starts the lnbits uvicorn server
|
||||
```bash
|
||||
poetry run lnbits
|
||||
```
|
||||
|
||||
This starts the lnbits uvicorn with hot reloading.
|
||||
```bash
|
||||
make dev
|
||||
# or
|
||||
poetry run lnbits --reload
|
||||
```
|
||||
|
||||
Precommit hooks
|
||||
=====
|
||||
|
||||
This ensures that all commits adhere to the formatting and linting rules.
|
||||
|
||||
```bash
|
||||
make install-pre-commit-hook
|
||||
```
|
||||
|
||||
Tests
|
||||
=====
|
||||
|
||||
|
||||
@@ -6,13 +6,13 @@ nav_order: 2
|
||||
---
|
||||
|
||||
|
||||
Making extensions
|
||||
Extension set up
|
||||
=================
|
||||
|
||||
Start off by cloning the [example extension](https://github.com/lnbits/example) into your `lnbits/extensions` folder and renaming it to `mysuperplugin`:
|
||||
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 lnbits/extensions
|
||||
git clone https://github.com/lnbits/example.git --depth=1 mysuperplugin # Let's not use dashes or anything; it doesn't like those.
|
||||
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.
|
||||
cd mysuperplugin
|
||||
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'.
|
||||
@@ -20,7 +20,18 @@ 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.
|
||||
|
||||
Going over the example extension's structure:
|
||||
1. Edit `manifest.json` and change the organisation name to your GitHub username.
|
||||
1. Push your changes to GitHub.
|
||||
1. In GitHub create a new release for your extension repo. Tag the release with `0.0.1`
|
||||
1. Copy the URL of the extension's raw `manifest.json` URL `https://raw.githubusercontent.com/[my-user-name]/mysuperplugin/master/manifest.json`
|
||||
1. If you are using the LMNbits Admin UI, go to the Admin UI > Server > Extension Sources, click "Add", paste the URL, then click "Save"
|
||||
1. If you are configuring LNbits via environment variables, add the URL to the .env file's `LNBITS_EXTENSIONS_MANIFESTS` variable. Restart the LNbits python process
|
||||
1. You will now see your extension in the LNbits > Extensions list. Click "Enable" to enable it.
|
||||
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.
|
||||
|
||||
@@ -9,7 +9,7 @@ nav_order: 2
|
||||
Websockets
|
||||
=================
|
||||
|
||||
`websockets` are a great way to add a two way instant data channel between server and client.
|
||||
`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")`.
|
||||
|
||||
|
||||
@@ -52,14 +52,15 @@ $ sudo nano .env
|
||||
-> set: `LNBITS_ADMIN_UI=true`
|
||||
|
||||
Now start LNbits once in the terminal window
|
||||
```
|
||||
$ poetry run lnbits
|
||||
```
|
||||
It will now show you the Super User Account:
|
||||
|
||||
`SUCCESS | ✔️ Access super user account at: https://127.0.0.1:5000/wallet?usr=5711d7..`
|
||||
|
||||
The `/wallet?usr=..` is your super user account. You just have to append it to your normal LNbits web domain.
|
||||
$ poetry run lnbits
|
||||
```
|
||||
You can now `cat` the Super User ID:
|
||||
```
|
||||
$ cat .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`
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ An `Extension Manifest` is a link to a `JSON` file which contains information ab
|
||||
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).
|
||||
**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!!
|
||||
|
||||
@@ -40,9 +40,9 @@ Click the `Manage` button in order to install a particular release of the extens
|
||||
Select the version to be installed (usually the last one) and click `Install`. One can also check the `Release Notes` first.
|
||||
|
||||
> **Note**:
|
||||
>
|
||||
>
|
||||
> For Github repository: the order of the releases is the one in the GitHub releases page
|
||||
>
|
||||
>
|
||||
> For Explicit Release: the order of the releases is the one in the "extensions" object
|
||||
|
||||
|
||||
@@ -109,12 +109,12 @@ 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:
|
||||
|
||||
|
||||
@@ -19,24 +19,24 @@ allow-self-payment=1
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary>Funding source only available via tor (e.g. Umbrel)</summary>
|
||||
|
||||
<details><summary>Funding source only available via tor (e.g. Start9 or Umbrel)</summary>
|
||||
<p>If you want your setup to stay behind tor then only apps, pos and wallets that have tor activated can communicate with your wallets. Most likely you will have trouble when people try to redeem your voucher through onion or when importing your lnbits wallets into a wallet-app that doesnt support tor. If you plan to let LNbits wallets interact with plain internet shops and services you should consider <a href="https://github.com/TrezorHannes/Dual-LND-Hybrid-VPS">setting up hybrid mode for your node</a>.</p>
|
||||
</details>
|
||||
|
||||
|
||||
<details><summary>Funding source is in a cloud</summary>
|
||||
<p>This means that you might not have access to some files which would allow certain administrative functions. E.g. on <a href="https://voltage.cloud/">Voltage</a> lnd.conf can not be edited. Payments from your node to LNbits wallets can therefore not be configurated in this case atm so you will need to take an extra wallet to send from funding source->wallet x->LNbits wallet (only) for the initial funding of the wallet.</p>
|
||||
</details>
|
||||
|
||||
|
||||
<details><summary>LNbits via clearnet domain</summary>
|
||||
<p><a href="https://github.com/TrezorHannes/Dual-LND-Hybrid-VPS">Step by step guide how to convert your Tor only node</a> into a clearnet node to make apps like LNbits accessible via https.</p>
|
||||
</details>
|
||||
|
||||
|
||||
<details><summary>Which funding sources can I use for LNbits?</summary>
|
||||
<p>There are several ways to run a LNbits instance funded from different sources. It is important to choose a source that has a good liquidity and good peers connected. If you use LNbits for public services your users´ payments can then flow happily in both directions. If you would like to fund your LNbits wallet via btc please see section Troubleshooting.</p>
|
||||
<p>The <a href="http://docs.lnbits.org/guide/wallets.html">LNbits manual</a> shows you which sources can be used and how to configure each: CLN, LND, LNPay, Cliche, OpenNode as well as bots.</p>
|
||||
</details>
|
||||
|
||||
|
||||
<!--Later to be added
|
||||
<details><summary>Advanced setup options</summary>
|
||||
<p>more text coming soon...</p>
|
||||
@@ -61,7 +61,7 @@ allow-self-payment=1
|
||||
<details><summary>LNbits is running via Tor only, you can't open it on a public domain like lnbits.yourdomain.com</summary>
|
||||
<ul>
|
||||
<li>Given that you want your setup to stay like this open your LNbits wallet using the .onion URI and create it again. In this way the QR is generated to be accessible via this .onion URI so via tor only. Do not generate that QR from a .local URI, because it will not be reachable via internet - only from within your home-LAN.</li>
|
||||
<li>Open your LN wallet app that you used to scan that QR and this time by using tor (see wallet app settings).
|
||||
<li>Open your LN wallet app that you used to scan that QR and this time by using tor (see wallet app settings).
|
||||
If the app doesn't offer tor, you can use Orbot (Android) instead. See section Installation->Clearnet for detailed instructions.</li>
|
||||
</ul>
|
||||
</details>
|
||||
@@ -70,10 +70,10 @@ allow-self-payment=1
|
||||
<details><summary>LNbits is running via Tor only, you want to offer public LN services via https</summary>
|
||||
<ul>
|
||||
<li>For this we need to partially open LNbits to a clearnet (domain/IP) through a https SSL certificate. Follow the instructions from <a href="https://docs.lnbits.org/guide/installation.html#reverse-proxy-with-automatic-https-using-caddy">this LNbits caddy installation instruction</a>.
|
||||
You need to have a domain and to be able to configure a CNAME for your DNS record as well as generate a subdomain dedicated to your LNbits instance like eg. lnbits.yourdomain.com.
|
||||
You need to have a domain and to be able to configure a CNAME for your DNS record as well as generate a subdomain dedicated to your LNbits instance like eg. lnbits.yourdomain.com.
|
||||
You also need access to your internet router to open the https port (usually 443) and forward it your LNbits IP within your LAN (usually 80). The ports might depend on your node implementation if those ports do not work please ask for them in a help group of your node supplier.</li>
|
||||
<li>You can also follow the Apache installation option, explained in the <a href="https://docs.lnbits.org/guide/installation.html#running-behind-an-apache2-reverse-proxy-over-https">LNbits installation manual</a>.</li>
|
||||
<li>If you run LNbits from a bundle node (Umbrel, myNode, Embassy, Raspiblitz etc), you can follow <a href="https://github.com/TrezorHannes/vps-lnbits">this extensive guide</a> with many options to switch your Tor only LNbits into a clearnet LNbits. For Citadel there is a HTTPS Option in your manual to activate https for LNbits in the newest version.</li>
|
||||
<li>If you run LNbits from a bundle node (myNode, Start9, Umbrel,Raspiblitz etc), you can follow <a href="https://github.com/TrezorHannes/vps-lnbits">this extensive guide</a> with many options to switch your Tor only LNbits into a clearnet LNbits. For Citadel there is a HTTPS Option in your manual to activate https for LNbits in the newest version.</li>
|
||||
</ul>
|
||||
</details>
|
||||
</li>
|
||||
@@ -89,9 +89,9 @@ allow-self-payment=1
|
||||
</li>
|
||||
<li>
|
||||
<details><summary>Wallet on your own funding source/node</summary>
|
||||
<p>Always save a copy of your wallet-URL, Export2phone-QR or LNDhub for your own wallets in a safe place.
|
||||
You can find all LNbits users and wallet-IDs in your LNbits user manager extension or in your sqlite database.
|
||||
To edit or read the LNbits database, go to the LNbits /data folder and look for the file called sqlite.db.
|
||||
<p>Always save a copy of your wallet-URL, Export2phone-QR or LNDhub for your own wallets in a safe place.
|
||||
You can find all LNbits users and wallet-IDs in your LNbits user manager extension or in your sqlite database.
|
||||
To edit or read the LNbits database, go to the LNbits /data folder and look for the file called sqlite.db.
|
||||
You can open and edit it with excel or with a dedicated SQL-Editor like <a href="https://sqlitebrowser.org/">SQLite browser</a>.</p>
|
||||
</details>
|
||||
</li>
|
||||
@@ -100,7 +100,7 @@ allow-self-payment=1
|
||||
|
||||
<details><summary>Configure a comment that people see when paying to my LNURLp QR</summary>
|
||||
<p>When you create a LNURL-p, by default the comment box is not filled. That means comments are not allowed to be attached to payments.<p>
|
||||
<p>In order to allow comments, add the characters lenght of the box, from 1 to 250. Once you put a number there,
|
||||
<p>In order to allow comments, add the characters lenght of the box, from 1 to 250. Once you put a number there,
|
||||
the comment box will be displayed in the payment process. You can also edit a LNURL-p already created and add that number.</p>
|
||||
|
||||

|
||||
@@ -113,16 +113,16 @@ allow-self-payment=1
|
||||
<li>
|
||||
<details><summary>A - Via an external swap service</summary>
|
||||
<p>If the user do not have full access of your LNbits, is just an external user, can use swap services like <a href="https://boltz.exchange/">Boltz</a>, <a href="https://fixedfloat.com/">FixedFloat</a>, <a href="https://swap.diamondhands.technology/">DiamondHands</a> or <a href="https://zigzag.io/">ZigZag</a>.</p>
|
||||
<p>This is useful if you provide only LNURL/LN invoices from your LNbits instance, but a payer only has onchain sats so
|
||||
<p>This is useful if you provide only LNURL/LN invoices from your LNbits instance, but a payer only has onchain sats so
|
||||
they will have to the swap those sats first on their side.</p>
|
||||
<p>The procedure is simple: user sends onchain btc to the swap service and provides the LNURL / LN invoice from LNbits as destination of the swap.</p>
|
||||
</details>
|
||||
</li>
|
||||
<li>
|
||||
<details><summary>B - Using the Onchain LNbits extension</summary>
|
||||
<p>Keep in mind that this is a separate wallet, not the LN btc one that is represented by LNbits as "your wallet" upon your LN funding source.
|
||||
This onchain wallet can be used also to swap LN btc to (e.g. your hardwarewallet) by using the LNbits Boltz or Deezy extension.
|
||||
If you run a webshop that is linked to your LNbits for LN payments, it is very handy to regularly drain all the sats from LN into onchain.
|
||||
<p>Keep in mind that this is a separate wallet, not the LN btc one that is represented by LNbits as "your wallet" upon your LN funding source.
|
||||
This onchain wallet can be used also to swap LN btc to (e.g. your hardwarewallet) by using the LNbits Boltz or Deezy extension.
|
||||
If you run a webshop that is linked to your LNbits for LN payments, it is very handy to regularly drain all the sats from LN into onchain.
|
||||
This leads to more space in your LN channels to be able to receive new fresh sats.</p>
|
||||
<p>Procedure:</p>
|
||||
<ul>
|
||||
@@ -130,25 +130,25 @@ allow-self-payment=1
|
||||
<li>Go to wallet information and copy the xpub</li>
|
||||
<li>Go to LNbits - Onchain extension and create a new watch-only wallet with that xpub</li>
|
||||
<li>Go to LNbits - Tipjar extension and create a new Tipjar. Select also the onchain option besides the LN wallet.</li>
|
||||
<li>Optional - Go to LNbits - SatsPay extension and create a new charge for onchain btc.
|
||||
<li>Optional - Go to LNbits - SatsPay extension and create a new charge for onchain btc.
|
||||
You can choose between onchain and LN or both. It will then create an invoice that can be shared.</li>
|
||||
<li>Optional - If you use your LNbits linked to a Wordpress + Woocommerce page, once you create/link a watch-only wallet to your LN btc shop wallet,
|
||||
<li>Optional - If you use your LNbits linked to a Wordpress + Woocommerce page, once you create/link a watch-only wallet to your LN btc shop wallet,
|
||||
the customer will have both options to pay on the same screen.</li>
|
||||
</ul>
|
||||
</details>
|
||||
</li>
|
||||
</ul>
|
||||
</details>
|
||||
|
||||
|
||||
<details><summary>Where can I see payment details?</summary>
|
||||
<p>When you receive a payment in LNbits, the transaction log will display only a resumed type of the transaction.
|
||||
<p>When you receive a payment in LNbits, the transaction log will display only a resumed type of the transaction.
|
||||
|
||||

|
||||
|
||||
<p>In your transaction overview you will find a little green arrow for received and a red arrow for sended funds.<p>
|
||||
<p>If you click on those arrows, a details popup shows attached messages as well as the sender´s name if given.</p>
|
||||
</details>
|
||||
|
||||
|
||||
<details><summary>Can I configure a name to the payments i make?</summary>
|
||||
<p>In LNbits this is currently not possible to do - but to receive. This is only possible if the sender's LN wallet supports <a href="https://github.com/lnurl/luds">LUD-18</a> (nameDesc) like e.g. <a href="https://darthcoin.substack.com/p/obw-open-bitcoin-wallet">Open Bitcion Wallet - OBW</a> does. You will then see an alias/pseudonym in the details section of your LNbits transactions (click the arrows). Note that you can give any name there and it might not be related to the real sender´s name(!) if your receive such.</p>
|
||||

|
||||
@@ -171,7 +171,7 @@ allow-self-payment=1
|
||||
<details><summary>ATM - deposit and withdraw in your shop or at your meetup</summary>
|
||||
<p>This is a do-it-yourself project consisting of a mini-computer (Raspberry Pi Zero), a coin acceptor, a display, a 3D printed case, and a Bitcoin Lightning wallet as a funding source. It exchanges fiat coins for valuable Bitcoin Lightning ⚡ Satoshis. The user can pick up the Satoshis via QR code (LNURL-withdraw) on a mobile phone wallet. You can get the components as individual parts and build the case yourself e.g. from <a href="https://www.Fulmo.org">Fulmo</a> who also made a <a href="https://blog.fulmo.org/the-lightningatm-pocket-edition/">guide</a> on it. The shop offers payments in Bitcoin and Lightning ⚡. The code can be found on <a href="https://github.com/21isenough/LightningATM">the ATM github project page></a>.</p>
|
||||
</details>
|
||||
|
||||
|
||||
<details><summary>POS Terminal - an offline terminal for merchants</summary>
|
||||
<p>The LNpos is a self-sufficient point of sale terminal which allows offline onchain payments and an offline Lightning ATM for withdrawals. Free and open source software, free from intermediaries, with integrated battery, WLAN, DIY. You can get the 3D print as well as the whole kit from the LNbits shop from 👇 Resources. It allows
|
||||
<li>LNPoS Online interactive Lightning payments</li>
|
||||
@@ -183,21 +183,21 @@ allow-self-payment=1
|
||||
</p>
|
||||
</p>
|
||||
</details>
|
||||
|
||||
|
||||
<details><summary>Hardware Wallet- build your own, stack harder</summary>
|
||||
<p>The hardwarewallet is a very cheap solution for builders. The projects´ <a hrel="https://github.com/lnbits/hardware-wallet">code and installation instructions for the LNbits hardware wallet can be found on github</a></p>
|
||||
<p>
|
||||
<img width="546" alt="Bildschirmfoto 2023-01-20 um 18 08 37" src="https://user-images.githubusercontent.com/63317640/213760948-38fd77b0-9247-4505-9433-f5af1b223527.png">
|
||||
</p>
|
||||
</details>
|
||||
|
||||
|
||||
<details><summary>Bitcoin Switch - turn things on with bitcoin</summary>
|
||||
<p>Candy dispenser, vending machines (online), grabbing machines, jukeboxes, bandits and <a href="https://github.com/cryptoteun/awesome-lnbits">all sorts of other things have already been build with LNbits´ tools</a>. Further info see 👇 Resources.</p>
|
||||
<p>
|
||||
<img width="549" alt="Bildschirmfoto 2023-01-20 um 18 11 55" src="https://user-images.githubusercontent.com/63317640/213761646-d25d4745-e50d-4389-98e5-f83237a8cf6b.png">
|
||||
</p>
|
||||
</details>
|
||||
|
||||
|
||||
<details><summary>Vending machine (offline)</summary>
|
||||
<p>This code works similar to the LNpos. Note that the <a href=" https://www.youtube.com/watch?v=Fg0UuuzsYXc&t=762s">setup-video for the vending machine</a> misses the new way of installing it via the new LNURLdevices extension. The <a href="https://github.com/arcbtc/LNURLVend">vending machine project code resides on github</a>.</p>
|
||||
<p>
|
||||
@@ -262,7 +262,7 @@ allow-self-payment=1
|
||||
</ul>
|
||||
|
||||
## Developing for LNbits
|
||||
<ul>
|
||||
<ul>
|
||||
<li><a href="http://docs.lnbits.org/devs/development.html">Making Estension / how to use Websockets / API reference</a></li>
|
||||
<li><a href="https://t.me/lnbits">Telegram LNbits Support Group</a></li></ul>
|
||||
</ul>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
## Defining a route with path parameters
|
||||
**old:**
|
||||
```python
|
||||
# with <>
|
||||
# with <>
|
||||
@offlineshop_ext.route("/lnurl/<item_id>", methods=["GET"])
|
||||
```
|
||||
|
||||
@@ -35,8 +35,8 @@ async def extensions(user: User = Depends(check_user_exists)):
|
||||
```python
|
||||
return (
|
||||
{
|
||||
"id": wallet.wallet.id,
|
||||
"name": wallet.wallet.name,
|
||||
"id": wallet.wallet.id,
|
||||
"name": wallet.wallet.name,
|
||||
"balance": wallet.wallet.balance_msat
|
||||
},
|
||||
HTTPStatus.OK,
|
||||
@@ -47,8 +47,8 @@ FastAPI returns `HTTPStatus.OK` by default id no Exception is raised
|
||||
**new:**
|
||||
```python
|
||||
return {
|
||||
"id": wallet.wallet.id,
|
||||
"name": wallet.wallet.name,
|
||||
"id": wallet.wallet.id,
|
||||
"name": wallet.wallet.name,
|
||||
"balance": wallet.wallet.balance_msat
|
||||
}
|
||||
```
|
||||
@@ -119,4 +119,4 @@ Instead of hitting the database over and over again, we can store a short lived
|
||||
Example:
|
||||
* Get transactions for a wallet ID
|
||||
* User data for a user id
|
||||
* Wallet data for a Admin / Invoice key
|
||||
* Wallet data for a Admin / Invoice key
|
||||
|
||||
@@ -18,6 +18,12 @@ A backend wallet can be configured using the following LNbits environment variab
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: **CoreLightningWallet**
|
||||
- `CORELIGHTNING_RPC`: /file/path/lightning-rpc
|
||||
|
||||
### CoreLightning REST
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: **CoreLightningRestWallet**
|
||||
- `CORELIGHTNING_REST_URL`: http://127.0.0.1:8185/
|
||||
- `CORELIGHTNING_REST_MACAROON`: /file/path/admin.macaroon or Base64/Hex
|
||||
- `CORELIGHTNING_REST_CERT`: /home/lightning/clnrest/tls.cert
|
||||
|
||||
### Spark (Core Lightning)
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: **SparkWallet**
|
||||
@@ -29,7 +35,7 @@ A backend wallet can be configured using the following LNbits environment variab
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: **LndRestWallet**
|
||||
- `LND_REST_ENDPOINT`: http://10.147.17.230:8080/
|
||||
- `LND_REST_CERT`: /file/path/tls.cert
|
||||
- `LND_REST_MACAROON`: /file/path/admin.macaroon or Bech64/Hex
|
||||
- `LND_REST_MACAROON`: /file/path/admin.macaroon or Base64/Hex
|
||||
|
||||
or
|
||||
|
||||
@@ -41,7 +47,7 @@ or
|
||||
- `LND_GRPC_ENDPOINT`: ip_address
|
||||
- `LND_GRPC_PORT`: port
|
||||
- `LND_GRPC_CERT`: /file/path/tls.cert
|
||||
- `LND_GRPC_MACAROON`: /file/path/admin.macaroon or Bech64/Hex
|
||||
- `LND_GRPC_MACAROON`: /file/path/admin.macaroon or Base64/Hex
|
||||
|
||||
You can also use an AES-encrypted macaroon (more info) instead by using
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ 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
|
||||
* 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
|
||||
|
||||
|
After Width: | Height: | Size: 58 KiB |
@@ -0,0 +1,11 @@
|
||||
<svg width="3032" height="902" viewBox="0 0 3032 902" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M158.566 493.857L1 901L450.49 355.202H264.831L501.791 1H187.881L36.4218 493.857H158.566Z" fill="#FF1FE1" stroke="black"/>
|
||||
<path d="M616.041 764.863V134.611H741.647V645.115H1023.41V764.863H616.041Z" fill="#F8F8F8"/>
|
||||
<path d="M1079.99 764.863V134.611H1198.81L1478.31 543.014V134.611H1600.52V764.863H1478.31L1198.81 360.241V764.863H1079.99Z" fill="#F8F8F8"/>
|
||||
<path d="M616.041 764.863V134.611H741.647V645.115H1023.41V764.863H616.041Z" stroke="black"/>
|
||||
<path d="M1079.99 764.863V134.611H1198.81L1478.31 543.014V134.611H1600.52V764.863H1478.31L1198.81 360.241V764.863H1079.99Z" stroke="black"/>
|
||||
<path d="M1693.87 768.522V148.458H1764.1V377.349H1770.05C1775.21 369.275 1782.35 358.981 1791.47 346.467C1800.8 333.751 1814.09 322.448 1831.35 312.557C1848.81 302.465 1872.41 297.419 1902.17 297.419C1940.66 297.419 1974.58 307.208 2003.94 326.787C2033.3 346.366 2056.22 374.119 2072.68 410.048C2089.15 445.976 2097.38 488.363 2097.38 537.209C2097.38 586.459 2089.15 629.149 2072.68 665.279C2056.22 701.207 2033.4 729.061 2004.24 748.842C1975.08 768.421 1941.45 778.21 1903.36 778.21C1874 778.21 1850.49 773.265 1832.84 763.375C1815.18 753.283 1801.59 741.878 1792.07 729.162C1782.55 716.244 1775.21 705.547 1770.05 697.069H1761.72V768.522H1693.87ZM1762.91 535.998C1762.91 571.119 1767.96 602.102 1778.08 628.947C1788.2 655.59 1802.98 676.481 1822.42 691.619C1841.86 706.556 1865.67 714.024 1893.84 714.024C1923.2 714.024 1947.7 706.152 1967.34 690.408C1987.18 674.463 2002.06 653.067 2011.98 626.222C2022.1 599.175 2027.15 569.1 2027.15 535.998C2027.15 503.299 2022.19 473.83 2012.28 447.59C2002.55 421.149 1987.78 400.258 1967.94 384.918C1948.3 369.376 1923.6 361.605 1893.84 361.605C1865.27 361.605 1841.27 368.972 1821.83 383.707C1802.38 398.24 1787.7 418.626 1777.78 444.866C1767.87 470.903 1762.91 501.281 1762.91 535.998Z" fill="white"/>
|
||||
<path d="M2204.81 768.522V303.474H2275.04V768.522H2204.81ZM2240.52 225.966C2226.83 225.966 2215.03 221.223 2205.11 211.736C2195.39 202.25 2190.52 190.846 2190.52 177.524C2190.52 164.202 2195.39 152.798 2205.11 143.311C2215.03 133.825 2226.83 129.081 2240.52 129.081C2254.21 129.081 2265.91 133.825 2275.63 143.311C2285.55 152.798 2290.51 164.202 2290.51 177.524C2290.51 190.846 2285.55 202.25 2275.63 211.736C2265.91 221.223 2254.21 225.966 2240.52 225.966Z" fill="white"/>
|
||||
<path d="M2602.45 303.474V364.027H2365.57V303.474H2602.45ZM2434.61 192.057H2504.84V635.305C2504.84 655.489 2507.72 670.628 2513.47 680.72C2519.42 690.61 2526.96 697.271 2536.09 700.702C2545.41 703.932 2555.23 705.547 2565.55 705.547C2573.28 705.547 2579.63 705.143 2584.59 704.335C2589.55 703.326 2593.52 702.519 2596.49 701.913L2610.78 766.1C2606.02 767.916 2599.37 769.733 2590.84 771.549C2582.31 773.568 2571.5 774.577 2558.41 774.577C2538.57 774.577 2519.12 770.237 2500.08 761.558C2481.23 752.879 2465.56 739.658 2453.06 721.896C2440.76 704.134 2434.61 681.729 2434.61 654.682V192.057Z" fill="white"/>
|
||||
<path d="M3023.67 407.625L2960.58 425.791C2956.61 415.094 2950.76 404.699 2943.02 394.607C2935.49 384.313 2925.17 375.835 2912.08 369.174C2898.98 362.513 2882.22 359.183 2861.79 359.183C2833.81 359.183 2810.5 365.743 2791.85 378.863C2773.4 391.781 2764.18 408.231 2764.18 428.214C2764.18 445.976 2770.53 460.004 2783.22 470.298C2795.92 480.592 2815.76 489.17 2842.74 496.033L2910.59 512.988C2951.46 523.08 2981.91 538.521 3001.94 559.311C3021.98 579.899 3032 606.441 3032 638.938C3032 665.581 3024.46 689.399 3009.38 710.391C2994.51 731.383 2973.67 747.934 2946.89 760.044C2920.11 772.155 2888.96 778.21 2853.45 778.21C2806.83 778.21 2768.25 767.916 2737.7 747.328C2707.14 726.74 2687.8 696.665 2679.67 657.104L2746.33 640.149C2752.67 665.178 2764.68 683.949 2782.33 696.464C2800.19 708.978 2823.5 715.235 2852.26 715.235C2885 715.235 2910.99 708.17 2930.23 694.041C2949.67 679.711 2959.39 662.554 2959.39 642.571C2959.39 626.424 2953.84 612.9 2942.73 602.001C2931.62 590.899 2914.56 582.624 2891.54 577.174L2815.36 559.008C2773.5 548.916 2742.75 533.273 2723.11 512.08C2703.67 490.684 2693.95 463.94 2693.95 431.847C2693.95 405.607 2701.19 382.395 2715.67 362.211C2730.36 342.026 2750.29 326.182 2775.49 314.676C2800.88 303.171 2829.65 297.419 2861.79 297.419C2907.02 297.419 2942.53 307.511 2968.32 327.695C2994.31 347.88 3012.76 374.523 3023.67 407.625Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 56 KiB |
@@ -1 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 101.041 30.0001"><defs><path id="a" d="M33.2619 148.1667h154.2143v68.7917H33.2619z"/></defs><g fill="#1f2234" aria-label="LNbits" font-family="sans-serif" font-size=".3095" font-weight="400" letter-spacing=".0031" style="line-height:1.25;white-space:pre;shape-inside:url(#a)" transform="matrix(72.4607 0 0 72.4607 -2399.2814 -10741.3589)"><g transform="matrix(.00244 0 0 .00244 33.0708 148.1594)"><circle cx="101.2976" cy="116.4167" r="84.6667" fill="#673ab7" fill-rule="evenodd"/><path fill="#eee" d="M79.1105 71.9667v49.0613h13.3803v40.141l31.2208-53.5213h-17.8404l17.8404-35.681z"/></g><g font-family="roboto"><path d="M33.6755 148.518h.0962v.0364h-.1416v-.22h.0454zM33.9796 148.5544h-.0453l-.0883-.1448v.1448h-.0453v-.22h.0453l.0884.145v-.145h.0452z" font-weight="700" style="-inkscape-font-specification:'roboto Bold'"/><path d="M34.1625 148.4744q0 .0375-.0172.0603-.0172.0227-.0462.0227-.031 0-.048-.0219l-.0014.019h-.0256v-.2322h.028v.0866q.0168-.021.0466-.021.0298 0 .0467.0225.017.0225.017.0617zm-.028-.003q0-.0286-.011-.044-.011-.0157-.0317-.0157-.0277 0-.0398.0257v.0707q.0129.0257.04.0257.0202 0 .0314-.0156.0112-.0156.0112-.0469zM34.231 148.5544h-.028v-.1635h.028zm-.0302-.2069q0-.007.0041-.0115.0042-.005.0124-.005.0082 0 .0124.005.0042.005.0042.0115 0 .007-.0042.0113-.0042.005-.0124.005-.0082 0-.0124-.005-.004-.005-.004-.0113zM34.3167 148.3513v.0396h.0305v.0216h-.0305v.1014q0 .01.004.0148.0042.005.014.005.0048 0 .0133-.002v.0227q-.011.003-.0215.003-.0187 0-.0283-.0113-.0095-.0113-.0095-.0322v-.1014h-.0297v-.0216h.0297v-.0396zM34.478 148.511q0-.0113-.0086-.0175-.0085-.006-.0298-.0109-.0211-.005-.0337-.0109-.0124-.006-.0184-.015t-.006-.0209q0-.02.017-.034.017-.0139.0435-.0139.0278 0 .045.0144.0174.0144.0174.0367h-.028q0-.0115-.0099-.0198-.0097-.008-.0245-.008-.0152 0-.0238.007-.0086.007-.0086.0174 0 .0101.008.0153.008.005.0288.01.021.005.034.0112.013.007.0192.0157.0063.009.0063.0222 0 .022-.0175.0352-.0175.0131-.0455.0131-.0197 0-.0348-.007-.015-.007-.0237-.0193-.0085-.0125-.0085-.027h.028q.0007.014.0111.0223.0106.008.0279.008.0158 0 .0253-.006.0097-.006.0097-.0172z" style="-inkscape-font-specification:roboto"/></g></g></svg>
|
||||
<svg width="3032" height="902" viewBox="0 0 3032 902" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M158.566 493.857L1 901L450.49 355.202H264.831L501.791 1H187.881L36.4218 493.857H158.566Z" fill="#FF1FE1" stroke="black"/>
|
||||
<path d="M616.041 764.863V134.611H741.647V645.115H1023.41V764.863H616.041Z" fill="#1E1E1E"/>
|
||||
<path d="M1079.99 764.863V134.611H1198.81L1478.31 543.014V134.611H1600.52V764.863H1478.31L1198.81 360.241V764.863H1079.99Z" fill="#1E1E1E"/>
|
||||
<path d="M616.041 764.863V134.611H741.647V645.115H1023.41V764.863H616.041Z" stroke="black"/>
|
||||
<path d="M1079.99 764.863V134.611H1198.81L1478.31 543.014V134.611H1600.52V764.863H1478.31L1198.81 360.241V764.863H1079.99Z" stroke="black"/>
|
||||
<path d="M1693.87 768.522V148.458H1764.1V377.349H1770.05C1775.21 369.275 1782.35 358.981 1791.47 346.467C1800.8 333.751 1814.09 322.448 1831.35 312.557C1848.81 302.465 1872.41 297.419 1902.17 297.419C1940.66 297.419 1974.58 307.208 2003.94 326.787C2033.3 346.366 2056.22 374.119 2072.68 410.048C2089.15 445.976 2097.38 488.363 2097.38 537.209C2097.38 586.459 2089.15 629.149 2072.68 665.279C2056.22 701.207 2033.4 729.061 2004.24 748.842C1975.08 768.421 1941.45 778.21 1903.36 778.21C1874 778.21 1850.49 773.265 1832.84 763.375C1815.18 753.282 1801.59 741.878 1792.07 729.162C1782.55 716.244 1775.21 705.547 1770.05 697.069H1761.72V768.522H1693.87ZM1762.91 535.998C1762.91 571.119 1767.96 602.102 1778.08 628.947C1788.2 655.59 1802.98 676.481 1822.42 691.619C1841.86 706.556 1865.67 714.024 1893.84 714.024C1923.2 714.024 1947.7 706.152 1967.34 690.408C1987.18 674.463 2002.06 653.067 2011.98 626.222C2022.1 599.175 2027.15 569.1 2027.15 535.998C2027.15 503.299 2022.2 473.83 2012.28 447.59C2002.55 421.149 1987.78 400.258 1967.94 384.918C1948.3 369.376 1923.6 361.605 1893.84 361.605C1865.27 361.605 1841.27 368.972 1821.83 383.707C1802.38 398.24 1787.7 418.626 1777.78 444.866C1767.87 470.903 1762.91 501.281 1762.91 535.998Z" fill="black"/>
|
||||
<path d="M2204.81 768.522V303.474H2275.04V768.522H2204.81ZM2240.52 225.966C2226.83 225.966 2215.03 221.223 2205.11 211.736C2195.39 202.25 2190.52 190.846 2190.52 177.524C2190.52 164.202 2195.39 152.798 2205.11 143.311C2215.03 133.825 2226.83 129.081 2240.52 129.081C2254.21 129.081 2265.91 133.825 2275.63 143.311C2285.55 152.798 2290.51 164.202 2290.51 177.524C2290.51 190.846 2285.55 202.25 2275.63 211.736C2265.91 221.223 2254.21 225.966 2240.52 225.966Z" fill="black"/>
|
||||
<path d="M2602.45 303.474V364.027H2365.57V303.474H2602.45ZM2434.61 192.057H2504.84V635.305C2504.84 655.489 2507.72 670.628 2513.47 680.72C2519.42 690.61 2526.96 697.271 2536.09 700.702C2545.41 703.932 2555.23 705.546 2565.55 705.546C2573.28 705.546 2579.63 705.143 2584.59 704.335C2589.55 703.326 2593.52 702.519 2596.5 701.913L2610.78 766.1C2606.02 767.916 2599.37 769.733 2590.84 771.549C2582.31 773.568 2571.5 774.577 2558.41 774.577C2538.57 774.577 2519.12 770.237 2500.08 761.558C2481.23 752.879 2465.56 739.658 2453.06 721.896C2440.76 704.134 2434.61 681.729 2434.61 654.682V192.057Z" fill="black"/>
|
||||
<path d="M3023.67 407.625L2960.58 425.791C2956.61 415.094 2950.76 404.699 2943.02 394.607C2935.49 384.313 2925.17 375.835 2912.08 369.174C2898.98 362.513 2882.22 359.183 2861.79 359.183C2833.81 359.183 2810.5 365.743 2791.85 378.863C2773.41 391.781 2764.18 408.231 2764.18 428.213C2764.18 445.976 2770.53 460.004 2783.23 470.298C2795.92 480.592 2815.76 489.17 2842.74 496.033L2910.59 512.988C2951.46 523.08 2981.91 538.521 3001.94 559.311C3021.98 579.899 3032 606.441 3032 638.938C3032 665.581 3024.46 689.399 3009.38 710.391C2994.51 731.383 2973.67 747.934 2946.89 760.044C2920.11 772.155 2888.96 778.21 2853.45 778.21C2806.83 778.21 2768.25 767.916 2737.7 747.328C2707.14 726.74 2687.8 696.665 2679.67 657.104L2746.33 640.149C2752.67 665.178 2764.68 683.949 2782.33 696.464C2800.19 708.978 2823.5 715.235 2852.26 715.235C2885 715.235 2910.99 708.17 2930.23 694.041C2949.67 679.711 2959.39 662.554 2959.39 642.571C2959.39 626.424 2953.84 612.9 2942.73 602.001C2931.62 590.899 2914.56 582.624 2891.54 577.174L2815.36 559.008C2773.5 548.916 2742.75 533.273 2723.11 512.079C2703.67 490.684 2693.95 463.94 2693.95 431.847C2693.95 405.607 2701.19 382.395 2715.67 362.211C2730.36 342.026 2750.29 326.182 2775.49 314.676C2800.88 303.171 2829.65 297.419 2861.79 297.419C2907.02 297.419 2942.53 307.511 2968.32 327.695C2994.31 347.88 3012.76 374.523 3023.67 407.625Z" fill="black"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 77 KiB |
@@ -1 +1,19 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 30 30"><g transform="translate(-2.9464 -5.625) scale(.17717)"><circle cx="101.2976" cy="116.4167" r="84.6667" fill="#673ab7" fill-rule="evenodd"/><path fill="#eee" d="M79.1105 71.9667v49.0613h13.3803v40.141l31.2208-53.5213h-17.8404l17.8404-35.681z"/></g></svg>
|
||||
<svg width="900" height="900" viewBox="0 0 900 900" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g filter="url(#filter0_i_10_29)">
|
||||
<circle cx="450" cy="450" r="450" fill="#1E1E1E"/>
|
||||
</g>
|
||||
<path d="M387.802 476.423L281.613 730.887L584.54 389.763H459.418L619.113 168.387H407.558L305.485 476.423H387.802Z" fill="#FF1FE1"/>
|
||||
<defs>
|
||||
<filter id="filter0_i_10_29" x="-0.1" y="-0.1" width="900.1" height="900.1" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feMorphology radius="0.6" operator="dilate" in="SourceAlpha" result="effect1_innerShadow_10_29"/>
|
||||
<feOffset dx="-1" dy="-0.4"/>
|
||||
<feGaussianBlur stdDeviation="0.35"/>
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 0.121569 0 0 0 0 0.882353 0 0 0 1 0"/>
|
||||
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_10_29"/>
|
||||
</filter>
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 314 B After Width: | Height: | Size: 1.1 KiB |
@@ -1,12 +1,15 @@
|
||||
{
|
||||
"nodes": {
|
||||
"flake-utils": {
|
||||
"inputs": {
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1676283394,
|
||||
"narHash": "sha256-XX2f9c3iySLCw54rJ/CZs+ZK6IQy7GXNY4nSOyu2QG4=",
|
||||
"lastModified": 1689068808,
|
||||
"narHash": "sha256-6ixXo3wt24N/melDWjq70UuHQLxGV8jZvooRanIHXw0=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "3db36a8b464d0c4532ba1c7dda728f4576d6d073",
|
||||
"rev": "919d646de7be200f3bf08cb76ae1f09402b6f9b4",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -15,6 +18,27 @@
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nix-github-actions": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"poetry2nix",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1688870561,
|
||||
"narHash": "sha256-4UYkifnPEw1nAzqqPOTL2MvWtm3sNGw1UTYTalkTcGY=",
|
||||
"owner": "nix-community",
|
||||
"repo": "nix-github-actions",
|
||||
"rev": "165b1650b753316aa7f1787f3005a8d2da0f5301",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-community",
|
||||
"repo": "nix-github-actions",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1678470307,
|
||||
@@ -34,16 +58,17 @@
|
||||
"poetry2nix": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils",
|
||||
"nix-github-actions": "nix-github-actions",
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1678513806,
|
||||
"narHash": "sha256-bEto1lp9bIQ+DlJsXQyRxN5l6B/oy3Skb7DueYRJJBo=",
|
||||
"lastModified": 1695386222,
|
||||
"narHash": "sha256-5lgnhCCGW0NH5+m5iTED8u6NSSM/dbH9LBPvX0x0XXg=",
|
||||
"owner": "nix-community",
|
||||
"repo": "poetry2nix",
|
||||
"rev": "bf239d53fafb53cf439a72b3a50e86dd6a3984a5",
|
||||
"rev": "093383b3d7fdd36846a7d84e128ca11865800538",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -57,6 +82,21 @@
|
||||
"nixpkgs": "nixpkgs",
|
||||
"poetry2nix": "poetry2nix"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
|
||||
@@ -28,13 +28,29 @@
|
||||
});
|
||||
overlays = {
|
||||
default = final: prev: {
|
||||
${projectName} = self.packages.${final.hostPlatform.system}.${projectName};
|
||||
${projectName} = self.packages.${prev.stdenv.hostPlatform.system}.${projectName};
|
||||
};
|
||||
};
|
||||
packages = forAllSystems (system: pkgs: {
|
||||
default = self.packages.${system}.${projectName};
|
||||
${projectName} = pkgs.poetry2nix.mkPoetryApplication {
|
||||
projectDir = ./.;
|
||||
meta.rev = self.dirtyRev or self.rev;
|
||||
overrides = pkgs.poetry2nix.overrides.withDefaults (final: prev: {
|
||||
ruff = prev.ruff.override { preferWheel = true; };
|
||||
fastapi = prev.fastapi.overridePythonAttrs (old: {
|
||||
postPatch = ''
|
||||
substituteInPlace pyproject.toml \
|
||||
--replace '"Framework :: Pydantic",' "" \
|
||||
--replace '"Framework :: Pydantic :: 1",' ""
|
||||
'';
|
||||
});
|
||||
bolt11 = prev.bolt11.overrideAttrs (old: {
|
||||
nativeBuildInputs = (old.nativeBuildInputs or [ ]) ++ [
|
||||
prev.poetry
|
||||
];
|
||||
});
|
||||
});
|
||||
};
|
||||
});
|
||||
nixosModules = {
|
||||
|
||||
@@ -7,37 +7,49 @@ import shutil
|
||||
import signal
|
||||
import sys
|
||||
import traceback
|
||||
from hashlib import sha256
|
||||
from http import HTTPStatus
|
||||
from pathlib import Path
|
||||
from typing import Callable, List
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.exceptions import HTTPException, RequestValidationError
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.middleware.gzip import GZipMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from loguru import logger
|
||||
from slowapi import Limiter
|
||||
from slowapi.util import get_remote_address
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
from lnbits.core.crud import get_installed_extensions
|
||||
from lnbits.core.helpers import migrate_extension_database
|
||||
from lnbits.core.tasks import register_task_listeners
|
||||
from lnbits.settings import get_wallet_class, set_wallet_class, settings
|
||||
from lnbits.core.services import websocketUpdater
|
||||
from lnbits.core.tasks import ( # register_watchdog,; unregister_watchdog,
|
||||
register_killswitch,
|
||||
register_task_listeners,
|
||||
)
|
||||
from lnbits.settings import settings
|
||||
from lnbits.tasks import cancel_all_tasks, create_permanent_task
|
||||
from lnbits.utils.cache import cache
|
||||
from lnbits.wallets import get_wallet_class, set_wallet_class
|
||||
|
||||
from .commands import db_versions, load_disabled_extension_list, migrate_databases
|
||||
from .core import (
|
||||
add_installed_extension,
|
||||
core_app,
|
||||
core_app_extra,
|
||||
update_installed_extension_state,
|
||||
)
|
||||
from .core.services import check_admin_settings
|
||||
from .core.views.generic import core_html_routes
|
||||
from .core import init_core_routers
|
||||
from .core.db import core_app_extra
|
||||
from .core.services import check_admin_settings, check_webpush_settings
|
||||
from .core.views.api import add_installed_extension
|
||||
from .core.views.generic import update_installed_extension_state
|
||||
from .extension_manager import Extension, InstallableExtension, get_valid_extensions
|
||||
from .helpers import template_renderer
|
||||
from .middleware import ExtensionsRedirectMiddleware, InstalledExtensionMiddleware
|
||||
from .middleware import (
|
||||
CustomGZipMiddleware,
|
||||
ExtensionsRedirectMiddleware,
|
||||
InstalledExtensionMiddleware,
|
||||
add_ip_block_middleware,
|
||||
add_ratelimit_middleware,
|
||||
)
|
||||
from .requestvars import g
|
||||
from .tasks import (
|
||||
catch_everything_and_restart,
|
||||
check_pending_payments,
|
||||
internal_invoice_listener,
|
||||
invoice_listener,
|
||||
@@ -46,12 +58,13 @@ from .tasks import (
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
|
||||
configure_logger()
|
||||
|
||||
app = FastAPI(
|
||||
title="LNbits API",
|
||||
description="API for LNbits, the free and open source bitcoin wallet and accounts system with plugins.",
|
||||
title=settings.lnbits_title,
|
||||
description=(
|
||||
"API for LNbits, the free and open source bitcoin wallet and "
|
||||
"accounts system with plugins."
|
||||
),
|
||||
version=settings.version,
|
||||
license_info={
|
||||
"name": "MIT License",
|
||||
@@ -59,6 +72,10 @@ 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))
|
||||
|
||||
app.mount("/static", StaticFiles(packages=[("lnbits", "static")]), name="static")
|
||||
app.mount(
|
||||
"/core/static",
|
||||
@@ -72,29 +89,36 @@ def create_app() -> FastAPI:
|
||||
CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]
|
||||
)
|
||||
|
||||
app.add_middleware(GZipMiddleware, minimum_size=1000)
|
||||
app.add_middleware(
|
||||
CustomGZipMiddleware, minimum_size=1000, exclude_paths=["/api/v1/payments/sse"]
|
||||
)
|
||||
|
||||
# order of these two middlewares is important
|
||||
app.add_middleware(InstalledExtensionMiddleware)
|
||||
app.add_middleware(ExtensionsRedirectMiddleware)
|
||||
|
||||
register_custom_extensions_path()
|
||||
|
||||
# adds security middleware
|
||||
add_ip_block_middleware(app)
|
||||
add_ratelimit_middleware(app)
|
||||
|
||||
register_startup(app)
|
||||
register_routes(app)
|
||||
register_async_tasks(app)
|
||||
register_exception_handlers(app)
|
||||
|
||||
# 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))
|
||||
register_shutdown(app)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
async def check_funding_source() -> None:
|
||||
|
||||
original_sigint_handler = signal.getsignal(signal.SIGINT)
|
||||
|
||||
def signal_handler(signal, frame):
|
||||
logger.debug("SIGINT received, terminating LNbits.")
|
||||
logger.debug(
|
||||
f"SIGINT received, terminating LNbits. signal: {signal}, frame: {frame}"
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
signal.signal(signal.SIGINT, signal_handler)
|
||||
@@ -116,17 +140,16 @@ async def check_funding_source() -> None:
|
||||
break
|
||||
|
||||
logger.error(
|
||||
f"The backend for {WALLET.__class__.__name__} isn't working properly: '{error_message}'",
|
||||
f"The backend for {WALLET.__class__.__name__} isn't "
|
||||
f"working properly: '{error_message}'",
|
||||
RuntimeWarning,
|
||||
)
|
||||
except:
|
||||
except Exception as e:
|
||||
logger.error(f"Error connecting to {WALLET.__class__.__name__}: {e}")
|
||||
pass
|
||||
|
||||
if settings.lnbits_admin_ui and retry_counter == timeout:
|
||||
logger.warning(
|
||||
f"Fallback to VoidWallet, because the backend for {WALLET.__class__.__name__} isn't working properly"
|
||||
)
|
||||
set_wallet_class("VoidWallet")
|
||||
set_void_wallet_class()
|
||||
WALLET = get_wallet_class()
|
||||
break
|
||||
else:
|
||||
@@ -137,16 +160,25 @@ async def check_funding_source() -> None:
|
||||
signal.signal(signal.SIGINT, original_sigint_handler)
|
||||
|
||||
logger.info(
|
||||
f"✔️ Backend {WALLET.__class__.__name__} connected and with a balance of {balance} msat."
|
||||
f"✔️ Backend {WALLET.__class__.__name__} connected "
|
||||
f"and with a balance of {balance} msat."
|
||||
)
|
||||
|
||||
|
||||
def set_void_wallet_class():
|
||||
logger.warning(
|
||||
"Fallback to VoidWallet, because the backend for "
|
||||
f"{settings.lnbits_backend_wallet_class} isn't working properly"
|
||||
)
|
||||
set_wallet_class("VoidWallet")
|
||||
|
||||
|
||||
async def check_installed_extensions(app: FastAPI):
|
||||
"""
|
||||
Check extensions that have been installed, but for some reason no longer present in the 'lnbits/extensions' directory.
|
||||
One reason might be a docker-container that was re-created.
|
||||
The 'data' directory (where the '.zip' files live) is expected to persist state.
|
||||
Zips that are missing will be re-downloaded.
|
||||
Check extensions that have been installed, but for some reason no longer present in
|
||||
the 'lnbits/extensions' directory. One reason might be a docker-container that was
|
||||
re-created. The 'data' directory (where the '.zip' files live) is expected to
|
||||
persist state. Zips that are missing will be re-downloaded.
|
||||
"""
|
||||
shutil.rmtree(os.path.join("lnbits", "upgrades"), True)
|
||||
await load_disabled_extension_list()
|
||||
@@ -154,11 +186,12 @@ async def check_installed_extensions(app: FastAPI):
|
||||
|
||||
for ext in installed_extensions:
|
||||
try:
|
||||
installed = check_installed_extension(ext)
|
||||
installed = check_installed_extension_files(ext)
|
||||
if not installed:
|
||||
await restore_installed_extension(app, ext)
|
||||
logger.info(
|
||||
f"✔️ Successfully re-installed extension: {ext.id} ({ext.installed_version})"
|
||||
"✔️ Successfully re-installed extension: "
|
||||
f"{ext.id} ({ext.installed_version})"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(e)
|
||||
@@ -166,6 +199,10 @@ async def check_installed_extensions(app: FastAPI):
|
||||
f"Failed to re-install extension: {ext.id} ({ext.installed_version})"
|
||||
)
|
||||
|
||||
logger.info(f"Installed Extensions ({len(installed_extensions)}):")
|
||||
for ext in installed_extensions:
|
||||
logger.info(f"{ext.id} ({ext.installed_version})")
|
||||
|
||||
|
||||
async def build_all_installed_extensions_list() -> List[InstallableExtension]:
|
||||
"""
|
||||
@@ -187,19 +224,15 @@ async def build_all_installed_extensions_list() -> List[InstallableExtension]:
|
||||
id=ext_id, name=ext_id, installed_release=release, icon=release.icon
|
||||
)
|
||||
installed_extensions.append(ext_info)
|
||||
await add_installed_extension(ext_info)
|
||||
await update_installed_extension_state(ext_id=ext_id, active=True)
|
||||
|
||||
return installed_extensions
|
||||
|
||||
|
||||
def check_installed_extension(ext: InstallableExtension) -> bool:
|
||||
def check_installed_extension_files(ext: InstallableExtension) -> bool:
|
||||
if ext.has_installed_version:
|
||||
return True
|
||||
|
||||
zip_files = glob.glob(
|
||||
os.path.join(settings.lnbits_data_folder, "extensions", "*.zip")
|
||||
)
|
||||
zip_files = glob.glob(os.path.join(settings.lnbits_data_folder, "zips", "*.zip"))
|
||||
|
||||
if f"./{str(ext.zip_path)}" not in zip_files:
|
||||
ext.download_archive()
|
||||
@@ -209,6 +242,9 @@ def check_installed_extension(ext: InstallableExtension) -> bool:
|
||||
|
||||
|
||||
async def restore_installed_extension(app: FastAPI, ext: InstallableExtension):
|
||||
await add_installed_extension(ext)
|
||||
await update_installed_extension_state(ext_id=ext.id, active=True)
|
||||
|
||||
extension = Extension.from_installable_ext(ext)
|
||||
register_ext_routes(app, extension)
|
||||
|
||||
@@ -223,28 +259,57 @@ async def restore_installed_extension(app: FastAPI, ext: InstallableExtension):
|
||||
|
||||
def register_routes(app: FastAPI) -> None:
|
||||
"""Register FastAPI routes / LNbits extensions."""
|
||||
app.include_router(core_app)
|
||||
app.include_router(core_html_routes)
|
||||
init_core_routers(app)
|
||||
|
||||
for ext in get_valid_extensions():
|
||||
try:
|
||||
register_ext_routes(app, ext)
|
||||
except Exception as e:
|
||||
logger.error(str(e))
|
||||
raise ImportError(
|
||||
f"Please make sure that the extension `{ext.code}` follows conventions."
|
||||
)
|
||||
logger.error(f"Could not load extension `{ext.code}`: {str(e)}")
|
||||
|
||||
|
||||
def register_custom_extensions_path():
|
||||
if settings.has_default_extension_path:
|
||||
return
|
||||
default_ext_path = os.path.join("lnbits", "extensions")
|
||||
if os.path.isdir(default_ext_path) and len(os.listdir(default_ext_path)) != 0:
|
||||
logger.warning(
|
||||
"You are using a custom extensions path, "
|
||||
+ "but the default extensions directory is not empty. "
|
||||
+ f"Please clean-up the '{default_ext_path}' directory."
|
||||
)
|
||||
logger.warning(
|
||||
f"You can move the existing '{default_ext_path}' directory to: "
|
||||
+ f" '{settings.lnbits_extensions_path}/extensions'"
|
||||
)
|
||||
|
||||
sys.path.append(str(Path(settings.lnbits_extensions_path, "extensions")))
|
||||
sys.path.append(str(Path(settings.lnbits_extensions_path, "upgrades")))
|
||||
|
||||
|
||||
def register_new_ext_routes(app: FastAPI) -> Callable:
|
||||
# Returns a function that registers new routes for an extension.
|
||||
# The returned function encapsulates (creates a closure around) the `app` object but does expose it.
|
||||
# The returned function encapsulates (creates a closure around)
|
||||
# the `app` object but does expose it.
|
||||
def register_new_ext_routes_fn(ext: Extension):
|
||||
register_ext_routes(app, ext)
|
||||
|
||||
return register_new_ext_routes_fn
|
||||
|
||||
|
||||
def register_new_ratelimiter(app: FastAPI) -> Callable:
|
||||
def register_new_ratelimiter_fn():
|
||||
limiter = Limiter(
|
||||
key_func=get_remote_address,
|
||||
default_limits=[
|
||||
f"{settings.lnbits_rate_limit_no}/{settings.lnbits_rate_limit_unit}"
|
||||
],
|
||||
)
|
||||
app.state.limiter = limiter
|
||||
|
||||
return register_new_ratelimiter_fn
|
||||
|
||||
|
||||
def register_ext_routes(app: FastAPI, ext: Extension) -> None:
|
||||
"""Register FastAPI routes for extension."""
|
||||
ext_module = importlib.import_module(ext.module_name)
|
||||
@@ -258,7 +323,10 @@ def register_ext_routes(app: FastAPI, ext: Extension) -> None:
|
||||
if hasattr(ext_module, f"{ext.code}_static_files"):
|
||||
ext_statics = getattr(ext_module, f"{ext.code}_static_files")
|
||||
for s in ext_statics:
|
||||
app.mount(s["path"], s["app"], s["name"])
|
||||
static_dir = Path(
|
||||
settings.lnbits_extensions_path, "extensions", *s["path"].split("/")
|
||||
)
|
||||
app.mount(s["path"], StaticFiles(directory=static_dir), s["name"])
|
||||
|
||||
if hasattr(ext_module, f"{ext.code}_redirect_paths"):
|
||||
ext_redirects = getattr(ext_module, f"{ext.code}_redirect_paths")
|
||||
@@ -278,18 +346,25 @@ def register_ext_routes(app: FastAPI, ext: Extension) -> None:
|
||||
def register_startup(app: FastAPI):
|
||||
@app.on_event("startup")
|
||||
async def lnbits_startup():
|
||||
|
||||
try:
|
||||
# wait till migration is done
|
||||
await migrate_databases()
|
||||
|
||||
# setup admin settings
|
||||
await check_admin_settings()
|
||||
await check_webpush_settings()
|
||||
|
||||
log_server_info()
|
||||
|
||||
# initialize WALLET
|
||||
set_wallet_class()
|
||||
try:
|
||||
set_wallet_class()
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error initializing {settings.lnbits_backend_wallet_class}: "
|
||||
f"{str(e)}"
|
||||
)
|
||||
set_void_wallet_class()
|
||||
|
||||
# initialize funding source
|
||||
await check_funding_source()
|
||||
@@ -297,11 +372,42 @@ def register_startup(app: FastAPI):
|
||||
# check extensions after restart
|
||||
await check_installed_extensions(app)
|
||||
|
||||
if settings.lnbits_admin_ui:
|
||||
initialize_server_logger()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(str(e))
|
||||
raise ImportError("Failed to run 'startup' event.")
|
||||
|
||||
|
||||
def register_shutdown(app: FastAPI):
|
||||
@app.on_event("shutdown")
|
||||
async def on_shutdown():
|
||||
cancel_all_tasks()
|
||||
# wait a bit to allow them to finish, so that cleanup can run without problems
|
||||
await asyncio.sleep(0.1)
|
||||
WALLET = get_wallet_class()
|
||||
await WALLET.cleanup()
|
||||
|
||||
|
||||
def initialize_server_logger():
|
||||
super_user_hash = sha256(settings.super_user.encode("utf-8")).hexdigest()
|
||||
|
||||
serverlog_queue = asyncio.Queue()
|
||||
|
||||
async def update_websocket_serverlog():
|
||||
while True:
|
||||
msg = await serverlog_queue.get()
|
||||
await websocketUpdater(super_user_hash, msg)
|
||||
|
||||
create_permanent_task(update_websocket_serverlog)
|
||||
|
||||
logger.add(
|
||||
lambda msg: serverlog_queue.put_nowait(msg),
|
||||
format=Formatter().format,
|
||||
)
|
||||
|
||||
|
||||
def log_server_info():
|
||||
logger.info("Starting LNbits")
|
||||
logger.info(f"Version: {settings.version}")
|
||||
@@ -312,7 +418,6 @@ def log_server_info():
|
||||
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"Git version: {settings.lnbits_commit}")
|
||||
logger.info(f"Database: {get_db_vendor_name()}")
|
||||
logger.info(f"Service fee: {settings.lnbits_service_fee}")
|
||||
|
||||
@@ -322,9 +427,11 @@ def get_db_vendor_name():
|
||||
return (
|
||||
"PostgreSQL"
|
||||
if db_url and db_url.startswith("postgres://")
|
||||
else "CockroachDB"
|
||||
if db_url and db_url.startswith("cockroachdb://")
|
||||
else "SQLite"
|
||||
else (
|
||||
"CockroachDB"
|
||||
if db_url and db_url.startswith("cockroachdb://")
|
||||
else "SQLite"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -335,17 +442,14 @@ def register_async_tasks(app):
|
||||
|
||||
@app.on_event("startup")
|
||||
async def listeners():
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.create_task(catch_everything_and_restart(check_pending_payments))
|
||||
loop.create_task(catch_everything_and_restart(invoice_listener))
|
||||
loop.create_task(catch_everything_and_restart(internal_invoice_listener))
|
||||
await register_task_listeners()
|
||||
create_permanent_task(check_pending_payments)
|
||||
create_permanent_task(invoice_listener)
|
||||
create_permanent_task(internal_invoice_listener)
|
||||
create_permanent_task(cache.invalidate_forever)
|
||||
register_task_listeners()
|
||||
register_killswitch()
|
||||
# await run_deferred_async() # calle: doesn't do anyting?
|
||||
|
||||
@app.on_event("shutdown")
|
||||
async def stop_listeners():
|
||||
pass
|
||||
|
||||
|
||||
def register_exception_handlers(app: FastAPI):
|
||||
@app.exception_handler(Exception)
|
||||
@@ -421,26 +525,32 @@ def configure_logger() -> None:
|
||||
logger.remove()
|
||||
log_level: str = "DEBUG" if settings.debug else "INFO"
|
||||
formatter = Formatter()
|
||||
logger.add(sys.stderr, level=log_level, format=formatter.format)
|
||||
|
||||
logger.add(sys.stdout, level=log_level, format=formatter.format)
|
||||
logging.getLogger("uvicorn").handlers = [InterceptHandler()]
|
||||
logging.getLogger("uvicorn.access").handlers = [InterceptHandler()]
|
||||
logging.getLogger("uvicorn.error").handlers = [InterceptHandler()]
|
||||
logging.getLogger("uvicorn.error").propagate = False
|
||||
|
||||
|
||||
class Formatter:
|
||||
def __init__(self):
|
||||
self.padding = 0
|
||||
self.minimal_fmt: str = "<green>{time:YYYY-MM-DD HH:mm:ss.SS}</green> | <level>{level}</level> | <level>{message}</level>\n"
|
||||
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: str = (
|
||||
"<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"
|
||||
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: str = self.minimal_fmt
|
||||
self.fmt = self.minimal_fmt
|
||||
|
||||
def format(self, record):
|
||||
function = "{function}".format(**record) # pylint: disable=C0209
|
||||
function = "{function}".format(**record)
|
||||
if function == "emit": # uvicorn logs
|
||||
return self.minimal_fmt
|
||||
return self.fmt
|
||||
|
||||
@@ -1,366 +1,7 @@
|
||||
import hashlib
|
||||
import re
|
||||
import time
|
||||
from decimal import Decimal
|
||||
from typing import List, NamedTuple, Optional
|
||||
|
||||
import bitstring
|
||||
import secp256k1
|
||||
from bech32 import CHARSET, bech32_decode, bech32_encode
|
||||
from ecdsa import SECP256k1, VerifyingKey
|
||||
from ecdsa.util import sigdecode_string
|
||||
|
||||
|
||||
class Route(NamedTuple):
|
||||
pubkey: str
|
||||
short_channel_id: str
|
||||
base_fee_msat: int
|
||||
ppm_fee: int
|
||||
cltv: int
|
||||
|
||||
|
||||
class Invoice:
|
||||
payment_hash: str
|
||||
amount_msat: int = 0
|
||||
description: Optional[str] = None
|
||||
description_hash: Optional[str] = None
|
||||
payee: Optional[str] = None
|
||||
date: int
|
||||
expiry: int = 3600
|
||||
secret: Optional[str] = None
|
||||
route_hints: List[Route] = []
|
||||
min_final_cltv_expiry: int = 18
|
||||
|
||||
|
||||
def decode(pr: str) -> Invoice:
|
||||
"""bolt11 decoder,
|
||||
based on https://github.com/rustyrussell/lightning-payencode/blob/master/lnaddr.py
|
||||
"""
|
||||
|
||||
hrp, decoded_data = bech32_decode(pr)
|
||||
if hrp is None or decoded_data is None:
|
||||
raise ValueError("Bad bech32 checksum")
|
||||
if not hrp.startswith("ln"):
|
||||
raise ValueError("Does not start with ln")
|
||||
|
||||
bitarray = _u5_to_bitarray(decoded_data)
|
||||
|
||||
# final signature 65 bytes, split it off.
|
||||
if len(bitarray) < 65 * 8:
|
||||
raise ValueError("Too short to contain signature")
|
||||
|
||||
# extract the signature
|
||||
signature = bitarray[-65 * 8 :].tobytes()
|
||||
|
||||
# the tagged fields as a bitstream
|
||||
data = bitstring.ConstBitStream(bitarray[: -65 * 8])
|
||||
|
||||
# build the invoice object
|
||||
invoice = Invoice()
|
||||
|
||||
# decode the amount from the hrp
|
||||
m = re.search(r"[^\d]+", hrp[2:])
|
||||
if m:
|
||||
amountstr = hrp[2 + m.end() :]
|
||||
if amountstr != "":
|
||||
invoice.amount_msat = _unshorten_amount(amountstr)
|
||||
|
||||
# pull out date
|
||||
date_bin = data.read(35)
|
||||
invoice.date = date_bin.uint # type: ignore
|
||||
|
||||
while data.pos != data.len:
|
||||
tag, tagdata, data = _pull_tagged(data)
|
||||
data_length = len(tagdata or []) / 5
|
||||
|
||||
if tag == "d":
|
||||
invoice.description = _trim_to_bytes(tagdata).decode()
|
||||
elif tag == "h" and data_length == 52:
|
||||
invoice.description_hash = _trim_to_bytes(tagdata).hex()
|
||||
elif tag == "p" and data_length == 52:
|
||||
invoice.payment_hash = _trim_to_bytes(tagdata).hex()
|
||||
elif tag == "x":
|
||||
invoice.expiry = tagdata.uint # type: ignore
|
||||
elif tag == "n":
|
||||
invoice.payee = _trim_to_bytes(tagdata).hex()
|
||||
# this won't work in most cases, we must extract the payee
|
||||
# from the signature
|
||||
elif tag == "s":
|
||||
invoice.secret = _trim_to_bytes(tagdata).hex()
|
||||
elif tag == "r":
|
||||
s = bitstring.ConstBitStream(tagdata)
|
||||
while s.pos + 264 + 64 + 32 + 32 + 16 < s.len:
|
||||
route = Route(
|
||||
pubkey=s.read(264).tobytes().hex(), # type: ignore
|
||||
short_channel_id=_readable_scid(s.read(64).intbe), # type: ignore
|
||||
base_fee_msat=s.read(32).intbe, # type: ignore
|
||||
ppm_fee=s.read(32).intbe, # type: ignore
|
||||
cltv=s.read(16).intbe, # type: ignore
|
||||
)
|
||||
invoice.route_hints.append(route)
|
||||
|
||||
# BOLT #11:
|
||||
# A reader MUST check that the `signature` is valid (see the `n` tagged
|
||||
# field specified below).
|
||||
# A reader MUST use the `n` field to validate the signature instead of
|
||||
# performing signature recovery if a valid `n` field is provided.
|
||||
message = bytearray([ord(c) for c in hrp]) + data.tobytes()
|
||||
sig = signature[0:64]
|
||||
if invoice.payee:
|
||||
key = VerifyingKey.from_string(bytes.fromhex(invoice.payee), curve=SECP256k1)
|
||||
key.verify(sig, message, hashlib.sha256, sigdecode=sigdecode_string)
|
||||
else:
|
||||
keys = VerifyingKey.from_public_key_recovery(
|
||||
sig, message, SECP256k1, hashlib.sha256
|
||||
)
|
||||
signaling_byte = signature[64]
|
||||
key = keys[int(signaling_byte)]
|
||||
invoice.payee = key.to_string("compressed").hex()
|
||||
|
||||
return invoice
|
||||
|
||||
|
||||
def encode(options):
|
||||
"""Convert options into LnAddr and pass it to the encoder"""
|
||||
addr = LnAddr()
|
||||
addr.currency = options["currency"]
|
||||
addr.fallback = options["fallback"] if options["fallback"] else None
|
||||
if options["amount"]:
|
||||
addr.amount = options["amount"]
|
||||
if options["timestamp"]:
|
||||
addr.date = int(options["timestamp"])
|
||||
|
||||
addr.paymenthash = bytes.fromhex(options["paymenthash"])
|
||||
|
||||
if options["description"]:
|
||||
addr.tags.append(("d", options["description"]))
|
||||
if options["description_hash"]:
|
||||
addr.tags.append(("h", options["description_hash"]))
|
||||
if options["expires"]:
|
||||
addr.tags.append(("x", options["expires"]))
|
||||
|
||||
if options["fallback"]:
|
||||
addr.tags.append(("f", options["fallback"]))
|
||||
if options["route"]:
|
||||
for r in options["route"]:
|
||||
splits = r.split("/")
|
||||
route = []
|
||||
while len(splits) >= 5:
|
||||
route.append(
|
||||
(
|
||||
bytes.fromhex(splits[0]),
|
||||
bytes.fromhex(splits[1]),
|
||||
int(splits[2]),
|
||||
int(splits[3]),
|
||||
int(splits[4]),
|
||||
)
|
||||
)
|
||||
splits = splits[5:]
|
||||
assert len(splits) == 0
|
||||
addr.tags.append(("r", route))
|
||||
return lnencode(addr, options["privkey"])
|
||||
|
||||
|
||||
def lnencode(addr, privkey):
|
||||
if addr.amount:
|
||||
amount = Decimal(str(addr.amount))
|
||||
# We can only send down to millisatoshi.
|
||||
if amount * 10**12 % 10:
|
||||
raise ValueError(f"Cannot encode {addr.amount}: too many decimal places")
|
||||
|
||||
amount = addr.currency + shorten_amount(amount)
|
||||
else:
|
||||
amount = addr.currency if addr.currency else ""
|
||||
|
||||
hrp = f"ln{amount}0n"
|
||||
|
||||
# Start with the timestamp
|
||||
data = bitstring.pack("uint:35", addr.date)
|
||||
|
||||
# Payment hash
|
||||
data += tagged_bytes("p", addr.paymenthash)
|
||||
tags_set = set()
|
||||
|
||||
for k, v in addr.tags:
|
||||
|
||||
# BOLT #11:
|
||||
#
|
||||
# A writer MUST NOT include more than one `d`, `h`, `n` or `x` fields,
|
||||
if k in ("d", "h", "n", "x"):
|
||||
if k in tags_set:
|
||||
raise ValueError(f"Duplicate '{k}' tag")
|
||||
|
||||
if k == "r":
|
||||
route = bitstring.BitArray()
|
||||
for step in v:
|
||||
pubkey, channel, feebase, feerate, cltv = step
|
||||
route.append(
|
||||
bitstring.BitArray(pubkey)
|
||||
+ bitstring.BitArray(channel)
|
||||
+ bitstring.pack("intbe:32", feebase)
|
||||
+ bitstring.pack("intbe:32", feerate)
|
||||
+ bitstring.pack("intbe:16", cltv)
|
||||
)
|
||||
data += tagged("r", route)
|
||||
elif k == "f":
|
||||
# NOTE: there was an error fallback here that's now removed
|
||||
continue
|
||||
elif k == "d":
|
||||
data += tagged_bytes("d", v.encode())
|
||||
elif k == "x":
|
||||
# Get minimal length by trimming leading 5 bits at a time.
|
||||
expirybits = bitstring.pack("intbe:64", v)[4:64]
|
||||
while expirybits.startswith("0b00000"):
|
||||
expirybits = expirybits[5:]
|
||||
data += tagged("x", expirybits)
|
||||
elif k == "h":
|
||||
data += tagged_bytes("h", v)
|
||||
elif k == "n":
|
||||
data += tagged_bytes("n", v)
|
||||
else:
|
||||
# FIXME: Support unknown tags?
|
||||
raise ValueError(f"Unknown tag {k}")
|
||||
|
||||
tags_set.add(k)
|
||||
|
||||
# BOLT #11:
|
||||
#
|
||||
# A writer MUST include either a `d` or `h` field, and MUST NOT include
|
||||
# both.
|
||||
if "d" in tags_set and "h" in tags_set:
|
||||
raise ValueError("Cannot include both 'd' and 'h'")
|
||||
if "d" not in tags_set and "h" not in tags_set:
|
||||
raise ValueError("Must include either 'd' or 'h'")
|
||||
|
||||
# We actually sign the hrp, then data (padded to 8 bits with zeroes).
|
||||
privkey = secp256k1.PrivateKey(bytes.fromhex(privkey))
|
||||
sig = privkey.ecdsa_sign_recoverable(
|
||||
bytearray([ord(c) for c in hrp]) + data.tobytes()
|
||||
)
|
||||
# This doesn't actually serialize, but returns a pair of values :(
|
||||
sig, recid = privkey.ecdsa_recoverable_serialize(sig)
|
||||
data += bytes(sig) + bytes([recid])
|
||||
|
||||
return bech32_encode(hrp, bitarray_to_u5(data))
|
||||
|
||||
|
||||
class LnAddr:
|
||||
def __init__(
|
||||
self,
|
||||
paymenthash=None,
|
||||
amount=None,
|
||||
currency="bc",
|
||||
tags=None,
|
||||
date=None,
|
||||
fallback=None,
|
||||
):
|
||||
self.date = int(time.time()) if not date else int(date)
|
||||
self.tags = [] if not tags else tags
|
||||
self.unknown_tags = []
|
||||
self.paymenthash = paymenthash
|
||||
self.signature = None
|
||||
self.pubkey = None
|
||||
self.fallback = fallback
|
||||
self.currency = currency
|
||||
self.amount = amount
|
||||
|
||||
def __str__(self):
|
||||
assert self.pubkey, "LnAddr, pubkey must be set"
|
||||
pubkey = bytes.hex(self.pubkey.serialize())
|
||||
tags = ", ".join([f"{k}={v}" for k, v in self.tags])
|
||||
return f"LnAddr[{pubkey}, amount={self.amount}{self.currency} tags=[{tags}]]"
|
||||
|
||||
|
||||
def shorten_amount(amount):
|
||||
"""Given an amount in bitcoin, shorten it"""
|
||||
# Convert to pico initially
|
||||
amount = int(amount * 10**12)
|
||||
units = ["p", "n", "u", "m", ""]
|
||||
unit = ""
|
||||
for unit in units:
|
||||
if amount % 1000 == 0:
|
||||
amount //= 1000
|
||||
else:
|
||||
break
|
||||
return str(amount) + unit
|
||||
|
||||
|
||||
def _unshorten_amount(amount: str) -> int:
|
||||
"""Given a shortened amount, return millisatoshis"""
|
||||
# BOLT #11:
|
||||
# The following `multiplier` letters are defined:
|
||||
#
|
||||
# * `m` (milli): multiply by 0.001
|
||||
# * `u` (micro): multiply by 0.000001
|
||||
# * `n` (nano): multiply by 0.000000001
|
||||
# * `p` (pico): multiply by 0.000000000001
|
||||
units = {"p": 10**12, "n": 10**9, "u": 10**6, "m": 10**3}
|
||||
unit = str(amount)[-1]
|
||||
|
||||
# BOLT #11:
|
||||
# A reader SHOULD fail if `amount` contains a non-digit, or is followed by
|
||||
# anything except a `multiplier` in the table above.
|
||||
if not re.fullmatch(r"\d+[pnum]?", str(amount)):
|
||||
raise ValueError(f"Invalid amount '{amount}'")
|
||||
|
||||
if unit in units:
|
||||
return int(int(amount[:-1]) * 100_000_000_000 / units[unit])
|
||||
else:
|
||||
return int(amount) * 100_000_000_000
|
||||
|
||||
|
||||
def _pull_tagged(stream):
|
||||
tag = stream.read(5).uint
|
||||
length = stream.read(5).uint * 32 + stream.read(5).uint
|
||||
return (CHARSET[tag], stream.read(length * 5), stream)
|
||||
|
||||
|
||||
# Tagged field containing BitArray
|
||||
def tagged(char, bits):
|
||||
# Tagged fields need to be zero-padded to 5 bits.
|
||||
while bits.len % 5 != 0:
|
||||
bits.append("0b0")
|
||||
return (
|
||||
bitstring.pack(
|
||||
"uint:5, uint:5, uint:5",
|
||||
CHARSET.find(char),
|
||||
(bits.len / 5) / 32,
|
||||
(bits.len / 5) % 32,
|
||||
)
|
||||
+ bits
|
||||
)
|
||||
|
||||
|
||||
def tagged_bytes(char, bits):
|
||||
return tagged(char, bitstring.BitArray(bits))
|
||||
|
||||
|
||||
def _trim_to_bytes(barr):
|
||||
# Adds a byte if necessary.
|
||||
b = barr.tobytes()
|
||||
if barr.len % 8 != 0:
|
||||
return b[:-1]
|
||||
return b
|
||||
|
||||
|
||||
def _readable_scid(short_channel_id: int) -> str:
|
||||
blockheight = (short_channel_id >> 40) & 0xFFFFFF
|
||||
transactionindex = (short_channel_id >> 16) & 0xFFFFFF
|
||||
outputindex = short_channel_id & 0xFFFF
|
||||
return f"{blockheight}x{transactionindex}x{outputindex}"
|
||||
|
||||
|
||||
def _u5_to_bitarray(arr: List[int]) -> bitstring.BitArray:
|
||||
ret = bitstring.BitArray()
|
||||
for a in arr:
|
||||
ret += bitstring.pack("uint:5", a)
|
||||
return ret
|
||||
|
||||
|
||||
def bitarray_to_u5(barr):
|
||||
assert barr.len % 5 == 0
|
||||
ret = []
|
||||
s = bitstring.ConstBitStream(barr)
|
||||
while s.pos != s.len:
|
||||
ret.append(s.read(5).uint) # type: ignore
|
||||
return ret
|
||||
from bolt11 import (
|
||||
Bolt11 as Invoice, # noqa: F401
|
||||
)
|
||||
from bolt11 import (
|
||||
decode, # noqa: F401
|
||||
encode, # noqa: F401
|
||||
)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
from loguru import logger
|
||||
@@ -13,8 +14,58 @@ from .db import COCKROACH, POSTGRES, SQLITE
|
||||
from .extension_manager import get_valid_extensions
|
||||
|
||||
|
||||
@click.command("migrate")
|
||||
def db_migrate():
|
||||
@click.group()
|
||||
def lnbits_cli():
|
||||
"""
|
||||
Python CLI for LNbits
|
||||
"""
|
||||
|
||||
|
||||
@lnbits_cli.group()
|
||||
def db():
|
||||
"""
|
||||
Database related commands
|
||||
"""
|
||||
|
||||
|
||||
def get_super_user() -> str:
|
||||
"""Get the superuser"""
|
||||
with open(Path(settings.lnbits_data_folder) / ".super_user", "r") as file:
|
||||
return file.readline()
|
||||
|
||||
|
||||
@lnbits_cli.command("superuser")
|
||||
def superuser():
|
||||
"""Prints the superuser"""
|
||||
click.echo(get_super_user())
|
||||
|
||||
|
||||
@lnbits_cli.command("superuser-url")
|
||||
def superuser_url():
|
||||
"""Prints the superuser"""
|
||||
click.echo(f"http://{settings.host}:{settings.port}/wallet?usr={get_super_user()}")
|
||||
|
||||
|
||||
@lnbits_cli.command("delete-settings")
|
||||
def delete_settings():
|
||||
"""Deletes the settings"""
|
||||
|
||||
async def wrap():
|
||||
async with core_db.connect() as conn:
|
||||
await conn.execute("DELETE from settings")
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.run_until_complete(wrap())
|
||||
|
||||
|
||||
@db.command("migrate")
|
||||
def database_migrate():
|
||||
"""Migrate databases"""
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.run_until_complete(migrate_databases())
|
||||
|
||||
|
||||
async def db_migrate():
|
||||
asyncio.create_task(migrate_databases())
|
||||
|
||||
|
||||
@@ -29,7 +80,8 @@ async def migrate_databases():
|
||||
)
|
||||
elif conn.type in {POSTGRES, COCKROACH}:
|
||||
exists = await conn.fetchone(
|
||||
"SELECT * FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'dbversions'"
|
||||
"SELECT * FROM information_schema.tables WHERE table_schema = 'public'"
|
||||
" AND table_name = 'dbversions'"
|
||||
)
|
||||
|
||||
if not exists:
|
||||
@@ -41,18 +93,37 @@ async def migrate_databases():
|
||||
|
||||
for ext in get_valid_extensions():
|
||||
current_version = current_versions.get(ext.code, 0)
|
||||
await migrate_extension_database(ext, current_version)
|
||||
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")
|
||||
def database_versions():
|
||||
"""Show current database versions"""
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.run_until_complete(db_versions())
|
||||
|
||||
|
||||
async def db_versions():
|
||||
"""Show current database versions"""
|
||||
async with core_db.connect() as conn:
|
||||
current_versions = await get_dbversions(conn)
|
||||
return current_versions
|
||||
return await get_dbversions(conn)
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
def main():
|
||||
"""main function"""
|
||||
lnbits_cli()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
@@ -1,15 +1,24 @@
|
||||
from fastapi.routing import APIRouter
|
||||
from fastapi import APIRouter
|
||||
|
||||
from lnbits.core.models import CoreAppExtra
|
||||
from lnbits.db import Database
|
||||
from .db import core_app_extra, db
|
||||
from .views.admin_api import admin_router
|
||||
from .views.api import api_router
|
||||
|
||||
db = Database("database")
|
||||
# this compat is needed for usermanager extension
|
||||
from .views.generic import generic_router, update_user_extension
|
||||
from .views.node_api import node_router, public_node_router, super_node_router
|
||||
from .views.public_api import public_router
|
||||
|
||||
core_app: APIRouter = APIRouter()
|
||||
# backwards compatibility for extensions
|
||||
core_app = APIRouter(tags=["Core"])
|
||||
|
||||
core_app_extra: CoreAppExtra = CoreAppExtra()
|
||||
|
||||
from .views.admin_api import * # noqa: F401,F403
|
||||
from .views.api import * # noqa: F401,F403
|
||||
from .views.generic import * # noqa: F401,F403
|
||||
from .views.public_api import * # noqa: F401,F403
|
||||
def init_core_routers(app):
|
||||
app.include_router(core_app)
|
||||
app.include_router(generic_router)
|
||||
app.include_router(public_router)
|
||||
app.include_router(api_router)
|
||||
app.include_router(node_router)
|
||||
app.include_router(super_node_router)
|
||||
app.include_router(public_node_router)
|
||||
app.include_router(admin_router)
|
||||
|
||||
@@ -1,18 +1,34 @@
|
||||
import datetime
|
||||
import json
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any, Dict, List, Literal, Optional
|
||||
from urllib.parse import urlparse
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import shortuuid
|
||||
from bolt11.decode import decode
|
||||
|
||||
from lnbits import bolt11
|
||||
from lnbits.db import Connection, Filters, Page
|
||||
from lnbits.core.db import db
|
||||
from lnbits.core.models import WalletType
|
||||
from lnbits.db import DB_TYPE, SQLITE, Connection, Database, Filters, Page
|
||||
from lnbits.extension_manager import InstallableExtension
|
||||
from lnbits.settings import AdminSettings, EditableSettings, SuperSettings, settings
|
||||
from lnbits.settings import (
|
||||
AdminSettings,
|
||||
EditableSettings,
|
||||
SuperSettings,
|
||||
WebPushSettings,
|
||||
settings,
|
||||
)
|
||||
|
||||
from . import db
|
||||
from .models import BalanceCheck, Payment, PaymentFilters, TinyURL, User, Wallet
|
||||
from .models import (
|
||||
BalanceCheck,
|
||||
Payment,
|
||||
PaymentFilters,
|
||||
PaymentHistoryPoint,
|
||||
TinyURL,
|
||||
User,
|
||||
Wallet,
|
||||
WebPushSubscription,
|
||||
)
|
||||
|
||||
# accounts
|
||||
# --------
|
||||
@@ -57,9 +73,11 @@ async def get_user(user_id: str, conn: Optional[Connection] = None) -> Optional[
|
||||
)
|
||||
wallets = await (conn or db).fetchall(
|
||||
"""
|
||||
SELECT *, COALESCE((SELECT balance FROM balances WHERE wallet = wallets.id), 0) AS balance_msat
|
||||
SELECT *, COALESCE((
|
||||
SELECT balance FROM balances WHERE wallet = wallets.id
|
||||
), 0) AS balance_msat
|
||||
FROM wallets
|
||||
WHERE "user" = ?
|
||||
WHERE "user" = ? and wallets.deleted = false
|
||||
""",
|
||||
(user_id,),
|
||||
)
|
||||
@@ -75,6 +93,7 @@ async def get_user(user_id: str, conn: Optional[Connection] = None) -> Optional[
|
||||
wallets=[Wallet(**w) for w in wallets],
|
||||
admin=user["id"] == settings.super_user
|
||||
or user["id"] in settings.lnbits_admin_users,
|
||||
super_user=user["id"] == settings.super_user,
|
||||
)
|
||||
|
||||
|
||||
@@ -87,9 +106,9 @@ async def add_installed_extension(
|
||||
conn: Optional[Connection] = None,
|
||||
) -> None:
|
||||
meta = {
|
||||
"installed_release": dict(ext.installed_release)
|
||||
if ext.installed_release
|
||||
else None,
|
||||
"installed_release": (
|
||||
dict(ext.installed_release) if ext.installed_release else None
|
||||
),
|
||||
"dependencies": ext.dependencies,
|
||||
}
|
||||
|
||||
@@ -97,9 +116,11 @@ async def add_installed_extension(
|
||||
|
||||
await (conn or db).execute(
|
||||
"""
|
||||
INSERT INTO installed_extensions (id, version, name, short_description, icon, stars, meta) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (id) DO
|
||||
UPDATE SET (version, name, active, short_description, icon, stars, meta) = (?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO installed_extensions
|
||||
(id, version, name, short_description, icon, stars, meta)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT (id) DO UPDATE SET
|
||||
(version, name, active, short_description, icon, stars, meta) =
|
||||
(?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
ext.id,
|
||||
@@ -142,6 +163,25 @@ async def delete_installed_extension(
|
||||
)
|
||||
|
||||
|
||||
async def drop_extension_db(*, ext_id: str, conn: Optional[Connection] = None) -> None:
|
||||
db_version = await (conn or db).fetchone(
|
||||
"SELECT * FROM dbversions WHERE db = ?", (ext_id,)
|
||||
)
|
||||
# Check that 'ext_id' is a valid extension id and not a malicious string
|
||||
assert db_version, f"Extension '{ext_id}' db version cannot be found"
|
||||
|
||||
is_file_based_db = await Database.clean_ext_db_files(ext_id)
|
||||
if is_file_based_db:
|
||||
return
|
||||
|
||||
# String formatting is required, params are not accepted for 'DROP SCHEMA'.
|
||||
# The `ext_id` value is verified above.
|
||||
await (conn or db).execute(
|
||||
f"DROP SCHEMA IF EXISTS {ext_id} CASCADE",
|
||||
(),
|
||||
)
|
||||
|
||||
|
||||
async def get_installed_extension(ext_id: str, conn: Optional[Connection] = None):
|
||||
row = await (conn or db).fetchone(
|
||||
"SELECT * FROM installed_extensions WHERE id = ?",
|
||||
@@ -213,15 +253,25 @@ async def create_wallet(
|
||||
|
||||
|
||||
async def update_wallet(
|
||||
wallet_id: str, new_name: str, conn: Optional[Connection] = None
|
||||
wallet_id: str,
|
||||
name: Optional[str] = None,
|
||||
currency: Optional[str] = None,
|
||||
conn: Optional[Connection] = None,
|
||||
) -> Optional[Wallet]:
|
||||
set_clause = []
|
||||
values = []
|
||||
if name:
|
||||
set_clause.append("name = ?")
|
||||
values.append(name)
|
||||
if currency is not None:
|
||||
set_clause.append("currency = ?")
|
||||
values.append(currency)
|
||||
values.append(wallet_id)
|
||||
await (conn or db).execute(
|
||||
"""
|
||||
UPDATE wallets SET
|
||||
name = ?
|
||||
WHERE id = ?
|
||||
f"""
|
||||
UPDATE wallets SET {', '.join(set_clause)} WHERE id = ?
|
||||
""",
|
||||
(new_name, wallet_id),
|
||||
tuple(values),
|
||||
)
|
||||
wallet = await get_wallet(wallet_id=wallet_id, conn=conn)
|
||||
assert wallet, "updated created wallet couldn't be retrieved"
|
||||
@@ -233,11 +283,8 @@ async def delete_wallet(
|
||||
) -> None:
|
||||
await (conn or db).execute(
|
||||
"""
|
||||
UPDATE wallets AS w
|
||||
SET
|
||||
"user" = 'del:' || w."user",
|
||||
adminkey = 'del:' || w.adminkey,
|
||||
inkey = 'del:' || w.inkey
|
||||
UPDATE wallets
|
||||
SET deleted = true
|
||||
WHERE id = ? AND "user" = ?
|
||||
""",
|
||||
(wallet_id, user_id),
|
||||
@@ -249,9 +296,8 @@ async def get_wallet(
|
||||
) -> Optional[Wallet]:
|
||||
row = await (conn or db).fetchone(
|
||||
"""
|
||||
SELECT *, COALESCE((SELECT balance FROM balances WHERE wallet = wallets.id), 0) AS balance_msat
|
||||
FROM wallets
|
||||
WHERE id = ?
|
||||
SELECT *, COALESCE((SELECT balance FROM balances WHERE wallet = wallets.id), 0)
|
||||
AS balance_msat FROM wallets WHERE id = ?
|
||||
""",
|
||||
(wallet_id,),
|
||||
)
|
||||
@@ -260,13 +306,14 @@ async def get_wallet(
|
||||
|
||||
|
||||
async def get_wallet_for_key(
|
||||
key: str, key_type: str = "invoice", conn: Optional[Connection] = None
|
||||
key: str,
|
||||
key_type: WalletType = WalletType.invoice,
|
||||
conn: Optional[Connection] = None,
|
||||
) -> Optional[Wallet]:
|
||||
row = await (conn or db).fetchone(
|
||||
"""
|
||||
SELECT *, COALESCE((SELECT balance FROM balances WHERE wallet = wallets.id), 0) AS balance_msat
|
||||
FROM wallets
|
||||
WHERE adminkey = ? OR inkey = ?
|
||||
SELECT *, COALESCE((SELECT balance FROM balances WHERE wallet = wallets.id), 0)
|
||||
AS balance_msat FROM wallets WHERE adminkey = ? OR inkey = ?
|
||||
""",
|
||||
(key, key),
|
||||
)
|
||||
@@ -274,7 +321,7 @@ async def get_wallet_for_key(
|
||||
if not row:
|
||||
return None
|
||||
|
||||
if key_type == "admin" and row["adminkey"] != key:
|
||||
if key_type == WalletType.admin and row["adminkey"] != key:
|
||||
return None
|
||||
|
||||
return Wallet(**row)
|
||||
@@ -285,6 +332,11 @@ async def get_total_balance(conn: Optional[Connection] = None):
|
||||
return 0 if row[0] is None else row[0]
|
||||
|
||||
|
||||
async def get_active_wallet_total_balance(conn: Optional[Connection] = None):
|
||||
row = await (conn or db).fetchone("SELECT SUM(balance) FROM balances")
|
||||
return 0 if row[0] is None else row[0]
|
||||
|
||||
|
||||
# wallet payments
|
||||
# ---------------
|
||||
|
||||
@@ -488,14 +540,16 @@ async def create_payment(
|
||||
webhook: Optional[str] = None,
|
||||
conn: Optional[Connection] = None,
|
||||
) -> Payment:
|
||||
# todo: add this when tests are fixed
|
||||
# previous_payment = await get_wallet_payment(wallet_id, payment_hash, conn=conn)
|
||||
# assert previous_payment is None, "Payment already exists"
|
||||
# we don't allow the creation of the same invoice twice
|
||||
# note: this can be removed if the db uniquess constarints are set appropriately
|
||||
previous_payment = await get_standalone_payment(checking_id, conn=conn)
|
||||
assert previous_payment is None, "Payment already exists"
|
||||
|
||||
try:
|
||||
invoice = bolt11.decode(payment_request)
|
||||
invoice = decode(payment_request)
|
||||
|
||||
if invoice.expiry:
|
||||
expiration_date = datetime.datetime.fromtimestamp(invoice.date + invoice.expiry)
|
||||
except:
|
||||
else:
|
||||
# assume maximum bolt11 expiry of 31 days to be on the safe side
|
||||
expiration_date = datetime.datetime.now() + datetime.timedelta(days=31)
|
||||
|
||||
@@ -516,9 +570,11 @@ async def create_payment(
|
||||
pending,
|
||||
memo,
|
||||
fee,
|
||||
json.dumps(extra)
|
||||
if extra and extra != {} and type(extra) is dict
|
||||
else None,
|
||||
(
|
||||
json.dumps(extra)
|
||||
if extra and extra != {} and isinstance(extra, dict)
|
||||
else None
|
||||
),
|
||||
webhook,
|
||||
db.datetime_to_timestamp(expiration_date),
|
||||
),
|
||||
@@ -580,7 +636,8 @@ async def update_payment_extra(
|
||||
) -> None:
|
||||
"""
|
||||
Only update the `extra` field for the payment.
|
||||
Old values in the `extra` JSON object will be kept unless the new `extra` overwrites them.
|
||||
Old values in the `extra` JSON object will be kept
|
||||
unless the new `extra` overwrites them.
|
||||
"""
|
||||
|
||||
amount_clause = "AND amount < 0" if outgoing else "AND amount > 0"
|
||||
@@ -600,10 +657,77 @@ async def update_payment_extra(
|
||||
)
|
||||
|
||||
|
||||
async def delete_payment(checking_id: str, conn: Optional[Connection] = None) -> None:
|
||||
await (conn or db).execute(
|
||||
"DELETE FROM apipayments WHERE checking_id = ?", (checking_id,)
|
||||
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:
|
||||
await payment.check_status()
|
||||
|
||||
|
||||
DateTrunc = Literal["hour", "day", "month"]
|
||||
sqlite_formats = {
|
||||
"hour": "%Y-%m-%d %H:00:00",
|
||||
"day": "%Y-%m-%d 00:00:00",
|
||||
"month": "%Y-%m-01 00:00:00",
|
||||
}
|
||||
|
||||
|
||||
async def get_payments_history(
|
||||
wallet_id: Optional[str] = None,
|
||||
group: DateTrunc = "day",
|
||||
filters: Optional[Filters] = None,
|
||||
) -> List[PaymentHistoryPoint]:
|
||||
if not filters:
|
||||
filters = Filters()
|
||||
where = ["(pending = False OR amount < 0)"]
|
||||
values = []
|
||||
if wallet_id:
|
||||
where.append("wallet = ?")
|
||||
values.append(wallet_id)
|
||||
|
||||
if DB_TYPE == SQLITE and group in sqlite_formats:
|
||||
date_trunc = f"strftime('{sqlite_formats[group]}', time, 'unixepoch')"
|
||||
elif group in ("day", "hour", "month"):
|
||||
date_trunc = f"date_trunc('{group}', time)"
|
||||
else:
|
||||
raise ValueError(f"Invalid group value: {group}")
|
||||
|
||||
transactions = await db.fetchall(
|
||||
f"""
|
||||
SELECT {date_trunc} date,
|
||||
SUM(CASE WHEN amount > 0 THEN amount ELSE 0 END) income,
|
||||
SUM(CASE WHEN amount < 0 THEN abs(amount) + abs(fee) ELSE 0 END) spending
|
||||
FROM apipayments
|
||||
{filters.where(where)}
|
||||
GROUP BY date
|
||||
ORDER BY date DESC
|
||||
""",
|
||||
filters.values(values),
|
||||
)
|
||||
if wallet_id:
|
||||
wallet = await get_wallet(wallet_id)
|
||||
if wallet:
|
||||
balance = wallet.balance_msat
|
||||
else:
|
||||
raise ValueError("Unknown wallet")
|
||||
else:
|
||||
balance = await get_total_balance()
|
||||
|
||||
# since we dont know the balance at the starting point,
|
||||
# we take the current balance and walk backwards
|
||||
results: list[PaymentHistoryPoint] = []
|
||||
for row in transactions:
|
||||
results.insert(
|
||||
0,
|
||||
PaymentHistoryPoint(
|
||||
balance=balance, date=row[0], income=row[1], spending=row[2]
|
||||
),
|
||||
)
|
||||
balance -= row.income - row.spending
|
||||
return results
|
||||
|
||||
|
||||
async def delete_wallet_payment(
|
||||
@@ -618,6 +742,10 @@ async def delete_wallet_payment(
|
||||
async def check_internal(
|
||||
payment_hash: str, conn: Optional[Connection] = None
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Returns the checking_id of the internal payment if it exists,
|
||||
otherwise None
|
||||
"""
|
||||
row = await (conn or db).fetchone(
|
||||
"""
|
||||
SELECT checking_id FROM apipayments
|
||||
@@ -634,7 +762,10 @@ async def check_internal(
|
||||
async def check_internal_pending(
|
||||
payment_hash: str, conn: Optional[Connection] = None
|
||||
) -> bool:
|
||||
"""Returns False if the internal payment is not pending anymore (and thus paid), otherwise True"""
|
||||
"""
|
||||
Returns False if the internal payment is not pending anymore
|
||||
(and thus paid), otherwise True
|
||||
"""
|
||||
row = await (conn or db).fetchone(
|
||||
"""
|
||||
SELECT pending FROM apipayments
|
||||
@@ -741,12 +872,17 @@ async def get_admin_settings(is_super_user: bool = False) -> Optional[AdminSetti
|
||||
return admin_settings
|
||||
|
||||
|
||||
async def delete_admin_settings():
|
||||
async def delete_admin_settings() -> None:
|
||||
await db.execute("DELETE FROM settings")
|
||||
|
||||
|
||||
async def update_admin_settings(data: EditableSettings):
|
||||
await db.execute("UPDATE settings SET editable_settings = ?", (json.dumps(data),))
|
||||
async def update_admin_settings(data: EditableSettings) -> None:
|
||||
row = await db.fetchone("SELECT editable_settings FROM settings")
|
||||
editable_settings = json.loads(row["editable_settings"]) if row else {}
|
||||
editable_settings.update(data.dict(exclude_unset=True))
|
||||
await db.execute(
|
||||
"UPDATE settings SET editable_settings = ?", (json.dumps(editable_settings),)
|
||||
)
|
||||
|
||||
|
||||
async def update_super_user(super_user: str) -> SuperSettings:
|
||||
@@ -781,6 +917,15 @@ async def update_migration_version(conn, db_name, version):
|
||||
)
|
||||
|
||||
|
||||
async def delete_dbversion(*, ext_id: str, conn: Optional[Connection] = None) -> None:
|
||||
await (conn or db).execute(
|
||||
"""
|
||||
DELETE FROM dbversions WHERE db = ?
|
||||
""",
|
||||
(ext_id,),
|
||||
)
|
||||
|
||||
|
||||
# tinyurl
|
||||
# -------
|
||||
|
||||
@@ -820,3 +965,82 @@ async def delete_tinyurl(tinyurl_id: str):
|
||||
"DELETE FROM tiny_url WHERE id = ?",
|
||||
(tinyurl_id,),
|
||||
)
|
||||
|
||||
|
||||
# push_notification
|
||||
# -----------------
|
||||
|
||||
|
||||
async def get_webpush_settings() -> Optional[WebPushSettings]:
|
||||
row = await db.fetchone("SELECT * FROM webpush_settings")
|
||||
if not row:
|
||||
return None
|
||||
vapid_keypair = json.loads(row["vapid_keypair"])
|
||||
return WebPushSettings(**vapid_keypair)
|
||||
|
||||
|
||||
async def create_webpush_settings(webpush_settings: dict):
|
||||
await db.execute(
|
||||
"INSERT INTO webpush_settings (vapid_keypair) VALUES (?)",
|
||||
(json.dumps(webpush_settings),),
|
||||
)
|
||||
return await get_webpush_settings()
|
||||
|
||||
|
||||
async def get_webpush_subscription(
|
||||
endpoint: str, user: str
|
||||
) -> Optional[WebPushSubscription]:
|
||||
row = await db.fetchone(
|
||||
"SELECT * FROM webpush_subscriptions WHERE endpoint = ? AND user = ?",
|
||||
(
|
||||
endpoint,
|
||||
user,
|
||||
),
|
||||
)
|
||||
return WebPushSubscription(**dict(row)) if row else None
|
||||
|
||||
|
||||
async def get_webpush_subscriptions_for_user(
|
||||
user: str,
|
||||
) -> List[WebPushSubscription]:
|
||||
rows = await db.fetchall(
|
||||
"SELECT * FROM webpush_subscriptions WHERE user = ?",
|
||||
(user,),
|
||||
)
|
||||
return [WebPushSubscription(**dict(row)) for row in rows]
|
||||
|
||||
|
||||
async def create_webpush_subscription(
|
||||
endpoint: str, user: str, data: str, host: str
|
||||
) -> WebPushSubscription:
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO webpush_subscriptions (endpoint, user, data, host)
|
||||
VALUES (?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
endpoint,
|
||||
user,
|
||||
data,
|
||||
host,
|
||||
),
|
||||
)
|
||||
subscription = await get_webpush_subscription(endpoint, user)
|
||||
assert subscription, "Newly created webpush subscription couldn't be retrieved"
|
||||
return subscription
|
||||
|
||||
|
||||
async def delete_webpush_subscription(endpoint: str, user: str) -> None:
|
||||
await db.execute(
|
||||
"DELETE FROM webpush_subscriptions WHERE endpoint = ? AND user = ?",
|
||||
(
|
||||
endpoint,
|
||||
user,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def delete_webpush_subscriptions(endpoint: str) -> None:
|
||||
await db.execute(
|
||||
"DELETE FROM webpush_subscriptions WHERE endpoint = ?", (endpoint,)
|
||||
)
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
from lnbits.core.models import CoreAppExtra
|
||||
from lnbits.db import Database
|
||||
|
||||
db = Database("database")
|
||||
core_app_extra: CoreAppExtra = CoreAppExtra()
|
||||
@@ -6,11 +6,11 @@ from uuid import UUID
|
||||
import httpx
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.core.db import db as core_db
|
||||
from lnbits.db import Connection
|
||||
from lnbits.extension_manager import Extension
|
||||
from lnbits.settings import settings
|
||||
|
||||
from . import db as core_db
|
||||
from .crud import update_migration_version
|
||||
|
||||
|
||||
@@ -51,7 +51,8 @@ async def stop_extension_background_work(ext_id: str, user: str):
|
||||
"""
|
||||
Stop background work for extension (like asyncio.Tasks, WebSockets, etc).
|
||||
Extensions SHOULD expose a DELETE enpoint at the root level of their API.
|
||||
This function tries first to call the endpoint using `http` and if if fails it tries using `https`.
|
||||
This function tries first to call the endpoint using `http`
|
||||
and if it fails it tries using `https`.
|
||||
"""
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
@@ -71,7 +72,7 @@ def to_valid_user_id(user_id: str) -> UUID:
|
||||
raise ValueError("User ID must have at least 128 bits")
|
||||
try:
|
||||
int(user_id, 16)
|
||||
except:
|
||||
except Exception:
|
||||
raise ValueError("Invalid hex string for User ID.")
|
||||
|
||||
return UUID(hex=user_id[:32], version=4)
|
||||
|
||||
@@ -239,7 +239,8 @@ async def m007_set_invoice_expiries(db):
|
||||
invoice.date + invoice.expiry
|
||||
)
|
||||
logger.info(
|
||||
f"Migration: {i+1}/{len(rows)} setting expiry of invoice {invoice.payment_hash} to {expiration_date}"
|
||||
f"Migration: {i+1}/{len(rows)} setting expiry of invoice"
|
||||
f" {invoice.payment_hash} to {expiration_date}"
|
||||
)
|
||||
await db.execute(
|
||||
"""
|
||||
@@ -251,7 +252,7 @@ async def m007_set_invoice_expiries(db):
|
||||
checking_id,
|
||||
),
|
||||
)
|
||||
except:
|
||||
except Exception:
|
||||
continue
|
||||
except OperationalError:
|
||||
# this is necessary now because it may be the case that this migration will
|
||||
@@ -301,3 +302,94 @@ async def m010_create_installed_extensions_table(db):
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
async def m011_optimize_balances_view(db):
|
||||
"""
|
||||
Make the calculation of the balance a single aggregation
|
||||
over the payments table instead of 2.
|
||||
"""
|
||||
await db.execute("DROP VIEW balances")
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE VIEW balances AS
|
||||
SELECT wallet, SUM(amount - abs(fee)) AS balance
|
||||
FROM apipayments
|
||||
WHERE (pending = false AND amount > 0) OR amount < 0
|
||||
GROUP BY wallet
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
async def m012_add_currency_to_wallet(db):
|
||||
await db.execute(
|
||||
"""
|
||||
ALTER TABLE wallets ADD COLUMN currency TEXT
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
async def m013_add_deleted_to_wallets(db):
|
||||
"""
|
||||
Adds deleted column to wallets.
|
||||
"""
|
||||
try:
|
||||
await db.execute(
|
||||
"ALTER TABLE wallets ADD COLUMN deleted BOOLEAN NOT NULL DEFAULT false"
|
||||
)
|
||||
except OperationalError:
|
||||
pass
|
||||
|
||||
|
||||
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()
|
||||
|
||||
for row in rows:
|
||||
try:
|
||||
user = row[2].split(":")[1]
|
||||
adminkey = row[3].split(":")[1]
|
||||
inkey = row[4].split(":")[1]
|
||||
await db.execute(
|
||||
"""
|
||||
UPDATE wallets SET user = ?, adminkey = ?, inkey = ?, deleted = true
|
||||
WHERE id = ?
|
||||
""",
|
||||
(user, adminkey, inkey, row[0]),
|
||||
)
|
||||
except Exception:
|
||||
continue
|
||||
except OperationalError:
|
||||
# this is necessary now because it may be the case that this migration will
|
||||
# run twice in some environments.
|
||||
# catching errors like this won't be necessary in anymore now that we
|
||||
# keep track of db versions so no migration ever runs twice.
|
||||
pass
|
||||
|
||||
|
||||
async def m015_create_push_notification_subscriptions_table(db):
|
||||
await db.execute(
|
||||
f"""
|
||||
CREATE TABLE IF NOT EXISTS webpush_subscriptions (
|
||||
endpoint TEXT NOT NULL,
|
||||
"user" TEXT NOT NULL,
|
||||
data TEXT NOT NULL,
|
||||
host TEXT NOT NULL,
|
||||
timestamp TIMESTAMP NOT NULL DEFAULT {db.timestamp_now},
|
||||
PRIMARY KEY (endpoint, "user")
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
@@ -3,17 +3,21 @@ import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from sqlite3 import Row
|
||||
from typing import Callable, Dict, List, Optional
|
||||
|
||||
from ecdsa import SECP256k1, SigningKey
|
||||
from fastapi import Query
|
||||
from lnurl import encode as lnurl_encode
|
||||
from loguru import logger
|
||||
from pydantic import BaseModel
|
||||
|
||||
from lnbits.db import Connection, FilterModel, FromRowModel
|
||||
from lnbits.helpers import url_for
|
||||
from lnbits.settings import get_wallet_class, settings
|
||||
from lnbits.settings import settings
|
||||
from lnbits.wallets import get_wallet_class
|
||||
from lnbits.wallets.base import PaymentStatus
|
||||
|
||||
|
||||
@@ -23,7 +27,9 @@ class Wallet(BaseModel):
|
||||
user: str
|
||||
adminkey: str
|
||||
inkey: str
|
||||
currency: Optional[str]
|
||||
balance_msat: int
|
||||
deleted: bool
|
||||
|
||||
@property
|
||||
def balance(self) -> int:
|
||||
@@ -37,11 +43,10 @@ class Wallet(BaseModel):
|
||||
|
||||
@property
|
||||
def lnurlwithdraw_full(self) -> str:
|
||||
|
||||
url = url_for("/withdraw", external=True, usr=self.user, wal=self.id)
|
||||
try:
|
||||
return lnurl_encode(url)
|
||||
except:
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
def lnurlauth_key(self, domain: str) -> SigningKey:
|
||||
@@ -58,6 +63,22 @@ class Wallet(BaseModel):
|
||||
return await get_standalone_payment(payment_hash)
|
||||
|
||||
|
||||
class WalletType(Enum):
|
||||
admin = 0
|
||||
invoice = 1
|
||||
invalid = 2
|
||||
|
||||
# backwards compatibility
|
||||
def __eq__(self, other):
|
||||
return self.value == other
|
||||
|
||||
|
||||
@dataclass
|
||||
class WalletTypeInfo:
|
||||
wallet_type: WalletType
|
||||
wallet: Wallet
|
||||
|
||||
|
||||
class User(BaseModel):
|
||||
id: str
|
||||
email: Optional[str] = None
|
||||
@@ -169,6 +190,8 @@ class Payment(FromRowModel):
|
||||
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(
|
||||
@@ -179,7 +202,8 @@ class Payment(FromRowModel):
|
||||
return PaymentStatus(None)
|
||||
|
||||
logger.debug(
|
||||
f"Checking {'outgoing' if self.is_out else 'incoming'} pending payment {self.checking_id}"
|
||||
f"Checking {'outgoing' if self.is_out else 'incoming'} "
|
||||
f"pending payment {self.checking_id}"
|
||||
)
|
||||
|
||||
WALLET = get_wallet_class()
|
||||
@@ -193,7 +217,8 @@ class Payment(FromRowModel):
|
||||
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}: expired {expiration_date}"
|
||||
f"Deleting expired incoming pending payment {self.checking_id}: "
|
||||
f"expired {expiration_date}"
|
||||
)
|
||||
await self.delete(conn)
|
||||
elif self.is_out and status.failed:
|
||||
@@ -203,15 +228,16 @@ class Payment(FromRowModel):
|
||||
await self.delete(conn)
|
||||
elif not status.pending:
|
||||
logger.info(
|
||||
f"Marking '{'in' if self.is_in else 'out'}' {self.checking_id} as not pending anymore: {status}"
|
||||
f"Marking '{'in' if self.is_in else 'out'}' "
|
||||
f"{self.checking_id} as not pending anymore: {status}"
|
||||
)
|
||||
await self.update_status(status, conn=conn)
|
||||
return status
|
||||
|
||||
async def delete(self, conn: Optional[Connection] = None) -> None:
|
||||
from .crud import delete_payment
|
||||
from .crud import delete_wallet_payment
|
||||
|
||||
await delete_payment(self.checking_id, conn=conn)
|
||||
await delete_wallet_payment(self.checking_id, self.wallet_id, conn=conn)
|
||||
|
||||
|
||||
class PaymentFilters(FilterModel):
|
||||
@@ -232,6 +258,13 @@ class PaymentFilters(FilterModel):
|
||||
webhook_status: Optional[int]
|
||||
|
||||
|
||||
class PaymentHistoryPoint(BaseModel):
|
||||
date: datetime.datetime
|
||||
income: int
|
||||
spending: int
|
||||
balance: int
|
||||
|
||||
|
||||
class BalanceCheck(BaseModel):
|
||||
wallet: str
|
||||
service: str
|
||||
@@ -244,6 +277,7 @@ class BalanceCheck(BaseModel):
|
||||
|
||||
class CoreAppExtra:
|
||||
register_new_ext_routes: Callable
|
||||
register_new_ratelimiter: Callable
|
||||
|
||||
|
||||
class TinyURL(BaseModel):
|
||||
@@ -256,3 +290,66 @@ class TinyURL(BaseModel):
|
||||
@classmethod
|
||||
def from_row(cls, row: Row):
|
||||
return cls(**dict(row))
|
||||
|
||||
|
||||
class ConversionData(BaseModel):
|
||||
from_: str = "sat"
|
||||
amount: float
|
||||
to: str = "usd"
|
||||
|
||||
|
||||
class Callback(BaseModel):
|
||||
callback: str
|
||||
|
||||
|
||||
class DecodePayment(BaseModel):
|
||||
data: str
|
||||
|
||||
|
||||
class CreateLnurl(BaseModel):
|
||||
description_hash: str
|
||||
callback: str
|
||||
amount: int
|
||||
comment: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
class CreateInvoice(BaseModel):
|
||||
unit: str = "sat"
|
||||
internal: bool = False
|
||||
out: bool = True
|
||||
amount: float = Query(None, ge=0)
|
||||
memo: Optional[str] = None
|
||||
description_hash: Optional[str] = None
|
||||
unhashed_description: Optional[str] = None
|
||||
expiry: Optional[int] = None
|
||||
lnurl_callback: Optional[str] = None
|
||||
lnurl_balance_check: Optional[str] = None
|
||||
extra: Optional[dict] = None
|
||||
webhook: Optional[str] = None
|
||||
bolt11: Optional[str] = None
|
||||
|
||||
|
||||
class CreateTopup(BaseModel):
|
||||
id: str
|
||||
amount: int
|
||||
|
||||
|
||||
class CreateLnurlAuth(BaseModel):
|
||||
callback: str
|
||||
|
||||
|
||||
class CreateWallet(BaseModel):
|
||||
name: Optional[str] = None
|
||||
|
||||
|
||||
class CreateWebPushSubscription(BaseModel):
|
||||
subscription: str
|
||||
|
||||
|
||||
class WebPushSubscription(BaseModel):
|
||||
endpoint: str
|
||||
user: str
|
||||
data: str
|
||||
host: str
|
||||
timestamp: str
|
||||
|
||||
@@ -1,31 +1,35 @@
|
||||
import asyncio
|
||||
import json
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Tuple, TypedDict
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import httpx
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from fastapi import Depends, WebSocket
|
||||
from lnurl import LnurlErrorResponse
|
||||
from lnurl import decode as decode_lnurl
|
||||
from loguru import logger
|
||||
from py_vapid import Vapid
|
||||
from py_vapid.utils import b64urlencode
|
||||
|
||||
from lnbits import bolt11
|
||||
from lnbits.core.db import db
|
||||
from lnbits.db import Connection
|
||||
from lnbits.decorators import WalletTypeInfo, require_admin_key
|
||||
from lnbits.helpers import url_for
|
||||
from lnbits.settings import (
|
||||
FAKE_WALLET,
|
||||
EditableSettings,
|
||||
SuperSettings,
|
||||
get_wallet_class,
|
||||
readonly_variables,
|
||||
send_admin_user_to_saas,
|
||||
settings,
|
||||
)
|
||||
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis, satoshis_amount_as_fiat
|
||||
from lnbits.wallets import FAKE_WALLET, get_wallet_class, set_wallet_class
|
||||
from lnbits.wallets.base import PaymentResponse, PaymentStatus
|
||||
|
||||
from . import db
|
||||
from .crud import (
|
||||
check_internal,
|
||||
check_internal_pending,
|
||||
@@ -37,14 +41,16 @@ from .crud import (
|
||||
get_account,
|
||||
get_standalone_payment,
|
||||
get_super_settings,
|
||||
get_total_balance,
|
||||
get_wallet,
|
||||
get_wallet_payment,
|
||||
update_admin_settings,
|
||||
update_payment_details,
|
||||
update_payment_status,
|
||||
update_super_user,
|
||||
)
|
||||
from .helpers import to_valid_user_id
|
||||
from .models import Payment
|
||||
from .models import Payment, Wallet
|
||||
|
||||
|
||||
class PaymentFailure(Exception):
|
||||
@@ -55,10 +61,49 @@ class InvoiceFailure(Exception):
|
||||
pass
|
||||
|
||||
|
||||
async def calculate_fiat_amounts(
|
||||
amount: float,
|
||||
wallet_id: str,
|
||||
currency: Optional[str] = None,
|
||||
extra: Optional[Dict] = None,
|
||||
conn: Optional[Connection] = None,
|
||||
) -> Tuple[int, Optional[Dict]]:
|
||||
wallet = await get_wallet(wallet_id, conn=conn)
|
||||
assert wallet, "invalid wallet_id"
|
||||
wallet_currency = wallet.currency or settings.lnbits_default_accounting_currency
|
||||
|
||||
if currency and currency != "sat":
|
||||
amount_sat = await fiat_amount_as_satoshis(amount, currency)
|
||||
extra = extra or {}
|
||||
if currency != wallet_currency:
|
||||
extra["fiat_currency"] = currency
|
||||
extra["fiat_amount"] = round(amount, ndigits=3)
|
||||
extra["fiat_rate"] = amount_sat / amount
|
||||
else:
|
||||
amount_sat = int(amount)
|
||||
|
||||
if wallet_currency:
|
||||
if wallet_currency == currency:
|
||||
fiat_amount = amount
|
||||
else:
|
||||
fiat_amount = await satoshis_amount_as_fiat(amount_sat, wallet_currency)
|
||||
extra = extra or {}
|
||||
extra["wallet_fiat_currency"] = wallet_currency
|
||||
extra["wallet_fiat_amount"] = round(fiat_amount, ndigits=3)
|
||||
extra["wallet_fiat_rate"] = amount_sat / fiat_amount
|
||||
|
||||
logger.debug(
|
||||
f"Calculated fiat amounts {wallet.id=} {amount=} {currency=}: {extra=}"
|
||||
)
|
||||
|
||||
return amount_sat, extra
|
||||
|
||||
|
||||
async def create_invoice(
|
||||
*,
|
||||
wallet_id: str,
|
||||
amount: int, # in satoshis
|
||||
amount: float,
|
||||
currency: Optional[str] = "sat",
|
||||
memo: str,
|
||||
description_hash: Optional[bytes] = None,
|
||||
unhashed_description: Optional[bytes] = None,
|
||||
@@ -71,24 +116,31 @@ async def create_invoice(
|
||||
if not amount > 0:
|
||||
raise InvoiceFailure("Amountless invoices not supported.")
|
||||
|
||||
if await get_wallet(wallet_id, conn=conn) is None:
|
||||
raise InvoiceFailure("Wallet does not exist.")
|
||||
|
||||
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()
|
||||
|
||||
amount_sat, extra = await calculate_fiat_amounts(
|
||||
amount, wallet_id, currency=currency, extra=extra, conn=conn
|
||||
)
|
||||
|
||||
ok, checking_id, payment_request, error_message = await wallet.create_invoice(
|
||||
amount=amount,
|
||||
amount=amount_sat,
|
||||
memo=invoice_memo,
|
||||
description_hash=description_hash,
|
||||
unhashed_description=unhashed_description,
|
||||
expiry=expiry or settings.lightning_invoice_expiry,
|
||||
)
|
||||
if not ok:
|
||||
if not ok or not payment_request or not checking_id:
|
||||
raise InvoiceFailure(error_message or "unexpected backend error.")
|
||||
|
||||
invoice = bolt11.decode(payment_request)
|
||||
|
||||
amount_msat = amount * 1000
|
||||
amount_msat = 1000 * amount_sat
|
||||
await create_payment(
|
||||
wallet_id=wallet_id,
|
||||
checking_id=checking_id,
|
||||
@@ -115,16 +167,23 @@ async def pay_invoice(
|
||||
) -> str:
|
||||
"""
|
||||
Pay a Lightning invoice.
|
||||
First, we create a temporary payment in the database with fees set to the reserve fee.
|
||||
We then check whether the balance of the payer would go negative.
|
||||
We then attempt to pay the invoice through the backend.
|
||||
If the payment is successful, we update the payment in the database with the payment details.
|
||||
First, we create a temporary payment in the database with fees set to the reserve
|
||||
fee. We then check whether the balance of the payer would go negative.
|
||||
We then attempt to pay the invoice through the backend. If the payment is
|
||||
successful, we update the payment in the database with the payment details.
|
||||
If the payment is unsuccessful, we delete the temporary payment.
|
||||
If the payment is still in flight, we hope that some other process will regularly check for the payment.
|
||||
If the payment is still in flight, we hope that some other process
|
||||
will regularly check for the payment.
|
||||
"""
|
||||
invoice = bolt11.decode(payment_request)
|
||||
|
||||
if not invoice.amount_msat or not invoice.amount_msat > 0:
|
||||
raise ValueError("Amountless invoices not supported.")
|
||||
if max_sat and invoice.amount_msat > max_sat * 1000:
|
||||
raise ValueError("Amount in invoice is too high.")
|
||||
|
||||
fee_reserve_msat = fee_reserve(invoice.amount_msat)
|
||||
async with (db.reuse_conn(conn) if conn else db.connect()) as conn:
|
||||
async with db.reuse_conn(conn) if conn else db.connect() as conn:
|
||||
temp_id = invoice.payment_hash
|
||||
internal_id = f"internal_{invoice.payment_hash}"
|
||||
|
||||
@@ -133,6 +192,10 @@ async def pay_invoice(
|
||||
if max_sat and invoice.amount_msat > max_sat * 1000:
|
||||
raise ValueError("Amount in invoice is too high.")
|
||||
|
||||
_, extra = await calculate_fiat_amounts(
|
||||
invoice.amount_msat / 1000, wallet_id, extra=extra, conn=conn
|
||||
)
|
||||
|
||||
# put all parameters that don't change here
|
||||
class PaymentKwargs(TypedDict):
|
||||
wallet_id: str
|
||||
@@ -151,11 +214,13 @@ async def pay_invoice(
|
||||
extra=extra,
|
||||
)
|
||||
|
||||
# we check if an internal invoice exists that has already been paid (not pending anymore)
|
||||
# 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.")
|
||||
|
||||
# check_internal() returns the checking_id of the invoice we're waiting for (pending only)
|
||||
# check_internal() returns the checking_id of the invoice we're waiting for
|
||||
# (pending only)
|
||||
internal_checking_id = await check_internal(invoice.payment_hash, conn=conn)
|
||||
if internal_checking_id:
|
||||
# perform additional checks on the internal payment
|
||||
@@ -172,7 +237,7 @@ async def pay_invoice(
|
||||
|
||||
logger.debug(f"creating temporary internal payment with id {internal_id}")
|
||||
# create a new payment from this wallet
|
||||
await create_payment(
|
||||
new_payment = await create_payment(
|
||||
checking_id=internal_id,
|
||||
fee=0,
|
||||
pending=False,
|
||||
@@ -184,7 +249,7 @@ async def pay_invoice(
|
||||
# create a temporary payment here so we can check if
|
||||
# the balance is enough in the next step
|
||||
try:
|
||||
await create_payment(
|
||||
new_payment = await create_payment(
|
||||
checking_id=temp_id,
|
||||
fee=-fee_reserve_msat,
|
||||
conn=conn,
|
||||
@@ -202,7 +267,8 @@ async def pay_invoice(
|
||||
logger.debug("balance is too low, deleting temporary payment")
|
||||
if not internal_checking_id and wallet.balance_msat > -fee_reserve_msat:
|
||||
raise PaymentFailure(
|
||||
f"You must reserve at least ({round(fee_reserve_msat/1000)} sat) to cover potential routing fees."
|
||||
f"You must reserve at least ({round(fee_reserve_msat/1000)} sat) to"
|
||||
" cover potential routing fees."
|
||||
)
|
||||
raise PermissionError("Insufficient balance.")
|
||||
|
||||
@@ -215,6 +281,7 @@ async def pay_invoice(
|
||||
await update_payment_status(
|
||||
checking_id=internal_checking_id, pending=False, conn=conn
|
||||
)
|
||||
await send_payment_notification(wallet, new_payment)
|
||||
|
||||
# notify receiver asynchronously
|
||||
from lnbits.tasks import internal_invoice_queue
|
||||
@@ -231,7 +298,8 @@ async def pay_invoice(
|
||||
|
||||
if payment.checking_id and payment.checking_id != temp_id:
|
||||
logger.warning(
|
||||
f"backend sent unexpected checking_id (expected: {temp_id} got: {payment.checking_id})"
|
||||
f"backend sent unexpected checking_id (expected: {temp_id} got:"
|
||||
f" {payment.checking_id})"
|
||||
)
|
||||
|
||||
logger.debug(f"backend: pay_invoice finished {temp_id}")
|
||||
@@ -248,14 +316,11 @@ async def pay_invoice(
|
||||
conn=conn,
|
||||
)
|
||||
wallet = await get_wallet(wallet_id, conn=conn)
|
||||
if wallet:
|
||||
await websocketUpdater(
|
||||
wallet_id,
|
||||
{
|
||||
"wallet_balance": wallet.balance or None,
|
||||
"payment": payment._asdict(),
|
||||
},
|
||||
)
|
||||
updated = await get_wallet_payment(
|
||||
wallet_id, payment.checking_id, conn=conn
|
||||
)
|
||||
if wallet and updated:
|
||||
await send_payment_notification(wallet, updated)
|
||||
logger.debug(f"payment successful {payment.checking_id}")
|
||||
elif payment.checking_id is None and payment.ok is False:
|
||||
# payment failed
|
||||
@@ -269,7 +334,8 @@ async def pay_invoice(
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f"didn't receive checking_id from backend, payment may be stuck in database: {temp_id}"
|
||||
"didn't receive checking_id from backend, payment may be stuck in"
|
||||
f" database: {temp_id}"
|
||||
)
|
||||
|
||||
return invoice.payment_hash
|
||||
@@ -301,9 +367,10 @@ async def redeem_lnurl_withdraw(
|
||||
extra=extra,
|
||||
conn=conn,
|
||||
)
|
||||
except:
|
||||
except Exception:
|
||||
logger.warning(
|
||||
f"failed to create invoice on redeem_lnurl_withdraw from {lnurl}. params: {res}"
|
||||
f"failed to create invoice on redeem_lnurl_withdraw "
|
||||
f"from {lnurl}. params: {res}"
|
||||
)
|
||||
return None
|
||||
|
||||
@@ -422,13 +489,26 @@ async def check_transaction_status(
|
||||
return status
|
||||
|
||||
|
||||
# WARN: this same value must be used for balance check and passed to WALLET.pay_invoice(), it may cause a vulnerability if the values differ
|
||||
# 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
|
||||
def fee_reserve(amount_msat: int) -> int:
|
||||
reserve_min = settings.lnbits_reserve_fee_min
|
||||
reserve_percent = settings.lnbits_reserve_fee_percent
|
||||
return max(int(reserve_min), int(amount_msat * reserve_percent / 100.0))
|
||||
|
||||
|
||||
async def send_payment_notification(wallet: Wallet, payment: Payment):
|
||||
await websocketUpdater(
|
||||
wallet.id,
|
||||
json.dumps(
|
||||
{
|
||||
"wallet_balance": wallet.balance,
|
||||
"payment": payment.dict(),
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def update_wallet_balance(wallet_id: str, amount: int):
|
||||
payment_hash, _ = await create_invoice(
|
||||
wallet_id=wallet_id,
|
||||
@@ -456,7 +536,7 @@ async def check_admin_settings():
|
||||
# create new settings if table is empty
|
||||
logger.warning("Settings DB empty. Inserting default settings.")
|
||||
settings_db = await init_admin_settings(settings.super_user)
|
||||
logger.warning("Initialized settings from enviroment variables.")
|
||||
logger.warning("Initialized settings from environment variables.")
|
||||
|
||||
if settings.super_user and settings.super_user != settings_db.super_user:
|
||||
# .env super_user overwrites DB super_user
|
||||
@@ -464,16 +544,8 @@ async def check_admin_settings():
|
||||
|
||||
update_cached_settings(settings_db.dict())
|
||||
|
||||
# printing settings for debugging
|
||||
logger.debug("Admin settings:")
|
||||
for key, value in settings.dict(exclude_none=True).items():
|
||||
logger.debug(f"{key}: {value}")
|
||||
|
||||
admin_url = f"{settings.lnbits_baseurl}wallet?usr={settings.super_user}"
|
||||
logger.success(f"✔️ Access super user account at: {admin_url}")
|
||||
|
||||
# saving it to .super_user file
|
||||
with open(".super_user", "w") as file:
|
||||
# saving superuser to {data_dir}/.super_user file
|
||||
with open(Path(settings.lnbits_data_folder) / ".super_user", "w") as file:
|
||||
file.write(settings.super_user)
|
||||
|
||||
# callback for saas
|
||||
@@ -484,13 +556,41 @@ async def check_admin_settings():
|
||||
):
|
||||
send_admin_user_to_saas()
|
||||
|
||||
logger.success(
|
||||
"✔️ Admin UI is enabled. run `poetry run lnbits-cli superuser` "
|
||||
"to get the superuser."
|
||||
)
|
||||
|
||||
|
||||
async def check_webpush_settings():
|
||||
if not settings.lnbits_webpush_privkey:
|
||||
vapid = Vapid()
|
||||
vapid.generate_keys()
|
||||
privkey = vapid.private_pem()
|
||||
assert vapid.public_key, "VAPID public key does not exist"
|
||||
pubkey = b64urlencode(
|
||||
vapid.public_key.public_bytes(
|
||||
serialization.Encoding.X962,
|
||||
serialization.PublicFormat.UncompressedPoint,
|
||||
)
|
||||
)
|
||||
push_settings = {
|
||||
"lnbits_webpush_privkey": privkey.decode(),
|
||||
"lnbits_webpush_pubkey": pubkey,
|
||||
}
|
||||
update_cached_settings(push_settings)
|
||||
await update_admin_settings(EditableSettings(**push_settings))
|
||||
|
||||
logger.info("Initialized webpush settings with generated VAPID key pair.")
|
||||
logger.info(f"Pubkey: {settings.lnbits_webpush_pubkey}")
|
||||
|
||||
|
||||
def update_cached_settings(sets_dict: dict):
|
||||
for key, value in sets_dict.items():
|
||||
if key not in readonly_variables:
|
||||
try:
|
||||
setattr(settings, key, value)
|
||||
except:
|
||||
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"])
|
||||
@@ -511,12 +611,12 @@ async def init_admin_settings(super_user: Optional[str] = None) -> SuperSettings
|
||||
|
||||
|
||||
class WebsocketConnectionManager:
|
||||
def __init__(self):
|
||||
def __init__(self) -> None:
|
||||
self.active_connections: List[WebSocket] = []
|
||||
|
||||
async def connect(self, websocket: WebSocket):
|
||||
async def connect(self, websocket: WebSocket, item_id: str):
|
||||
logger.debug(f"Websocket connected to {item_id}")
|
||||
await websocket.accept()
|
||||
logger.debug(websocket)
|
||||
self.active_connections.append(websocket)
|
||||
|
||||
def disconnect(self, websocket: WebSocket):
|
||||
@@ -533,3 +633,20 @@ websocketManager = WebsocketConnectionManager()
|
||||
|
||||
async def websocketUpdater(item_id, data):
|
||||
return await websocketManager.send_data(f"{data}", item_id)
|
||||
|
||||
|
||||
async def switch_to_voidwallet() -> None:
|
||||
WALLET = get_wallet_class()
|
||||
if WALLET.__class__.__name__ == "VoidWallet":
|
||||
return
|
||||
set_wallet_class("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
|
||||
|
||||
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 4.2 KiB |
@@ -1,43 +0,0 @@
|
||||
new Vue({
|
||||
el: '#vue',
|
||||
data: function () {
|
||||
return {
|
||||
searchTerm: '',
|
||||
filteredExtensions: [],
|
||||
maxStars: 5,
|
||||
user: null
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.filteredExtensions = this.g.extensions
|
||||
},
|
||||
watch: {
|
||||
searchTerm(term) {
|
||||
// Reset the filter
|
||||
this.filteredExtensions = this.g.extensions
|
||||
if (term !== '') {
|
||||
// Filter the extensions list
|
||||
function extensionNameContains(searchTerm) {
|
||||
return function (extension) {
|
||||
return (
|
||||
extension.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
extension.shortDescription
|
||||
.toLowerCase()
|
||||
.includes(searchTerm.toLowerCase())
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
this.filteredExtensions = this.filteredExtensions.filter(
|
||||
extensionNameContains(term)
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
created() {
|
||||
if (window.user) {
|
||||
this.user = LNbits.map.user(window.user)
|
||||
}
|
||||
},
|
||||
mixins: [windowMixin]
|
||||
})
|
||||
@@ -5,14 +5,22 @@ new Vue({
|
||||
return {
|
||||
disclaimerDialog: {
|
||||
show: false,
|
||||
data: {}
|
||||
data: {},
|
||||
description: ''
|
||||
},
|
||||
walletName: ''
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
formatDescription() {
|
||||
return LNbits.utils.convertMarkdown(this.description)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
createWallet: function () {
|
||||
LNbits.href.createWallet(this.walletName)
|
||||
LNbits.api.createAccount(this.walletName).then(res => {
|
||||
window.location = '/wallet?usr=' + res.data.user + '&wal=' + res.data.id
|
||||
})
|
||||
},
|
||||
processing: function () {
|
||||
this.$q.notify({
|
||||
@@ -21,5 +29,8 @@ new Vue({
|
||||
icon: null
|
||||
})
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.description = SITE_DESCRIPTION
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
function shortenNodeId(nodeId) {
|
||||
return nodeId
|
||||
? nodeId.substring(0, 5) + '...' + nodeId.substring(nodeId.length - 5)
|
||||
: '...'
|
||||
}
|
||||
|
||||
Vue.component('lnbits-node-ranks', {
|
||||
props: ['ranks'],
|
||||
data: function () {
|
||||
return {
|
||||
user: {},
|
||||
stats: [
|
||||
{label: 'Capacity', key: 'capacity'},
|
||||
{label: 'Channels', key: 'channelcount'},
|
||||
{label: 'Age', key: 'age'},
|
||||
{label: 'Growth', key: 'growth'},
|
||||
{label: 'Availability', key: 'availability'}
|
||||
]
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<q-card class='q-my-none'>
|
||||
<div class='column q-ma-md'>
|
||||
<h5 class='text-subtitle1 text-bold q-my-none'>1ml Node Rank</h5>
|
||||
<div v-for='stat in stats' class='q-gutter-sm'>
|
||||
<div class='row items-center'>
|
||||
<div class='col-9'>{{ stat.label }}</div>
|
||||
<div class='col-3 text-subtitle1 text-bold'>
|
||||
{{ (ranks && ranks[stat.key]) ?? '-' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</q-card>
|
||||
`
|
||||
})
|
||||
|
||||
Vue.component('lnbits-channel-stats', {
|
||||
props: ['stats'],
|
||||
data: function () {
|
||||
return {
|
||||
states: [
|
||||
{label: 'Active', value: 'active', color: 'green'},
|
||||
{label: 'Pending', value: 'pending', color: 'orange'},
|
||||
{label: 'Inactive', value: 'inactive', color: 'grey'},
|
||||
{label: 'Closed', value: 'closed', color: 'red'}
|
||||
]
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<q-card>
|
||||
<div class='column q-ma-md'>
|
||||
<h5 class='text-subtitle1 text-bold q-my-none'>Channels</h5>
|
||||
<div v-for='state in states' class='q-gutter-sm'>
|
||||
<div class='row'>
|
||||
<div class='col-9'>
|
||||
<q-badge rounded size='md' :color='state.color'>{{ state.label }}</q-badge>
|
||||
</div>
|
||||
<div class='col-3 text-subtitle1 text-bold'>
|
||||
{{ (stats?.counts && stats.counts[state.value]) ?? "-" }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</q-card>
|
||||
`,
|
||||
created: function () {
|
||||
if (window.user) {
|
||||
this.user = LNbits.map.user(window.user)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
Vue.component('lnbits-stat', {
|
||||
props: ['title', 'amount', 'msat', 'btc'],
|
||||
computed: {
|
||||
value: function () {
|
||||
return (
|
||||
this.amount ??
|
||||
(this.btc
|
||||
? LNbits.utils.formatSat(this.btc)
|
||||
: LNbits.utils.formatMsat(this.msat))
|
||||
)
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<div class='text-overline text-primary'>
|
||||
{{ title }}
|
||||
</div>
|
||||
<div>
|
||||
<span class='text-h4 text-bold q-my-none'>{{ value }}</span>
|
||||
<span class='text-h5' v-if='msat != undefined'>sats</span>
|
||||
<span class='text-h5' v-if='btc != undefined'>BTC</span>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
`
|
||||
})
|
||||
|
||||
Vue.component('lnbits-node-qrcode', {
|
||||
props: ['info'],
|
||||
mixins: [windowMixin],
|
||||
template: `
|
||||
<q-card class="my-card">
|
||||
<q-card-section>
|
||||
<div class="text-h6">
|
||||
<div style="text-align: center">
|
||||
<qrcode
|
||||
:value="info.addresses[0]"
|
||||
:options="{width: 250}"
|
||||
v-if='info.addresses[0]'
|
||||
class="rounded-borders"
|
||||
></qrcode>
|
||||
<div v-else class='text-subtitle1'>
|
||||
No addresses available
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
<q-card-actions vertical>
|
||||
<q-btn
|
||||
dense
|
||||
unelevated
|
||||
size="md"
|
||||
@click="copyText(info.id)"
|
||||
>Public Key<q-tooltip> Click to copy </q-tooltip>
|
||||
</q-btn>
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
`
|
||||
})
|
||||
|
||||
Vue.component('lnbits-node-info', {
|
||||
props: ['info'],
|
||||
data() {
|
||||
return {
|
||||
showDialog: false
|
||||
}
|
||||
},
|
||||
mixins: [windowMixin],
|
||||
methods: {
|
||||
shortenNodeId
|
||||
},
|
||||
template: `
|
||||
<div class='row items-baseline q-gutter-x-sm'>
|
||||
<div class='text-h4 text-bold'>{{ this.info.alias }}</div>
|
||||
<div class='row items-center q-gutter-sm'>
|
||||
<div class='text-subtitle1 text-light'>{{ this.info.backend_name }}</div>
|
||||
<q-badge
|
||||
:style='\`background-color: #\${this.info.color}\`'
|
||||
class='text-bold'
|
||||
>
|
||||
#{{ this.info.color }}
|
||||
</q-badge>
|
||||
<div class='text-bold'>{{ shortenNodeId(this.info.id) }}</div>
|
||||
<q-btn
|
||||
size='xs'
|
||||
flat
|
||||
dense
|
||||
icon='content_paste'
|
||||
@click='copyText(info.id)'
|
||||
></q-btn>
|
||||
<q-btn
|
||||
size='xs'
|
||||
flat
|
||||
dense
|
||||
icon='qr_code'
|
||||
@click='showDialog = true'
|
||||
></q-btn>
|
||||
</div>
|
||||
<q-dialog v-model="showDialog">
|
||||
<lnbits-node-qrcode :info='info'></lnbits-node-qrcode>
|
||||
</q-dialog>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
// update cache version every time there is a new deployment
|
||||
// so the service worker reinitializes the cache
|
||||
const CACHE_VERSION = 30
|
||||
const CACHE_VERSION = 58
|
||||
const CURRENT_CACHE = `lnbits-${CACHE_VERSION}-`
|
||||
|
||||
const getApiKey = request => {
|
||||
@@ -56,3 +56,32 @@ self.addEventListener('fetch', event => {
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
// Handle and show incoming push notifications
|
||||
self.addEventListener('push', function (event) {
|
||||
if (!(self.Notification && self.Notification.permission === 'granted')) {
|
||||
return
|
||||
}
|
||||
|
||||
let data = event.data.json()
|
||||
const title = data.title
|
||||
const body = data.body
|
||||
const url = data.url
|
||||
|
||||
event.waitUntil(
|
||||
self.registration.showNotification(title, {
|
||||
body: body,
|
||||
icon: '/favicon.ico',
|
||||
data: {
|
||||
url: url
|
||||
}
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
// User can click on the notification message to open wallet
|
||||
// Installed app will open when `url_handlers` in web app manifest is supported
|
||||
self.addEventListener('notificationclick', function (event) {
|
||||
event.notification.close()
|
||||
event.waitUntil(clients.openWindow(event.notification.data.url))
|
||||
})
|
||||
|
||||
@@ -3,45 +3,24 @@
|
||||
Vue.component(VueQrcode.name, VueQrcode)
|
||||
Vue.use(VueQrcodeReader)
|
||||
|
||||
function generateChart(canvas, payments) {
|
||||
var txs = []
|
||||
var n = 0
|
||||
var data = {
|
||||
labels: [],
|
||||
income: [],
|
||||
outcome: [],
|
||||
cumulative: []
|
||||
}
|
||||
|
||||
_.each(
|
||||
payments.filter(p => !p.pending).sort((a, b) => a.time - b.time),
|
||||
tx => {
|
||||
txs.push({
|
||||
hour: Quasar.utils.date.formatDate(tx.date, 'YYYY-MM-DDTHH:00'),
|
||||
sat: tx.sat
|
||||
})
|
||||
function generateChart(canvas, rawData) {
|
||||
const data = rawData.reduce(
|
||||
(previous, current) => {
|
||||
previous.labels.push(current.date)
|
||||
previous.income.push(current.income)
|
||||
previous.spending.push(current.spending)
|
||||
previous.cumulative.push(current.balance)
|
||||
return previous
|
||||
},
|
||||
{
|
||||
labels: [],
|
||||
income: [],
|
||||
spending: [],
|
||||
cumulative: []
|
||||
}
|
||||
)
|
||||
|
||||
_.each(_.groupBy(txs, 'hour'), (value, day) => {
|
||||
var income = _.reduce(
|
||||
value,
|
||||
(memo, tx) => (tx.sat >= 0 ? memo + tx.sat : memo),
|
||||
0
|
||||
)
|
||||
var outcome = _.reduce(
|
||||
value,
|
||||
(memo, tx) => (tx.sat < 0 ? memo + Math.abs(tx.sat) : memo),
|
||||
0
|
||||
)
|
||||
n = n + income - outcome
|
||||
data.labels.push(day)
|
||||
data.income.push(income)
|
||||
data.outcome.push(outcome)
|
||||
data.cumulative.push(n)
|
||||
})
|
||||
|
||||
new Chart(canvas.getContext('2d'), {
|
||||
return new Chart(canvas.getContext('2d'), {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: data.labels,
|
||||
@@ -64,7 +43,7 @@ function generateChart(canvas, payments) {
|
||||
backgroundColor: window.Color('rgb(76,175,80)').alpha(0.5).rgbString() // green
|
||||
},
|
||||
{
|
||||
data: data.outcome,
|
||||
data: data.spending,
|
||||
type: 'bar',
|
||||
label: 'out',
|
||||
barPercentage: 0.75,
|
||||
@@ -85,7 +64,7 @@ function generateChart(canvas, payments) {
|
||||
{
|
||||
type: 'time',
|
||||
display: true,
|
||||
offset: true,
|
||||
//offset: true,
|
||||
time: {
|
||||
minUnit: 'hour',
|
||||
stepSize: 3
|
||||
@@ -137,6 +116,9 @@ new Vue({
|
||||
comment: ''
|
||||
},
|
||||
paymentChecker: null,
|
||||
copy: {
|
||||
show: false
|
||||
},
|
||||
camera: {
|
||||
show: false,
|
||||
camera: 'auto'
|
||||
@@ -148,14 +130,7 @@ new Vue({
|
||||
{
|
||||
name: 'time',
|
||||
align: 'left',
|
||||
label: this.$t('date'),
|
||||
field: 'date',
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
name: 'memo',
|
||||
align: 'left',
|
||||
label: this.$t('memo'),
|
||||
label: this.$t('memo') + '/' + this.$t('date'),
|
||||
field: 'date',
|
||||
sortable: true
|
||||
},
|
||||
@@ -177,8 +152,89 @@ new Vue({
|
||||
filter: null,
|
||||
loading: false
|
||||
},
|
||||
paymentsCSV: {
|
||||
columns: [
|
||||
{
|
||||
name: 'pending',
|
||||
align: 'left',
|
||||
label: 'Pending',
|
||||
field: 'pending'
|
||||
},
|
||||
{
|
||||
name: 'memo',
|
||||
align: 'left',
|
||||
label: this.$t('memo'),
|
||||
field: 'memo'
|
||||
},
|
||||
{
|
||||
name: 'time',
|
||||
align: 'left',
|
||||
label: this.$t('date'),
|
||||
field: 'date',
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
name: 'amount',
|
||||
align: 'right',
|
||||
label: this.$t('amount') + ' (' + LNBITS_DENOMINATION + ')',
|
||||
field: 'sat',
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
name: 'fee',
|
||||
align: 'right',
|
||||
label: this.$t('fee') + ' (m' + LNBITS_DENOMINATION + ')',
|
||||
field: 'fee'
|
||||
},
|
||||
{
|
||||
name: 'tag',
|
||||
align: 'right',
|
||||
label: this.$t('tag'),
|
||||
field: 'tag'
|
||||
},
|
||||
{
|
||||
name: 'payment_hash',
|
||||
align: 'right',
|
||||
label: this.$t('payment_hash'),
|
||||
field: 'payment_hash'
|
||||
},
|
||||
{
|
||||
name: 'payment_proof',
|
||||
align: 'right',
|
||||
label: this.$t('payment_proof'),
|
||||
field: 'payment_proof'
|
||||
},
|
||||
{
|
||||
name: 'webhook',
|
||||
align: 'right',
|
||||
label: this.$t('webhook'),
|
||||
field: 'webhook'
|
||||
},
|
||||
{
|
||||
name: 'fiat_currency',
|
||||
align: 'right',
|
||||
label: 'Fiat Currency',
|
||||
field: row => row.extra.wallet_fiat_currency
|
||||
},
|
||||
{
|
||||
name: 'fiat_amount',
|
||||
align: 'right',
|
||||
label: 'Fiat Amount',
|
||||
field: row => row.extra.wallet_fiat_amount
|
||||
}
|
||||
],
|
||||
filter: null,
|
||||
loading: false
|
||||
},
|
||||
paymentsChart: {
|
||||
show: false
|
||||
show: false,
|
||||
group: {value: 'hour', label: 'Hour'},
|
||||
groupOptions: [
|
||||
{value: 'month', label: 'Month'},
|
||||
{value: 'day', label: 'Day'},
|
||||
{value: 'hour', label: 'Hour'}
|
||||
],
|
||||
instance: null
|
||||
},
|
||||
disclaimerDialog: {
|
||||
show: false,
|
||||
@@ -186,7 +242,10 @@ new Vue({
|
||||
},
|
||||
balance: 0,
|
||||
credit: 0,
|
||||
newName: ''
|
||||
update: {
|
||||
name: null,
|
||||
currency: null
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -228,9 +287,27 @@ new Vue({
|
||||
},
|
||||
showChart: function () {
|
||||
this.paymentsChart.show = true
|
||||
this.$nextTick(() => {
|
||||
generateChart(this.$refs.canvas, this.payments)
|
||||
})
|
||||
LNbits.api
|
||||
.request(
|
||||
'GET',
|
||||
'/api/v1/payments/history?group=' + this.paymentsChart.group.value,
|
||||
this.g.wallet.adminkey
|
||||
)
|
||||
.then(response => {
|
||||
this.$nextTick(() => {
|
||||
if (this.paymentsChart.instance) {
|
||||
this.paymentsChart.instance.destroy()
|
||||
}
|
||||
this.paymentsChart.instance = generateChart(
|
||||
this.$refs.canvas,
|
||||
response.data
|
||||
)
|
||||
})
|
||||
})
|
||||
.catch(err => {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
this.paymentsChart.show = false
|
||||
})
|
||||
},
|
||||
focusInput(el) {
|
||||
this.$nextTick(() => this.$refs[el].focus())
|
||||
@@ -243,7 +320,6 @@ new Vue({
|
||||
this.receive.data.amount = null
|
||||
this.receive.data.memo = null
|
||||
this.receive.unit = 'sat'
|
||||
this.receive.paymentChecker = null
|
||||
this.receive.minMax = [0, 2100000000000000]
|
||||
this.receive.lnurl = null
|
||||
this.focusInput('setAmount')
|
||||
@@ -253,10 +329,13 @@ new Vue({
|
||||
this.parse.invoice = null
|
||||
this.parse.lnurlpay = null
|
||||
this.parse.lnurlauth = null
|
||||
this.parse.copy.show =
|
||||
window.isSecureContext && navigator.clipboard?.readText !== undefined
|
||||
this.parse.data.request = ''
|
||||
this.parse.data.comment = ''
|
||||
this.parse.data.paymentChecker = null
|
||||
this.parse.camera.show = false
|
||||
this.focusInput('textArea')
|
||||
},
|
||||
updateBalance: function (credit) {
|
||||
LNbits.api
|
||||
@@ -285,11 +364,6 @@ new Vue({
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
closeReceiveDialog: function () {
|
||||
setTimeout(() => {
|
||||
clearInterval(this.receive.paymentChecker)
|
||||
}, 10000)
|
||||
},
|
||||
closeParseDialog: function () {
|
||||
setTimeout(() => {
|
||||
clearInterval(this.parse.paymentChecker)
|
||||
@@ -302,7 +376,6 @@ new Vue({
|
||||
if (this.receive.paymentHash === paymentHash) {
|
||||
this.receive.show = false
|
||||
this.receive.paymentHash = null
|
||||
clearInterval(this.receive.paymentChecker)
|
||||
}
|
||||
},
|
||||
createInvoice: function () {
|
||||
@@ -347,19 +420,7 @@ new Vue({
|
||||
}
|
||||
}
|
||||
|
||||
clearInterval(this.receive.paymentChecker)
|
||||
setTimeout(() => {
|
||||
clearInterval(this.receive.paymentChecker)
|
||||
}, 40000)
|
||||
this.receive.paymentChecker = setInterval(() => {
|
||||
let hash = response.data.payment_hash
|
||||
|
||||
LNbits.api.getPayment(this.g.wallet, hash).then(response => {
|
||||
if (response.data.paid) {
|
||||
this.onPaymentReceived(hash)
|
||||
}
|
||||
})
|
||||
}, 5000)
|
||||
this.fetchPayments()
|
||||
})
|
||||
.catch(err => {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
@@ -641,7 +702,7 @@ new Vue({
|
||||
|
||||
LNbits.api
|
||||
.authLnurl(this.g.wallet, this.parse.lnurlauth.callback)
|
||||
.then(response => {
|
||||
.then(_ => {
|
||||
dismissAuthMsg()
|
||||
this.$q.notify({
|
||||
message: `Authentication successful.`,
|
||||
@@ -664,35 +725,38 @@ new Vue({
|
||||
}
|
||||
})
|
||||
},
|
||||
updateWalletName: function () {
|
||||
let newName = this.newName
|
||||
let adminkey = this.g.wallet.adminkey
|
||||
if (!newName || !newName.length) return
|
||||
updateWallet: function (data) {
|
||||
LNbits.api
|
||||
.request('PUT', '/api/v1/wallet/' + newName, adminkey, {})
|
||||
.then(res => {
|
||||
this.newName = ''
|
||||
.request('PATCH', '/api/v1/wallet', this.g.wallet.adminkey, data)
|
||||
.then(_ => {
|
||||
this.$q.notify({
|
||||
message: `Wallet named updated.`,
|
||||
message: `Wallet updated.`,
|
||||
type: 'positive',
|
||||
timeout: 3500
|
||||
})
|
||||
LNbits.href.updateWallet(
|
||||
res.data.name,
|
||||
this.user.id,
|
||||
this.g.wallet.id
|
||||
)
|
||||
window.location.reload()
|
||||
})
|
||||
.catch(err => {
|
||||
this.newName = ''
|
||||
LNbits.utils.notifyApiError(err)
|
||||
})
|
||||
},
|
||||
deleteWallet: function (walletId, user) {
|
||||
deleteWallet: function () {
|
||||
LNbits.utils
|
||||
.confirmDialog('Are you sure you want to delete this wallet?')
|
||||
.onOk(() => {
|
||||
LNbits.href.deleteWallet(walletId, user)
|
||||
LNbits.api
|
||||
.deleteWallet(this.g.wallet)
|
||||
.then(_ => {
|
||||
this.$q.notify({
|
||||
timeout: 3000,
|
||||
message: `Wallet deleted!`,
|
||||
spinner: true
|
||||
})
|
||||
})
|
||||
.catch(err => {
|
||||
this.paymentsTable.loading = false
|
||||
LNbits.utils.notifyApiError(err)
|
||||
})
|
||||
})
|
||||
},
|
||||
fetchPayments: function (props) {
|
||||
@@ -738,25 +802,33 @@ new Vue({
|
||||
// status is important for export but it is not in paymentsTable
|
||||
// because it is manually added with payment detail link and icons
|
||||
// and would cause duplication in the list
|
||||
let columns = structuredClone(this.paymentsTable.columns)
|
||||
columns.unshift({
|
||||
name: 'pending',
|
||||
align: 'left',
|
||||
label: 'Pending',
|
||||
field: 'pending'
|
||||
LNbits.api.getPayments(this.g.wallet, {}).then(response => {
|
||||
const payments = response.data.data.map(LNbits.map.payment)
|
||||
LNbits.utils.exportCSV(this.paymentsCSV.columns, payments)
|
||||
})
|
||||
},
|
||||
pasteToTextArea: function () {
|
||||
this.$refs.textArea.focus() // Set cursor to textarea
|
||||
navigator.clipboard.readText().then(text => {
|
||||
this.$refs.textArea.value = text
|
||||
})
|
||||
LNbits.utils.exportCSV(columns, this.payments)
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
payments: function () {
|
||||
this.fetchBalance()
|
||||
},
|
||||
'paymentsChart.group': function () {
|
||||
this.showChart()
|
||||
}
|
||||
},
|
||||
created: function () {
|
||||
this.fetchBalance()
|
||||
this.fetchPayments()
|
||||
|
||||
this.update.name = this.g.wallet.name
|
||||
this.update.currency = this.g.wallet.currency
|
||||
|
||||
LNbits.api
|
||||
.request('GET', '/api/v1/currencies')
|
||||
.then(response => {
|
||||
|
||||
@@ -4,34 +4,106 @@ from typing import Dict
|
||||
import httpx
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.tasks import SseListenersDict, register_invoice_listener
|
||||
|
||||
from . import db
|
||||
from .crud import get_balance_notify, get_wallet
|
||||
from .models import Payment
|
||||
from .services import websocketUpdater
|
||||
from lnbits.core.crud import (
|
||||
get_balance_notify,
|
||||
get_wallet,
|
||||
get_webpush_subscriptions_for_user,
|
||||
)
|
||||
from lnbits.core.db import db
|
||||
from lnbits.core.models import Payment
|
||||
from lnbits.core.services import (
|
||||
get_balance_delta,
|
||||
send_payment_notification,
|
||||
switch_to_voidwallet,
|
||||
)
|
||||
from lnbits.settings import get_wallet_class, settings
|
||||
from lnbits.tasks import (
|
||||
SseListenersDict,
|
||||
create_permanent_task,
|
||||
create_task,
|
||||
register_invoice_listener,
|
||||
send_push_notification,
|
||||
)
|
||||
|
||||
api_invoice_listeners: Dict[str, asyncio.Queue] = SseListenersDict(
|
||||
"api_invoice_listeners"
|
||||
)
|
||||
|
||||
|
||||
async def register_task_listeners():
|
||||
def register_killswitch():
|
||||
"""
|
||||
Registers an invoice listener queue for the core tasks.
|
||||
Incoming payaments in this queue will eventually trigger the signals sent to all other extensions
|
||||
Registers a killswitch which will check lnbits-status repository for a signal from
|
||||
LNbits and will switch to VoidWallet if the killswitch is triggered.
|
||||
"""
|
||||
logger.debug("Starting killswitch task")
|
||||
create_permanent_task(killswitch_task)
|
||||
|
||||
|
||||
async def killswitch_task():
|
||||
while True:
|
||||
WALLET = get_wallet_class()
|
||||
if settings.lnbits_killswitch and WALLET.__class__.__name__ != "VoidWallet":
|
||||
with httpx.Client() as client:
|
||||
try:
|
||||
r = client.get(settings.lnbits_status_manifest, timeout=4)
|
||||
r.raise_for_status()
|
||||
if r.status_code == 200:
|
||||
ks = r.json().get("killswitch")
|
||||
if ks and ks == 1:
|
||||
logger.error(
|
||||
"Switching to VoidWallet. Killswitch triggered."
|
||||
)
|
||||
await switch_to_voidwallet()
|
||||
except (httpx.ConnectError, httpx.RequestError):
|
||||
logger.error(
|
||||
"Cannot fetch lnbits status manifest."
|
||||
f" {settings.lnbits_status_manifest}"
|
||||
)
|
||||
await asyncio.sleep(settings.lnbits_killswitch_interval * 60)
|
||||
|
||||
|
||||
async def register_watchdog():
|
||||
"""
|
||||
Registers a watchdog which will check lnbits balance and nodebalance
|
||||
and will switch to VoidWallet if the watchdog delta is reached.
|
||||
"""
|
||||
# TODO: implement watchdog properly
|
||||
# logger.debug("Starting watchdog task")
|
||||
# create_permanent_task(watchdog_task)
|
||||
|
||||
|
||||
async def watchdog_task():
|
||||
while True:
|
||||
WALLET = get_wallet_class()
|
||||
if settings.lnbits_watchdog and WALLET.__class__.__name__ != "VoidWallet":
|
||||
try:
|
||||
delta, *_ = await get_balance_delta()
|
||||
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}")
|
||||
await switch_to_voidwallet()
|
||||
except Exception as e:
|
||||
logger.error("Error in watchdog task", e)
|
||||
await asyncio.sleep(settings.lnbits_watchdog_interval * 60)
|
||||
|
||||
|
||||
def register_task_listeners():
|
||||
"""
|
||||
Registers an invoice listener queue for the core tasks. Incoming payments in this
|
||||
queue will eventually trigger the signals sent to all other extensions
|
||||
and fulfill other core tasks such as dispatching webhooks.
|
||||
"""
|
||||
invoice_paid_queue = asyncio.Queue(5)
|
||||
# we register invoice_paid_queue to receive all incoming invoices
|
||||
register_invoice_listener(invoice_paid_queue, "core/tasks.py")
|
||||
# register a worker that will react to invoices
|
||||
asyncio.create_task(wait_for_paid_invoices(invoice_paid_queue))
|
||||
create_task(wait_for_paid_invoices(invoice_paid_queue))
|
||||
|
||||
|
||||
async def wait_for_paid_invoices(invoice_paid_queue: asyncio.Queue):
|
||||
"""
|
||||
This worker dispatches events to all extensions, dispatches webhooks and balance notifys.
|
||||
This worker dispatches events to all extensions,
|
||||
dispatches webhooks and balance notifys.
|
||||
"""
|
||||
while True:
|
||||
payment = await invoice_paid_queue.get()
|
||||
@@ -40,13 +112,7 @@ async def wait_for_paid_invoices(invoice_paid_queue: asyncio.Queue):
|
||||
await dispatch_api_invoice_listeners(payment)
|
||||
wallet = await get_wallet(payment.wallet_id)
|
||||
if wallet:
|
||||
await websocketUpdater(
|
||||
payment.wallet_id,
|
||||
{
|
||||
"wallet_balance": wallet.balance or None,
|
||||
"payment": payment._asdict(),
|
||||
},
|
||||
)
|
||||
await send_payment_notification(wallet, payment)
|
||||
# dispatch webhook
|
||||
if payment.webhook and not payment.webhook_status:
|
||||
await dispatch_webhook(payment)
|
||||
@@ -61,6 +127,8 @@ async def wait_for_paid_invoices(invoice_paid_queue: asyncio.Queue):
|
||||
except (httpx.ConnectError, httpx.RequestError):
|
||||
pass
|
||||
|
||||
await send_payment_push_notification(payment)
|
||||
|
||||
|
||||
async def dispatch_api_invoice_listeners(payment: Payment):
|
||||
"""
|
||||
@@ -79,11 +147,15 @@ async def dispatch_webhook(payment: Payment):
|
||||
"""
|
||||
Dispatches the webhook to the webhook url.
|
||||
"""
|
||||
logger.debug("sending webhook", payment.webhook)
|
||||
|
||||
if not payment.webhook:
|
||||
return await mark_webhook_sent(payment, -1)
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
data = payment.dict()
|
||||
try:
|
||||
logger.debug("sending webhook", payment.webhook)
|
||||
r = await client.post(payment.webhook, json=data, timeout=40) # type: ignore
|
||||
r = await client.post(payment.webhook, json=data, timeout=40)
|
||||
await mark_webhook_sent(payment, r.status_code)
|
||||
except (httpx.ConnectError, httpx.RequestError):
|
||||
await mark_webhook_sent(payment, -1)
|
||||
@@ -97,3 +169,24 @@ async def mark_webhook_sent(payment: Payment, status: int) -> None:
|
||||
""",
|
||||
(status, payment.payment_hash),
|
||||
)
|
||||
|
||||
|
||||
async def send_payment_push_notification(payment: Payment):
|
||||
wallet = await get_wallet(payment.wallet_id)
|
||||
|
||||
if wallet:
|
||||
subscriptions = await get_webpush_subscriptions_for_user(wallet.user)
|
||||
|
||||
amount = int(payment.amount / 1000)
|
||||
|
||||
title = f"LNbits: {wallet.name}"
|
||||
body = f"You just received {amount} sat{'s'[:amount^1]}!"
|
||||
|
||||
if payment.memo:
|
||||
body += f"\r\n{payment.memo}"
|
||||
|
||||
for subscription in subscriptions:
|
||||
url = (
|
||||
f"https://{subscription.host}/wallet?usr={wallet.user}&wal={wallet.id}"
|
||||
)
|
||||
await send_push_notification(subscription, title, body, url)
|
||||
|
||||
@@ -9,12 +9,46 @@
|
||||
<ul>
|
||||
{%raw%}
|
||||
<li>Funding Source: {{settings.lnbits_backend_wallet_class}}</li>
|
||||
<li>Balance: {{balance / 1000}} sats</li>
|
||||
<li>
|
||||
Node Balance: {{(auditData.node_balance_msats /
|
||||
1000).toLocaleString()}} sats
|
||||
</li>
|
||||
<li>
|
||||
LNbits Balance: {{(auditData.lnbits_balance_msats /
|
||||
1000).toLocaleString()}} sats
|
||||
</li>
|
||||
<li>
|
||||
Reserve Percent: {{(auditData.node_balance_msats /
|
||||
auditData.lnbits_balance_msats * 100).toFixed(2)}} %
|
||||
</li>
|
||||
{%endraw%}
|
||||
</ul>
|
||||
<br />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<div v-if="'{{LNBITS_NODE_UI_AVAILABLE}}' === 'True'">
|
||||
<div>Node Management</div>
|
||||
<q-toggle
|
||||
label="Node UI"
|
||||
v-model="formData.lnbits_node_ui"
|
||||
></q-toggle>
|
||||
<q-toggle
|
||||
v-if="formData.lnbits_node_ui"
|
||||
label="Public node UI"
|
||||
v-model="formData.lnbits_public_node_ui"
|
||||
></q-toggle>
|
||||
<q-toggle
|
||||
v-if="formData.lnbits_node_ui"
|
||||
label="Transactions Tab (Disable on large CLN nodes)"
|
||||
v-model="formData.lnbits_node_ui_transactions"
|
||||
></q-toggle>
|
||||
<br />
|
||||
</div>
|
||||
<p v-else>Node Management not supported by active funding source</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 col-md-4">
|
||||
<div class="row">
|
||||
@@ -36,7 +70,8 @@
|
||||
<p>Invoice Expiry</p>
|
||||
<q-input
|
||||
filled
|
||||
v-model="formData.lightning_invoice_expiry"
|
||||
v-model.number="formData.lightning_invoice_expiry"
|
||||
type="number"
|
||||
label="Invoice expiry (seconds)"
|
||||
mask="#######"
|
||||
>
|
||||
@@ -80,7 +115,7 @@
|
||||
<q-expansion-item
|
||||
expand-separator
|
||||
icon="payments"
|
||||
:label="fund"
|
||||
:label="prettyFunding.get(fund) || fund"
|
||||
v-if="funding_sources.get(fund)"
|
||||
>
|
||||
<q-card>
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
<q-tab-panel name="security">
|
||||
<q-card-section class="q-pa-none">
|
||||
<h6 class="q-my-none" v-text="$t('security_tools')"></h6>
|
||||
<br />
|
||||
<div>
|
||||
<div class="row">
|
||||
<div v-if="serverlogEnabled" class="column" style="width: 100%">
|
||||
<div
|
||||
class="col bg-primary"
|
||||
style="padding-left: 5px; max-height: 20px; color: #fafafa"
|
||||
v-text="$t('server_logs')"
|
||||
></div>
|
||||
<div class="col" style="background-color: #292929">
|
||||
<q-scroll-area
|
||||
ref="logScroll"
|
||||
style="padding: 10px; color: #fafafa; height: 320px"
|
||||
>
|
||||
<small v-for="log in logs"
|
||||
>{% raw %}{{ log }}{% endraw %}<br
|
||||
/></small>
|
||||
</q-scroll-area>
|
||||
</div>
|
||||
</div>
|
||||
<q-btn
|
||||
@click="toggleServerLog"
|
||||
dense
|
||||
flat
|
||||
color="primary"
|
||||
:label="(serverlogEnabled) ? $t('disable_server_log') : $t('enable_server_log')"
|
||||
></q-btn>
|
||||
</div>
|
||||
<br />
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 col-md-12">
|
||||
<p v-text="$t('ip_blocker')"></p>
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-6">
|
||||
<q-input
|
||||
filled
|
||||
v-model="formBlockedIPs"
|
||||
@keydown.enter="addBlockedIPs"
|
||||
type="text"
|
||||
:label="$t('enter_ip')"
|
||||
:hint="$t('block_access_hint')"
|
||||
>
|
||||
<q-btn
|
||||
@click="addExtensionsManifest"
|
||||
dense
|
||||
flat
|
||||
icon="add"
|
||||
></q-btn>
|
||||
</q-input>
|
||||
<div>
|
||||
{%raw%}
|
||||
<q-chip
|
||||
v-for="blocked_ip in formData.lnbits_blocked_ips"
|
||||
:key="blocked_ip"
|
||||
removable
|
||||
@remove="removeBlockedIPs(blocked_ip)"
|
||||
color="primary"
|
||||
text-color="white"
|
||||
>
|
||||
{{ blocked_ip }}
|
||||
</q-chip>
|
||||
{%endraw%}
|
||||
</div>
|
||||
<br />
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<q-input
|
||||
filled
|
||||
v-model="formAllowedIPs"
|
||||
@keydown.enter="addAllowedIPs"
|
||||
type="text"
|
||||
:label="$t('enter_ip')"
|
||||
:hint="$t('allow_access_hint')"
|
||||
>
|
||||
<q-btn
|
||||
@click="addExtensionsManifest"
|
||||
dense
|
||||
flat
|
||||
icon="add"
|
||||
></q-btn>
|
||||
</q-input>
|
||||
<div>
|
||||
{%raw%}
|
||||
<q-chip
|
||||
v-for="allowed_ip in formData.lnbits_allowed_ips"
|
||||
:key="allowed_ip"
|
||||
removable
|
||||
@remove="removeAllowedIPs(allowed_ip)"
|
||||
color="primary"
|
||||
text-color="white"
|
||||
>
|
||||
{{ allowed_ip }}
|
||||
</q-chip>
|
||||
{%endraw%}
|
||||
</div>
|
||||
<br />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-12">
|
||||
<p v-text="$t('rate_limiter')"></p>
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-6">
|
||||
<q-input
|
||||
filled
|
||||
type="number"
|
||||
v-model.number="formData.lnbits_rate_limit_no"
|
||||
:label="$t('number_of_requests')"
|
||||
></q-input>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<q-select
|
||||
filled
|
||||
:options="[$t('second'),$t('minute'),$t('hour')]"
|
||||
v-model="formData.lnbits_rate_limit_unit"
|
||||
:label="$t('time_unit')"
|
||||
></q-select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<br />
|
||||
<div>
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 col-md-6">
|
||||
<q-item tag="label" v-ripple>
|
||||
<q-item-section>
|
||||
<q-item-label v-text="$t('enable_notifications')"></q-item-label>
|
||||
<q-item-label
|
||||
caption
|
||||
v-text="$t('enable_notifications_desc')"
|
||||
></q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section avatar>
|
||||
<q-toggle
|
||||
size="md"
|
||||
v-model="formData.lnbits_notifications"
|
||||
checked-icon="check"
|
||||
color="green"
|
||||
unchecked-icon="clear"
|
||||
/>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<br />
|
||||
<p
|
||||
v-if="!formData.lnbits_notifications"
|
||||
v-text="$t('notifications_disabled')"
|
||||
></p>
|
||||
<div v-if="formData.lnbits_notifications">
|
||||
{% include "admin/_tab_security_notifications.html" %}
|
||||
</div>
|
||||
<br />
|
||||
<div>
|
||||
<p v-text="$t('notification_source')"></p>
|
||||
<q-input
|
||||
filled
|
||||
v-model="formData.lnbits_status_manifest"
|
||||
type="text"
|
||||
:label="$t('notification_source_label')"
|
||||
/>
|
||||
</div>
|
||||
<br />
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<p v-text="$t('killswitch')"></p>
|
||||
<q-item tag="label" v-ripple>
|
||||
<q-item-section>
|
||||
<q-item-label v-text="$t('enable_killswitch')"></q-item-label>
|
||||
<q-item-label
|
||||
caption
|
||||
v-text="$t('enable_killswitch_desc')"
|
||||
></q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section avatar>
|
||||
<q-toggle
|
||||
size="md"
|
||||
v-model="formData.lnbits_killswitch"
|
||||
checked-icon="check"
|
||||
color="green"
|
||||
unchecked-icon="clear"
|
||||
/>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item tag="label" v-ripple>
|
||||
<q-item-section>
|
||||
<q-item-label v-text="$t('killswitch_interval')"></q-item-label>
|
||||
<q-item-label
|
||||
caption
|
||||
v-text="$t('killswitch_interval_desc')"
|
||||
></q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-input
|
||||
filled
|
||||
v-model="formData.lnbits_killswitch_interval"
|
||||
type="number"
|
||||
/>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<br />
|
||||
<p v-text="$t('watchdog')"></p>
|
||||
<q-item disabled tag="label" v-ripple>
|
||||
<q-tooltip><span v-text="$t('coming_soon')"></span></q-tooltip>
|
||||
<q-item-section>
|
||||
<q-item-label v-text="$t('enable_watchdog')"></q-item-label>
|
||||
<q-item-label
|
||||
caption
|
||||
v-text="$t('enable_watchdog_desc')"
|
||||
></q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section avatar>
|
||||
<q-toggle
|
||||
size="md"
|
||||
v-model="formData.lnbits_watchdog"
|
||||
checked-icon="check"
|
||||
color="green"
|
||||
unchecked-icon="clear"
|
||||
/>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item disabled tag="label" v-ripple>
|
||||
<q-tooltip><span v-text="$t('coming_soon')"></span></q-tooltip>
|
||||
<q-item-section>
|
||||
<q-item-label v-text="$t('watchdog_interval')"></q-item-label>
|
||||
<q-item-label
|
||||
caption
|
||||
v-text="$t('watchdog_interval_desc')"
|
||||
></q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-input
|
||||
filled
|
||||
v-model="formData.lnbits_watchdog_interval"
|
||||
type="number"
|
||||
/>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item disabled tag="label" v-ripple>
|
||||
<q-tooltip><span v-text="$t('coming_soon')"></span></q-tooltip>
|
||||
<q-item-section>
|
||||
<q-item-label v-text="$t('watchdog_delta')"></q-item-label>
|
||||
<q-item-label
|
||||
caption
|
||||
v-text="$t('watchdog_delta_desc')"
|
||||
></q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-input
|
||||
filled
|
||||
v-model="formData.lnbits_watchdog_delta"
|
||||
type="number"
|
||||
/>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<br />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-tab-panel>
|
||||
@@ -0,0 +1,70 @@
|
||||
{% raw %}
|
||||
<q-banner v-if="updateAvailable" class="bg-primary text-white">
|
||||
<q-icon size="28px" name="update"></q-icon>
|
||||
|
||||
<span v-text="$t('update_available', {version: statusData.version})"></span>
|
||||
<template v-slot:action>
|
||||
<a
|
||||
class="q-btn"
|
||||
color="white"
|
||||
target="_blank"
|
||||
href="https://github.com/lnbits/lnbits/releases"
|
||||
v-text="$t('releases')"
|
||||
></a>
|
||||
</template>
|
||||
</q-banner>
|
||||
<q-banner v-if="!updateAvailable" class="bg-green text-white">
|
||||
<q-icon size="28px" name="checkmark"></q-icon>
|
||||
<span v-text="$t('latest_update', {version: statusData.version})"></span>
|
||||
</q-banner>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<q-table
|
||||
dense
|
||||
flat
|
||||
:data="statusData.notifications"
|
||||
:columns="statusDataTable.columns"
|
||||
:no-data-label="$t('no_notifications')"
|
||||
>
|
||||
<template v-slot:header="props">
|
||||
<q-tr :props="props">
|
||||
<q-th auto-width> </q-th>
|
||||
<q-th v-for="col in props.cols" :key="col.name" :props="props"
|
||||
>{{ col.label }}</q-th
|
||||
>
|
||||
</q-tr>
|
||||
</template>
|
||||
<template v-slot:body="props">
|
||||
<q-tr :props="props">
|
||||
<q-td auto-width class="text-center">
|
||||
<q-icon
|
||||
v-if="props.row.type === 'update'"
|
||||
size="18px"
|
||||
name="update"
|
||||
color="green"
|
||||
></q-icon>
|
||||
<q-icon
|
||||
v-if="props.row.type === 'warning'"
|
||||
size="18px"
|
||||
name="warning"
|
||||
color="red"
|
||||
></q-icon>
|
||||
</q-td>
|
||||
<q-td auto-width key="date" :props="props">
|
||||
{{ formatDate(props.row.date) }}
|
||||
</q-td>
|
||||
<q-td key="message" :props="props"
|
||||
>{{ props.row.message }}
|
||||
<a
|
||||
v-if="props.row.link"
|
||||
target="_blank"
|
||||
:href="props.row.link"
|
||||
v-text="$t('more')"
|
||||
></a
|
||||
></q-td>
|
||||
</q-tr>
|
||||
</template>
|
||||
</q-table>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
{% endraw %}
|
||||
@@ -54,6 +54,32 @@
|
||||
<br />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 col-md-6">
|
||||
<p>Allowed currencies</p>
|
||||
<q-select
|
||||
filled
|
||||
v-model="formData.lnbits_allowed_currencies"
|
||||
multiple
|
||||
hint="Limit the number of available fiat currencies"
|
||||
label="Allowed currencies"
|
||||
:options="{{ currencies | safe }}"
|
||||
></q-select>
|
||||
<br />
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<p>Default Accounting Currency</p>
|
||||
<q-select
|
||||
filled
|
||||
v-model="formData.lnbits_default_accounting_currency"
|
||||
clearable
|
||||
hint="Default currency for accounting"
|
||||
label="Currency"
|
||||
:options="formData.lnbits_allowed_currencies?.length ? formData.lnbits_allowed_currencies : {{ currencies }}"
|
||||
></q-select>
|
||||
<br />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 col-md-6">
|
||||
<p>Admin Extensions</p>
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
v-model="formData.lnbits_site_description"
|
||||
filled
|
||||
type="textarea"
|
||||
hint="Use plain text or raw HTML"
|
||||
hint="Use plain text, Markdown, or raw HTML"
|
||||
/>
|
||||
</div>
|
||||
<br />
|
||||
|
||||
@@ -88,6 +88,12 @@
|
||||
@update="val => tab = val.name"
|
||||
></q-tab>
|
||||
|
||||
<q-tab
|
||||
name="security"
|
||||
:label="$t('security')"
|
||||
@update="val => tab = val.name"
|
||||
></q-tab>
|
||||
|
||||
<q-tab
|
||||
name="theme"
|
||||
:label="$t('theme')"
|
||||
@@ -101,7 +107,8 @@
|
||||
<q-tab-panels v-model="tab" animated>
|
||||
{% include "admin/_tab_funding.html" %} {% include
|
||||
"admin/_tab_users.html" %} {% include "admin/_tab_server.html" %} {%
|
||||
include "admin/_tab_theme.html" %}
|
||||
include "admin/_tab_security.html" %} {% include
|
||||
"admin/_tab_theme.html" %}
|
||||
</q-tab-panels>
|
||||
</q-form>
|
||||
</q-card>
|
||||
@@ -164,6 +171,8 @@
|
||||
data: function () {
|
||||
return {
|
||||
settings: {},
|
||||
logs: [],
|
||||
serverlogEnabled: false,
|
||||
lnbits_theme_options: [
|
||||
'classic',
|
||||
'bitcoin',
|
||||
@@ -175,10 +184,30 @@
|
||||
'monochrome',
|
||||
'salvador'
|
||||
],
|
||||
auditData: {},
|
||||
statusData: {},
|
||||
statusDataTable: {
|
||||
columns: [
|
||||
{
|
||||
name: 'date',
|
||||
align: 'left',
|
||||
label: this.$t('date'),
|
||||
field: 'date'
|
||||
},
|
||||
{
|
||||
name: 'message',
|
||||
align: 'left',
|
||||
label: this.$t('memo'),
|
||||
field: 'message'
|
||||
}
|
||||
]
|
||||
},
|
||||
formData: {},
|
||||
formAddAdmin: '',
|
||||
formAddUser: '',
|
||||
formAddExtensionsManifest: '',
|
||||
formAllowedIPs: '',
|
||||
formBlockedIPs: '',
|
||||
isSuperUser: false,
|
||||
wallet: {},
|
||||
cancel: {},
|
||||
@@ -187,6 +216,21 @@
|
||||
},
|
||||
tab: 'funding',
|
||||
needsRestart: false,
|
||||
prettyFunding: new Map([
|
||||
['VoidWallet', 'Void Wallet'],
|
||||
['FakeWallet', 'Fake Wallet'],
|
||||
['CoreLightningWallet', 'Core Lightning'],
|
||||
['CoreLightningRestWallet', 'Core Lightning'],
|
||||
['LndRestWallet', 'Lightning Network Daemon (LND Rest)'],
|
||||
['LndWallet', 'Lightning Network Daemon (LND)'],
|
||||
['LnTipsWallet', 'LN.Tips'],
|
||||
['LNPayWallet', 'LN Pay'],
|
||||
['EclairWallet', 'Eclair (ACINQ)'],
|
||||
['LNbitsWallet', 'LNBits'],
|
||||
['OpenNodeWallet', 'OpenNode'],
|
||||
['ClicheWallet', 'Cliche (NBD)'],
|
||||
['SparkWallet', 'Spark']
|
||||
]),
|
||||
funding_sources: new Map([
|
||||
['VoidWallet', null],
|
||||
[
|
||||
@@ -207,6 +251,23 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
[
|
||||
'CoreLightningRestWallet',
|
||||
{
|
||||
corelightning_rest_url: {
|
||||
value: null,
|
||||
label: 'Endpoint'
|
||||
},
|
||||
corelightning_rest_cert: {
|
||||
value: null,
|
||||
label: 'Certificate'
|
||||
},
|
||||
corelightning_rest_macaroon: {
|
||||
value: null,
|
||||
label: 'Macaroon'
|
||||
}
|
||||
}
|
||||
],
|
||||
[
|
||||
'LndRestWallet',
|
||||
{
|
||||
@@ -347,13 +408,20 @@
|
||||
])
|
||||
}
|
||||
},
|
||||
created: function () {
|
||||
created() {
|
||||
this.getSettings()
|
||||
this.getAudit()
|
||||
this.balance = +'{{ balance|safe }}'
|
||||
},
|
||||
computed: {
|
||||
lnbitsVersion() {
|
||||
return LNBITS_VERSION
|
||||
},
|
||||
checkChanges() {
|
||||
return !_.isEqual(this.settings, this.formData)
|
||||
},
|
||||
updateAvailable() {
|
||||
return LNBITS_VERSION !== this.statusData.version
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -364,7 +432,6 @@
|
||||
//admin_users = [...admin_users, addUser]
|
||||
this.formData.lnbits_admin_users = [...admin_users, addUser]
|
||||
this.formAddAdmin = ''
|
||||
//console.log(this.checkChanges)
|
||||
}
|
||||
},
|
||||
removeAdminUser(user) {
|
||||
@@ -406,6 +473,68 @@
|
||||
m => m !== manifest
|
||||
)
|
||||
},
|
||||
async toggleServerLog() {
|
||||
this.serverlogEnabled = !this.serverlogEnabled
|
||||
if (this.serverlogEnabled) {
|
||||
const wsProto = location.protocol !== 'http:' ? 'wss://' : 'ws://'
|
||||
const digestHex = await LNbits.utils.digestMessage(this.g.user.id)
|
||||
const localUrl =
|
||||
wsProto +
|
||||
document.domain +
|
||||
':' +
|
||||
location.port +
|
||||
'/api/v1/ws/' +
|
||||
digestHex
|
||||
this.ws = new WebSocket(localUrl)
|
||||
this.ws.addEventListener('message', async ({data}) => {
|
||||
this.logs.push(data.toString())
|
||||
const scrollArea = this.$refs.logScroll
|
||||
if (scrollArea) {
|
||||
const scrollTarget = scrollArea.getScrollTarget()
|
||||
const duration = 0
|
||||
scrollArea.setScrollPosition(scrollTarget.scrollHeight, duration)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
this.ws.close()
|
||||
}
|
||||
},
|
||||
addAllowedIPs() {
|
||||
const allowedIPs = this.formAllowedIPs.trim()
|
||||
const allowed_ips = this.formData.lnbits_allowed_ips
|
||||
if (
|
||||
allowedIPs &&
|
||||
allowedIPs.length &&
|
||||
!allowed_ips.includes(allowedIPs)
|
||||
) {
|
||||
this.formData.lnbits_allowed_ips = [...allowed_ips, allowedIPs]
|
||||
this.formAllowedIPs = ''
|
||||
}
|
||||
},
|
||||
removeAllowedIPs(allowed_ip) {
|
||||
const allowed_ips = this.formData.lnbits_allowed_ips
|
||||
this.formData.lnbits_allowed_ips = allowed_ips.filter(
|
||||
a => a !== allowed_ip
|
||||
)
|
||||
},
|
||||
addBlockedIPs() {
|
||||
const blockedIPs = this.formBlockedIPs.trim()
|
||||
const blocked_ips = this.formData.lnbits_blocked_ips
|
||||
if (
|
||||
blockedIPs &&
|
||||
blockedIPs.length &&
|
||||
!blocked_ips.includes(blockedIPs)
|
||||
) {
|
||||
this.formData.lnbits_blocked_ips = [...blocked_ips, blockedIPs]
|
||||
this.formBlockedIPs = ''
|
||||
}
|
||||
},
|
||||
removeBlockedIPs(blocked_ip) {
|
||||
const blocked_ips = this.formData.lnbits_blocked_ips
|
||||
this.formData.lnbits_blocked_ips = blocked_ips.filter(
|
||||
b => b !== blocked_ip
|
||||
)
|
||||
},
|
||||
restartServer() {
|
||||
LNbits.api
|
||||
.request('GET', '/admin/api/v1/restart/?usr=' + this.g.user.id)
|
||||
@@ -449,12 +578,43 @@
|
||||
this.settings.lnbits_allowed_funding_sources.map(f => {
|
||||
let opts = this.funding_sources.get(f)
|
||||
if (!opts) return
|
||||
|
||||
Object.keys(opts).forEach(e => {
|
||||
opts[e].value = this.settings[e]
|
||||
})
|
||||
})
|
||||
},
|
||||
formatDate(date) {
|
||||
return moment(date * 1000).fromNow()
|
||||
},
|
||||
getNotifications() {
|
||||
if (this.settings.lnbits_notifications) {
|
||||
axios
|
||||
.get(this.settings.lnbits_status_manifest)
|
||||
.then(response => {
|
||||
this.statusData = response.data
|
||||
})
|
||||
.catch(error => {
|
||||
this.formData.lnbits_notifications = false
|
||||
error.response.data = {}
|
||||
error.response.data.message = 'Could not fetch status manifest.'
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
}
|
||||
},
|
||||
getAudit() {
|
||||
LNbits.api
|
||||
.request(
|
||||
'GET',
|
||||
'/admin/api/v1/audit/?usr=' + this.g.user.id,
|
||||
this.g.user.wallets[0].adminkey
|
||||
)
|
||||
.then(response => {
|
||||
this.auditData = response.data
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
getSettings() {
|
||||
LNbits.api
|
||||
.request(
|
||||
@@ -465,8 +625,9 @@
|
||||
.then(response => {
|
||||
this.isSuperUser = response.data.is_super_user || false
|
||||
this.settings = response.data
|
||||
this.formData = _.clone(this.settings)
|
||||
this.formData = {...this.settings}
|
||||
this.updateFundingData()
|
||||
this.getNotifications()
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
@@ -487,13 +648,17 @@
|
||||
.then(response => {
|
||||
this.needsRestart =
|
||||
this.settings.lnbits_backend_wallet_class !==
|
||||
this.formData.lnbits_backend_wallet_class
|
||||
this.formData.lnbits_backend_wallet_class ||
|
||||
this.settings.lnbits_killswitch !==
|
||||
this.formData.lnbits_killswitch
|
||||
this.settings = this.formData
|
||||
this.formData = _.clone(this.settings)
|
||||
this.updateFundingData()
|
||||
this.$q.notify({
|
||||
type: 'positive',
|
||||
message: 'Success! Settings changed!',
|
||||
message: `Success! Settings changed! ${
|
||||
this.needsRestart ? 'Restart required!' : ''
|
||||
}`,
|
||||
icon: null
|
||||
})
|
||||
})
|
||||
|
||||
@@ -108,7 +108,8 @@
|
||||
class="text-subtitle2 gt-sm"
|
||||
style="font-size: 11px; height: 34px"
|
||||
>
|
||||
{{ extension.shortDescription }}
|
||||
{{ extension.shortDescription ||
|
||||
extension.installedRelease?.description }}
|
||||
</div>
|
||||
<div class="text-subtitle1 lt-md q-mt-sm q-mb-xs">
|
||||
{{ extension.name }}
|
||||
@@ -164,7 +165,7 @@
|
||||
<q-toggle
|
||||
v-if="extension.isAvailable && extension.isInstalled && g.user.admin"
|
||||
:label="extension.isActive ? $t('activated'): $t('deactivated') "
|
||||
color="secodary"
|
||||
color="secondary"
|
||||
style="max-height: 21px"
|
||||
v-model="extension.isActive"
|
||||
@input="toggleExtension(extension)"
|
||||
@@ -184,7 +185,7 @@
|
||||
flat
|
||||
color="primary"
|
||||
type="a"
|
||||
:href="[extension.id, '?usr=', g.user.id].join('')"
|
||||
:href="[extension.id, '/?usr=', g.user.id].join('')"
|
||||
>{%raw%}{{ $t('open') }}{%endraw%}</q-btn
|
||||
>
|
||||
<q-btn
|
||||
@@ -198,8 +199,11 @@
|
||||
>
|
||||
{%raw%}{{ $t('disable') }}{%endraw%}</q-btn
|
||||
>
|
||||
<q-badge v-if="extension.isAdminOnly && !user.admin">
|
||||
{%raw%}{{ $t('admin_only') }}{%endraw%}
|
||||
</q-badge>
|
||||
<q-btn
|
||||
v-if="extension.isInstalled && !user.extensions.includes(extension.id) && extension.isActive"
|
||||
v-else-if="extension.isInstalled && extension.isActive && !user.extensions.includes(extension.id)"
|
||||
flat
|
||||
color="primary"
|
||||
type="a"
|
||||
@@ -255,6 +259,17 @@
|
||||
{%raw%}{{ $t('confirm_continue') }}{%endraw%}
|
||||
</p>
|
||||
|
||||
<div class="row q-mt-lg">
|
||||
<q-checkbox
|
||||
v-model="uninstallAndDropDb"
|
||||
value="false"
|
||||
label="Cleanup database tables"
|
||||
>
|
||||
<q-tooltip class="bg-grey-8" anchor="bottom left" self="top left">
|
||||
{%raw%}{{ $t('extension_db_drop_info') }}{%endraw%}
|
||||
</q-tooltip>
|
||||
</q-checkbox>
|
||||
</div>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn outline color="grey" @click="uninstallExtension()"
|
||||
>{%raw%}{{ $t('uninstall_confirm') }}{%endraw%}</q-btn
|
||||
@@ -266,6 +281,32 @@
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<q-dialog v-model="showDropDbDialog">
|
||||
<q-card v-if="selectedExtension" class="q-pa-lg">
|
||||
<h6 class="q-my-md text-primary">{%raw%}{{ $t('warning') }}{%endraw%}</h6>
|
||||
<p>{%raw%}{{ $t('extension_db_drop_warning') }}{%endraw%} <br /></p>
|
||||
<q-input
|
||||
v-model="dropDbExtensionId"
|
||||
:label="selectedExtension.id"
|
||||
></q-input>
|
||||
<br />
|
||||
<p>{%raw%}{{ $t('confirm_continue') }}{%endraw%}</p>
|
||||
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
:disable="dropDbExtensionId !== selectedExtension.id"
|
||||
outline
|
||||
color="red"
|
||||
@click="dropExtensionDb()"
|
||||
>{%raw%}{{ $t('confirm') }}{%endraw%}</q-btn
|
||||
>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
|
||||
>{%raw%}{{ $t('cancel') }}{%endraw%}</q-btn
|
||||
>
|
||||
</div>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<q-dialog v-model="showUpgradeDialog">
|
||||
<q-card class="q-pa-lg lnbits__dialog-card">
|
||||
<q-card-section>
|
||||
@@ -300,6 +341,11 @@
|
||||
{%raw%}{{ $t('repository') }}{%endraw%}
|
||||
<br />
|
||||
<small v-text="repoName"></small>
|
||||
<q-tooltip
|
||||
><span
|
||||
v-text="selectedExtensionRepos[repoName].repo"
|
||||
></span
|
||||
></q-tooltip>
|
||||
</div>
|
||||
<div class="col-2"></div>
|
||||
</div>
|
||||
@@ -389,6 +435,13 @@
|
||||
>
|
||||
{%raw%}{{ $t('uninstall') }}{%endraw%}</q-btn
|
||||
>
|
||||
<q-btn
|
||||
v-else-if="selectedExtension?.hasDatabaseTables"
|
||||
@click="showDropDb()"
|
||||
flat
|
||||
color="red"
|
||||
:label="$t('drop_db')"
|
||||
></q-btn>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto">
|
||||
{%raw%}{{ $t('close') }}{%endraw%}</q-btn
|
||||
>
|
||||
@@ -407,8 +460,11 @@
|
||||
filteredExtensions: null,
|
||||
showUninstallDialog: false,
|
||||
showUpgradeDialog: false,
|
||||
showDropDbDialog: false,
|
||||
dropDbExtensionId: '',
|
||||
selectedExtension: null,
|
||||
selectedExtensionRepos: null,
|
||||
uninstallAndDropDb: false,
|
||||
maxStars: 5,
|
||||
user: null
|
||||
}
|
||||
@@ -497,6 +553,40 @@
|
||||
this.filteredExtensions = this.extensions.concat([])
|
||||
this.handleTabChanged('installed')
|
||||
this.tab = 'installed'
|
||||
this.$q.notify({
|
||||
type: 'positive',
|
||||
message: 'Extension uninstalled!'
|
||||
})
|
||||
if (this.uninstallAndDropDb) {
|
||||
this.showDropDb()
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
extension.inProgress = false
|
||||
})
|
||||
},
|
||||
|
||||
dropExtensionDb: async function () {
|
||||
const extension = this.selectedExtension
|
||||
this.showUpgradeDialog = false
|
||||
this.showDropDbDialog = false
|
||||
this.dropDbExtensionId = ''
|
||||
extension.inProgress = true
|
||||
LNbits.api
|
||||
.request(
|
||||
'DELETE',
|
||||
`/api/v1/extension/${extension.id}/db?usr=${this.g.user.id}`,
|
||||
this.g.user.wallets[0].adminkey
|
||||
)
|
||||
.then(response => {
|
||||
extension.installedRelease = null
|
||||
extension.inProgress = false
|
||||
extension.hasDatabaseTables = false
|
||||
this.$q.notify({
|
||||
type: 'positive',
|
||||
message: 'Extension DB deleted!'
|
||||
})
|
||||
})
|
||||
.catch(err => {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
@@ -525,6 +615,11 @@
|
||||
showUninstall: function () {
|
||||
this.showUpgradeDialog = false
|
||||
this.showUninstallDialog = true
|
||||
this.uninstallAndDropDb = false
|
||||
},
|
||||
|
||||
showDropDb: function () {
|
||||
this.showDropDbDialog = true
|
||||
},
|
||||
|
||||
showUpgrade: async function (extension) {
|
||||
@@ -541,7 +636,8 @@
|
||||
this.selectedExtensionRepos = data.reduce((repos, release) => {
|
||||
repos[release.source_repo] = repos[release.source_repo] || {
|
||||
releases: [],
|
||||
isInstalled: false
|
||||
isInstalled: false,
|
||||
repo: release.repo
|
||||
}
|
||||
release.inProgress = false
|
||||
release.error = null
|
||||
|
||||
@@ -61,7 +61,9 @@
|
||||
></q-btn>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else>{{SITE_DESCRIPTION | safe}}</p>
|
||||
<div v-else>
|
||||
<div v-html="formatDescription"></div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
@@ -124,6 +126,16 @@
|
||||
</a>
|
||||
</div>
|
||||
<div class="col q-pl-md">
|
||||
<a href="https://start9.com/">
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '/static/images/start9.png' : '/static/images/start9l.png'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<a href="https://getumbrel.com/">
|
||||
<q-img
|
||||
contain
|
||||
@@ -131,9 +143,7 @@
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<div class="col q-pl-md">
|
||||
<a href="https://mynodebtc.com">
|
||||
<q-img
|
||||
contain
|
||||
@@ -141,7 +151,9 @@
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col q-pl-md">
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<a href="https://github.com/shesek/spark-wallet">
|
||||
<q-img
|
||||
contain
|
||||
@@ -149,9 +161,7 @@
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<div class="col q-pl-md">
|
||||
<a href="https://voltage.cloud">
|
||||
<q-img
|
||||
contain
|
||||
@@ -159,7 +169,6 @@
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col q-pl-md"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -149,6 +149,7 @@
|
||||
{% raw %}
|
||||
<template v-slot:header="props">
|
||||
<q-tr :props="props">
|
||||
<q-th auto-width></q-th>
|
||||
<q-th v-for="col in props.cols" :key="col.name" :props="props"
|
||||
>{{ col.label }}</q-th
|
||||
>
|
||||
@@ -174,7 +175,7 @@
|
||||
</q-icon>
|
||||
</q-td>
|
||||
<q-td
|
||||
key="memo"
|
||||
key="time"
|
||||
:props="props"
|
||||
style="white-space: normal; word-break: break-all"
|
||||
>
|
||||
@@ -192,8 +193,12 @@
|
||||
</q-badge>
|
||||
{{ props.row.memo }}
|
||||
<br />
|
||||
<q-tooltip>{{ props.row.date }}</q-tooltip>
|
||||
<i> {{ props.row.dateFrom }}</i>
|
||||
|
||||
<i>
|
||||
{{ props.row.dateFrom }}<q-tooltip
|
||||
>{{ props.row.date }}</q-tooltip
|
||||
></i
|
||||
>
|
||||
</q-td>
|
||||
|
||||
{% endraw %}
|
||||
@@ -206,10 +211,17 @@
|
||||
parseFloat(String(props.row.fsat).replaceAll(",", "")) / 100
|
||||
}}
|
||||
</q-td>
|
||||
|
||||
<q-td auto-width key="amount" v-else :props="props">
|
||||
{{ props.row.fsat }}<br />
|
||||
<i>fee {{ props.row.fee }}</i>
|
||||
<i v-if="props.row.extra.wallet_fiat_currency">
|
||||
{{ props.row.extra.wallet_fiat_currency }} {{
|
||||
props.row.extra.wallet_fiat_amount }}
|
||||
<br />
|
||||
</i>
|
||||
<i v-if="props.row.extra.fiat_currency">
|
||||
{{ props.row.extra.fiat_currency }} {{
|
||||
props.row.extra.fiat_amount }}
|
||||
</i>
|
||||
</q-td>
|
||||
</q-tr>
|
||||
|
||||
@@ -323,7 +335,7 @@
|
||||
:options="{width:240}"
|
||||
></qrcode>
|
||||
</a>
|
||||
<p v-text="$('drain_funds_desc')"></p>
|
||||
<p v-text="$t('drain_funds_desc')"></p>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
@@ -356,23 +368,54 @@
|
||||
<div class="" style="max-width: 320px">
|
||||
<q-input
|
||||
filled
|
||||
v-model.trim="newName"
|
||||
label="Label"
|
||||
dense="dense"
|
||||
v-model.trim="update.name"
|
||||
label="Name"
|
||||
dense
|
||||
@update:model-value="(e) => console.log(e)"
|
||||
/>
|
||||
</div>
|
||||
<q-btn
|
||||
:disable="!newName.length"
|
||||
:disable="!update.name.length"
|
||||
unelevated
|
||||
class="q-mt-sm"
|
||||
color="primary"
|
||||
:label="$t('update_name')"
|
||||
@click="updateWalletName()"
|
||||
@click="updateWallet({ name: update.name })"
|
||||
></q-btn>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-separator></q-separator>
|
||||
<q-expansion-item
|
||||
group="extras"
|
||||
icon="attach_money"
|
||||
:label="$t('fiat_tracking')"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<div style="max-width: 360px">
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
clearable
|
||||
v-model="update.currency"
|
||||
type="text"
|
||||
:label="$t('currency')"
|
||||
:options="receive.units.filter((u) => u !== 'sat')"
|
||||
></q-select>
|
||||
</div>
|
||||
<q-btn
|
||||
:disable="!update.name.length"
|
||||
unelevated
|
||||
class="q-mt-sm"
|
||||
color="primary"
|
||||
:label="$t('update_currency')"
|
||||
@click="updateWallet({ currency: update.currency || '' })"
|
||||
></q-btn>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
|
||||
<q-separator></q-separator>
|
||||
<q-expansion-item
|
||||
group="extras"
|
||||
@@ -385,7 +428,7 @@
|
||||
<q-btn
|
||||
unelevated
|
||||
color="red-10"
|
||||
@click="deleteWallet('{{ wallet.id }}', '{{ user.id }}')"
|
||||
@click="deleteWallet()"
|
||||
:label="$t('delete_wallet')"
|
||||
></q-btn>
|
||||
</q-card-section>
|
||||
@@ -423,7 +466,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<q-dialog v-model="receive.show" @hide="closeReceiveDialog">
|
||||
<q-dialog v-model="receive.show">
|
||||
{% raw %}
|
||||
<q-card
|
||||
v-if="!receive.paymentReq"
|
||||
@@ -438,7 +481,7 @@
|
||||
filled
|
||||
dense
|
||||
v-model.number="receive.data.amount"
|
||||
:label="$('amount')+' ({{LNBITS_DENOMINATION}}) *'"
|
||||
:label="$t('amount')+' ({{LNBITS_DENOMINATION}}) *'"
|
||||
mask="#.##"
|
||||
fill-mask="0"
|
||||
reverse-fill-mask
|
||||
@@ -706,6 +749,7 @@
|
||||
v-model.trim="parse.data.request"
|
||||
type="textarea"
|
||||
:label="$t('paste_invoice_label')"
|
||||
ref="textArea"
|
||||
>
|
||||
</q-input>
|
||||
<div class="row q-mt-lg">
|
||||
@@ -716,6 +760,17 @@
|
||||
type="submit"
|
||||
:label="$t('read')"
|
||||
></q-btn>
|
||||
<q-icon
|
||||
name="content_paste"
|
||||
color="grey"
|
||||
class="q-mt-xs q-ml-sm q-mr-auto"
|
||||
v-if="parse.copy.show"
|
||||
@click="pasteToTextArea"
|
||||
><q-tooltip
|
||||
>{% raw %}{{$t('paste_from_clipboard')}}{% endraw
|
||||
%}</q-tooltip
|
||||
></q-icon
|
||||
>
|
||||
<q-btn
|
||||
v-close-popup
|
||||
flat
|
||||
@@ -772,6 +827,19 @@
|
||||
<q-dialog v-model="paymentsChart.show">
|
||||
<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>
|
||||
@@ -783,10 +851,14 @@
|
||||
indicator-color="transparent"
|
||||
align="justify"
|
||||
>
|
||||
<q-tab icon="file_download" label="Receive" @click="showReceiveDialog">
|
||||
<q-tab
|
||||
icon="file_download"
|
||||
@click="showReceiveDialog"
|
||||
:label="$t('receive')"
|
||||
>
|
||||
</q-tab>
|
||||
|
||||
<q-tab @click="showParseDialog" icon="file_upload" label="Send">
|
||||
<q-tab @click="showParseDialog" icon="file_upload" :label="$t('send')">
|
||||
</q-tab>
|
||||
<q-btn
|
||||
round
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
<q-tab-panel name="channels">
|
||||
<q-dialog v-model="connectPeerDialog.show">
|
||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<q-form class="q-gutter-md">
|
||||
<q-input
|
||||
dense
|
||||
type="text"
|
||||
filled
|
||||
v-model="connectPeerDialog.data.uri"
|
||||
label="Node URI"
|
||||
hint="pubkey@host:port"
|
||||
></q-input>
|
||||
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
:label="$t('connect')"
|
||||
color="primary"
|
||||
@click="connectPeer"
|
||||
></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>
|
||||
|
||||
<q-dialog v-model="openChannelDialog.show">
|
||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<q-form class="q-gutter-md">
|
||||
<q-input
|
||||
dense
|
||||
type="text"
|
||||
filled
|
||||
v-model="openChannelDialog.data.peer_id"
|
||||
label="Peer ID"
|
||||
></q-input>
|
||||
<q-input
|
||||
dense
|
||||
type="number"
|
||||
filled
|
||||
v-model.number="openChannelDialog.data.funding_amount"
|
||||
label="Funding Amount"
|
||||
></q-input>
|
||||
<q-expansion-item icon="warning" label="Advanced">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<div class="column q-gutter-md">
|
||||
<q-input
|
||||
dense
|
||||
type="number"
|
||||
filled
|
||||
v-model.number="openChannelDialog.data.push_amount"
|
||||
label="Push Amount"
|
||||
hint="This gifts sats to the other side!"
|
||||
></q-input>
|
||||
|
||||
<q-input
|
||||
dense
|
||||
type="number"
|
||||
filled
|
||||
v-model.number="openChannelDialog.data.fee_rate"
|
||||
label="Fee Rate"
|
||||
></q-input>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
:label="$t('open')"
|
||||
color="primary"
|
||||
@click="openChannel"
|
||||
></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>
|
||||
|
||||
<q-dialog v-model="closeChannelDialog.show">
|
||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<q-form class="q-gutter-md">
|
||||
<div>
|
||||
<q-checkbox v-model="closeChannelDialog.data.force" label="Force" />
|
||||
</div>
|
||||
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
:label="$t('close')"
|
||||
color="primary"
|
||||
@click="closeChannel"
|
||||
></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>
|
||||
|
||||
<q-card-section class="q-pa-none">
|
||||
<div class="row q-col-gutter-lg">
|
||||
<div class="col-12 col-xl-6">
|
||||
<q-card class="full-height">
|
||||
<q-card-section class="q-gutter-y-sm">
|
||||
<div class="row items-center q-mt-none q-gutter-x-sm no-wrap">
|
||||
<div class="col-grow text-h6 q-my-none col-grow">Channels</div>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
clearable
|
||||
v-model="channels.filter"
|
||||
placeholder="Search..."
|
||||
class="col-auto"
|
||||
></q-input>
|
||||
<q-select
|
||||
dense
|
||||
size="sm"
|
||||
style="min-width: 200px"
|
||||
filled
|
||||
multiple
|
||||
clearable
|
||||
v-model="stateFilters"
|
||||
:options="this.states"
|
||||
class="col-auto"
|
||||
></q-select>
|
||||
<q-btn
|
||||
unelevated
|
||||
color="primary"
|
||||
size="md"
|
||||
class="col-auto"
|
||||
@click="showOpenChannelDialog()"
|
||||
>
|
||||
Open channel
|
||||
</q-btn>
|
||||
</div>
|
||||
{% raw %}
|
||||
<div>
|
||||
<div class="text-subtitle1 col-grow">Total</div>
|
||||
<lnbits-channel-balance
|
||||
:balance="this.totalBalance"
|
||||
></lnbits-channel-balance>
|
||||
</div>
|
||||
<q-separator></q-separator>
|
||||
<q-table
|
||||
dense
|
||||
flat
|
||||
:data="this.filteredChannels"
|
||||
:filter="channels.filter"
|
||||
no-data-label="No channels opened"
|
||||
>
|
||||
<template v-slot:header="props">
|
||||
<q-tr :props="props" style="height: 0"> </q-tr>
|
||||
</template>
|
||||
<template v-slot:body="props">
|
||||
<q-tr :props="props">
|
||||
<div class="q-pb-sm">
|
||||
<div class="row items-center q-gutter-sm">
|
||||
<div class="text-subtitle1 col-grow">
|
||||
{{props.row.name}}
|
||||
</div>
|
||||
<div class="text-caption" v-if="props.row.short_id">
|
||||
{{ props.row.short_id }}
|
||||
<q-btn
|
||||
size="xs"
|
||||
flat
|
||||
dense
|
||||
icon="content_paste"
|
||||
@click="copyText(props.row.short_id)"
|
||||
></q-btn>
|
||||
</div>
|
||||
<q-badge
|
||||
rounded
|
||||
:color="states.find(s => s.value == props.row.state)?.color"
|
||||
>
|
||||
{{ states.find(s => s.value == props.row.state)?.label
|
||||
}}
|
||||
</q-badge>
|
||||
<q-btn
|
||||
:disable='props.row.state !== "active"'
|
||||
flat
|
||||
dense
|
||||
size="md"
|
||||
@click="showCloseChannelDialog(props.row)"
|
||||
icon="cancel"
|
||||
color="pink"
|
||||
></q-btn>
|
||||
</div>
|
||||
<lnbits-channel-balance
|
||||
:balance="props.row.balance"
|
||||
:color="props.row.color"
|
||||
></lnbits-channel-balance>
|
||||
</div>
|
||||
</q-tr>
|
||||
</template>
|
||||
</q-table>
|
||||
|
||||
{% endraw %}
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
<div class="col-12 col-xl-6">
|
||||
<q-card class="full-height">
|
||||
<q-card-section class="column q-gutter-y-sm">
|
||||
{% raw %}
|
||||
<div
|
||||
class="row items-center q-mt-none justify-between q-gutter-x-md no-wrap"
|
||||
>
|
||||
<div class="col-grow text-h6 q-my-none">Peers</div>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
clearable
|
||||
v-model="peers.filter"
|
||||
placeholder="Search..."
|
||||
class="col-auto"
|
||||
></q-input>
|
||||
<q-btn
|
||||
class="col-auto"
|
||||
color="primary"
|
||||
@click="connectPeerDialog.show = true"
|
||||
>
|
||||
Connect Peer
|
||||
</q-btn>
|
||||
</div>
|
||||
<q-separator></q-separator>
|
||||
<q-table
|
||||
dense
|
||||
flat
|
||||
:data="peers.data"
|
||||
:filter="peers.filter"
|
||||
no-data-label="No transactions made yet"
|
||||
>
|
||||
<template v-slot:header="props">
|
||||
<q-tr :props="props" style="height: 0"> </q-tr>
|
||||
</template>
|
||||
<template v-slot:body="props">
|
||||
<q-tr :props="props">
|
||||
<div class="row no-wrap items-center q-gutter-sm">
|
||||
<div class="q-my-sm col-grow">
|
||||
<div class="text-subtitle1 text-bold">
|
||||
{{ props.row.alias }}
|
||||
</div>
|
||||
<div class="row items-center q-gutter-sm">
|
||||
<q-badge
|
||||
:style="`background-color: #${props.row.color}`"
|
||||
class="text-bold"
|
||||
>
|
||||
#{{ props.row.color }}
|
||||
</q-badge>
|
||||
<div class="text-bold">
|
||||
{{ shortenNodeId(props.row.id) }}
|
||||
</div>
|
||||
<q-btn
|
||||
size="xs"
|
||||
flat
|
||||
dense
|
||||
icon="content_paste"
|
||||
@click="copyText(props.row.id)"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
size="xs"
|
||||
flat
|
||||
dense
|
||||
icon="qr_code"
|
||||
@click="showNodeInfoDialog(props.row)"
|
||||
></q-btn>
|
||||
</div>
|
||||
</div>
|
||||
<q-btn
|
||||
unelevated
|
||||
color="primary"
|
||||
@click="showOpenChannelDialog(props.row.id)"
|
||||
>
|
||||
Open channel
|
||||
</q-btn>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
size="md"
|
||||
@click="disconnectPeer(props.row.id)"
|
||||
icon="cancel"
|
||||
color="pink"
|
||||
></q-btn>
|
||||
</div>
|
||||
</q-tr>
|
||||
</template>
|
||||
</q-table>
|
||||
|
||||
{% endraw %}
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-tab-panel>
|
||||
@@ -0,0 +1,70 @@
|
||||
<q-tab-panel name="dashboard">
|
||||
<q-card-section class="q-pa-none">
|
||||
{% raw %}
|
||||
<lnbits-node-info :info="this.info"></lnbits-node-info>
|
||||
<div class="row q-col-gutter-lg q-mt-sm">
|
||||
<div class="col-12 col-md-8 q-gutter-y-md">
|
||||
<div class="row q-col-gutter-md q-pb-lg">
|
||||
<div class="col-12 col-md-6 col-xl-4 q-gutter-y-md">
|
||||
<lnbits-stat
|
||||
title="Total Capacity"
|
||||
:msat="this.channel_stats.total_capacity"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-12 col-md-6 col-xl-4 q-gutter-y-md">
|
||||
<lnbits-stat title="Balance" :msat="this.info.balance_msat" />
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-6 col-xl-4 q-gutter-y-md">
|
||||
<lnbits-stat
|
||||
title="Fees collected"
|
||||
:msat="this.info.fees?.total_msat"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-6 col-xl-4 q-gutter-y-md">
|
||||
<lnbits-stat
|
||||
title="Onchain Balance"
|
||||
:btc="this.info.onchain_balance_sat / 100000000"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-6 col-xl-4 q-gutter-y-md">
|
||||
<lnbits-stat
|
||||
title="Onchain Confirmed"
|
||||
:btc="this.info.onchain_confirmed_sat / 100000000"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-12 col-md-6 col-xl-4 q-gutter-y-md">
|
||||
<lnbits-stat title="Peers" :amount="this.info.num_peers" />
|
||||
</div>
|
||||
<div class="col-12 col-md-6 col-xl-4 q-gutter-y-md">
|
||||
<lnbits-stat
|
||||
:title="$t('avg_channel_size')"
|
||||
:msat="this.channel_stats.avg_size"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-12 col-md-6 col-xl-4 q-gutter-y-md">
|
||||
<lnbits-stat
|
||||
:title="$t('biggest_channel_size')"
|
||||
:msat="this.channel_stats.biggest_size"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-12 col-md-6 col-xl-4 q-gutter-y-md">
|
||||
<lnbits-stat
|
||||
:title="$t('smallest_channel_size')"
|
||||
:msat="this.channel_stats.smallest_size"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column col-12 col-md-4 q-gutter-y-md">
|
||||
<lnbits-node-ranks :ranks="this.ranks"></lnbits-node-ranks>
|
||||
<lnbits-channel-stats
|
||||
:stats="this.channel_stats"
|
||||
></lnbits-channel-stats>
|
||||
</div>
|
||||
</div>
|
||||
{% endraw %}
|
||||
</q-card-section>
|
||||
</q-tab-panel>
|
||||
@@ -0,0 +1,316 @@
|
||||
<q-tab-panel name="transactions">
|
||||
<q-card-section class="q-pa-none">
|
||||
<q-dialog v-model="transactionDetailsDialog.show">
|
||||
<q-card class="my-card">
|
||||
<q-card-section>
|
||||
{% raw %}
|
||||
<div class="text-center q-mb-lg">
|
||||
<div
|
||||
v-if="transactionDetailsDialog.data.isIn && transactionDetailsDialog.data.pending"
|
||||
>
|
||||
<q-icon
|
||||
size="18px"
|
||||
:name="'call_received'"
|
||||
:color="'green'"
|
||||
></q-icon>
|
||||
<span v-text="$t('payment_received')"></span>
|
||||
</div>
|
||||
<div class="row q-my-md">
|
||||
<div class="col-3"><b v-text="$t('payment_hash')"></b>:</div>
|
||||
<div class="col-9 text-wrap mono">
|
||||
{{ transactionDetailsDialog.data.payment_hash }}
|
||||
<q-icon
|
||||
name="content_copy"
|
||||
@click="copyText(transactionDetailsDialog.data.payment_hash)"
|
||||
size="1em"
|
||||
color="grey"
|
||||
class="q-mb-xs cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="row"
|
||||
v-if="transactionDetailsDialog.data.preimage && !transactionDetailsDialog.data.pending"
|
||||
>
|
||||
<div class="col-3"><b v-text="$t('payment_proof')"></b>:</div>
|
||||
<div class="col-9 text-wrap mono">
|
||||
{{ transactionDetailsDialog.data.preimage }}
|
||||
<q-icon
|
||||
name="content_copy"
|
||||
@click="copyText(transactionDetailsDialog.data.preimage)"
|
||||
size="1em"
|
||||
color="grey"
|
||||
class="q-mb-xs cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="transactionDetailsDialog.data.bolt11"
|
||||
class="text-center q-mb-lg"
|
||||
>
|
||||
<a :href="'lightning:' + transactionDetailsDialog.data.bolt11">
|
||||
<q-responsive :ratio="1" class="q-mx-xl">
|
||||
<qrcode
|
||||
:value="'lightning:' + transactionDetailsDialog.data.bolt11.toUpperCase()"
|
||||
:options="{width: 340}"
|
||||
class="rounded-borders"
|
||||
></qrcode>
|
||||
</q-responsive>
|
||||
</a>
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
@click="copyText(transactionDetailsDialog.data.bolt11)"
|
||||
:label="$t('copy_invoice')"
|
||||
class="q-mt-sm"
|
||||
></q-btn>
|
||||
</div>
|
||||
</div>
|
||||
{% endraw %}
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<div class="row q-col-gutter-md q-pb-lg"></div>
|
||||
|
||||
<div class="row q-col-gutter-lg">
|
||||
<div class="col-12 col-lg-6 q-gutter-y-md">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<div class="row items-center no-wrap q-mb-sm">
|
||||
<div class="col text-h6 q-my-none">Payments</div>
|
||||
<q-input
|
||||
v-if="payments.length > 10"
|
||||
filled
|
||||
dense
|
||||
clearable
|
||||
v-model="paymentsTable.filter"
|
||||
debounce="300"
|
||||
placeholder="Search by tag, memo, amount"
|
||||
class="q-mb-md"
|
||||
>
|
||||
</q-input>
|
||||
</div>
|
||||
<q-table
|
||||
dense
|
||||
flat
|
||||
:data="paymentsTable.data"
|
||||
:columns="paymentsTable.columns"
|
||||
:pagination.sync="paymentsTable.pagination"
|
||||
row-key="payment_hash"
|
||||
no-data-label="No transactions made yet"
|
||||
:filter="paymentsTable.filter"
|
||||
@request="getPayments"
|
||||
>
|
||||
{% raw %}
|
||||
<template v-slot:body-cell-pending="props">
|
||||
<q-td auto-width class="text-center">
|
||||
<q-icon
|
||||
v-if="!props.row.pending"
|
||||
size="xs"
|
||||
name="call_made"
|
||||
color="green"
|
||||
@click="showTransactionDetailsDialog(props.row)"
|
||||
></q-icon>
|
||||
<q-icon
|
||||
v-else
|
||||
size="xs"
|
||||
name="settings_ethernet"
|
||||
color="grey"
|
||||
@click="showTransactionDetailsDialog(props.row)"
|
||||
>
|
||||
<q-tooltip>Pending</q-tooltip>
|
||||
</q-icon>
|
||||
<q-dialog v-model="props.row.expand" :props="props">
|
||||
<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">
|
||||
<qrcode
|
||||
:value="'lightning:' + props.row.bolt11.toUpperCase()"
|
||||
:options="{width: 340}"
|
||||
class="rounded-borders"
|
||||
></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>
|
||||
</q-td>
|
||||
</template>
|
||||
<template v-slot:body-cell-date="props">
|
||||
<q-td auto-width key="date" :props="props">
|
||||
<lnbits-date :ts="props.row.time"></lnbits-date>
|
||||
</q-td>
|
||||
</template>
|
||||
<template v-slot:body-cell-destination="props">
|
||||
<q-td auto-width key="destination">
|
||||
<div class="row items-center justify-between no-wrap">
|
||||
<q-badge
|
||||
:style="`background-color: #${props.row.destination?.color}`"
|
||||
class="text-bold"
|
||||
>
|
||||
{{ props.row.destination?.alias }}
|
||||
</q-badge>
|
||||
<div>
|
||||
<q-btn
|
||||
size="xs"
|
||||
flat
|
||||
dense
|
||||
icon="content_paste"
|
||||
@click="copyText(info.id)"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
size="xs"
|
||||
flat
|
||||
dense
|
||||
icon="qr_code"
|
||||
@click="showNodeInfoDialog(props.row.destination)"
|
||||
></q-btn>
|
||||
</div>
|
||||
</div>
|
||||
</q-td>
|
||||
</template>
|
||||
{% endraw %}
|
||||
</q-table>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
<div class="col-12 col-lg-6 q-gutter-y-md">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<div class="row items-center no-wrap q-mb-sm">
|
||||
<div class="col text-h6 q-my-none">Invoices</div>
|
||||
<q-input
|
||||
v-if="payments.length > 10"
|
||||
filled
|
||||
dense
|
||||
clearable
|
||||
v-model="paymentsTable.filter"
|
||||
debounce="300"
|
||||
placeholder="Search by tag, memo, amount"
|
||||
class="q-mb-md"
|
||||
>
|
||||
</q-input>
|
||||
</div>
|
||||
|
||||
<q-table
|
||||
dense
|
||||
flat
|
||||
:data="invoiceTable.data"
|
||||
:columns="invoiceTable.columns"
|
||||
:pagination.sync="invoiceTable.pagination"
|
||||
no-data-label="No transactions made yet"
|
||||
:filter="invoiceTable.filter"
|
||||
@request="getInvoices"
|
||||
>
|
||||
{% raw %}
|
||||
<template v-slot:body-cell-pending="props">
|
||||
<q-td auto-width class="text-center">
|
||||
<q-icon
|
||||
v-if="!props.row.pending"
|
||||
size="xs"
|
||||
name="call_received"
|
||||
color="green"
|
||||
@click="showTransactionDetailsDialog(props.row)"
|
||||
></q-icon>
|
||||
<q-icon
|
||||
v-else
|
||||
size="xs"
|
||||
name="settings_ethernet"
|
||||
color="grey"
|
||||
@click="showTransactionDetailsDialog(props.row)"
|
||||
>
|
||||
<q-tooltip>Pending</q-tooltip>
|
||||
</q-icon>
|
||||
</q-td>
|
||||
</template>
|
||||
|
||||
<template v-slot:body-cell-paid_at="props">
|
||||
<q-td auto-width :props="props">
|
||||
<lnbits-date
|
||||
v-if="props.row.paid_at"
|
||||
:ts="props.row.paid_at"
|
||||
></lnbits-date>
|
||||
</q-td>
|
||||
</template>
|
||||
|
||||
<template v-slot:body-cell-expiry="props">
|
||||
<q-td auto-width :props="props">
|
||||
<lnbits-date
|
||||
v-if="props.row.expiry"
|
||||
:ts="props.row.expiry"
|
||||
></lnbits-date>
|
||||
</q-td>
|
||||
</template>
|
||||
|
||||
{% endraw %}
|
||||
</q-table>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-tab-panel>
|
||||
@@ -0,0 +1,500 @@
|
||||
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
|
||||
%} {% block page %}
|
||||
|
||||
<q-dialog v-model="nodeInfoDialog.show">
|
||||
<lnbits-node-qrcode :info="nodeInfoDialog.data"></lnbits-node-qrcode>
|
||||
</q-dialog>
|
||||
|
||||
<div class="row q-col-gutter-md justify-center">
|
||||
<div class="col q-gutter-y-md">
|
||||
<q-card>
|
||||
<div class="q-pa-md">
|
||||
<div class="q-gutter-y-md">
|
||||
<q-tabs v-model="tab" active-color="primary" align="justify">
|
||||
<q-tab
|
||||
name="dashboard"
|
||||
:label="$t('dashboard')"
|
||||
@update="val => tab = val.name"
|
||||
></q-tab>
|
||||
<q-tab
|
||||
name="channels"
|
||||
:label="$t('channels')"
|
||||
@update="val => tab = val.name"
|
||||
></q-tab>
|
||||
<q-tab
|
||||
name="transactions"
|
||||
:label="$t('transactions')"
|
||||
@update="val => tab = val.name"
|
||||
></q-tab>
|
||||
</q-tabs>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<q-form name="settings_form" id="settings_form">
|
||||
<q-tab-panels v-model="tab" animated>
|
||||
{% include "node/_tab_dashboard.html" %} {% include
|
||||
"node/_tab_channels.html" %} {% include "node/_tab_transactions.html"
|
||||
%}
|
||||
</q-tab-panels>
|
||||
</q-form>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %} {% block scripts %} {{ window_vars(user) }}
|
||||
<script src="/core/static/js/node.js"></script>
|
||||
<script>
|
||||
Vue.component(VueQrcode.name, VueQrcode)
|
||||
Vue.use(VueQrcodeReader)
|
||||
|
||||
{% raw %}
|
||||
Vue.component('lnbits-stat',
|
||||
{
|
||||
props: ['title', 'amount', 'msat', 'btc'],
|
||||
computed: {
|
||||
value: function () {
|
||||
return this.amount ?? (this.btc ? LNbits.utils.formatSat(this.btc) : LNbits.utils.formatMsat(this.msat))
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<div class='text-overline text-primary'>
|
||||
{{ title }}
|
||||
</div>
|
||||
<div>
|
||||
<span class='text-h4 text-bold q-my-none'>{{ value }}</span>
|
||||
<span class='text-h5' v-if='msat != undefined'>sats</span>
|
||||
<span class='text-h5' v-if='btc != undefined'>BTC</span>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
`
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
Vue.component('lnbits-channel-balance',
|
||||
{
|
||||
props: ['balance', 'color'],
|
||||
methods: {
|
||||
formatMsat: function (msat) {
|
||||
return LNbits.utils.formatMsat(msat)
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div>
|
||||
<div class="row items-center justify-between">
|
||||
<span class="text-weight-thin">
|
||||
Local: {{ formatMsat(balance.local_msat) }}
|
||||
sats
|
||||
</span>
|
||||
<span class="text-weight-thin">
|
||||
Remote: {{ formatMsat(balance.remote_msat) }}
|
||||
sats
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<q-linear-progress
|
||||
rounded
|
||||
size="25px"
|
||||
:value="balance.local_msat / balance.total_msat"
|
||||
:color="color"
|
||||
:style="\`color: #\${this.color}\`"
|
||||
>
|
||||
<div class="absolute-full flex flex-center">
|
||||
<q-badge
|
||||
color="white"
|
||||
text-color="accent"
|
||||
:label="formatMsat(balance.total_msat) + ' sats'"
|
||||
>
|
||||
{{ balance.alias }}
|
||||
</q-badge>
|
||||
</div>
|
||||
</q-linear-progress>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
)
|
||||
|
||||
Vue.component('lnbits-date',
|
||||
{
|
||||
props: ['ts'],
|
||||
computed: {
|
||||
date: function () {
|
||||
return Quasar.utils.date.formatDate(
|
||||
new Date(this.ts * 1000),
|
||||
'YYYY-MM-DD HH:mm'
|
||||
)
|
||||
},
|
||||
dateFrom: function () {
|
||||
return moment(this.date).fromNow()
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div>
|
||||
<q-tooltip>{{ this.date }}</q-tooltip>
|
||||
{{ this.dateFrom }}
|
||||
</div>
|
||||
`
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
Vue.component('lnbits-date',
|
||||
{
|
||||
props: ['ts'],
|
||||
computed: {
|
||||
date: function () {
|
||||
return Quasar.utils.date.formatDate(
|
||||
new Date(this.ts * 1000),
|
||||
'YYYY-MM-DD HH:mm'
|
||||
)
|
||||
},
|
||||
dateFrom: function () {
|
||||
return moment(this.date).fromNow()
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div>
|
||||
<q-tooltip>{{ this.date }}</q-tooltip>
|
||||
{{ this.dateFrom }}
|
||||
</div>
|
||||
`
|
||||
}
|
||||
)
|
||||
|
||||
{% endraw %}
|
||||
|
||||
|
||||
new Vue({
|
||||
el: '#vue',
|
||||
config: {
|
||||
globalProperties: {
|
||||
LNbits,
|
||||
msg: 'hello'
|
||||
}
|
||||
},
|
||||
mixins: [windowMixin],
|
||||
data: function () {
|
||||
return {
|
||||
isSuperUser: false,
|
||||
wallet: {},
|
||||
tab: 'dashboard',
|
||||
payments: 1000,
|
||||
info: {},
|
||||
channel_stats: {},
|
||||
|
||||
channels: {
|
||||
data: [],
|
||||
filter: ''
|
||||
},
|
||||
|
||||
|
||||
activeBalance: {},
|
||||
ranks: {},
|
||||
|
||||
peers: {
|
||||
data: [],
|
||||
filter: '',
|
||||
},
|
||||
|
||||
connectPeerDialog: {
|
||||
show: false,
|
||||
data: {}
|
||||
},
|
||||
|
||||
openChannelDialog: {
|
||||
show: false,
|
||||
data: {}
|
||||
},
|
||||
|
||||
closeChannelDialog: {
|
||||
show: false,
|
||||
data: {}
|
||||
},
|
||||
|
||||
nodeInfoDialog: {
|
||||
show: false,
|
||||
data: {}
|
||||
},
|
||||
|
||||
transactionDetailsDialog: {
|
||||
show: false,
|
||||
data: {}
|
||||
},
|
||||
|
||||
states: [
|
||||
{label: 'Active', value: 'active', color: 'green'},
|
||||
{label: 'Pending', value: 'pending', color: 'orange'},
|
||||
{label: 'Inactive', value: 'inactive', color: 'grey'},
|
||||
{label: 'Closed', value: 'closed', color: 'red'}
|
||||
],
|
||||
|
||||
stateFilters: [
|
||||
{label: 'Active', value: 'active'},
|
||||
{label: 'Pending', value: 'pending'}
|
||||
],
|
||||
|
||||
paymentsTable: {
|
||||
data: [],
|
||||
columns: [
|
||||
{
|
||||
name: 'pending',
|
||||
label: ''
|
||||
},
|
||||
{
|
||||
name: 'memo',
|
||||
align: 'left',
|
||||
label: this.$t('memo'),
|
||||
field: 'memo'
|
||||
},
|
||||
{
|
||||
name: 'date',
|
||||
align: 'left',
|
||||
label: this.$t('date'),
|
||||
field: 'date',
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
name: 'sat',
|
||||
align: 'right',
|
||||
label: this.$t('amount') + ' (' + LNBITS_DENOMINATION + ')',
|
||||
field: row => this.formatMsat(row.amount),
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
name: 'fee',
|
||||
align: 'right',
|
||||
label: this.$t('fee') + ' (m' + LNBITS_DENOMINATION + ')',
|
||||
field: 'fee'
|
||||
},
|
||||
{
|
||||
name: 'destination',
|
||||
align: 'right',
|
||||
label: 'Destination',
|
||||
field: 'destination'
|
||||
}
|
||||
],
|
||||
pagination: {
|
||||
rowsPerPage: 10,
|
||||
page: 1,
|
||||
rowsNumber: 10
|
||||
},
|
||||
filter: null
|
||||
},
|
||||
invoiceTable: {
|
||||
data: [],
|
||||
columns: [
|
||||
{
|
||||
name: 'pending',
|
||||
label: ''
|
||||
},
|
||||
{
|
||||
name: 'memo',
|
||||
align: 'left',
|
||||
label: this.$t('memo'),
|
||||
field: 'memo'
|
||||
},
|
||||
{
|
||||
name: 'paid_at',
|
||||
field: 'paid_at',
|
||||
align: 'right',
|
||||
label: 'Paid at',
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
name: 'expiry',
|
||||
label: this.$t('expiry'),
|
||||
field: 'expiry',
|
||||
align: 'right',
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
name: 'amount',
|
||||
label: this.$t('amount') + ' (' + LNBITS_DENOMINATION + ')',
|
||||
field: row => this.formatMsat(row.amount),
|
||||
sortable: true
|
||||
},
|
||||
],
|
||||
pagination: {
|
||||
rowsPerPage: 10,
|
||||
page: 1,
|
||||
rowsNumber: 10
|
||||
},
|
||||
filter: null
|
||||
}
|
||||
}
|
||||
},
|
||||
created: function () {
|
||||
this.getInfo()
|
||||
this.get1MLStats()
|
||||
},
|
||||
watch: {
|
||||
tab: function (val) {
|
||||
if (val === 'transactions' && !this.paymentsTable.data.length) {
|
||||
this.getPayments()
|
||||
this.getInvoices()
|
||||
} else if (val === 'channels' && !this.channels.data.length) {
|
||||
this.getChannels()
|
||||
this.getPeers()
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
checkChanges() {
|
||||
return !_.isEqual(this.settings, this.formData)
|
||||
},
|
||||
filteredChannels: function () {
|
||||
return this.stateFilters ? this.channels.data.filter(channel => {
|
||||
return this.stateFilters.find(({value}) => value == channel.state)
|
||||
}) : this.channels.data
|
||||
},
|
||||
totalBalance: function () {
|
||||
return this.filteredChannels.reduce(
|
||||
(balance, channel) => {
|
||||
balance.local_msat += channel.balance.local_msat
|
||||
balance.remote_msat += channel.balance.remote_msat
|
||||
balance.total_msat += channel.balance.total_msat
|
||||
return balance
|
||||
},
|
||||
{local_msat: 0, remote_msat: 0, total_msat: 0}
|
||||
)
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
formatMsat: function (msat) {
|
||||
return LNbits.utils.formatMsat(msat)
|
||||
},
|
||||
api: function (method, url, options) {
|
||||
const params = new URLSearchParams(options?.query)
|
||||
params.set('usr', this.g.user.id)
|
||||
return LNbits.api.request(method, `/node/api/v1${url}?${params}`, {}, options?.data)
|
||||
.catch(error => {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
getChannels: function () {
|
||||
return this.api('GET', '/channels')
|
||||
.then(response => {
|
||||
this.channels.data = response.data
|
||||
})
|
||||
},
|
||||
getInfo: function () {
|
||||
return this.api('GET', '/info')
|
||||
.then(response => {
|
||||
this.info = response.data
|
||||
this.channel_stats = response.data.channel_stats
|
||||
})
|
||||
},
|
||||
get1MLStats: function () {
|
||||
return this.api('GET', '/rank')
|
||||
.then(response => {
|
||||
this.ranks = response.data
|
||||
})
|
||||
},
|
||||
getPayments: function (props) {
|
||||
if (props) {
|
||||
this.paymentsTable.pagination = props.pagination
|
||||
}
|
||||
let pagination = this.paymentsTable.pagination
|
||||
const query = {
|
||||
limit: pagination.rowsPerPage,
|
||||
offset: ((pagination.page - 1) * pagination.rowsPerPage) ?? 0,
|
||||
}
|
||||
return this.api('GET', '/payments', {query})
|
||||
.then(response => {
|
||||
this.paymentsTable.data = response.data.data
|
||||
this.paymentsTable.pagination.rowsNumber = response.data.total
|
||||
})
|
||||
},
|
||||
getInvoices: function (props) {
|
||||
if (props) {
|
||||
this.invoiceTable.pagination = props.pagination
|
||||
}
|
||||
let pagination = this.invoiceTable.pagination
|
||||
const query = {
|
||||
limit: pagination.rowsPerPage,
|
||||
offset: ((pagination.page - 1) * pagination.rowsPerPage) ?? 0,
|
||||
}
|
||||
return this.api('GET', '/invoices', {query})
|
||||
.then(response => {
|
||||
this.invoiceTable.data = response.data.data
|
||||
this.invoiceTable.pagination.rowsNumber = response.data.total
|
||||
})
|
||||
},
|
||||
getPeers: function () {
|
||||
return this.api('GET', '/peers')
|
||||
.then(response => {
|
||||
this.peers.data = response.data
|
||||
console.log('peers', this.peers)
|
||||
})
|
||||
},
|
||||
connectPeer: function () {
|
||||
console.log('peer', this.connectPeerDialog)
|
||||
this.api('POST', '/peers', {data: this.connectPeerDialog.data})
|
||||
.then(() => {
|
||||
this.connectPeerDialog.show = false
|
||||
this.getPeers()
|
||||
})
|
||||
},
|
||||
disconnectPeer: function(id) {
|
||||
LNbits.utils
|
||||
.confirmDialog(
|
||||
'Do you really wanna disconnect this peer?'
|
||||
)
|
||||
.onOk(() => {
|
||||
this.api('DELETE', `/peers/${id}`)
|
||||
.then(response => {
|
||||
this.$q.notify({
|
||||
message: 'Disconnected',
|
||||
icon: null
|
||||
})
|
||||
this.needsRestart = true
|
||||
this.getPeers()
|
||||
})
|
||||
})
|
||||
},
|
||||
openChannel: function () {
|
||||
this.api('POST', '/channels', {data: this.openChannelDialog.data})
|
||||
.then(response => {
|
||||
this.openChannelDialog.show = false
|
||||
this.getChannels()
|
||||
})
|
||||
.catch(error => {
|
||||
console.log(error)
|
||||
})
|
||||
},
|
||||
showCloseChannelDialog: function (channel) {
|
||||
this.closeChannelDialog.show = true
|
||||
this.closeChannelDialog.data = {force: false, short_id: channel.short_id, ...channel.point}
|
||||
},
|
||||
closeChannel: function () {
|
||||
this.api('DELETE', '/channels', {query: this.closeChannelDialog.data})
|
||||
.then(response => {
|
||||
this.closeChannelDialog.show = false
|
||||
this.getChannels()
|
||||
})
|
||||
},
|
||||
showOpenChannelDialog: function (peer_id) {
|
||||
this.openChannelDialog.show = true
|
||||
this.openChannelDialog.data = {peer_id, funding_amount: 0}
|
||||
},
|
||||
showNodeInfoDialog: function (node) {
|
||||
this.nodeInfoDialog.show = true
|
||||
this.nodeInfoDialog.data = node
|
||||
},
|
||||
showTransactionDetailsDialog: function (details) {
|
||||
this.transactionDetailsDialog.show = true
|
||||
this.transactionDetailsDialog.data = details
|
||||
console.log('details', details)
|
||||
},
|
||||
exportCSV: function () {
|
||||
|
||||
},
|
||||
shortenNodeId
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,143 @@
|
||||
{% extends "public.html" %} {% from "macros.jinja" import window_vars with
|
||||
context %} {% block page %}
|
||||
|
||||
<div class="q-ma-lg-xl q-mx-auto q-ma-xl" style="max-width: 1048px">
|
||||
<lnbits-node-info :info="this.info"></lnbits-node-info>
|
||||
|
||||
<div class="row q-col-gutter-lg q-mt-sm">
|
||||
<div class="col-12 col-md-8 q-gutter-y-md">
|
||||
<div class="row q-col-gutter-md q-pb-lg">
|
||||
<div class="col-12 col-md-6 q-gutter-y-md">
|
||||
<lnbits-stat
|
||||
title="Total Capacity"
|
||||
:msat="this.channel_stats.total_capacity"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-6 q-gutter-y-md">
|
||||
<lnbits-stat title="Peers" :amount="this.info.num_peers" />
|
||||
</div>
|
||||
<div class="col-12 col-md-6 q-gutter-y-md">
|
||||
<lnbits-stat
|
||||
:title="$t('avg_channel_size')"
|
||||
:msat="this.channel_stats.avg_size"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-12 col-md-6 q-gutter-y-md">
|
||||
<lnbits-stat
|
||||
:title="$t('biggest_channel_size')"
|
||||
:msat="this.channel_stats.biggest_size"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-12 col-md-6 q-gutter-y-md">
|
||||
<lnbits-stat
|
||||
:title="$t('smallest_channel_size')"
|
||||
:msat="this.channel_stats.smallest_size"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-12 col-md-6 q-gutter-y-md">
|
||||
<lnbits-stat
|
||||
:title="$t('smallest_channel_size')"
|
||||
:msat="this.channel_stats.smallest_size"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column col-12 col-md-4 q-gutter-y-md">
|
||||
<lnbits-node-ranks :ranks="this.ranks"></lnbits-node-ranks>
|
||||
<lnbits-channel-stats :stats="this.channel_stats"></lnbits-channel-stats>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %} {% block scripts %} {{ window_vars(user) }}
|
||||
<script src="/core/static/js/node.js"></script>
|
||||
<script>
|
||||
Vue.component(VueQrcode.name, VueQrcode)
|
||||
Vue.use(VueQrcodeReader)
|
||||
|
||||
{% raw %}
|
||||
|
||||
{% endraw %}
|
||||
|
||||
|
||||
new Vue({
|
||||
el: '#vue',
|
||||
config: {
|
||||
globalProperties: {
|
||||
LNbits,
|
||||
msg: 'hello'
|
||||
}
|
||||
},
|
||||
mixins: [windowMixin],
|
||||
data: function () {
|
||||
return {
|
||||
isSuperUser: false,
|
||||
wallet: {},
|
||||
tab: 'dashboard',
|
||||
payments: 1000,
|
||||
info: {},
|
||||
channel_stats: {},
|
||||
channels: [],
|
||||
activeBalance: {},
|
||||
ranks: {},
|
||||
|
||||
peers: [],
|
||||
|
||||
connectPeerDialog: {
|
||||
show: false,
|
||||
data: {}
|
||||
},
|
||||
|
||||
openChannelDialog: {
|
||||
show: false,
|
||||
data: {}
|
||||
},
|
||||
|
||||
closeChannelDialog: {
|
||||
show: false,
|
||||
data: {}
|
||||
},
|
||||
|
||||
nodeInfoDialog: {
|
||||
show: false,
|
||||
data: {}
|
||||
},
|
||||
|
||||
states: [
|
||||
{label: 'Active', value: 'active', color: 'green'},
|
||||
{label: 'Pending', value: 'pending', color: 'orange'},
|
||||
{label: 'Inactive', value: 'inactive', color: 'grey'},
|
||||
{label: 'Closed', value: 'closed', color: 'red'}
|
||||
],
|
||||
}
|
||||
},
|
||||
created: function () {
|
||||
this.getInfo()
|
||||
this.get1MLStats()
|
||||
},
|
||||
methods: {
|
||||
formatMsat: function (msat) {
|
||||
return LNbits.utils.formatMsat(msat)
|
||||
},
|
||||
api: function (method, url, data) {
|
||||
return LNbits.api.request(method, '/node/public/api/v1' + url, {}, data)
|
||||
},
|
||||
getInfo: function () {
|
||||
this.api('GET', '/info', {})
|
||||
.then(response => {
|
||||
this.info = response.data
|
||||
this.channel_stats = response.data.channel_stats
|
||||
})
|
||||
},
|
||||
get1MLStats: function () {
|
||||
this.api('GET', '/rank', {})
|
||||
.then(response => {
|
||||
this.ranks = response.data
|
||||
})
|
||||
},
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
@@ -6,22 +6,49 @@ from subprocess import Popen
|
||||
from typing import Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from fastapi import Body, Depends
|
||||
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 User
|
||||
from lnbits.core.services import update_cached_settings, update_wallet_balance
|
||||
from lnbits.core.models import CreateTopup, User
|
||||
from lnbits.core.services import (
|
||||
get_balance_delta,
|
||||
update_cached_settings,
|
||||
update_wallet_balance,
|
||||
)
|
||||
from lnbits.decorators import check_admin, check_super_user
|
||||
from lnbits.server import server_restart
|
||||
from lnbits.settings import AdminSettings, EditableSettings, settings
|
||||
from lnbits.settings import AdminSettings, UpdateSettings, settings
|
||||
|
||||
from .. import core_app
|
||||
from .. import core_app_extra
|
||||
from ..crud import delete_admin_settings, get_admin_settings, update_admin_settings
|
||||
|
||||
admin_router = APIRouter()
|
||||
|
||||
@core_app.get("/admin/api/v1/settings/")
|
||||
|
||||
@admin_router.get(
|
||||
"/admin/api/v1/audit",
|
||||
name="Audit",
|
||||
description="show the current balance of the node and the LNbits database",
|
||||
dependencies=[Depends(check_admin)],
|
||||
)
|
||||
async def api_auditor():
|
||||
try:
|
||||
delta, node_balance, total_balance = await get_balance_delta()
|
||||
return {
|
||||
"delta_msats": int(delta),
|
||||
"node_balance_msats": int(node_balance),
|
||||
"lnbits_balance_msats": int(total_balance),
|
||||
}
|
||||
except Exception:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
detail="Could not audit balance.",
|
||||
)
|
||||
|
||||
|
||||
@admin_router.get("/admin/api/v1/settings/", response_model=Optional[AdminSettings])
|
||||
async def api_get_settings(
|
||||
user: User = Depends(check_admin),
|
||||
) -> Optional[AdminSettings]:
|
||||
@@ -29,21 +56,20 @@ async def api_get_settings(
|
||||
return admin_settings
|
||||
|
||||
|
||||
@core_app.put(
|
||||
@admin_router.put(
|
||||
"/admin/api/v1/settings/",
|
||||
status_code=HTTPStatus.OK,
|
||||
)
|
||||
async def api_update_settings(
|
||||
data: EditableSettings, user: User = Depends(check_admin)
|
||||
):
|
||||
async def api_update_settings(data: UpdateSettings, user: User = Depends(check_admin)):
|
||||
await update_admin_settings(data)
|
||||
admin_settings = await get_admin_settings(user.super_user)
|
||||
assert admin_settings, "Updated admin settings not found."
|
||||
update_cached_settings(admin_settings.dict())
|
||||
core_app_extra.register_new_ratelimiter()
|
||||
return {"status": "Success"}
|
||||
|
||||
|
||||
@core_app.delete(
|
||||
@admin_router.delete(
|
||||
"/admin/api/v1/settings/",
|
||||
status_code=HTTPStatus.OK,
|
||||
dependencies=[Depends(check_super_user)],
|
||||
@@ -53,7 +79,7 @@ async def api_delete_settings() -> None:
|
||||
server_restart.set()
|
||||
|
||||
|
||||
@core_app.get(
|
||||
@admin_router.get(
|
||||
"/admin/api/v1/restart/",
|
||||
status_code=HTTPStatus.OK,
|
||||
dependencies=[Depends(check_super_user)],
|
||||
@@ -63,27 +89,31 @@ async def api_restart_server() -> dict[str, str]:
|
||||
return {"status": "Success"}
|
||||
|
||||
|
||||
@core_app.put(
|
||||
@admin_router.put(
|
||||
"/admin/api/v1/topup/",
|
||||
name="Topup",
|
||||
status_code=HTTPStatus.OK,
|
||||
dependencies=[Depends(check_super_user)],
|
||||
)
|
||||
async def api_topup_balance(
|
||||
id: str = Body(...), amount: int = Body(...)
|
||||
) -> dict[str, str]:
|
||||
async def api_topup_balance(data: CreateTopup) -> dict[str, str]:
|
||||
try:
|
||||
await get_wallet(id)
|
||||
except:
|
||||
await get_wallet(data.id)
|
||||
except Exception:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.FORBIDDEN, detail="wallet does not exist."
|
||||
)
|
||||
|
||||
await update_wallet_balance(wallet_id=id, amount=int(amount))
|
||||
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"}
|
||||
|
||||
|
||||
@core_app.get(
|
||||
@admin_router.get(
|
||||
"/admin/api/v1/backup/",
|
||||
status_code=HTTPStatus.OK,
|
||||
dependencies=[Depends(check_super_user)],
|
||||
@@ -100,10 +130,10 @@ async def api_download_backup() -> FileResponse:
|
||||
p = urlparse(db_url)
|
||||
command = (
|
||||
f"pg_dump --host={p.hostname} "
|
||||
f'--dbname={p.path.replace("/", "")} '
|
||||
f"--dbname={p.path.replace('/', '')} "
|
||||
f"--username={p.username} "
|
||||
f"--no-password "
|
||||
f"--format=c "
|
||||
"--no-password "
|
||||
"--format=c "
|
||||
f"--file={pg_backup_filename}"
|
||||
)
|
||||
proc = Popen(
|
||||
|
||||
@@ -1,39 +1,53 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
from http import HTTPStatus
|
||||
from io import BytesIO
|
||||
from typing import Dict, List, Optional, Tuple, Union
|
||||
from urllib.parse import ParseResult, parse_qs, urlencode, urlparse, urlunparse
|
||||
from typing import Dict, List, Optional, Union
|
||||
from urllib.parse import ParseResult, parse_qs, unquote, urlencode, urlparse, urlunparse
|
||||
|
||||
import async_timeout
|
||||
import httpx
|
||||
import pyqrcode
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
Body,
|
||||
Depends,
|
||||
Header,
|
||||
Query,
|
||||
Request,
|
||||
Response,
|
||||
WebSocket,
|
||||
WebSocketDisconnect,
|
||||
)
|
||||
from fastapi.exceptions import HTTPException
|
||||
from fastapi.responses import JSONResponse
|
||||
from loguru import logger
|
||||
from pydantic import BaseModel
|
||||
from pydantic.fields import Field
|
||||
from sse_starlette.sse import EventSourceResponse
|
||||
from starlette.responses import RedirectResponse, StreamingResponse
|
||||
|
||||
from lnbits import bolt11, lnurl
|
||||
from lnbits.core.db import core_app_extra, db
|
||||
from lnbits.core.helpers import (
|
||||
migrate_extension_database,
|
||||
stop_extension_background_work,
|
||||
)
|
||||
from lnbits.core.models import Payment, PaymentFilters, User, Wallet
|
||||
from lnbits.core.models import (
|
||||
ConversionData,
|
||||
CreateInvoice,
|
||||
CreateLnurl,
|
||||
CreateLnurlAuth,
|
||||
CreateWallet,
|
||||
CreateWebPushSubscription,
|
||||
DecodePayment,
|
||||
Payment,
|
||||
PaymentFilters,
|
||||
PaymentHistoryPoint,
|
||||
Query,
|
||||
User,
|
||||
Wallet,
|
||||
WalletType,
|
||||
WebPushSubscription,
|
||||
)
|
||||
from lnbits.db import Filters, Page
|
||||
from lnbits.decorators import (
|
||||
WalletTypeInfo,
|
||||
@@ -52,28 +66,37 @@ from lnbits.extension_manager import (
|
||||
get_valid_extensions,
|
||||
)
|
||||
from lnbits.helpers import generate_filter_params_openapi, url_for
|
||||
from lnbits.settings import get_wallet_class, settings
|
||||
from lnbits.settings import settings
|
||||
from lnbits.utils.exchange_rates import (
|
||||
currencies,
|
||||
fiat_amount_as_satoshis,
|
||||
satoshis_amount_as_fiat,
|
||||
)
|
||||
|
||||
from .. import core_app, core_app_extra, db
|
||||
from ..crud import (
|
||||
DateTrunc,
|
||||
add_installed_extension,
|
||||
create_account,
|
||||
create_tinyurl,
|
||||
create_wallet,
|
||||
create_webpush_subscription,
|
||||
delete_dbversion,
|
||||
delete_installed_extension,
|
||||
delete_tinyurl,
|
||||
delete_wallet,
|
||||
delete_webpush_subscription,
|
||||
drop_extension_db,
|
||||
get_dbversions,
|
||||
get_payments,
|
||||
get_payments_history,
|
||||
get_payments_paginated,
|
||||
get_standalone_payment,
|
||||
get_tinyurl,
|
||||
get_tinyurl_by_url,
|
||||
get_total_balance,
|
||||
get_wallet_for_key,
|
||||
get_webpush_subscription,
|
||||
save_balance_check,
|
||||
update_pending_payments,
|
||||
update_wallet,
|
||||
)
|
||||
from ..services import (
|
||||
@@ -88,15 +111,17 @@ from ..services import (
|
||||
)
|
||||
from ..tasks import api_invoice_listeners
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
@core_app.get("/api/v1/health", status_code=HTTPStatus.OK)
|
||||
|
||||
@api_router.get("/api/v1/health", status_code=HTTPStatus.OK)
|
||||
async def health():
|
||||
return
|
||||
|
||||
|
||||
@core_app.get("/api/v1/wallet")
|
||||
@api_router.get("/api/v1/wallet")
|
||||
async def api_wallet(wallet: WalletTypeInfo = Depends(get_key_type)):
|
||||
if wallet.wallet_type == 0:
|
||||
if wallet.wallet_type == WalletType.admin:
|
||||
return {
|
||||
"id": wallet.wallet.id,
|
||||
"name": wallet.wallet.name,
|
||||
@@ -106,8 +131,8 @@ async def api_wallet(wallet: WalletTypeInfo = Depends(get_key_type)):
|
||||
return {"name": wallet.wallet.name, "balance": wallet.wallet.balance_msat}
|
||||
|
||||
|
||||
@core_app.put("/api/v1/wallet/{new_name}")
|
||||
async def api_update_wallet(
|
||||
@api_router.put("/api/v1/wallet/{new_name}")
|
||||
async def api_update_wallet_name(
|
||||
new_name: str, wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||
):
|
||||
await update_wallet(wallet.wallet.id, new_name)
|
||||
@@ -118,7 +143,45 @@ async def api_update_wallet(
|
||||
}
|
||||
|
||||
|
||||
@core_app.get(
|
||||
@api_router.patch("/api/v1/wallet", response_model=Wallet)
|
||||
async def api_update_wallet(
|
||||
name: Optional[str] = Body(None),
|
||||
currency: Optional[str] = Body(None),
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||
):
|
||||
return await update_wallet(wallet.wallet.id, name, currency)
|
||||
|
||||
|
||||
@api_router.delete("/api/v1/wallet")
|
||||
async def api_delete_wallet(
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||
) -> None:
|
||||
await delete_wallet(
|
||||
user_id=wallet.wallet.user,
|
||||
wallet_id=wallet.wallet.id,
|
||||
)
|
||||
|
||||
|
||||
@api_router.post("/api/v1/wallet", response_model=Wallet)
|
||||
async def api_create_wallet(
|
||||
data: CreateWallet,
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||
) -> Wallet:
|
||||
return await create_wallet(user_id=wallet.wallet.user, wallet_name=data.name)
|
||||
|
||||
|
||||
@api_router.post("/api/v1/account", response_model=Wallet)
|
||||
async def api_create_account(data: CreateWallet) -> Wallet:
|
||||
if len(settings.lnbits_allowed_users) > 0:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail="Account creation is disabled.",
|
||||
)
|
||||
account = await create_account()
|
||||
return await create_wallet(user_id=account.id, wallet_name=data.name)
|
||||
|
||||
|
||||
@api_router.get(
|
||||
"/api/v1/payments",
|
||||
name="Payment List",
|
||||
summary="get list of payments",
|
||||
@@ -130,16 +193,7 @@ async def api_payments(
|
||||
wallet: WalletTypeInfo = Depends(get_key_type),
|
||||
filters: Filters = Depends(parse_filters(PaymentFilters)),
|
||||
):
|
||||
pending_payments = await get_payments(
|
||||
wallet_id=wallet.wallet.id,
|
||||
pending=True,
|
||||
exclude_uncheckable=True,
|
||||
filters=filters,
|
||||
)
|
||||
for payment in pending_payments:
|
||||
await check_transaction_status(
|
||||
wallet_id=payment.wallet_id, payment_hash=payment.payment_hash
|
||||
)
|
||||
await update_pending_payments(wallet.wallet.id)
|
||||
return await get_payments(
|
||||
wallet_id=wallet.wallet.id,
|
||||
pending=True,
|
||||
@@ -148,7 +202,22 @@ async def api_payments(
|
||||
)
|
||||
|
||||
|
||||
@core_app.get(
|
||||
@api_router.get(
|
||||
"/api/v1/payments/history",
|
||||
name="Get payments history",
|
||||
response_model=List[PaymentHistoryPoint],
|
||||
openapi_extra=generate_filter_params_openapi(PaymentFilters),
|
||||
)
|
||||
async def api_payments_history(
|
||||
wallet: WalletTypeInfo = Depends(get_key_type),
|
||||
group: DateTrunc = Query("day"),
|
||||
filters: Filters[PaymentFilters] = Depends(parse_filters(PaymentFilters)),
|
||||
):
|
||||
await update_pending_payments(wallet.wallet.id)
|
||||
return await get_payments_history(wallet.wallet.id, group, filters)
|
||||
|
||||
|
||||
@api_router.get(
|
||||
"/api/v1/payments/paginated",
|
||||
name="Payment List",
|
||||
summary="get paginated list of payments",
|
||||
@@ -160,16 +229,7 @@ async def api_payments_paginated(
|
||||
wallet: WalletTypeInfo = Depends(get_key_type),
|
||||
filters: Filters = Depends(parse_filters(PaymentFilters)),
|
||||
):
|
||||
pending = await get_payments_paginated(
|
||||
wallet_id=wallet.wallet.id,
|
||||
pending=True,
|
||||
exclude_uncheckable=True,
|
||||
filters=filters,
|
||||
)
|
||||
for payment in pending.data:
|
||||
await check_transaction_status(
|
||||
wallet_id=payment.wallet_id, payment_hash=payment.payment_hash
|
||||
)
|
||||
await update_pending_payments(wallet.wallet.id)
|
||||
page = await get_payments_paginated(
|
||||
wallet_id=wallet.wallet.id,
|
||||
pending=True,
|
||||
@@ -179,57 +239,37 @@ async def api_payments_paginated(
|
||||
return page
|
||||
|
||||
|
||||
class CreateInvoiceData(BaseModel):
|
||||
out: Optional[bool] = True
|
||||
amount: float = Query(None, ge=0)
|
||||
memo: Optional[str] = None
|
||||
unit: Optional[str] = "sat"
|
||||
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
|
||||
internal: Optional[bool] = False
|
||||
bolt11: Optional[str] = None
|
||||
|
||||
|
||||
async def api_payments_create_invoice(data: CreateInvoiceData, wallet: Wallet):
|
||||
async def api_payments_create_invoice(data: CreateInvoice, wallet: Wallet):
|
||||
description_hash = b""
|
||||
unhashed_description = b""
|
||||
memo = data.memo or settings.lnbits_site_title
|
||||
if data.description_hash or data.unhashed_description:
|
||||
try:
|
||||
description_hash = (
|
||||
bytes.fromhex(data.description_hash) if data.description_hash else b""
|
||||
)
|
||||
unhashed_description = (
|
||||
bytes.fromhex(data.unhashed_description)
|
||||
if data.unhashed_description
|
||||
else b""
|
||||
)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail="'description_hash' and 'unhashed_description' must be a valid hex strings",
|
||||
)
|
||||
if data.description_hash:
|
||||
try:
|
||||
description_hash = bytes.fromhex(data.description_hash)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail="'description_hash' must be a valid hex string",
|
||||
)
|
||||
if data.unhashed_description:
|
||||
try:
|
||||
unhashed_description = bytes.fromhex(data.unhashed_description)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail="'unhashed_description' must be a valid hex string",
|
||||
)
|
||||
# do not save memo if description_hash or unhashed_description is set
|
||||
memo = ""
|
||||
else:
|
||||
description_hash = b""
|
||||
unhashed_description = b""
|
||||
memo = data.memo or settings.lnbits_site_title
|
||||
|
||||
if data.unit == "sat":
|
||||
amount = int(data.amount)
|
||||
else:
|
||||
assert data.unit is not None, "unit not set"
|
||||
price_in_sats = await fiat_amount_as_satoshis(data.amount, data.unit)
|
||||
amount = price_in_sats
|
||||
|
||||
async with db.connect() as conn:
|
||||
try:
|
||||
_, payment_request = await create_invoice(
|
||||
payment_hash, payment_request = await create_invoice(
|
||||
wallet_id=wallet.id,
|
||||
amount=amount,
|
||||
amount=data.amount,
|
||||
memo=memo,
|
||||
currency=data.unit,
|
||||
description_hash=description_hash,
|
||||
unhashed_description=unhashed_description,
|
||||
expiry=data.expiry,
|
||||
@@ -238,6 +278,12 @@ async def api_payments_create_invoice(data: CreateInvoiceData, wallet: Wallet):
|
||||
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:
|
||||
@@ -280,7 +326,7 @@ async def api_payments_create_invoice(data: CreateInvoiceData, wallet: Wallet):
|
||||
"payment_hash": invoice.payment_hash,
|
||||
"payment_request": payment_request,
|
||||
# maintain backwards compatibility with API clients:
|
||||
"checking_id": invoice.payment_hash,
|
||||
"checking_id": checking_id,
|
||||
"lnurl_response": lnurl_response,
|
||||
}
|
||||
|
||||
@@ -304,15 +350,24 @@ async def api_payments_pay_invoice(bolt11: str, wallet: Wallet):
|
||||
}
|
||||
|
||||
|
||||
@core_app.post(
|
||||
@api_router.post(
|
||||
"/api/v1/payments",
|
||||
summary="Create or pay an invoice",
|
||||
description="""
|
||||
This endpoint can be used both to generate and pay a BOLT11 invoice.
|
||||
To generate a new invoice for receiving funds into the authorized account,
|
||||
specify at least the first four fields in the POST body: `out: false`,
|
||||
`amount`, `unit`, and `memo`. To pay an arbitrary invoice from the funds
|
||||
already in the authorized account, specify `out: true` and use the `bolt11`
|
||||
field to supply the BOLT11 invoice to be paid.
|
||||
""",
|
||||
status_code=HTTPStatus.CREATED,
|
||||
)
|
||||
async def api_payments_create(
|
||||
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
||||
invoiceData: CreateInvoiceData = Body(...),
|
||||
invoiceData: CreateInvoice = Body(...),
|
||||
):
|
||||
if invoiceData.out is True and wallet.wallet_type == 0:
|
||||
if invoiceData.out is True and wallet.wallet_type == WalletType.admin:
|
||||
if not invoiceData.bolt11:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
@@ -331,17 +386,9 @@ async def api_payments_create(
|
||||
)
|
||||
|
||||
|
||||
class CreateLNURLData(BaseModel):
|
||||
description_hash: str
|
||||
callback: str
|
||||
amount: int
|
||||
comment: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
@core_app.post("/api/v1/payments/lnurl")
|
||||
@api_router.post("/api/v1/payments/lnurl")
|
||||
async def api_payments_pay_lnurl(
|
||||
data: CreateLNURLData, wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||
data: CreateLnurl, wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||
):
|
||||
domain = urlparse(data.callback).netloc
|
||||
|
||||
@@ -377,13 +424,12 @@ async def api_payments_pay_lnurl(
|
||||
if invoice.amount_msat != data.amount:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail=f"{domain} returned an invalid invoice. Expected {data.amount} msat, got {invoice.amount_msat}.",
|
||||
)
|
||||
|
||||
if invoice.description_hash != data.description_hash:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail=f"{domain} returned an invalid invoice. Expected description_hash == {data.description_hash}, got {invoice.description_hash}.",
|
||||
detail=(
|
||||
(
|
||||
f"{domain} returned an invalid invoice. Expected"
|
||||
f" {data.amount} msat, got {invoice.amount_msat}."
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
extra = {}
|
||||
@@ -421,38 +467,24 @@ async def subscribe_wallet_invoices(request: Request, wallet: Wallet):
|
||||
logger.debug(f"adding sse listener for wallet: {uid}")
|
||||
api_invoice_listeners[uid] = payment_queue
|
||||
|
||||
send_queue: asyncio.Queue[Tuple[str, Payment]] = asyncio.Queue(0)
|
||||
|
||||
async def payment_received() -> None:
|
||||
while True:
|
||||
try:
|
||||
async with async_timeout.timeout(1):
|
||||
payment: Payment = await payment_queue.get()
|
||||
if payment.wallet_id == this_wallet_id:
|
||||
logger.debug("sse listener: payment received", payment)
|
||||
await send_queue.put(("payment-received", payment))
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
|
||||
task = asyncio.create_task(payment_received())
|
||||
|
||||
try:
|
||||
while True:
|
||||
if await request.is_disconnected():
|
||||
await request.close()
|
||||
break
|
||||
typ, data = await send_queue.get()
|
||||
if data:
|
||||
jdata = json.dumps(dict(data.dict(), pending=False))
|
||||
yield dict(data=jdata, event=typ)
|
||||
payment: Payment = await payment_queue.get()
|
||||
if payment.wallet_id == this_wallet_id:
|
||||
logger.debug("sse listener: payment received", payment)
|
||||
yield dict(data=payment.json(), event="payment-received")
|
||||
except asyncio.CancelledError:
|
||||
logger.debug(f"removing listener for wallet {uid}")
|
||||
except Exception as exc:
|
||||
logger.error(f"Error in sse: {exc}")
|
||||
finally:
|
||||
api_invoice_listeners.pop(uid)
|
||||
task.cancel()
|
||||
return
|
||||
|
||||
|
||||
@core_app.get("/api/v1/payments/sse")
|
||||
@api_router.get("/api/v1/payments/sse")
|
||||
async def api_payments_sse(
|
||||
request: Request, wallet: WalletTypeInfo = Depends(get_key_type)
|
||||
):
|
||||
@@ -464,14 +496,15 @@ async def api_payments_sse(
|
||||
|
||||
|
||||
# TODO: refactor this route into a public and admin one
|
||||
@core_app.get("/api/v1/payments/{payment_hash}")
|
||||
@api_router.get("/api/v1/payments/{payment_hash}")
|
||||
async def api_payment(payment_hash, X_Api_Key: Optional[str] = Header(None)):
|
||||
# We use X_Api_Key here because we want this call to work with and without keys
|
||||
# If a valid key is given, we also return the field "details", otherwise not
|
||||
wallet = await get_wallet_for_key(X_Api_Key) if type(X_Api_Key) == str else None # type: ignore
|
||||
|
||||
# we have to specify the wallet id here, because postgres and sqlite return internal payments in different order
|
||||
# and get_standalone_payment otherwise just fetches the first one, causing unpredictable results
|
||||
wallet = await get_wallet_for_key(X_Api_Key) if isinstance(X_Api_Key, str) else None
|
||||
wallet = wallet if wallet and not wallet.deleted else None
|
||||
# we have to specify the wallet id here, because postgres and sqlite return
|
||||
# internal payments in different order and get_standalone_payment otherwise
|
||||
# just fetches the first one, causing unpredictable results
|
||||
payment = await get_standalone_payment(
|
||||
payment_hash, wallet_id=wallet.id if wallet else None
|
||||
)
|
||||
@@ -508,12 +541,12 @@ async def api_payment(payment_hash, X_Api_Key: Optional[str] = Header(None)):
|
||||
return {"paid": not payment.pending, "preimage": payment.preimage}
|
||||
|
||||
|
||||
@core_app.get("/api/v1/lnurlscan/{code}")
|
||||
@api_router.get("/api/v1/lnurlscan/{code}")
|
||||
async def api_lnurlscan(code: str, wallet: WalletTypeInfo = Depends(get_key_type)):
|
||||
try:
|
||||
url = lnurl.decode(code)
|
||||
domain = urlparse(url).netloc
|
||||
except:
|
||||
except Exception:
|
||||
# parse internet identifier (user@domain.com)
|
||||
name_domain = code.split("@")
|
||||
if len(name_domain) == 2 and len(name_domain[1].split(".")) >= 2:
|
||||
@@ -541,8 +574,9 @@ async def api_lnurlscan(code: str, wallet: WalletTypeInfo = Depends(get_key_type
|
||||
assert lnurlauth_key.verifying_key
|
||||
params.update(pubkey=lnurlauth_key.verifying_key.to_string("compressed").hex())
|
||||
else:
|
||||
async with httpx.AsyncClient() as client:
|
||||
async with httpx.AsyncClient(follow_redirects=True) as client:
|
||||
r = await client.get(url, timeout=5)
|
||||
r.raise_for_status()
|
||||
if r.is_error:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.SERVICE_UNAVAILABLE,
|
||||
@@ -622,45 +656,28 @@ async def api_lnurlscan(code: str, wallet: WalletTypeInfo = Depends(get_key_type
|
||||
return params
|
||||
|
||||
|
||||
class DecodePayment(BaseModel):
|
||||
data: str
|
||||
|
||||
|
||||
@core_app.post("/api/v1/payments/decode", status_code=HTTPStatus.OK)
|
||||
async def api_payments_decode(data: DecodePayment, response: Response):
|
||||
@api_router.post("/api/v1/payments/decode", status_code=HTTPStatus.OK)
|
||||
async def api_payments_decode(data: DecodePayment) -> JSONResponse:
|
||||
payment_str = data.data
|
||||
try:
|
||||
if payment_str[:5] == "LNURL":
|
||||
url = lnurl.decode(payment_str)
|
||||
return {"domain": url}
|
||||
return JSONResponse({"domain": url})
|
||||
else:
|
||||
invoice = bolt11.decode(payment_str)
|
||||
return {
|
||||
"payment_hash": invoice.payment_hash,
|
||||
"amount_msat": invoice.amount_msat,
|
||||
"description": invoice.description,
|
||||
"description_hash": invoice.description_hash,
|
||||
"payee": invoice.payee,
|
||||
"date": invoice.date,
|
||||
"expiry": invoice.expiry,
|
||||
"secret": invoice.secret,
|
||||
"route_hints": invoice.route_hints,
|
||||
"min_final_cltv_expiry": invoice.min_final_cltv_expiry,
|
||||
}
|
||||
except:
|
||||
response.status_code = HTTPStatus.BAD_REQUEST
|
||||
return {"message": "Failed to decode"}
|
||||
return JSONResponse(invoice.data)
|
||||
except Exception as exc:
|
||||
return JSONResponse(
|
||||
{"message": f"Failed to decode: {str(exc)}"},
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
class Callback(BaseModel):
|
||||
callback: str = Query(...)
|
||||
|
||||
|
||||
@core_app.post("/api/v1/lnurlauth")
|
||||
@api_router.post("/api/v1/lnurlauth")
|
||||
async def api_perform_lnurlauth(
|
||||
callback: Callback, wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||
data: CreateLnurlAuth, wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||
):
|
||||
err = await perform_lnurlauth(callback.callback, wallet=wallet)
|
||||
err = await perform_lnurlauth(data.callback, wallet=wallet)
|
||||
if err:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.SERVICE_UNAVAILABLE, detail=err.reason
|
||||
@@ -668,18 +685,18 @@ async def api_perform_lnurlauth(
|
||||
return ""
|
||||
|
||||
|
||||
@core_app.get("/api/v1/currencies")
|
||||
@api_router.get("/api/v1/currencies")
|
||||
async def api_list_currencies_available():
|
||||
if len(settings.lnbits_allowed_currencies) > 0:
|
||||
return [
|
||||
item
|
||||
for item in currencies.keys()
|
||||
if item.upper() in settings.lnbits_allowed_currencies
|
||||
]
|
||||
return list(currencies.keys())
|
||||
|
||||
|
||||
class ConversionData(BaseModel):
|
||||
from_: str = Field("sat", alias="from")
|
||||
amount: float
|
||||
to: str = Query("usd")
|
||||
|
||||
|
||||
@core_app.post("/api/v1/conversion")
|
||||
@api_router.post("/api/v1/conversion")
|
||||
async def api_fiat_as_sats(data: ConversionData):
|
||||
output = {}
|
||||
if data.from_ == "sat":
|
||||
@@ -697,8 +714,8 @@ async def api_fiat_as_sats(data: ConversionData):
|
||||
return output
|
||||
|
||||
|
||||
@core_app.get("/api/v1/qrcode/{data}", response_class=StreamingResponse)
|
||||
async def img(request: Request, data):
|
||||
@api_router.get("/api/v1/qrcode/{data}", response_class=StreamingResponse)
|
||||
async def img(data):
|
||||
qr = pyqrcode.create(data)
|
||||
stream = BytesIO()
|
||||
qr.svg(stream, scale=3)
|
||||
@@ -718,31 +735,9 @@ async def img(request: Request, data):
|
||||
)
|
||||
|
||||
|
||||
@core_app.get("/api/v1/audit", dependencies=[Depends(check_admin)])
|
||||
async def api_auditor():
|
||||
WALLET = get_wallet_class()
|
||||
total_balance = await get_total_balance()
|
||||
error_message, node_balance = await WALLET.status()
|
||||
|
||||
if not error_message:
|
||||
delta = node_balance - total_balance
|
||||
else:
|
||||
node_balance, delta = 0, 0
|
||||
|
||||
return {
|
||||
"node_balance_msats": int(node_balance),
|
||||
"lnbits_balance_msats": int(total_balance),
|
||||
"delta_msats": int(delta),
|
||||
"timestamp": int(time.time()),
|
||||
}
|
||||
|
||||
|
||||
# UNIVERSAL WEBSOCKET MANAGER
|
||||
|
||||
|
||||
@core_app.websocket("/api/v1/ws/{item_id}")
|
||||
@api_router.websocket("/api/v1/ws/{item_id}")
|
||||
async def websocket_connect(websocket: WebSocket, item_id: str):
|
||||
await websocketManager.connect(websocket)
|
||||
await websocketManager.connect(websocket, item_id)
|
||||
try:
|
||||
while True:
|
||||
await websocket.receive_text()
|
||||
@@ -750,25 +745,25 @@ async def websocket_connect(websocket: WebSocket, item_id: str):
|
||||
websocketManager.disconnect(websocket)
|
||||
|
||||
|
||||
@core_app.post("/api/v1/ws/{item_id}")
|
||||
@api_router.post("/api/v1/ws/{item_id}")
|
||||
async def websocket_update_post(item_id: str, data: str):
|
||||
try:
|
||||
await websocketUpdater(item_id, data)
|
||||
return {"sent": True, "data": data}
|
||||
except:
|
||||
except Exception:
|
||||
return {"sent": False, "data": data}
|
||||
|
||||
|
||||
@core_app.get("/api/v1/ws/{item_id}/{data}")
|
||||
@api_router.get("/api/v1/ws/{item_id}/{data}")
|
||||
async def websocket_update_get(item_id: str, data: str):
|
||||
try:
|
||||
await websocketUpdater(item_id, data)
|
||||
return {"sent": True, "data": data}
|
||||
except:
|
||||
except Exception:
|
||||
return {"sent": False, "data": data}
|
||||
|
||||
|
||||
@core_app.post("/api/v1/extension")
|
||||
@api_router.post("/api/v1/extension")
|
||||
async def api_install_extension(
|
||||
data: CreateExtension, user: User = Depends(check_admin)
|
||||
):
|
||||
@@ -820,11 +815,14 @@ async def api_install_extension(
|
||||
ext_info.clean_extension_files()
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to install extension {ext_info.id} ({ext_info.installed_version}).",
|
||||
detail=(
|
||||
f"Failed to install extension {ext_info.id} "
|
||||
f"({ext_info.installed_version})."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@core_app.delete("/api/v1/extension/{ext_id}")
|
||||
@api_router.delete("/api/v1/extension/{ext_id}")
|
||||
async def api_uninstall_extension(ext_id: str, user: User = Depends(check_admin)):
|
||||
installable_extensions = await InstallableExtension.get_installable_extensions()
|
||||
|
||||
@@ -843,7 +841,10 @@ async def api_uninstall_extension(ext_id: str, user: User = Depends(check_admin)
|
||||
if installed_ext and ext_id in installed_ext.dependencies:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail=f"Cannot uninstall. Extension '{installed_ext.name}' depends on this one.",
|
||||
detail=(
|
||||
f"Cannot uninstall. Extension '{installed_ext.name}' "
|
||||
"depends on this one."
|
||||
),
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -864,7 +865,7 @@ async def api_uninstall_extension(ext_id: str, user: User = Depends(check_admin)
|
||||
)
|
||||
|
||||
|
||||
@core_app.get(
|
||||
@api_router.get(
|
||||
"/api/v1/extension/{ext_id}/releases", dependencies=[Depends(check_admin)]
|
||||
)
|
||||
async def get_extension_releases(ext_id: str):
|
||||
@@ -881,7 +882,7 @@ async def get_extension_releases(ext_id: str):
|
||||
)
|
||||
|
||||
|
||||
@core_app.get(
|
||||
@api_router.get(
|
||||
"/api/v1/extension/release/{org}/{repo}/{tag_name}",
|
||||
dependencies=[Depends(check_admin)],
|
||||
)
|
||||
@@ -902,10 +903,37 @@ async def get_extension_release(org: str, repo: str, tag_name: str):
|
||||
)
|
||||
|
||||
|
||||
# TINYURL
|
||||
@api_router.delete(
|
||||
"/api/v1/extension/{ext_id}/db",
|
||||
dependencies=[Depends(check_admin)],
|
||||
)
|
||||
async def delete_extension_db(ext_id: str):
|
||||
try:
|
||||
db_version = (await get_dbversions()).get(ext_id, None)
|
||||
if not db_version:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail=f"Unknown extension id: {ext_id}",
|
||||
)
|
||||
await drop_extension_db(ext_id=ext_id)
|
||||
await delete_dbversion(ext_id=ext_id)
|
||||
logger.success(f"Database removed for extension '{ext_id}'")
|
||||
except HTTPException as ex:
|
||||
logger.error(ex)
|
||||
raise ex
|
||||
except Exception as ex:
|
||||
logger.error(ex)
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
detail=f"Cannot delete data for extension '{ext_id}'",
|
||||
)
|
||||
|
||||
|
||||
@core_app.post("/api/v1/tinyurl")
|
||||
@api_router.post(
|
||||
"/api/v1/tinyurl",
|
||||
name="Tinyurl",
|
||||
description="creates a tinyurl",
|
||||
)
|
||||
async def api_create_tinyurl(
|
||||
url: str, endless: bool = False, wallet: WalletTypeInfo = Depends(get_key_type)
|
||||
):
|
||||
@@ -916,13 +944,17 @@ async def api_create_tinyurl(
|
||||
if tinyurl.wallet == wallet.wallet.inkey:
|
||||
return tinyurl
|
||||
return await create_tinyurl(url, endless, wallet.wallet.inkey)
|
||||
except:
|
||||
except Exception:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST, detail="Unable to create tinyurl"
|
||||
)
|
||||
|
||||
|
||||
@core_app.get("/api/v1/tinyurl/{tinyurl_id}")
|
||||
@api_router.get(
|
||||
"/api/v1/tinyurl/{tinyurl_id}",
|
||||
name="Tinyurl",
|
||||
description="get a tinyurl by id",
|
||||
)
|
||||
async def api_get_tinyurl(
|
||||
tinyurl_id: str, wallet: WalletTypeInfo = Depends(get_key_type)
|
||||
):
|
||||
@@ -934,13 +966,17 @@ async def api_get_tinyurl(
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.FORBIDDEN, detail="Wrong key provided."
|
||||
)
|
||||
except:
|
||||
except Exception:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Unable to fetch tinyurl"
|
||||
)
|
||||
|
||||
|
||||
@core_app.delete("/api/v1/tinyurl/{tinyurl_id}")
|
||||
@api_router.delete(
|
||||
"/api/v1/tinyurl/{tinyurl_id}",
|
||||
name="Tinyurl",
|
||||
description="delete a tinyurl by id",
|
||||
)
|
||||
async def api_delete_tinyurl(
|
||||
tinyurl_id: str, wallet: WalletTypeInfo = Depends(get_key_type)
|
||||
):
|
||||
@@ -953,22 +989,56 @@ async def api_delete_tinyurl(
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.FORBIDDEN, detail="Wrong key provided."
|
||||
)
|
||||
except:
|
||||
except Exception:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST, detail="Unable to delete"
|
||||
)
|
||||
|
||||
|
||||
@core_app.get("/t/{tinyurl_id}")
|
||||
@api_router.get(
|
||||
"/t/{tinyurl_id}",
|
||||
name="Tinyurl",
|
||||
description="redirects a tinyurl by id",
|
||||
)
|
||||
async def api_tinyurl(tinyurl_id: str):
|
||||
try:
|
||||
tinyurl = await get_tinyurl(tinyurl_id)
|
||||
if tinyurl:
|
||||
response = RedirectResponse(url=tinyurl.url)
|
||||
return response
|
||||
else:
|
||||
return
|
||||
except:
|
||||
tinyurl = await get_tinyurl(tinyurl_id)
|
||||
if tinyurl:
|
||||
response = RedirectResponse(url=tinyurl.url)
|
||||
return response
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="unable to find tinyurl"
|
||||
)
|
||||
|
||||
|
||||
@api_router.post("/api/v1/webpush", status_code=HTTPStatus.CREATED)
|
||||
async def api_create_webpush_subscription(
|
||||
request: Request,
|
||||
data: CreateWebPushSubscription,
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||
) -> WebPushSubscription:
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
@api_router.delete("/api/v1/webpush", status_code=HTTPStatus.OK)
|
||||
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)
|
||||
|
||||
@@ -1,28 +1,30 @@
|
||||
import asyncio
|
||||
from http import HTTPStatus
|
||||
from typing import List, Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from fastapi import Depends, Query, Request, status
|
||||
from fastapi.exceptions import HTTPException
|
||||
from fastapi.responses import FileResponse, RedirectResponse
|
||||
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse
|
||||
from fastapi.routing import APIRouter
|
||||
from loguru import logger
|
||||
from pydantic.types import UUID4
|
||||
from starlette.responses import HTMLResponse, JSONResponse
|
||||
|
||||
from lnbits.core import db
|
||||
from lnbits.core.db import db
|
||||
from lnbits.core.helpers import to_valid_user_id
|
||||
from lnbits.core.models import User
|
||||
from lnbits.decorators import check_admin, check_user_exists
|
||||
from lnbits.helpers import template_renderer, url_for
|
||||
from lnbits.settings import get_wallet_class, settings
|
||||
from lnbits.settings import settings
|
||||
from lnbits.wallets import get_wallet_class
|
||||
|
||||
from ...extension_manager import InstallableExtension, get_valid_extensions
|
||||
from ...utils.exchange_rates import currencies
|
||||
from ..crud import (
|
||||
create_account,
|
||||
create_wallet,
|
||||
delete_wallet,
|
||||
get_balance_check,
|
||||
get_dbversions,
|
||||
get_inactive_extensions,
|
||||
get_installed_extensions,
|
||||
get_user,
|
||||
@@ -32,22 +34,24 @@ from ..crud import (
|
||||
)
|
||||
from ..services import pay_invoice, redeem_lnurl_withdraw
|
||||
|
||||
core_html_routes: APIRouter = APIRouter(tags=["Core NON-API Website Routes"])
|
||||
generic_router = APIRouter(
|
||||
tags=["Core NON-API Website Routes"], include_in_schema=False
|
||||
)
|
||||
|
||||
|
||||
@core_html_routes.get("/favicon.ico", response_class=FileResponse)
|
||||
@generic_router.get("/favicon.ico", response_class=FileResponse)
|
||||
async def favicon():
|
||||
return FileResponse("lnbits/core/static/favicon.ico")
|
||||
|
||||
|
||||
@core_html_routes.get("/", response_class=HTMLResponse)
|
||||
@generic_router.get("/", response_class=HTMLResponse)
|
||||
async def home(request: Request, lightning: str = ""):
|
||||
return template_renderer().TemplateResponse(
|
||||
"core/index.html", {"request": request, "lnurl": lightning}
|
||||
)
|
||||
|
||||
|
||||
@core_html_routes.get("/robots.txt", response_class=HTMLResponse)
|
||||
@generic_router.get("/robots.txt", response_class=HTMLResponse)
|
||||
async def robots():
|
||||
data = """
|
||||
User-agent: *
|
||||
@@ -56,7 +60,7 @@ async def robots():
|
||||
return HTMLResponse(content=data, media_type="text/plain")
|
||||
|
||||
|
||||
@core_html_routes.get(
|
||||
@generic_router.get(
|
||||
"/extensions", name="install.extensions", response_class=HTMLResponse
|
||||
)
|
||||
async def extensions_install(
|
||||
@@ -113,6 +117,7 @@ async def extensions_install(
|
||||
|
||||
all_extensions = list(map(lambda e: e.code, get_valid_extensions()))
|
||||
inactive_extensions = await get_inactive_extensions()
|
||||
db_version = await get_dbversions()
|
||||
extensions = list(
|
||||
map(
|
||||
lambda ext: {
|
||||
@@ -124,14 +129,16 @@ async def extensions_install(
|
||||
"isFeatured": ext.featured,
|
||||
"dependencies": ext.dependencies,
|
||||
"isInstalled": ext.id in installed_exts_ids,
|
||||
"hasDatabaseTables": ext.id in db_version,
|
||||
"isAvailable": ext.id in all_extensions,
|
||||
"isAdminOnly": ext.id in settings.lnbits_admin_extensions,
|
||||
"isActive": ext.id not in inactive_extensions,
|
||||
"latestRelease": dict(ext.latest_release)
|
||||
if ext.latest_release
|
||||
else None,
|
||||
"installedRelease": dict(ext.installed_release)
|
||||
if ext.installed_release
|
||||
else None,
|
||||
"latestRelease": (
|
||||
dict(ext.latest_release) if ext.latest_release else None
|
||||
),
|
||||
"installedRelease": (
|
||||
dict(ext.installed_release) if ext.installed_release else None
|
||||
),
|
||||
},
|
||||
installable_exts,
|
||||
)
|
||||
@@ -150,77 +157,55 @@ async def extensions_install(
|
||||
raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e))
|
||||
|
||||
|
||||
@core_html_routes.get(
|
||||
@generic_router.get(
|
||||
"/wallet",
|
||||
response_class=HTMLResponse,
|
||||
description="""
|
||||
Args:
|
||||
|
||||
just **wallet_name**: create a new user, then create a new wallet for user with wallet_name<br>
|
||||
just **user_id**: return the first user wallet or create one if none found (with default wallet_name)<br>
|
||||
**user_id** and **wallet_name**: create a new wallet for user with wallet_name<br>
|
||||
**user_id** and **wallet_id**: return that wallet if user is the owner<br>
|
||||
nothing: create everything<br>
|
||||
""",
|
||||
description="show wallet page",
|
||||
)
|
||||
async def wallet(
|
||||
request: Request = Query(None),
|
||||
nme: Optional[str] = Query(None),
|
||||
usr: Optional[UUID4] = Query(None),
|
||||
request: Request,
|
||||
usr: UUID4 = Query(...),
|
||||
wal: Optional[UUID4] = Query(None),
|
||||
):
|
||||
user_id = usr.hex if usr else None
|
||||
wallet_id = wal.hex if wal else None
|
||||
wallet_name = nme
|
||||
user_id = usr.hex
|
||||
user = await get_user(user_id)
|
||||
|
||||
if not user_id:
|
||||
new_user = await create_account()
|
||||
user = await get_user(new_user.id)
|
||||
assert user, "Newly created user has to exist."
|
||||
logger.info(f"Create user {user.id}")
|
||||
else:
|
||||
user = await get_user(user_id)
|
||||
if not user:
|
||||
return template_renderer().TemplateResponse(
|
||||
"error.html", {"request": request, "err": "User does not exist."}
|
||||
)
|
||||
if (
|
||||
len(settings.lnbits_allowed_users) > 0
|
||||
and user_id not in settings.lnbits_allowed_users
|
||||
and user_id not in settings.lnbits_admin_users
|
||||
and user_id != settings.super_user
|
||||
):
|
||||
return template_renderer().TemplateResponse(
|
||||
"error.html", {"request": request, "err": "User not authorized."}
|
||||
)
|
||||
if user_id == settings.super_user or user_id in settings.lnbits_admin_users:
|
||||
user.admin = True
|
||||
if user_id == settings.super_user:
|
||||
user.super_user = True
|
||||
|
||||
if not wallet_id:
|
||||
if user.wallets and not wallet_name:
|
||||
wallet = user.wallets[0]
|
||||
else:
|
||||
wallet = await create_wallet(user_id=user.id, wallet_name=wallet_name)
|
||||
logger.info(
|
||||
f"Created new wallet {wallet_name if wallet_name else '(no name)'} for user {user.id}"
|
||||
)
|
||||
|
||||
return RedirectResponse(
|
||||
f"/wallet?usr={user.id}&wal={wallet.id}",
|
||||
status_code=status.HTTP_307_TEMPORARY_REDIRECT,
|
||||
if not user:
|
||||
return template_renderer().TemplateResponse(
|
||||
"error.html", {"request": request, "err": "User does not exist."}
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f"Access {'user '+ user.id + ' ' if user else ''} {'wallet ' + wallet_name if wallet_name else ''}"
|
||||
)
|
||||
if not wal:
|
||||
if len(user.wallets) == 0:
|
||||
wallet = await create_wallet(user_id=user.id)
|
||||
return RedirectResponse(url=f"/wallet?usr={user_id}&wal={wallet.id}")
|
||||
return RedirectResponse(url=f"/wallet?usr={user_id}&wal={user.wallets[0].id}")
|
||||
else:
|
||||
wallet_id = wal.hex
|
||||
|
||||
userwallet = user.get_wallet(wallet_id)
|
||||
if not userwallet:
|
||||
if not userwallet or userwallet.deleted:
|
||||
return template_renderer().TemplateResponse(
|
||||
"error.html", {"request": request, "err": "Wallet not found"}
|
||||
)
|
||||
|
||||
if (
|
||||
len(settings.lnbits_allowed_users) > 0
|
||||
and user_id not in settings.lnbits_allowed_users
|
||||
and user_id not in settings.lnbits_admin_users
|
||||
and user_id != settings.super_user
|
||||
):
|
||||
return template_renderer().TemplateResponse(
|
||||
"error.html", {"request": request, "err": "User not authorized."}
|
||||
)
|
||||
|
||||
if user_id == settings.super_user or user_id in settings.lnbits_admin_users:
|
||||
user.admin = True
|
||||
if user_id == settings.super_user:
|
||||
user.super_user = True
|
||||
|
||||
logger.debug(f"Access user {user.id} wallet {userwallet.name}")
|
||||
|
||||
return template_renderer().TemplateResponse(
|
||||
"core/wallet.html",
|
||||
{
|
||||
@@ -233,13 +218,21 @@ async def wallet(
|
||||
)
|
||||
|
||||
|
||||
@core_html_routes.get("/withdraw", response_class=JSONResponse)
|
||||
@generic_router.get("/withdraw", response_class=JSONResponse)
|
||||
async def lnurl_full_withdraw(request: Request):
|
||||
user = await get_user(request.query_params.get("usr"))
|
||||
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."}
|
||||
|
||||
wallet = user.get_wallet(request.query_params.get("wal"))
|
||||
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."}
|
||||
|
||||
@@ -249,27 +242,39 @@ async def lnurl_full_withdraw(request: Request):
|
||||
"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]}",
|
||||
"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),
|
||||
}
|
||||
|
||||
|
||||
@core_html_routes.get("/withdraw/cb", response_class=JSONResponse)
|
||||
@generic_router.get("/withdraw/cb", response_class=JSONResponse)
|
||||
async def lnurl_full_withdraw_callback(request: Request):
|
||||
user = await get_user(request.query_params.get("usr"))
|
||||
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."}
|
||||
|
||||
wallet = user.get_wallet(request.query_params.get("wal"))
|
||||
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:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
asyncio.create_task(pay())
|
||||
@@ -281,40 +286,18 @@ async def lnurl_full_withdraw_callback(request: Request):
|
||||
return {"status": "OK"}
|
||||
|
||||
|
||||
@core_html_routes.get("/deletewallet", response_class=RedirectResponse)
|
||||
async def deletewallet(wal: str = Query(...), usr: str = Query(...)):
|
||||
user = await get_user(usr)
|
||||
if not user:
|
||||
raise HTTPException(HTTPStatus.FORBIDDEN, "User not found.")
|
||||
|
||||
user_wallet_ids = [u.id for u in user.wallets]
|
||||
|
||||
if wal not in user_wallet_ids:
|
||||
raise HTTPException(HTTPStatus.FORBIDDEN, "Not your wallet.")
|
||||
else:
|
||||
await delete_wallet(user_id=user.id, wallet_id=wal)
|
||||
user_wallet_ids.remove(wal)
|
||||
logger.debug("Deleted wallet {wal} of user {user.id}")
|
||||
|
||||
if user_wallet_ids:
|
||||
return RedirectResponse(
|
||||
url_for("/wallet", usr=user.id, wal=user_wallet_ids[0]),
|
||||
status_code=status.HTTP_307_TEMPORARY_REDIRECT,
|
||||
)
|
||||
|
||||
return RedirectResponse(
|
||||
url_for("/"), status_code=status.HTTP_307_TEMPORARY_REDIRECT
|
||||
)
|
||||
|
||||
|
||||
@core_html_routes.get("/withdraw/notify/{service}")
|
||||
@generic_router.get("/withdraw/notify/{service}")
|
||||
async def lnurl_balance_notify(request: Request, service: str):
|
||||
bc = await get_balance_check(request.query_params.get("wal"), service)
|
||||
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)
|
||||
|
||||
|
||||
@core_html_routes.get(
|
||||
@generic_router.get(
|
||||
"/lnurlwallet", response_class=RedirectResponse, name="core.lnurlwallet"
|
||||
)
|
||||
async def lnurlwallet(request: Request):
|
||||
@@ -324,10 +307,14 @@ async def lnurlwallet(request: Request):
|
||||
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,
|
||||
request.query_params.get("lightning"),
|
||||
lightning_param,
|
||||
"LNbits initial funding: voucher redeem.",
|
||||
{"tag": "lnurlwallet"},
|
||||
5, # wait 5 seconds before sending the invoice to the service
|
||||
@@ -340,13 +327,15 @@ async def lnurlwallet(request: Request):
|
||||
)
|
||||
|
||||
|
||||
@core_html_routes.get("/service-worker.js", response_class=FileResponse)
|
||||
@generic_router.get("/service-worker.js", response_class=FileResponse)
|
||||
async def service_worker():
|
||||
return FileResponse("lnbits/core/static/js/service-worker.js")
|
||||
|
||||
|
||||
@core_html_routes.get("/manifest/{usr}.webmanifest")
|
||||
async def manifest(usr: str):
|
||||
@generic_router.get("/manifest/{usr}.webmanifest")
|
||||
async def manifest(request: Request, usr: str):
|
||||
host = urlparse(str(request.url)).netloc
|
||||
|
||||
user = await get_user(usr)
|
||||
if not user:
|
||||
raise HTTPException(status_code=HTTPStatus.NOT_FOUND)
|
||||
@@ -356,9 +345,11 @@ async def manifest(usr: str):
|
||||
"name": settings.lnbits_site_title + " Wallet",
|
||||
"icons": [
|
||||
{
|
||||
"src": settings.lnbits_custom_logo
|
||||
if settings.lnbits_custom_logo
|
||||
else "https://cdn.jsdelivr.net/gh/lnbits/lnbits@0.3.0/docs/logos/lnbits.png",
|
||||
"src": (
|
||||
settings.lnbits_custom_logo
|
||||
if settings.lnbits_custom_logo
|
||||
else "https://cdn.jsdelivr.net/gh/lnbits/lnbits@0.3.0/docs/logos/lnbits.png"
|
||||
),
|
||||
"type": "image/png",
|
||||
"sizes": "900x900",
|
||||
}
|
||||
@@ -378,10 +369,49 @@ async def manifest(usr: str):
|
||||
}
|
||||
for wallet in user.wallets
|
||||
],
|
||||
"url_handlers": [{"origin": f"https://{host}"}],
|
||||
}
|
||||
|
||||
|
||||
@core_html_routes.get("/admin", response_class=HTMLResponse)
|
||||
@generic_router.get("/node", response_class=HTMLResponse)
|
||||
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()
|
||||
|
||||
return template_renderer().TemplateResponse(
|
||||
"node/index.html",
|
||||
{
|
||||
"request": request,
|
||||
"user": user.dict(),
|
||||
"settings": settings.dict(),
|
||||
"balance": balance,
|
||||
"wallets": user.wallets[0].dict(),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@generic_router.get("/node/public", response_class=HTMLResponse)
|
||||
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()
|
||||
|
||||
return template_renderer().TemplateResponse(
|
||||
"node/public.html",
|
||||
{
|
||||
"request": request,
|
||||
"settings": settings.dict(),
|
||||
"balance": balance,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@generic_router.get("/admin", response_class=HTMLResponse)
|
||||
async def index(request: Request, user: User = Depends(check_admin)):
|
||||
if not settings.lnbits_admin_ui:
|
||||
raise HTTPException(status_code=HTTPStatus.NOT_FOUND)
|
||||
@@ -396,11 +426,12 @@ async def index(request: Request, user: User = Depends(check_admin)):
|
||||
"user": user.dict(),
|
||||
"settings": settings.dict(),
|
||||
"balance": balance,
|
||||
"currencies": list(currencies.keys()),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@core_html_routes.get("/uuidv4/{hex_value}")
|
||||
@generic_router.get("/uuidv4/{hex_value}")
|
||||
async def hex_to_uuid4(hex_value: str):
|
||||
try:
|
||||
user_id = to_valid_user_id(hex_value).hex
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
from http import HTTPStatus
|
||||
from typing import List, Optional
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from starlette.status import HTTP_503_SERVICE_UNAVAILABLE
|
||||
|
||||
from lnbits.decorators import check_admin, check_super_user, parse_filters
|
||||
from lnbits.nodes import get_node_class
|
||||
from lnbits.settings import settings
|
||||
|
||||
from ...db import Filters, Page
|
||||
from ...nodes.base import (
|
||||
ChannelPoint,
|
||||
Node,
|
||||
NodeChannel,
|
||||
NodeInfoResponse,
|
||||
NodeInvoice,
|
||||
NodeInvoiceFilters,
|
||||
NodePayment,
|
||||
NodePaymentsFilters,
|
||||
NodePeerInfo,
|
||||
PublicNodeInfo,
|
||||
)
|
||||
from ...utils.cache import cache
|
||||
|
||||
|
||||
def require_node():
|
||||
NODE = get_node_class()
|
||||
if not NODE:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_IMPLEMENTED,
|
||||
detail="Active backend does not implement Node API",
|
||||
)
|
||||
if not settings.lnbits_node_ui:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.SERVICE_UNAVAILABLE,
|
||||
detail="Not enabled",
|
||||
)
|
||||
return NODE
|
||||
|
||||
|
||||
def check_public():
|
||||
if not (settings.lnbits_node_ui and settings.lnbits_public_node_ui):
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.SERVICE_UNAVAILABLE,
|
||||
detail="Not enabled",
|
||||
)
|
||||
|
||||
|
||||
node_router = APIRouter(prefix="/node/api/v1", dependencies=[Depends(check_admin)])
|
||||
super_node_router = APIRouter(
|
||||
prefix="/node/api/v1", dependencies=[Depends(check_super_user)]
|
||||
)
|
||||
public_node_router = APIRouter(
|
||||
prefix="/node/public/api/v1", dependencies=[Depends(check_public)]
|
||||
)
|
||||
|
||||
|
||||
@node_router.get(
|
||||
"/ok",
|
||||
description="Check if node api can be enabled",
|
||||
status_code=200,
|
||||
dependencies=[Depends(require_node)],
|
||||
)
|
||||
async def api_get_ok():
|
||||
pass
|
||||
|
||||
|
||||
@public_node_router.get("/info", response_model=PublicNodeInfo)
|
||||
async def api_get_public_info(node: Node = Depends(require_node)) -> PublicNodeInfo:
|
||||
return await cache.save_result(node.get_public_info, key="node:public_info")
|
||||
|
||||
|
||||
@node_router.get("/info")
|
||||
async def api_get_info(
|
||||
node: Node = Depends(require_node),
|
||||
) -> Optional[NodeInfoResponse]:
|
||||
return await node.get_info()
|
||||
|
||||
|
||||
@node_router.get("/channels")
|
||||
async def api_get_channels(
|
||||
node: Node = Depends(require_node),
|
||||
) -> Optional[List[NodeChannel]]:
|
||||
return await node.get_channels()
|
||||
|
||||
|
||||
@super_node_router.post("/channels", response_model=ChannelPoint)
|
||||
async def api_create_channel(
|
||||
node: Node = Depends(require_node),
|
||||
peer_id: str = Body(),
|
||||
funding_amount: int = Body(),
|
||||
push_amount: Optional[int] = Body(None),
|
||||
fee_rate: Optional[int] = Body(None),
|
||||
):
|
||||
return await node.open_channel(peer_id, funding_amount, push_amount, fee_rate)
|
||||
|
||||
|
||||
@super_node_router.delete("/channels")
|
||||
async def api_delete_channel(
|
||||
short_id: Optional[str],
|
||||
funding_txid: Optional[str],
|
||||
output_index: Optional[int],
|
||||
force: bool = False,
|
||||
node: Node = Depends(require_node),
|
||||
) -> Optional[List[NodeChannel]]:
|
||||
return await node.close_channel(
|
||||
short_id,
|
||||
ChannelPoint(funding_txid=funding_txid, output_index=output_index)
|
||||
if funding_txid is not None and output_index is not None
|
||||
else None,
|
||||
force,
|
||||
)
|
||||
|
||||
|
||||
@node_router.get("/payments", response_model=Page[NodePayment])
|
||||
async def api_get_payments(
|
||||
node: Node = Depends(require_node),
|
||||
filters: Filters = Depends(parse_filters(NodePaymentsFilters)),
|
||||
) -> Optional[Page[NodePayment]]:
|
||||
if not settings.lnbits_node_ui_transactions:
|
||||
raise HTTPException(
|
||||
HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="You can enable node transactions in the Admin UI",
|
||||
)
|
||||
return await node.get_payments(filters)
|
||||
|
||||
|
||||
@node_router.get("/invoices", response_model=Page[NodeInvoice])
|
||||
async def api_get_invoices(
|
||||
node: Node = Depends(require_node),
|
||||
filters: Filters = Depends(parse_filters(NodeInvoiceFilters)),
|
||||
) -> Optional[Page[NodeInvoice]]:
|
||||
if not settings.lnbits_node_ui_transactions:
|
||||
raise HTTPException(
|
||||
HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="You can enable node transactions in the Admin UI",
|
||||
)
|
||||
return await node.get_invoices(filters)
|
||||
|
||||
|
||||
@node_router.get("/peers", response_model=List[NodePeerInfo])
|
||||
async def api_get_peers(node: Node = Depends(require_node)) -> List[NodePeerInfo]:
|
||||
return await node.get_peers()
|
||||
|
||||
|
||||
@super_node_router.post("/peers")
|
||||
async def api_connect_peer(
|
||||
uri: str = Body(embed=True), node: Node = Depends(require_node)
|
||||
):
|
||||
return await node.connect_peer(uri)
|
||||
|
||||
|
||||
@super_node_router.delete("/peers/{peer_id}")
|
||||
async def api_disconnect_peer(peer_id: str, node: Node = Depends(require_node)):
|
||||
return await node.disconnect_peer(peer_id)
|
||||
|
||||
|
||||
class NodeRank(BaseModel):
|
||||
capacity: Optional[int]
|
||||
channelcount: Optional[int]
|
||||
age: Optional[int]
|
||||
growth: Optional[int]
|
||||
availability: Optional[int]
|
||||
|
||||
|
||||
# Same for public and private api
|
||||
@node_router.get(
|
||||
"/rank",
|
||||
description="Retrieve node ranks from https://1ml.com",
|
||||
response_model=Optional[NodeRank],
|
||||
)
|
||||
@public_node_router.get(
|
||||
"/rank",
|
||||
description="Retrieve node ranks from https://1ml.com",
|
||||
response_model=Optional[NodeRank],
|
||||
)
|
||||
async def api_get_1ml_stats(node: Node = Depends(require_node)) -> Optional[NodeRank]:
|
||||
node_id = await node.get_id()
|
||||
async with httpx.AsyncClient() as client:
|
||||
r = await client.get(url=f"https://1ml.com/node/{node_id}/json", timeout=15)
|
||||
try:
|
||||
r.raise_for_status()
|
||||
return r.json()["noderank"]
|
||||
except httpx.HTTPStatusError:
|
||||
raise HTTPException(status_code=404, detail="Node not found on 1ml.com")
|
||||
@@ -1,18 +1,18 @@
|
||||
import asyncio
|
||||
import datetime
|
||||
from http import HTTPStatus
|
||||
|
||||
from fastapi import HTTPException
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from loguru import logger
|
||||
|
||||
from lnbits import bolt11
|
||||
|
||||
from .. import core_app
|
||||
from ..crud import get_standalone_payment
|
||||
from ..tasks import api_invoice_listeners
|
||||
|
||||
public_router = APIRouter()
|
||||
|
||||
@core_app.get("/public/v1/payment/{payment_hash}")
|
||||
|
||||
@public_router.get("/public/v1/payment/{payment_hash}")
|
||||
async def api_public_payment_longpolling(payment_hash):
|
||||
payment = await get_standalone_payment(payment_hash)
|
||||
|
||||
@@ -25,10 +25,9 @@ async def api_public_payment_longpolling(payment_hash):
|
||||
|
||||
try:
|
||||
invoice = bolt11.decode(payment.bolt11)
|
||||
expiration = datetime.datetime.fromtimestamp(invoice.date + invoice.expiry)
|
||||
if expiration < datetime.datetime.now():
|
||||
if invoice.has_expired():
|
||||
return {"status": "expired"}
|
||||
except:
|
||||
except Exception:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST, detail="Invalid bolt11 invoice."
|
||||
)
|
||||
|
||||
@@ -47,25 +47,22 @@ if settings.lnbits_database_url:
|
||||
lambda value, curs: float(value) if value is not None else None,
|
||||
)
|
||||
)
|
||||
register_type(
|
||||
new_type(
|
||||
(1082, 1083, 1266),
|
||||
"DATE2INT",
|
||||
lambda value, curs: time.mktime(value.timetuple())
|
||||
if value is not None
|
||||
else None,
|
||||
)
|
||||
)
|
||||
|
||||
register_type(new_type((1184, 1114), "TIMESTAMP2INT", _parse_timestamp))
|
||||
else:
|
||||
if os.path.isdir(settings.lnbits_data_folder):
|
||||
DB_TYPE = SQLITE
|
||||
if not os.path.isdir(settings.lnbits_data_folder):
|
||||
os.mkdir(settings.lnbits_data_folder)
|
||||
logger.info(f"Created {settings.lnbits_data_folder}")
|
||||
DB_TYPE = SQLITE
|
||||
|
||||
|
||||
def compat_timestamp_placeholder():
|
||||
if DB_TYPE == POSTGRES:
|
||||
return "to_timestamp(?)"
|
||||
elif DB_TYPE == COCKROACH:
|
||||
return "cast(? AS timestamp)"
|
||||
else:
|
||||
raise NotADirectoryError(
|
||||
f"LNBITS_DATA_FOLDER named {settings.lnbits_data_folder} was not created"
|
||||
f" - please 'mkdir {settings.lnbits_data_folder}' and try again"
|
||||
)
|
||||
return "?"
|
||||
|
||||
|
||||
class Compat:
|
||||
@@ -116,15 +113,9 @@ class Compat:
|
||||
return "BIGINT"
|
||||
return "INT"
|
||||
|
||||
@classmethod
|
||||
@property
|
||||
def timestamp_placeholder(cls):
|
||||
if DB_TYPE == POSTGRES:
|
||||
return "to_timestamp(?)"
|
||||
elif DB_TYPE == COCKROACH:
|
||||
return "cast(? AS timestamp)"
|
||||
else:
|
||||
return "?"
|
||||
def timestamp_placeholder(self) -> str:
|
||||
return compat_timestamp_placeholder()
|
||||
|
||||
|
||||
class Connection(Compat):
|
||||
@@ -303,6 +294,21 @@ class Database(Compat):
|
||||
async def reuse_conn(self, conn: Connection):
|
||||
yield conn
|
||||
|
||||
@classmethod
|
||||
async def clean_ext_db_files(cls, ext_id: str) -> bool:
|
||||
"""
|
||||
If the extension DB is stored directly on the filesystem (like SQLite) then
|
||||
delete the files and return True. Otherwise do nothing and return False.
|
||||
"""
|
||||
|
||||
if DB_TYPE == SQLITE:
|
||||
db_file = os.path.join(settings.lnbits_data_folder, f"ext_{ext_id}.sqlite3")
|
||||
if os.path.isfile(db_file):
|
||||
os.remove(db_file)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
class Operator(Enum):
|
||||
GT = "gt"
|
||||
@@ -360,7 +366,6 @@ class Page(BaseModel, Generic[T]):
|
||||
|
||||
class Filter(BaseModel, Generic[TFilterModel]):
|
||||
field: str
|
||||
nested: Optional[List[str]]
|
||||
op: Operator = Operator.EQ
|
||||
values: list[Any]
|
||||
|
||||
@@ -375,55 +380,36 @@ class Filter(BaseModel, Generic[TFilterModel]):
|
||||
split = key[:-1].split("[")
|
||||
if len(split) != 2:
|
||||
raise ValueError("Invalid key")
|
||||
field_names = split[0].split(".")
|
||||
field = split[0]
|
||||
op = Operator(split[1])
|
||||
else:
|
||||
field_names = key.split(".")
|
||||
field = key
|
||||
op = Operator("eq")
|
||||
|
||||
field = field_names[0]
|
||||
nested = field_names[1:]
|
||||
|
||||
if field in model.__fields__:
|
||||
compare_field = model.__fields__[field]
|
||||
values = []
|
||||
for raw_value in raw_values:
|
||||
# If there is a nested field, pydantic expects a dict, so the raw value is turned into a dict before
|
||||
# and the converted value is extracted afterwards
|
||||
for name in reversed(nested):
|
||||
raw_value = {name: raw_value}
|
||||
|
||||
validated, errors = compare_field.validate(raw_value, {}, loc="none")
|
||||
if errors:
|
||||
raise ValidationError(errors=[errors], model=model)
|
||||
|
||||
for name in nested:
|
||||
if isinstance(validated, dict):
|
||||
validated = validated[name]
|
||||
else:
|
||||
validated = getattr(validated, name)
|
||||
|
||||
values.append(validated)
|
||||
else:
|
||||
raise ValueError("Unknown filter field")
|
||||
|
||||
return cls(field=field, op=op, nested=nested, values=values, model=model)
|
||||
return cls(field=field, op=op, values=values, model=model)
|
||||
|
||||
@property
|
||||
def statement(self):
|
||||
accessor = self.field
|
||||
if self.nested:
|
||||
for name in self.nested:
|
||||
accessor = f"({accessor} ->> '{name}')"
|
||||
if self.model and self.model.__fields__[self.field].type_ == datetime.datetime:
|
||||
placeholder = Compat.timestamp_placeholder
|
||||
placeholder = compat_timestamp_placeholder()
|
||||
else:
|
||||
placeholder = "?"
|
||||
if self.op in (Operator.INCLUDE, Operator.EXCLUDE):
|
||||
placeholders = ", ".join([placeholder] * len(self.values))
|
||||
stmt = [f"{accessor} {self.op.as_sql} ({placeholders})"]
|
||||
stmt = [f"{self.field} {self.op.as_sql} ({placeholders})"]
|
||||
else:
|
||||
stmt = [f"{accessor} {self.op.as_sql} {placeholder}"] * len(self.values)
|
||||
stmt = [f"{self.field} {self.op.as_sql} {placeholder}"] * len(self.values)
|
||||
return " OR ".join(stmt)
|
||||
|
||||
|
||||
@@ -432,8 +418,8 @@ class Filters(BaseModel, Generic[TFilterModel]):
|
||||
Generic helper class for filtering and sorting data.
|
||||
For usage in an api endpoint, use the `parse_filters` dependency.
|
||||
|
||||
When constructing this class manually always make sure to pass a model so that the values can be validated.
|
||||
Otherwise, make sure to validate the inputs manually.
|
||||
When constructing this class manually always make sure to pass a model so that
|
||||
the values can be validated. Otherwise, make sure to validate the inputs manually.
|
||||
"""
|
||||
|
||||
filters: List[Filter[TFilterModel]] = []
|
||||
@@ -476,7 +462,7 @@ class Filters(BaseModel, Generic[TFilterModel]):
|
||||
if self.search and self.model:
|
||||
if DB_TYPE == POSTGRES:
|
||||
where_stmts.append(
|
||||
f"lower(concat({f', '.join(self.model.__search_fields__)})) LIKE ?"
|
||||
f"lower(concat({', '.join(self.model.__search_fields__)})) LIKE ?"
|
||||
)
|
||||
elif DB_TYPE == SQLITE:
|
||||
where_stmts.append(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from http import HTTPStatus
|
||||
from typing import Literal, Optional, Type
|
||||
|
||||
from fastapi import Query, Request, Security, status
|
||||
from fastapi import Query, Request, Security
|
||||
from fastapi.exceptions import HTTPException
|
||||
from fastapi.openapi.models import APIKey, APIKeyIn
|
||||
from fastapi.security import APIKeyHeader, APIKeyQuery
|
||||
@@ -9,7 +9,7 @@ from fastapi.security.base import SecurityBase
|
||||
from pydantic.types import UUID4
|
||||
|
||||
from lnbits.core.crud import get_user, get_wallet_for_key
|
||||
from lnbits.core.models import User, Wallet
|
||||
from lnbits.core.models import User, WalletType, WalletTypeInfo
|
||||
from lnbits.db import Filter, Filters, TFilterModel
|
||||
from lnbits.requestvars import g
|
||||
from lnbits.settings import settings
|
||||
@@ -25,7 +25,7 @@ class KeyChecker(SecurityBase):
|
||||
):
|
||||
self.scheme_name = scheme_name or self.__class__.__name__
|
||||
self.auto_error = auto_error
|
||||
self._key_type = "invoice"
|
||||
self._key_type = WalletType.invoice
|
||||
self._api_key = api_key
|
||||
if api_key:
|
||||
key = APIKey(
|
||||
@@ -39,7 +39,7 @@ class KeyChecker(SecurityBase):
|
||||
name="X-API-KEY",
|
||||
description="Wallet API Key - HEADER",
|
||||
)
|
||||
self.wallet = None # type: ignore
|
||||
self.wallet = None
|
||||
self.model: APIKey = key
|
||||
|
||||
async def __call__(self, request: Request):
|
||||
@@ -49,16 +49,16 @@ class KeyChecker(SecurityBase):
|
||||
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
|
||||
self.wallet = await get_wallet_for_key(key_value, self._key_type) # type: ignore
|
||||
if not self.wallet:
|
||||
# FIXME: Find another way to validate the key. A fetch from DB should be
|
||||
# avoided here. Also, we should not return the wallet here - thats
|
||||
# silly. Possibly store it in a Redis DB
|
||||
wallet = await get_wallet_for_key(key_value, self._key_type)
|
||||
if not wallet or wallet.deleted:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.UNAUTHORIZED,
|
||||
detail="Invalid key or expired key.",
|
||||
detail="Invalid key or wallet.",
|
||||
)
|
||||
|
||||
self.wallet = wallet # type: ignore
|
||||
except KeyError:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST, detail="`X-API-KEY` header missing."
|
||||
@@ -81,7 +81,7 @@ class WalletInvoiceKeyChecker(KeyChecker):
|
||||
api_key: Optional[str] = None,
|
||||
):
|
||||
super().__init__(scheme_name, auto_error, api_key)
|
||||
self._key_type = "invoice"
|
||||
self._key_type = WalletType.invoice
|
||||
|
||||
|
||||
class WalletAdminKeyChecker(KeyChecker):
|
||||
@@ -100,16 +100,7 @@ class WalletAdminKeyChecker(KeyChecker):
|
||||
api_key: Optional[str] = None,
|
||||
):
|
||||
super().__init__(scheme_name, auto_error, api_key)
|
||||
self._key_type = "admin"
|
||||
|
||||
|
||||
class WalletTypeInfo:
|
||||
wallet_type: int
|
||||
wallet: Wallet
|
||||
|
||||
def __init__(self, wallet_type: int, wallet: Wallet) -> None:
|
||||
self.wallet_type = wallet_type
|
||||
self.wallet = wallet
|
||||
self._key_type = WalletType.admin
|
||||
|
||||
|
||||
api_key_header = APIKeyHeader(
|
||||
@@ -129,11 +120,6 @@ async def get_key_type(
|
||||
api_key_header: str = Security(api_key_header),
|
||||
api_key_query: str = Security(api_key_query),
|
||||
) -> WalletTypeInfo:
|
||||
# 0: admin
|
||||
# 1: invoice
|
||||
# 2: invalid
|
||||
pathname = r["path"].split("/")[1]
|
||||
|
||||
token = api_key_header or api_key_query
|
||||
|
||||
if not token:
|
||||
@@ -142,38 +128,40 @@ async def get_key_type(
|
||||
detail="Invoice (or Admin) key required.",
|
||||
)
|
||||
|
||||
for typenr, WalletChecker in zip(
|
||||
[0, 1], [WalletAdminKeyChecker, WalletInvoiceKeyChecker]
|
||||
for wallet_type, WalletChecker in zip(
|
||||
[WalletType.admin, WalletType.invoice],
|
||||
[WalletAdminKeyChecker, WalletInvoiceKeyChecker],
|
||||
):
|
||||
try:
|
||||
checker = WalletChecker(api_key=token)
|
||||
await checker.__call__(r)
|
||||
wallet = WalletTypeInfo(typenr, checker.wallet) # type: ignore
|
||||
if wallet is None or wallet.wallet is None:
|
||||
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 pathname in 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 e:
|
||||
if e.status_code == HTTPStatus.BAD_REQUEST:
|
||||
except HTTPException as exc:
|
||||
if exc.status_code == HTTPStatus.BAD_REQUEST:
|
||||
raise
|
||||
elif e.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
|
||||
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:
|
||||
except Exception:
|
||||
raise
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Wallet does not exist."
|
||||
@@ -199,7 +187,7 @@ async def require_admin_key(
|
||||
# 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=status.HTTP_401_UNAUTHORIZED, detail="Admin key required."
|
||||
status_code=HTTPStatus.UNAUTHORIZED, detail="Admin key required."
|
||||
)
|
||||
else:
|
||||
return wallet
|
||||
@@ -220,11 +208,12 @@ async def require_invoice_key(
|
||||
|
||||
wallet = await get_key_type(r, token)
|
||||
|
||||
if wallet.wallet_type > 1:
|
||||
# If wallet type is not invoice then return the unauthorized status
|
||||
# This also covers when the user passes an invalid key type
|
||||
if (
|
||||
wallet.wallet_type != WalletType.admin
|
||||
and wallet.wallet_type != WalletType.invoice
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
status_code=HTTPStatus.UNAUTHORIZED,
|
||||
detail="Invoice (or Admin) key required.",
|
||||
)
|
||||
else:
|
||||
@@ -259,10 +248,6 @@ async def check_admin(usr: UUID4) -> User:
|
||||
status_code=HTTPStatus.UNAUTHORIZED,
|
||||
detail="User not authorized. No admin privileges.",
|
||||
)
|
||||
user.admin = True
|
||||
user.super_user = False
|
||||
if user.id == settings.super_user:
|
||||
user.super_user = True
|
||||
|
||||
return user
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ class ExplicitRelease(BaseModel):
|
||||
archive: str
|
||||
hash: str
|
||||
dependencies: List[str] = []
|
||||
repo: Optional[str]
|
||||
icon: Optional[str]
|
||||
short_description: Optional[str]
|
||||
min_lnbits_version: Optional[str]
|
||||
@@ -98,18 +99,18 @@ async def fetch_github_repo_info(
|
||||
) -> Tuple[GitHubRepo, GitHubRepoRelease, ExtensionConfig]:
|
||||
repo_url = f"https://api.github.com/repos/{org}/{repository}"
|
||||
error_msg = "Cannot fetch extension repo"
|
||||
repo = await gihub_api_get(repo_url, error_msg)
|
||||
repo = await github_api_get(repo_url, error_msg)
|
||||
github_repo = GitHubRepo.parse_obj(repo)
|
||||
|
||||
lates_release_url = (
|
||||
f"https://api.github.com/repos/{org}/{repository}/releases/latest"
|
||||
)
|
||||
error_msg = "Cannot fetch extension releases"
|
||||
latest_release: Any = await gihub_api_get(lates_release_url, error_msg)
|
||||
latest_release: Any = await github_api_get(lates_release_url, error_msg)
|
||||
|
||||
config_url = f"https://raw.githubusercontent.com/{org}/{repository}/{github_repo.default_branch}/config.json"
|
||||
error_msg = "Cannot fetch config for extension"
|
||||
config = await gihub_api_get(config_url, error_msg)
|
||||
config = await github_api_get(config_url, error_msg)
|
||||
|
||||
return (
|
||||
github_repo,
|
||||
@@ -120,14 +121,14 @@ async def fetch_github_repo_info(
|
||||
|
||||
async def fetch_manifest(url) -> Manifest:
|
||||
error_msg = "Cannot fetch extensions manifest"
|
||||
manifest = await gihub_api_get(url, error_msg)
|
||||
manifest = await github_api_get(url, error_msg)
|
||||
return Manifest.parse_obj(manifest)
|
||||
|
||||
|
||||
async def fetch_github_releases(org: str, repo: str) -> List[GitHubRepoRelease]:
|
||||
releases_url = f"https://api.github.com/repos/{org}/{repo}/releases"
|
||||
error_msg = "Cannot fetch extension releases"
|
||||
releases = await gihub_api_get(releases_url, error_msg)
|
||||
releases = await github_api_get(releases_url, error_msg)
|
||||
return [GitHubRepoRelease.parse_obj(r) for r in releases]
|
||||
|
||||
|
||||
@@ -138,11 +139,11 @@ async def fetch_github_release_config(
|
||||
f"https://raw.githubusercontent.com/{org}/{repo}/{tag_name}/config.json"
|
||||
)
|
||||
error_msg = "Cannot fetch GitHub extension config"
|
||||
config = await gihub_api_get(config_url, error_msg)
|
||||
config = await github_api_get(config_url, error_msg)
|
||||
return ExtensionConfig.parse_obj(config)
|
||||
|
||||
|
||||
async def gihub_api_get(url: str, error_msg: Optional[str]) -> Any:
|
||||
async def github_api_get(url: str, error_msg: Optional[str]) -> Any:
|
||||
async with httpx.AsyncClient() as client:
|
||||
headers = (
|
||||
{"Authorization": "Bearer " + settings.lnbits_ext_github_token}
|
||||
@@ -181,12 +182,19 @@ class Extension(NamedTuple):
|
||||
upgrade_hash: Optional[str] = ""
|
||||
|
||||
@property
|
||||
def module_name(self):
|
||||
return (
|
||||
f"lnbits.extensions.{self.code}"
|
||||
if self.upgrade_hash == ""
|
||||
else f"lnbits.upgrades.{self.code}-{self.upgrade_hash}.{self.code}"
|
||||
)
|
||||
def module_name(self) -> str:
|
||||
if self.is_upgrade_extension:
|
||||
if settings.has_default_extension_path:
|
||||
return f"lnbits.upgrades.{self.code}-{self.upgrade_hash}"
|
||||
return f"{self.code}-{self.upgrade_hash}"
|
||||
|
||||
if settings.has_default_extension_path:
|
||||
return f"lnbits.extensions.{self.code}"
|
||||
return self.code
|
||||
|
||||
@property
|
||||
def is_upgrade_extension(self) -> bool:
|
||||
return self.upgrade_hash != ""
|
||||
|
||||
@classmethod
|
||||
def from_installable_ext(cls, ext_info: "InstallableExtension") -> "Extension":
|
||||
@@ -203,8 +211,8 @@ class Extension(NamedTuple):
|
||||
|
||||
|
||||
class ExtensionManager:
|
||||
def __init__(self):
|
||||
p = Path(settings.lnbits_path, "extensions")
|
||||
def __init__(self) -> None:
|
||||
p = Path(settings.lnbits_extensions_path, "extensions")
|
||||
Path(p).mkdir(parents=True, exist_ok=True)
|
||||
self._extension_folders: List[Path] = [f for f in p.iterdir() if f.is_dir()]
|
||||
|
||||
@@ -254,6 +262,7 @@ class ExtensionRelease(BaseModel):
|
||||
html_url: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
warning: Optional[str] = None
|
||||
repo: Optional[str] = None
|
||||
icon: Optional[str] = None
|
||||
|
||||
@classmethod
|
||||
@@ -267,7 +276,7 @@ class ExtensionRelease(BaseModel):
|
||||
archive=r.zipball_url,
|
||||
source_repo=source_repo,
|
||||
is_github_release=True,
|
||||
# description=r.body, # bad for JSON
|
||||
repo=f"https://github.com/{source_repo}",
|
||||
html_url=r.html_url,
|
||||
)
|
||||
|
||||
@@ -286,6 +295,7 @@ class ExtensionRelease(BaseModel):
|
||||
is_version_compatible=e.is_version_compatible(),
|
||||
warning=e.warning,
|
||||
html_url=e.html_url,
|
||||
repo=e.repo,
|
||||
icon=e.icon,
|
||||
)
|
||||
|
||||
@@ -327,21 +337,25 @@ class InstallableExtension(BaseModel):
|
||||
|
||||
@property
|
||||
def zip_path(self) -> Path:
|
||||
extensions_data_dir = Path(settings.lnbits_data_folder, "extensions")
|
||||
extensions_data_dir = Path(settings.lnbits_data_folder, "zips")
|
||||
Path(extensions_data_dir).mkdir(parents=True, exist_ok=True)
|
||||
return Path(extensions_data_dir, f"{self.id}.zip")
|
||||
|
||||
@property
|
||||
def ext_dir(self) -> Path:
|
||||
return Path(settings.lnbits_path, "extensions", self.id)
|
||||
return Path(settings.lnbits_extensions_path, "extensions", self.id)
|
||||
|
||||
@property
|
||||
def ext_upgrade_dir(self) -> Path:
|
||||
return Path("lnbits", "upgrades", f"{self.id}-{self.hash}")
|
||||
return Path(
|
||||
settings.lnbits_extensions_path, "upgrades", f"{self.id}-{self.hash}"
|
||||
)
|
||||
|
||||
@property
|
||||
def module_name(self) -> str:
|
||||
return f"lnbits.extensions.{self.id}"
|
||||
if settings.has_default_extension_path:
|
||||
return f"lnbits.extensions.{self.id}"
|
||||
return self.id
|
||||
|
||||
@property
|
||||
def module_installed(self) -> bool:
|
||||
@@ -386,21 +400,26 @@ class InstallableExtension(BaseModel):
|
||||
|
||||
def extract_archive(self):
|
||||
logger.info(f"Extracting extension {self.name} ({self.installed_version}).")
|
||||
Path("lnbits", "upgrades").mkdir(parents=True, exist_ok=True)
|
||||
shutil.rmtree(self.ext_upgrade_dir, True)
|
||||
with zipfile.ZipFile(self.zip_path, "r") as zip_ref:
|
||||
zip_ref.extractall(self.ext_upgrade_dir)
|
||||
generated_dir_name = os.listdir(self.ext_upgrade_dir)[0]
|
||||
os.rename(
|
||||
Path(self.ext_upgrade_dir, generated_dir_name),
|
||||
Path(self.ext_upgrade_dir, self.id),
|
||||
Path(settings.lnbits_extensions_path, "upgrades").mkdir(
|
||||
parents=True, exist_ok=True
|
||||
)
|
||||
|
||||
tmp_dir = Path(settings.lnbits_data_folder, "unzip-temp", self.hash)
|
||||
shutil.rmtree(tmp_dir, True)
|
||||
|
||||
with zipfile.ZipFile(self.zip_path, "r") as zip_ref:
|
||||
zip_ref.extractall(tmp_dir)
|
||||
generated_dir_name = os.listdir(tmp_dir)[0]
|
||||
shutil.rmtree(self.ext_upgrade_dir, True)
|
||||
shutil.copytree(
|
||||
Path(tmp_dir, generated_dir_name),
|
||||
Path(self.ext_upgrade_dir),
|
||||
)
|
||||
shutil.rmtree(tmp_dir, True)
|
||||
|
||||
# Pre-packed extensions can be upgraded
|
||||
# Mark the extension as installed so we know it is not the pre-packed version
|
||||
with open(
|
||||
Path(self.ext_upgrade_dir, self.id, "config.json"), "r+"
|
||||
) as json_file:
|
||||
with open(Path(self.ext_upgrade_dir, "config.json"), "r+") as json_file:
|
||||
config_json = json.load(json_file)
|
||||
|
||||
self.name = config_json.get("name")
|
||||
@@ -416,14 +435,14 @@ class InstallableExtension(BaseModel):
|
||||
)
|
||||
|
||||
shutil.rmtree(self.ext_dir, True)
|
||||
shutil.copytree(
|
||||
Path(self.ext_upgrade_dir, self.id),
|
||||
Path(settings.lnbits_path, "extensions", self.id),
|
||||
)
|
||||
shutil.copytree(Path(self.ext_upgrade_dir), Path(self.ext_dir))
|
||||
logger.success(f"Extension {self.name} ({self.installed_version}) installed.")
|
||||
|
||||
def nofiy_upgrade(self) -> None:
|
||||
"""Update the list of upgraded extensions. The middleware will perform redirects based on this"""
|
||||
"""
|
||||
Update the list of upgraded extensions. The middleware will perform
|
||||
redirects based on this
|
||||
"""
|
||||
|
||||
clean_upgraded_exts = list(
|
||||
filter(
|
||||
|
||||
@@ -4,13 +4,10 @@ from typing import Any, List, Optional, Type
|
||||
|
||||
import jinja2
|
||||
import shortuuid
|
||||
from pydantic.schema import (
|
||||
field_schema,
|
||||
get_flat_models_from_fields,
|
||||
get_model_name_map,
|
||||
)
|
||||
from pydantic.schema import field_schema
|
||||
|
||||
from lnbits.jinja2_templating import Jinja2Templates
|
||||
from lnbits.nodes import get_node_class
|
||||
from lnbits.requestvars import g
|
||||
from lnbits.settings import settings
|
||||
|
||||
@@ -34,6 +31,10 @@ def url_for(endpoint: str, external: Optional[bool] = False, **params: Any) -> s
|
||||
def template_renderer(additional_folders: Optional[List] = None) -> Jinja2Templates:
|
||||
folders = ["lnbits/templates", "lnbits/core/templates"]
|
||||
if additional_folders:
|
||||
additional_folders += [
|
||||
Path(settings.lnbits_extensions_path, "extensions", f)
|
||||
for f in additional_folders
|
||||
]
|
||||
folders.extend(additional_folders)
|
||||
t = Jinja2Templates(loader=jinja2.FileSystemLoader(folders))
|
||||
|
||||
@@ -48,9 +49,12 @@ def template_renderer(additional_folders: Optional[List] = None) -> Jinja2Templa
|
||||
t.env.globals["SITE_TAGLINE"] = settings.lnbits_site_tagline
|
||||
t.env.globals["SITE_DESCRIPTION"] = settings.lnbits_site_description
|
||||
t.env.globals["LNBITS_THEME_OPTIONS"] = settings.lnbits_theme_options
|
||||
t.env.globals["COMMIT_VERSION"] = settings.lnbits_commit
|
||||
t.env.globals["LNBITS_VERSION"] = settings.version
|
||||
t.env.globals["LNBITS_ADMIN_UI"] = settings.lnbits_admin_ui
|
||||
t.env.globals["LNBITS_NODE_UI"] = (
|
||||
settings.lnbits_node_ui and get_node_class() is not None
|
||||
)
|
||||
t.env.globals["LNBITS_NODE_UI_AVAILABLE"] = get_node_class() is not None
|
||||
t.env.globals["EXTENSIONS"] = [
|
||||
e
|
||||
for e in get_valid_extensions()
|
||||
@@ -69,6 +73,8 @@ def template_renderer(additional_folders: Optional[List] = None) -> Jinja2Templa
|
||||
t.env.globals["INCLUDED_JS"] = vendor_files["js"]
|
||||
t.env.globals["INCLUDED_CSS"] = vendor_files["css"]
|
||||
|
||||
t.env.globals["WEBPUSH_PUBKEY"] = settings.lnbits_webpush_pubkey
|
||||
|
||||
return t
|
||||
|
||||
|
||||
@@ -90,32 +96,25 @@ def get_current_extension_name() -> str:
|
||||
with open(config_path) as json_file:
|
||||
config = json.load(json_file)
|
||||
ext_name = config["name"]
|
||||
except:
|
||||
except Exception:
|
||||
ext_name = extension_director_name
|
||||
return ext_name
|
||||
|
||||
|
||||
def generate_filter_params_openapi(model: Type[FilterModel], keep_optional=False):
|
||||
"""
|
||||
Generate openapi documentation for Filters. This is intended to be used along parse_filters (see example)
|
||||
Generate openapi documentation for Filters. This is intended to be used along
|
||||
parse_filters (see example)
|
||||
:param model: Filter model
|
||||
:param keep_optional: If false, all parameters will be optional, otherwise inferred from model
|
||||
:param keep_optional: If false, all parameters will be optional,
|
||||
otherwise inferred from model
|
||||
"""
|
||||
fields = list(model.__fields__.values())
|
||||
models = get_flat_models_from_fields(fields, set())
|
||||
namemap = get_model_name_map(models)
|
||||
params = []
|
||||
for field in fields:
|
||||
schema, definitions, _ = field_schema(field, model_name_map=namemap)
|
||||
|
||||
# Support nested definition
|
||||
if "$ref" in schema:
|
||||
name = schema["$ref"].split("/")[-1]
|
||||
schema = definitions[name]
|
||||
schema, _, _ = field_schema(field, model_name_map={})
|
||||
|
||||
description = "Supports Filtering"
|
||||
if schema["type"] == "object":
|
||||
description += f". Nested attributes can be filtered too, e.g. `{field.alias}.[additional].[attributes]`"
|
||||
if (
|
||||
hasattr(model, "__search_fields__")
|
||||
and field.name in model.__search_fields__
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
from typing import Union
|
||||
|
||||
from bech32 import bech32_decode, bech32_encode, convertbits
|
||||
from fastapi.datastructures import URL
|
||||
|
||||
|
||||
def decode(lnurl: str) -> str:
|
||||
@@ -10,8 +13,8 @@ def decode(lnurl: str) -> str:
|
||||
return bytes(bech32_data).decode()
|
||||
|
||||
|
||||
def encode(url: str) -> str:
|
||||
bech32_data = convertbits(url.encode(), 8, 5, True)
|
||||
def encode(url: Union[str, URL]) -> str:
|
||||
bech32_data = convertbits(str(url).encode(), 8, 5, True)
|
||||
assert bech32_data
|
||||
lnurl = bech32_encode("lnurl", bech32_data)
|
||||
return lnurl.upper()
|
||||
|
||||
@@ -2,9 +2,15 @@ from http import HTTPStatus
|
||||
from typing import Any, List, Tuple, Union
|
||||
from urllib.parse import parse_qs
|
||||
|
||||
from fastapi import FastAPI, Request, Response
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from slowapi import _rate_limit_exceeded_handler
|
||||
from slowapi.errors import RateLimitExceeded
|
||||
from slowapi.middleware import SlowAPIMiddleware
|
||||
from starlette.middleware.gzip import GZipMiddleware
|
||||
from starlette.types import ASGIApp, Receive, Scope, Send
|
||||
|
||||
from lnbits.core.db import core_app_extra
|
||||
from lnbits.helpers import template_renderer
|
||||
from lnbits.settings import settings
|
||||
|
||||
@@ -12,7 +18,8 @@ from lnbits.settings import settings
|
||||
class InstalledExtensionMiddleware:
|
||||
# This middleware class intercepts calls made to the extensions API and:
|
||||
# - it blocks the calls if the extension has been disabled or uninstalled.
|
||||
# - it redirects the calls to the latest version of the extension if the extension has been upgraded.
|
||||
# - it redirects the calls to the latest version of the extension
|
||||
# if the extension has been upgraded.
|
||||
# - otherwise it has no effect
|
||||
def __init__(self, app: ASGIApp) -> None:
|
||||
self.app = app
|
||||
@@ -83,9 +90,10 @@ class InstalledExtensionMiddleware:
|
||||
self, headers: List[Any], msg: str, status_code: HTTPStatus
|
||||
) -> Union[HTMLResponse, JSONResponse]:
|
||||
"""
|
||||
Build an HTTP response containing the `msg` as HTTP body and the `status_code` as HTTP code.
|
||||
If the `accept` HTTP header is present int the request and contains the value of `text/html`
|
||||
then return an `HTMLResponse`, otherwise return an `JSONResponse`.
|
||||
Build an HTTP response containing the `msg` as HTTP body and the `status_code`
|
||||
as HTTP code. If the `accept` HTTP header is present int the request and
|
||||
contains the value of `text/html` then return an `HTMLResponse`,
|
||||
otherwise return an `JSONResponse`.
|
||||
"""
|
||||
accept_header: str = next(
|
||||
(
|
||||
@@ -110,9 +118,21 @@ class InstalledExtensionMiddleware:
|
||||
)
|
||||
|
||||
|
||||
class CustomGZipMiddleware(GZipMiddleware):
|
||||
def __init__(self, *args, exclude_paths=None, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.exclude_paths = exclude_paths or []
|
||||
|
||||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||
if "path" in scope and scope["path"] in self.exclude_paths:
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
await super().__call__(scope, receive, send)
|
||||
|
||||
|
||||
class ExtensionsRedirectMiddleware:
|
||||
# Extensions are allowed to specify redirect paths.
|
||||
# A call to a path outside the scope of the extension can be redirected to one of the extension's endpoints.
|
||||
# Extensions are allowed to specify redirect paths. A call to a path outside the
|
||||
# scope of the extension can be redirected to one of the extension's endpoints.
|
||||
# Eg: redirect `GET /.well-known` to `GET /lnurlp/api/v1/well-known`
|
||||
|
||||
def __init__(self, app: ASGIApp) -> None:
|
||||
@@ -189,3 +209,39 @@ class ExtensionsRedirectMiddleware:
|
||||
]
|
||||
|
||||
return "/" + "/".join(elements)
|
||||
|
||||
|
||||
def add_ratelimit_middleware(app: FastAPI):
|
||||
core_app_extra.register_new_ratelimiter()
|
||||
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
||||
app.add_middleware(SlowAPIMiddleware)
|
||||
|
||||
|
||||
def add_ip_block_middleware(app: FastAPI):
|
||||
@app.middleware("http")
|
||||
async def block_allow_ip_middleware(request: Request, call_next):
|
||||
if not request.client:
|
||||
return JSONResponse(
|
||||
status_code=403, # Forbidden
|
||||
content={"detail": "No request client"},
|
||||
)
|
||||
if (
|
||||
request.client.host in settings.lnbits_blocked_ips
|
||||
and request.client.host not in settings.lnbits_allowed_ips
|
||||
):
|
||||
return JSONResponse(
|
||||
status_code=403, # Forbidden
|
||||
content={"detail": "IP is blocked"},
|
||||
)
|
||||
# this try: except: block is not needed on latest FastAPI
|
||||
# (await call_next(request) is enough)
|
||||
# https://stackoverflow.com/questions/71222144/runtimeerror-no-response-returned-in-fastapi-when-refresh-request
|
||||
# TODO: remove after https://github.com/lnbits/lnbits/pull/1609 is merged
|
||||
try:
|
||||
return await call_next(request)
|
||||
except RuntimeError as exc:
|
||||
if str(exc) == "No response returned." and await request.is_disconnected():
|
||||
return Response(status_code=HTTPStatus.NO_CONTENT)
|
||||
raise # bubble up different exceptions
|
||||
|
||||
app.middleware("http")(block_allow_ip_middleware)
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
from typing import Optional
|
||||
|
||||
from .base import Node
|
||||
|
||||
|
||||
def get_node_class() -> Optional[Node]:
|
||||
return NODE
|
||||
|
||||
|
||||
def set_node_class(node: Node):
|
||||
global NODE
|
||||
NODE = node
|
||||
|
||||
|
||||
NODE: Optional[Node] = None
|
||||
@@ -0,0 +1,223 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING, List, Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from lnbits.db import FilterModel, Filters, Page
|
||||
from lnbits.utils.cache import cache
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from lnbits.wallets.base import Wallet
|
||||
|
||||
|
||||
class NodePeerInfo(BaseModel):
|
||||
id: str
|
||||
alias: Optional[str] = None
|
||||
color: Optional[str] = None
|
||||
last_timestamp: Optional[int] = None
|
||||
addresses: Optional[list[str]] = None
|
||||
|
||||
|
||||
class ChannelState(Enum):
|
||||
ACTIVE = "active"
|
||||
PENDING = "pending"
|
||||
CLOSED = "closed"
|
||||
INACTIVE = "inactive"
|
||||
|
||||
|
||||
class ChannelBalance(BaseModel):
|
||||
local_msat: int
|
||||
remote_msat: int
|
||||
total_msat: int
|
||||
|
||||
|
||||
class ChannelPoint(BaseModel):
|
||||
funding_txid: str
|
||||
output_index: int
|
||||
|
||||
|
||||
class NodeChannel(BaseModel):
|
||||
short_id: Optional[str] = None
|
||||
point: Optional[ChannelPoint] = None
|
||||
peer_id: str
|
||||
balance: ChannelBalance
|
||||
state: ChannelState
|
||||
name: Optional[str]
|
||||
color: Optional[str]
|
||||
|
||||
|
||||
class ChannelStats(BaseModel):
|
||||
counts: dict[ChannelState, int]
|
||||
avg_size: int
|
||||
biggest_size: Optional[int]
|
||||
smallest_size: Optional[int]
|
||||
total_capacity: int
|
||||
|
||||
@classmethod
|
||||
def from_list(cls, channels: list[NodeChannel]):
|
||||
counts: dict[ChannelState, int] = {}
|
||||
for channel in channels:
|
||||
counts[channel.state] = counts.get(channel.state, 0) + 1
|
||||
|
||||
return cls(
|
||||
counts=counts,
|
||||
avg_size=int(
|
||||
sum(channel.balance.total_msat for channel in channels) / len(channels)
|
||||
),
|
||||
biggest_size=max(channel.balance.total_msat for channel in channels),
|
||||
smallest_size=min(channel.balance.total_msat for channel in channels),
|
||||
total_capacity=sum(channel.balance.total_msat for channel in channels),
|
||||
)
|
||||
|
||||
|
||||
class NodeFees(BaseModel):
|
||||
total_msat: int
|
||||
daily_msat: Optional[int] = None
|
||||
weekly_msat: Optional[int] = None
|
||||
monthly_msat: Optional[int] = None
|
||||
|
||||
|
||||
class PublicNodeInfo(BaseModel):
|
||||
id: str
|
||||
backend_name: str
|
||||
alias: str
|
||||
color: str
|
||||
num_peers: int
|
||||
blockheight: int
|
||||
channel_stats: ChannelStats
|
||||
addresses: list[str]
|
||||
|
||||
|
||||
class NodeInfoResponse(PublicNodeInfo):
|
||||
onchain_balance_sat: int
|
||||
onchain_confirmed_sat: int
|
||||
fees: NodeFees
|
||||
balance_msat: int
|
||||
|
||||
|
||||
class NodePayment(BaseModel):
|
||||
pending: bool
|
||||
amount: int
|
||||
fee: Optional[int] = None
|
||||
memo: Optional[str] = None
|
||||
time: int
|
||||
bolt11: str
|
||||
preimage: Optional[str]
|
||||
payment_hash: str
|
||||
expiry: Optional[float] = None
|
||||
destination: Optional[NodePeerInfo] = None
|
||||
|
||||
|
||||
class NodeInvoice(BaseModel):
|
||||
pending: bool
|
||||
amount: int
|
||||
memo: Optional[str]
|
||||
bolt11: str
|
||||
preimage: Optional[str]
|
||||
payment_hash: str
|
||||
paid_at: Optional[int] = None
|
||||
expiry: Optional[int] = None
|
||||
|
||||
|
||||
class NodeInvoiceFilters(FilterModel):
|
||||
pass
|
||||
|
||||
|
||||
class NodePaymentsFilters(FilterModel):
|
||||
pass
|
||||
|
||||
|
||||
class Node(ABC):
|
||||
wallet: Wallet
|
||||
|
||||
def __init__(self, wallet: Wallet):
|
||||
self.wallet = wallet
|
||||
self.id: Optional[str] = None
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return self.__class__.__name__
|
||||
|
||||
async def get_id(self):
|
||||
if not self.id:
|
||||
self.id = await self._get_id()
|
||||
return self.id
|
||||
|
||||
@abstractmethod
|
||||
async def _get_id(self) -> str:
|
||||
pass
|
||||
|
||||
async def get_peers(self) -> list[NodePeerInfo]:
|
||||
peer_ids = await self.get_peer_ids()
|
||||
return [await self.get_peer_info(peer_id) for peer_id in peer_ids]
|
||||
|
||||
@abstractmethod
|
||||
async def get_peer_ids(self) -> list[str]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def connect_peer(self, uri: str):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def disconnect_peer(self, peer_id: str):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def _get_peer_info(self, peer_id: str) -> NodePeerInfo:
|
||||
pass
|
||||
|
||||
async def get_peer_info(self, peer_id: str) -> NodePeerInfo:
|
||||
key = f"node:peers:{peer_id}"
|
||||
info = cache.get(key)
|
||||
if not info:
|
||||
info = await self._get_peer_info(peer_id)
|
||||
if info.last_timestamp:
|
||||
cache.set(key, info)
|
||||
return info
|
||||
|
||||
@abstractmethod
|
||||
async def open_channel(
|
||||
self,
|
||||
peer_id: str,
|
||||
local_amount: int,
|
||||
push_amount: Optional[int] = None,
|
||||
fee_rate: Optional[int] = None,
|
||||
) -> ChannelPoint:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def close_channel(
|
||||
self,
|
||||
short_id: Optional[str] = None,
|
||||
point: Optional[ChannelPoint] = None,
|
||||
force: bool = False,
|
||||
):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def get_channels(self) -> List[NodeChannel]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def get_info(self) -> NodeInfoResponse:
|
||||
pass
|
||||
|
||||
async def get_public_info(self) -> PublicNodeInfo:
|
||||
info = await self.get_info()
|
||||
return PublicNodeInfo(**info.__dict__)
|
||||
|
||||
@abstractmethod
|
||||
async def get_payments(
|
||||
self, filters: Filters[NodePaymentsFilters]
|
||||
) -> Page[NodePayment]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def get_invoices(
|
||||
self, filters: Filters[NodeInvoiceFilters]
|
||||
) -> Page[NodeInvoice]:
|
||||
pass
|
||||
@@ -0,0 +1,323 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from http import HTTPStatus
|
||||
from typing import TYPE_CHECKING, List, Optional
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from lnbits.db import Filters, Page
|
||||
|
||||
from ..utils.cache import cache
|
||||
|
||||
try:
|
||||
from pyln.client import RpcError # type: ignore
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# override the false type
|
||||
class RpcError(RpcError): # type: ignore
|
||||
error: dict
|
||||
|
||||
except ImportError: # pragma: nocover
|
||||
LightningRpc = None
|
||||
|
||||
from lnbits.nodes.base import (
|
||||
ChannelBalance,
|
||||
ChannelPoint,
|
||||
ChannelState,
|
||||
ChannelStats,
|
||||
Node,
|
||||
NodeFees,
|
||||
NodeInvoice,
|
||||
NodeInvoiceFilters,
|
||||
NodePaymentsFilters,
|
||||
NodePeerInfo,
|
||||
)
|
||||
|
||||
from .base import NodeChannel, NodeInfoResponse, NodePayment
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from lnbits.wallets import CoreLightningWallet
|
||||
|
||||
|
||||
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"])
|
||||
else:
|
||||
raise HTTPException(status_code=500, detail=e.error["message"])
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
class CoreLightningNode(Node):
|
||||
wallet: CoreLightningWallet
|
||||
|
||||
async def ln_rpc(self, method, *args, **kwargs) -> dict:
|
||||
loop = asyncio.get_event_loop()
|
||||
fn = getattr(self.wallet.ln, method)
|
||||
return await loop.run_in_executor(None, lambda: fn(*args, **kwargs))
|
||||
|
||||
@catch_rpc_errors
|
||||
async def connect_peer(self, uri: str):
|
||||
# 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"])
|
||||
else:
|
||||
raise
|
||||
|
||||
@catch_rpc_errors
|
||||
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:
|
||||
raise HTTPException(
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
detail=e.error["message"],
|
||||
)
|
||||
else:
|
||||
raise
|
||||
|
||||
@catch_rpc_errors
|
||||
async def open_channel(
|
||||
self,
|
||||
peer_id: str,
|
||||
local_amount: int,
|
||||
push_amount: Optional[int] = None,
|
||||
fee_rate: Optional[int] = None,
|
||||
) -> ChannelPoint:
|
||||
try:
|
||||
result = await self.ln_rpc(
|
||||
"fundchannel",
|
||||
peer_id,
|
||||
amount=local_amount,
|
||||
push_msat=int(push_amount * 1000) if push_amount else None,
|
||||
feerate=fee_rate,
|
||||
)
|
||||
return ChannelPoint(
|
||||
funding_txid=result["txid"],
|
||||
output_index=result["outnum"],
|
||||
)
|
||||
except RpcError as e:
|
||||
message = e.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.",
|
||||
)
|
||||
|
||||
if "Unknown peer" in message:
|
||||
raise HTTPException(
|
||||
HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
detail=(
|
||||
"We where able to connect to the peer but CLN "
|
||||
"can't find it when opening a channel."
|
||||
),
|
||||
)
|
||||
|
||||
if "Owning subdaemon openingd died" in message:
|
||||
# https://github.com/ElementsProject/lightning/issues/2798#issuecomment-511205719
|
||||
raise HTTPException(
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
detail=(
|
||||
"Likely the peer didn't like our channel opening "
|
||||
"proposal and disconnected from us."
|
||||
),
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
@catch_rpc_errors
|
||||
async def close_channel(
|
||||
self,
|
||||
short_id: Optional[str] = None,
|
||||
point: Optional[ChannelPoint] = None,
|
||||
force: bool = False,
|
||||
):
|
||||
if not short_id:
|
||||
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"]
|
||||
if (
|
||||
"Short channel ID not active:" in message
|
||||
or "Short channel ID not found" in message
|
||||
):
|
||||
raise HTTPException(HTTPStatus.BAD_REQUEST, detail=message)
|
||||
else:
|
||||
raise
|
||||
|
||||
@catch_rpc_errors
|
||||
async def _get_id(self) -> str:
|
||||
info = await self.ln_rpc("getinfo")
|
||||
return info["id"]
|
||||
|
||||
@catch_rpc_errors
|
||||
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"]]
|
||||
|
||||
@catch_rpc_errors
|
||||
async def _get_peer_info(self, peer_id: str) -> NodePeerInfo:
|
||||
result = await self.ln_rpc("listnodes", peer_id)
|
||||
nodes = result["nodes"]
|
||||
if len(nodes) == 0:
|
||||
return NodePeerInfo(id=peer_id)
|
||||
node = nodes[0]
|
||||
if "last_timestamp" in node:
|
||||
return NodePeerInfo(
|
||||
id=node["nodeid"],
|
||||
alias=node["alias"],
|
||||
color=node["color"],
|
||||
last_timestamp=node["last_timestamp"],
|
||||
addresses=[
|
||||
address["address"] + ":" + str(address["port"])
|
||||
for address in node["addresses"]
|
||||
],
|
||||
)
|
||||
else:
|
||||
return NodePeerInfo(id=node["nodeid"])
|
||||
|
||||
@catch_rpc_errors
|
||||
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"]}
|
||||
|
||||
return [
|
||||
NodeChannel(
|
||||
short_id=ch.get("short_channel_id"),
|
||||
point=ChannelPoint(
|
||||
funding_txid=ch["funding_txid"],
|
||||
output_index=ch["funding_output"],
|
||||
),
|
||||
peer_id=ch["peer_id"],
|
||||
balance=ChannelBalance(
|
||||
local_msat=ch["our_amount_msat"],
|
||||
remote_msat=ch["amount_msat"] - ch["our_amount_msat"],
|
||||
total_msat=ch["amount_msat"],
|
||||
),
|
||||
name=nodes_by_id.get(ch["peer_id"], {}).get("alias"),
|
||||
color=nodes_by_id.get(ch["peer_id"], {}).get("color"),
|
||||
state=(
|
||||
ChannelState.ACTIVE
|
||||
if ch["state"] == "CHANNELD_NORMAL"
|
||||
else ChannelState.PENDING
|
||||
if ch["state"] in ("CHANNELD_AWAITING_LOCKIN", "OPENINGD")
|
||||
else ChannelState.CLOSED
|
||||
if ch["state"]
|
||||
in (
|
||||
"CHANNELD_CLOSING",
|
||||
"CLOSINGD_COMPLETE",
|
||||
"CLOSINGD_SIGEXCHANGE",
|
||||
"ONCHAIN",
|
||||
)
|
||||
else ChannelState.INACTIVE
|
||||
),
|
||||
)
|
||||
for ch in funds["channels"]
|
||||
]
|
||||
|
||||
@catch_rpc_errors
|
||||
async def get_info(self) -> NodeInfoResponse:
|
||||
info = await self.ln_rpc("getinfo")
|
||||
funds = await self.ln_rpc("listfunds")
|
||||
|
||||
channels = await self.get_channels()
|
||||
active_channels = [
|
||||
channel for channel in channels if channel.state == ChannelState.ACTIVE
|
||||
]
|
||||
return NodeInfoResponse(
|
||||
id=info["id"],
|
||||
backend_name="CLN",
|
||||
alias=info["alias"],
|
||||
color=info["color"],
|
||||
onchain_balance_sat=sum(output["value"] for output in funds["outputs"]),
|
||||
onchain_confirmed_sat=sum(
|
||||
output["amount_msat"] / 1000
|
||||
for output in funds["outputs"]
|
||||
if output["status"] == "confirmed"
|
||||
),
|
||||
channel_stats=ChannelStats.from_list(channels),
|
||||
num_peers=info["num_peers"],
|
||||
blockheight=info["blockheight"],
|
||||
balance_msat=sum(channel.balance.local_msat for channel in active_channels),
|
||||
fees=NodeFees(total_msat=info["fees_collected_msat"]),
|
||||
addresses=[address["address"] for address in info["address"]],
|
||||
)
|
||||
|
||||
@catch_rpc_errors
|
||||
async def get_payments(
|
||||
self, filters: Filters[NodePaymentsFilters]
|
||||
) -> Page[NodePayment]:
|
||||
async def get_payments():
|
||||
result = await self.ln_rpc("listpays")
|
||||
return [
|
||||
NodePayment(
|
||||
bolt11=pay["bolt11"],
|
||||
amount=pay["amount_msat"],
|
||||
fee=int(pay["amount_msat"]) - int(pay["amount_sent_msat"]),
|
||||
memo=pay.get("description"),
|
||||
time=pay["created_at"],
|
||||
preimage=pay.get("preimage"),
|
||||
payment_hash=pay["payment_hash"],
|
||||
pending=pay["status"] != "complete",
|
||||
destination=await self.get_peer_info(pay["destination"]),
|
||||
)
|
||||
for pay in reversed(result["pays"])
|
||||
if pay["status"] != "failed"
|
||||
]
|
||||
|
||||
results = await cache.save_result(get_payments, key="node:payments")
|
||||
count = len(results)
|
||||
if filters.offset:
|
||||
results = results[filters.offset :]
|
||||
if filters.limit:
|
||||
results = results[: filters.limit]
|
||||
return Page(data=results, total=count)
|
||||
|
||||
@catch_rpc_errors
|
||||
async def get_invoices(
|
||||
self, filters: Filters[NodeInvoiceFilters]
|
||||
) -> Page[NodeInvoice]:
|
||||
result = await cache.save_result(
|
||||
lambda: self.ln_rpc("listinvoices"), key="node:invoices"
|
||||
)
|
||||
invoices = result["invoices"]
|
||||
invoices.reverse()
|
||||
count = len(invoices)
|
||||
if filters.offset:
|
||||
invoices = invoices[filters.offset :]
|
||||
if filters.limit:
|
||||
invoices = invoices[: filters.limit]
|
||||
return Page(
|
||||
data=[
|
||||
NodeInvoice(
|
||||
bolt11=invoice["bolt11"],
|
||||
amount=invoice["amount_msat"],
|
||||
preimage=invoice.get("payment_preimage"),
|
||||
memo=invoice["description"],
|
||||
paid_at=invoice.get("paid_at"),
|
||||
expiry=invoice["expires_at"],
|
||||
payment_hash=invoice["payment_hash"],
|
||||
pending=invoice["status"] != "paid",
|
||||
)
|
||||
for invoice in invoices
|
||||
],
|
||||
total=count,
|
||||
)
|
||||
@@ -0,0 +1,382 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
from http import HTTPStatus
|
||||
from typing import TYPE_CHECKING, List, Optional
|
||||
|
||||
from fastapi import HTTPException
|
||||
from httpx import HTTPStatusError
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.db import Filters, Page
|
||||
from lnbits.nodes import Node
|
||||
from lnbits.nodes.base import (
|
||||
ChannelBalance,
|
||||
ChannelPoint,
|
||||
ChannelState,
|
||||
ChannelStats,
|
||||
NodeChannel,
|
||||
NodeFees,
|
||||
NodeInfoResponse,
|
||||
NodeInvoice,
|
||||
NodeInvoiceFilters,
|
||||
NodePayment,
|
||||
NodePaymentsFilters,
|
||||
NodePeerInfo,
|
||||
PublicNodeInfo,
|
||||
)
|
||||
from lnbits.utils.cache import cache
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from lnbits.wallets import LndRestWallet
|
||||
|
||||
|
||||
def msat(raw: str) -> int:
|
||||
return int(raw) * 1000
|
||||
|
||||
|
||||
def _decode_bytes(data: str) -> str:
|
||||
return base64.b64decode(data).hex()
|
||||
|
||||
|
||||
def _parse_channel_point(raw: str) -> ChannelPoint:
|
||||
funding_tx, output_index = raw.split(":")
|
||||
return ChannelPoint(
|
||||
funding_txid=funding_tx,
|
||||
output_index=int(output_index),
|
||||
)
|
||||
|
||||
|
||||
class LndRestNode(Node):
|
||||
wallet: LndRestWallet
|
||||
|
||||
async def request(
|
||||
self, method: str, path: str, json: Optional[dict] = None, **kwargs
|
||||
):
|
||||
response = await self.wallet.client.request(
|
||||
method, f"{self.wallet.endpoint}{path}", json=json, **kwargs
|
||||
)
|
||||
try:
|
||||
response.raise_for_status()
|
||||
except HTTPStatusError as e:
|
||||
json = e.response.json()
|
||||
if json:
|
||||
error = json.get("error") or json
|
||||
raise HTTPException(e.response.status_code, detail=error.get("message"))
|
||||
return response.json()
|
||||
|
||||
def get(self, path: str, **kwargs):
|
||||
return self.request("GET", path, **kwargs)
|
||||
|
||||
async def _get_id(self) -> str:
|
||||
info = await self.get("/v1/getinfo")
|
||||
return info["identity_pubkey"]
|
||||
|
||||
async def get_peer_ids(self) -> list[str]:
|
||||
response = await self.get("/v1/peers")
|
||||
return [p["pub_key"] for p in response["peers"]]
|
||||
|
||||
async def connect_peer(self, uri: str):
|
||||
try:
|
||||
pubkey, host = uri.split("@")
|
||||
except ValueError:
|
||||
raise HTTPException(400, detail="Invalid peer URI")
|
||||
await self.request(
|
||||
"POST",
|
||||
"/v1/peers",
|
||||
json={
|
||||
"addr": {"pubkey": pubkey, "host": host},
|
||||
"perm": True,
|
||||
"timeout": 30,
|
||||
},
|
||||
)
|
||||
|
||||
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:
|
||||
raise HTTPException(
|
||||
HTTPStatus.BAD_REQUEST, detail="Peer is not connected"
|
||||
)
|
||||
raise
|
||||
|
||||
async def _get_peer_info(self, peer_id: str) -> NodePeerInfo:
|
||||
try:
|
||||
response = await self.get("/v1/graph/node/" + peer_id)
|
||||
except HTTPException:
|
||||
return NodePeerInfo(id=peer_id)
|
||||
node = response["node"]
|
||||
return NodePeerInfo(
|
||||
id=peer_id,
|
||||
alias=node["alias"],
|
||||
color=node["color"].strip("#"),
|
||||
last_timestamp=node["last_update"],
|
||||
addresses=[a["addr"] for a in node["addresses"]],
|
||||
)
|
||||
|
||||
async def open_channel(
|
||||
self,
|
||||
peer_id: str,
|
||||
local_amount: int,
|
||||
push_amount: Optional[int] = None,
|
||||
fee_rate: Optional[int] = None,
|
||||
) -> ChannelPoint:
|
||||
response = await self.request(
|
||||
"POST",
|
||||
"/v1/channels",
|
||||
data=json.dumps(
|
||||
{
|
||||
# 'node_pubkey': base64.b64encode(peer_id.encode()).decode(),
|
||||
"node_pubkey_string": peer_id,
|
||||
"sat_per_vbyte": fee_rate,
|
||||
"local_funding_amount": local_amount,
|
||||
"push_sat": push_amount,
|
||||
}
|
||||
),
|
||||
)
|
||||
return ChannelPoint(
|
||||
# WHY IS THIS REVERSED?!
|
||||
funding_txid=bytes(
|
||||
reversed(base64.b64decode(response["funding_txid_bytes"]))
|
||||
).hex(),
|
||||
output_index=response["output_index"],
|
||||
)
|
||||
|
||||
async def _close_channel(
|
||||
self,
|
||||
point: ChannelPoint,
|
||||
force: bool = False,
|
||||
):
|
||||
async with self.wallet.client.stream(
|
||||
"DELETE",
|
||||
f"{self.wallet.endpoint}/v1/channels/{point.funding_txid}/{point.output_index}",
|
||||
params={"force": force},
|
||||
timeout=None,
|
||||
) as stream:
|
||||
async for chunk in stream.aiter_text():
|
||||
if chunk:
|
||||
chunk = json.loads(chunk)
|
||||
logger.info(f"LND Channel close update: {chunk['result']}")
|
||||
|
||||
async def close_channel(
|
||||
self,
|
||||
short_id: Optional[str] = None,
|
||||
point: Optional[ChannelPoint] = None,
|
||||
force: bool = False,
|
||||
):
|
||||
if not point:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST, detail="Channel point required"
|
||||
)
|
||||
|
||||
asyncio.create_task(self._close_channel(point, force))
|
||||
|
||||
async def get_channels(self) -> List[NodeChannel]:
|
||||
normal, pending, closed = await asyncio.gather(
|
||||
self.get("/v1/channels"),
|
||||
self.get("/v1/channels/pending"),
|
||||
self.get("/v1/channels/closed"),
|
||||
)
|
||||
|
||||
channels = []
|
||||
|
||||
async def parse_pending(raw_channels, state):
|
||||
for channel in raw_channels:
|
||||
channel = channel["channel"]
|
||||
info = await self.get_peer_info(channel["remote_node_pub"])
|
||||
channels.append(
|
||||
NodeChannel(
|
||||
peer_id=info.id,
|
||||
state=state,
|
||||
name=info.alias,
|
||||
color=info.color,
|
||||
point=_parse_channel_point(channel["channel_point"]),
|
||||
balance=ChannelBalance(
|
||||
local_msat=msat(channel["local_balance"]),
|
||||
remote_msat=msat(channel["remote_balance"]),
|
||||
total_msat=msat(channel["capacity"]),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
await parse_pending(pending["pending_open_channels"], ChannelState.PENDING)
|
||||
await parse_pending(
|
||||
pending["pending_force_closing_channels"], ChannelState.CLOSED
|
||||
)
|
||||
await parse_pending(pending["waiting_close_channels"], ChannelState.CLOSED)
|
||||
|
||||
for channel in closed["channels"]:
|
||||
info = await self.get_peer_info(channel["remote_pubkey"])
|
||||
channels.append(
|
||||
NodeChannel(
|
||||
peer_id=info.id,
|
||||
state=ChannelState.CLOSED,
|
||||
name=info.alias,
|
||||
color=info.color,
|
||||
point=_parse_channel_point(channel["channel_point"]),
|
||||
balance=ChannelBalance(
|
||||
local_msat=0,
|
||||
remote_msat=0,
|
||||
total_msat=msat(channel["capacity"]),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
for channel in normal["channels"]:
|
||||
info = await self.get_peer_info(channel["remote_pubkey"])
|
||||
channels.append(
|
||||
NodeChannel(
|
||||
short_id=channel["chan_id"],
|
||||
point=_parse_channel_point(channel["channel_point"]),
|
||||
peer_id=channel["remote_pubkey"],
|
||||
balance=ChannelBalance(
|
||||
local_msat=msat(channel["local_balance"]),
|
||||
remote_msat=msat(channel["remote_balance"]),
|
||||
total_msat=msat(channel["capacity"]),
|
||||
),
|
||||
state=ChannelState.ACTIVE
|
||||
if channel["active"]
|
||||
else ChannelState.INACTIVE,
|
||||
# name=channel['peer_alias'],
|
||||
name=info.alias,
|
||||
color=info.color,
|
||||
)
|
||||
)
|
||||
|
||||
return channels
|
||||
|
||||
async def get_public_info(self) -> PublicNodeInfo:
|
||||
info = await self.get("/v1/getinfo")
|
||||
channels = await self.get_channels()
|
||||
return PublicNodeInfo(
|
||||
backend_name="LND",
|
||||
id=info["identity_pubkey"],
|
||||
color=info["color"].lstrip("#"),
|
||||
alias=info["alias"],
|
||||
num_peers=info["num_peers"],
|
||||
blockheight=info["block_height"],
|
||||
addresses=info["uris"],
|
||||
channel_stats=ChannelStats.from_list(channels),
|
||||
)
|
||||
|
||||
async def get_info(self) -> NodeInfoResponse:
|
||||
public = await self.get_public_info()
|
||||
onchain = await self.get("/v1/balance/blockchain")
|
||||
fee_report = await self.get("/v1/fees")
|
||||
balance = await self.get("/v1/balance/channels")
|
||||
return NodeInfoResponse(
|
||||
**public.dict(),
|
||||
onchain_balance_sat=onchain["total_balance"],
|
||||
onchain_confirmed_sat=onchain["confirmed_balance"],
|
||||
balance_msat=balance["local_balance"]["msat"],
|
||||
fees=NodeFees(
|
||||
total_msat=0,
|
||||
daily_msat=fee_report["day_fee_sum"],
|
||||
weekly_msat=fee_report["week_fee_sum"],
|
||||
monthly_msat=fee_report["month_fee_sum"],
|
||||
),
|
||||
)
|
||||
|
||||
async def get_payments(
|
||||
self, filters: Filters[NodePaymentsFilters]
|
||||
) -> Page[NodePayment]:
|
||||
count_key = "node:payments_count"
|
||||
payments_count = cache.get(count_key)
|
||||
if not payments_count and filters.offset:
|
||||
# this forces fetching the payments count
|
||||
await self.get_payments(Filters(limit=1))
|
||||
payments_count = cache.get(count_key)
|
||||
|
||||
if filters.offset and payments_count:
|
||||
index_offset = max(payments_count + 1 - filters.offset, 0)
|
||||
else:
|
||||
index_offset = 0
|
||||
|
||||
response = await self.get(
|
||||
"/v1/payments",
|
||||
params={
|
||||
"index_offset": index_offset,
|
||||
"max_payments": filters.limit,
|
||||
"include_incomplete": True,
|
||||
"reversed": True,
|
||||
"count_total_payments": not index_offset,
|
||||
},
|
||||
)
|
||||
|
||||
if not filters.offset:
|
||||
payments_count = int(response["total_num_payments"])
|
||||
|
||||
cache.set(count_key, payments_count)
|
||||
|
||||
payments = [
|
||||
NodePayment(
|
||||
payment_hash=payment["payment_hash"],
|
||||
pending=payment["status"] == "IN_FLIGHT",
|
||||
amount=payment["value_msat"],
|
||||
fee=payment["fee_msat"],
|
||||
time=payment["creation_date"],
|
||||
destination=await self.get_peer_info(
|
||||
payment["htlcs"][0]["route"]["hops"][-1]["pub_key"]
|
||||
)
|
||||
if payment["htlcs"]
|
||||
else None,
|
||||
bolt11=payment["payment_request"],
|
||||
preimage=payment["payment_preimage"],
|
||||
)
|
||||
for payment in response["payments"]
|
||||
]
|
||||
|
||||
payments.sort(key=lambda p: p.time, reverse=True)
|
||||
|
||||
return Page(data=payments, total=payments_count or 0)
|
||||
|
||||
async def get_invoices(
|
||||
self, filters: Filters[NodeInvoiceFilters]
|
||||
) -> Page[NodeInvoice]:
|
||||
last_invoice_key = "node:last_invoice_index"
|
||||
last_invoice_index = cache.get(last_invoice_key)
|
||||
if not last_invoice_index and filters.offset:
|
||||
# this forces fetching the last invoice index so
|
||||
await self.get_invoices(Filters(limit=1))
|
||||
last_invoice_index = cache.get(last_invoice_key)
|
||||
|
||||
if filters.offset and last_invoice_index:
|
||||
index_offset = max(last_invoice_index + 1 - filters.offset, 0)
|
||||
else:
|
||||
index_offset = 0
|
||||
|
||||
response = await self.get(
|
||||
"/v1/invoices",
|
||||
params={
|
||||
"index_offset": index_offset,
|
||||
"num_max_invoices": filters.limit,
|
||||
"reversed": True,
|
||||
},
|
||||
)
|
||||
|
||||
if not filters.offset:
|
||||
last_invoice_index = int(response["last_index_offset"])
|
||||
|
||||
cache.set(last_invoice_key, last_invoice_index)
|
||||
|
||||
invoices = [
|
||||
NodeInvoice(
|
||||
payment_hash=_decode_bytes(invoice["r_hash"]),
|
||||
amount=invoice["value_msat"],
|
||||
memo=invoice["memo"],
|
||||
pending=invoice["state"] == "OPEN",
|
||||
paid_at=invoice["settle_date"],
|
||||
expiry=invoice["creation_date"] + invoice["expiry"],
|
||||
preimage=_decode_bytes(invoice["r_preimage"]),
|
||||
bolt11=invoice["payment_request"],
|
||||
)
|
||||
for invoice in reversed(response["invoices"])
|
||||
]
|
||||
|
||||
return Page(
|
||||
data=invoices,
|
||||
total=last_invoice_index or 0,
|
||||
)
|
||||
@@ -1,13 +1,10 @@
|
||||
import uvloop
|
||||
|
||||
uvloop.install()
|
||||
|
||||
import multiprocessing as mp
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
import uvicorn
|
||||
from uvicorn.supervisors import ChangeReload
|
||||
|
||||
from lnbits.settings import set_cli_settings, settings
|
||||
|
||||
@@ -41,9 +38,15 @@ def main(
|
||||
# create data dir if it does not exist
|
||||
Path(settings.lnbits_data_folder).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# create `extensions`` dir if it does not exist
|
||||
Path(settings.lnbits_extensions_path, "extensions").mkdir(
|
||||
parents=True, exist_ok=True
|
||||
)
|
||||
|
||||
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
|
||||
# this beautiful beast parses all command line arguments and
|
||||
# passes them to the uvicorn server
|
||||
d = dict()
|
||||
for a in ctx.args:
|
||||
item = a.split("=")
|
||||
@@ -70,7 +73,14 @@ def main(
|
||||
)
|
||||
|
||||
server = uvicorn.Server(config=config)
|
||||
process = mp.Process(target=server.run)
|
||||
|
||||
if config.should_reload:
|
||||
sock = config.bind_socket()
|
||||
run = ChangeReload(config, target=server.run, sockets=[sock]).run
|
||||
else:
|
||||
run = server.run
|
||||
|
||||
process = mp.Process(target=run)
|
||||
process.start()
|
||||
server_restart.wait()
|
||||
server_restart.clear()
|
||||
|
||||
@@ -1,42 +1,36 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import importlib.metadata
|
||||
import inspect
|
||||
import json
|
||||
import subprocess
|
||||
from os import path
|
||||
from sqlite3 import Row
|
||||
from typing import Any, List, Optional
|
||||
|
||||
import httpx
|
||||
from loguru import logger
|
||||
from pydantic import BaseSettings, Extra, Field, validator
|
||||
from pydantic import BaseModel, BaseSettings, Extra, Field, validator
|
||||
|
||||
|
||||
def list_parse_fallback(v):
|
||||
try:
|
||||
return json.loads(v)
|
||||
except Exception:
|
||||
replaced = v.replace(" ", "")
|
||||
if replaced:
|
||||
return replaced.split(",")
|
||||
def list_parse_fallback(v: str):
|
||||
v = v.replace(" ", "")
|
||||
if len(v) > 0:
|
||||
if v.startswith("[") or v.startswith("{"):
|
||||
return json.loads(v)
|
||||
else:
|
||||
return []
|
||||
return v.split(",")
|
||||
else:
|
||||
return []
|
||||
|
||||
|
||||
class LNbitsSettings(BaseSettings):
|
||||
class LNbitsSettings(BaseModel):
|
||||
@classmethod
|
||||
def validate(cls, val):
|
||||
if type(val) == str:
|
||||
def validate_list(cls, val):
|
||||
if isinstance(val, str):
|
||||
val = val.split(",") if val else []
|
||||
return val
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
env_file_encoding = "utf-8"
|
||||
case_sensitive = False
|
||||
json_loads = list_parse_fallback
|
||||
extra = Extra.ignore
|
||||
|
||||
|
||||
class UsersSettings(LNbitsSettings):
|
||||
lnbits_admin_users: List[str] = Field(default=[])
|
||||
@@ -89,6 +83,8 @@ class ThemesSettings(LNbitsSettings):
|
||||
default="https://shop.lnbits.com/;/static/images/lnbits-shop-light.png;/static/images/lnbits-shop-dark.png"
|
||||
) # sneaky sneaky
|
||||
lnbits_ad_space_enabled: bool = Field(default=False)
|
||||
lnbits_allowed_currencies: List[str] = Field(default=[])
|
||||
lnbits_default_accounting_currency: Optional[str] = Field(default=None)
|
||||
|
||||
|
||||
class OpsSettings(LNbitsSettings):
|
||||
@@ -100,6 +96,24 @@ class OpsSettings(LNbitsSettings):
|
||||
lnbits_denomination: str = Field(default="sats")
|
||||
|
||||
|
||||
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_notifications: bool = Field(default=False)
|
||||
lnbits_killswitch: bool = Field(default=False)
|
||||
lnbits_killswitch_interval: int = Field(default=60)
|
||||
lnbits_watchdog: bool = Field(default=False)
|
||||
lnbits_watchdog_interval: int = Field(default=60)
|
||||
lnbits_watchdog_delta: int = Field(default=1_000_000)
|
||||
lnbits_status_manifest: str = Field(
|
||||
default=(
|
||||
"https://raw.githubusercontent.com/lnbits/lnbits-status/main/manifest.json"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class FakeWalletFundingSource(LNbitsSettings):
|
||||
fake_wallet_secret: str = Field(default="ToTheMoon1")
|
||||
|
||||
@@ -120,6 +134,12 @@ class CoreLightningFundingSource(LNbitsSettings):
|
||||
clightning_rpc: Optional[str] = Field(default=None)
|
||||
|
||||
|
||||
class CoreLightningRestFundingSource(LNbitsSettings):
|
||||
corelightning_rest_url: Optional[str] = Field(default=None)
|
||||
corelightning_rest_macaroon: Optional[str] = Field(default=None)
|
||||
corelightning_rest_cert: Optional[str] = Field(default=None)
|
||||
|
||||
|
||||
class EclairFundingSource(LNbitsSettings):
|
||||
eclair_url: Optional[str] = Field(default=None)
|
||||
eclair_pass: Optional[str] = Field(default=None)
|
||||
@@ -182,7 +202,7 @@ class BoltzExtensionSettings(LNbitsSettings):
|
||||
|
||||
|
||||
class LightningSettings(LNbitsSettings):
|
||||
lightning_invoice_expiry: int = Field(default=600)
|
||||
lightning_invoice_expiry: int = Field(default=3600)
|
||||
|
||||
|
||||
class FundingSourcesSettings(
|
||||
@@ -190,6 +210,7 @@ class FundingSourcesSettings(
|
||||
LNbitsFundingSource,
|
||||
ClicheFundingSource,
|
||||
CoreLightningFundingSource,
|
||||
CoreLightningRestFundingSource,
|
||||
EclairFundingSource,
|
||||
LndRestFundingSource,
|
||||
LndGrpcFundingSource,
|
||||
@@ -201,14 +222,32 @@ class FundingSourcesSettings(
|
||||
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)
|
||||
|
||||
|
||||
class NodeUISettings(LNbitsSettings):
|
||||
# on-off switch for node ui
|
||||
lnbits_node_ui: bool = Field(default=False)
|
||||
# whether to display the public node ui (only if lnbits_node_ui is True)
|
||||
lnbits_public_node_ui: bool = Field(default=False)
|
||||
# can be used to disable the transactions tab in the node ui
|
||||
# (recommended for large cln nodes)
|
||||
lnbits_node_ui_transactions: bool = Field(default=False)
|
||||
|
||||
|
||||
class EditableSettings(
|
||||
UsersSettings,
|
||||
ExtensionsSettings,
|
||||
ThemesSettings,
|
||||
OpsSettings,
|
||||
SecuritySettings,
|
||||
FundingSourcesSettings,
|
||||
BoltzExtensionSettings,
|
||||
LightningSettings,
|
||||
WebPushSettings,
|
||||
NodeUISettings,
|
||||
):
|
||||
@validator(
|
||||
"lnbits_admin_users",
|
||||
@@ -219,7 +258,7 @@ class EditableSettings(
|
||||
)
|
||||
@classmethod
|
||||
def validate_editable_settings(cls, val):
|
||||
return super().validate(val)
|
||||
return super().validate_list(val)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict):
|
||||
@@ -227,6 +266,18 @@ class EditableSettings(
|
||||
**{k: v for k, v in d.items() if k in inspect.signature(cls).parameters}
|
||||
)
|
||||
|
||||
# fixes openapi.json validation, remove field env_names
|
||||
class Config:
|
||||
@staticmethod
|
||||
def schema_extra(schema: dict[str, Any]) -> None:
|
||||
for prop in schema.get("properties", {}).values():
|
||||
prop.pop("env_names", None)
|
||||
|
||||
|
||||
class UpdateSettings(EditableSettings):
|
||||
class Config:
|
||||
extra = Extra.forbid
|
||||
|
||||
|
||||
class EnvSettings(LNbitsSettings):
|
||||
debug: bool = Field(default=False)
|
||||
@@ -234,11 +285,16 @@ class EnvSettings(LNbitsSettings):
|
||||
host: str = Field(default="127.0.0.1")
|
||||
port: int = Field(default=5000)
|
||||
forwarded_allow_ips: str = Field(default="*")
|
||||
lnbits_title: str = Field(default="LNbits API")
|
||||
lnbits_path: str = Field(default=".")
|
||||
lnbits_commit: str = Field(default="unknown")
|
||||
lnbits_extensions_path: str = Field(default="lnbits")
|
||||
super_user: str = Field(default="")
|
||||
version: str = Field(default="0.0.0")
|
||||
|
||||
@property
|
||||
def has_default_extension_path(self) -> bool:
|
||||
return self.lnbits_extensions_path == "lnbits"
|
||||
|
||||
|
||||
class SaaSSettings(LNbitsSettings):
|
||||
lnbits_saas_callback: Optional[str] = Field(default=None)
|
||||
@@ -257,6 +313,7 @@ class SuperUserSettings(LNbitsSettings):
|
||||
"VoidWallet",
|
||||
"FakeWallet",
|
||||
"CoreLightningWallet",
|
||||
"CoreLightningRestWallet",
|
||||
"LndRestWallet",
|
||||
"EclairWallet",
|
||||
"LndWallet",
|
||||
@@ -295,19 +352,25 @@ class ReadOnlySettings(
|
||||
)
|
||||
@classmethod
|
||||
def validate_readonly_settings(cls, val):
|
||||
return super().validate(val)
|
||||
return super().validate_list(val)
|
||||
|
||||
@classmethod
|
||||
def readonly_fields(cls):
|
||||
return [f for f in inspect.signature(cls).parameters if not f.startswith("_")]
|
||||
|
||||
|
||||
class Settings(EditableSettings, ReadOnlySettings, TransientSettings):
|
||||
class Settings(EditableSettings, ReadOnlySettings, TransientSettings, BaseSettings):
|
||||
@classmethod
|
||||
def from_row(cls, row: Row) -> "Settings":
|
||||
data = dict(row)
|
||||
return cls(**data)
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
env_file_encoding = "utf-8"
|
||||
case_sensitive = False
|
||||
json_loads = list_parse_fallback
|
||||
|
||||
|
||||
class SuperSettings(EditableSettings):
|
||||
super_user: str
|
||||
@@ -323,19 +386,6 @@ def set_cli_settings(**kwargs):
|
||||
setattr(settings, key, value)
|
||||
|
||||
|
||||
# set wallet class after settings are loaded
|
||||
def set_wallet_class(class_name: Optional[str] = None):
|
||||
backend_wallet_class = class_name or settings.lnbits_backend_wallet_class
|
||||
wallet_class = getattr(wallets_module, backend_wallet_class)
|
||||
global WALLET
|
||||
WALLET = wallet_class()
|
||||
|
||||
|
||||
def get_wallet_class():
|
||||
# wallet_class = getattr(wallets_module, settings.lnbits_backend_wallet_class)
|
||||
return WALLET
|
||||
|
||||
|
||||
def send_admin_user_to_saas():
|
||||
if settings.lnbits_saas_callback:
|
||||
with httpx.Client() as client:
|
||||
@@ -356,7 +406,8 @@ def send_admin_user_to_saas():
|
||||
logger.success("sent super_user to saas application")
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"error sending super_user to saas: {settings.lnbits_saas_callback}. Error: {str(e)}"
|
||||
"error sending super_user to saas:"
|
||||
f" {settings.lnbits_saas_callback}. Error: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@@ -367,18 +418,6 @@ settings = Settings()
|
||||
|
||||
settings.lnbits_path = str(path.dirname(path.realpath(__file__)))
|
||||
|
||||
try:
|
||||
settings.lnbits_commit = (
|
||||
subprocess.check_output(
|
||||
["git", "-C", settings.lnbits_path, "rev-parse", "HEAD"],
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
.strip()
|
||||
.decode("ascii")
|
||||
)
|
||||
except:
|
||||
settings.lnbits_commit = "docker"
|
||||
|
||||
settings.version = importlib.metadata.version("lnbits")
|
||||
|
||||
# printing environment variable for debugging
|
||||
@@ -388,8 +427,10 @@ if not settings.lnbits_admin_ui:
|
||||
logger.debug(f"{key}: {value}")
|
||||
|
||||
|
||||
wallets_module = importlib.import_module("lnbits.wallets")
|
||||
FAKE_WALLET = getattr(wallets_module, "FakeWallet")()
|
||||
def get_wallet_class():
|
||||
"""
|
||||
Backwards compatibility
|
||||
"""
|
||||
from lnbits.wallets import get_wallet_class
|
||||
|
||||
# initialize as fake wallet
|
||||
WALLET = FAKE_WALLET
|
||||
return get_wallet_class()
|
||||
|
||||
@@ -325,7 +325,7 @@ body[data-theme='autumn'].body--dark {
|
||||
[data-theme=flamingo] .q-drawer--dark,
|
||||
body[data-theme=flamingo].body--dark,
|
||||
[data-theme=flamingo] .q-menu--dark {
|
||||
background: #803a45 !important;
|
||||
background: #2f032f !important;
|
||||
}
|
||||
|
||||
/* IF WANTING TO SET A DARKER BG COLOR IN THE FUTURE
|
||||
@@ -336,38 +336,38 @@ body[data-theme='flamingo'].body--dark {
|
||||
*/
|
||||
[data-theme=flamingo] .q-card--dark,
|
||||
[data-theme=flamingo] .q-stepper--dark {
|
||||
background: #ec7599 !important;
|
||||
background: #bc23bc !important;
|
||||
}
|
||||
|
||||
[data-theme=flamingo] .bg-primary {
|
||||
background: #d11d53 !important;
|
||||
background: #ff00ff !important;
|
||||
}
|
||||
[data-theme=flamingo] .text-primary {
|
||||
color: #d11d53 !important;
|
||||
color: #ff00ff !important;
|
||||
}
|
||||
[data-theme=flamingo] .bg-secondary {
|
||||
background: #db3e6d !important;
|
||||
background: #fda3fd !important;
|
||||
}
|
||||
[data-theme=flamingo] .text-secondary {
|
||||
color: #db3e6d !important;
|
||||
color: #fda3fd !important;
|
||||
}
|
||||
[data-theme=flamingo] .bg-dark {
|
||||
background: #803a45 !important;
|
||||
background: #2f032f !important;
|
||||
}
|
||||
[data-theme=flamingo] .text-dark {
|
||||
color: #803a45 !important;
|
||||
color: #2f032f !important;
|
||||
}
|
||||
[data-theme=flamingo] .bg-info {
|
||||
background: #ec7599 !important;
|
||||
background: #bc23bc !important;
|
||||
}
|
||||
[data-theme=flamingo] .text-info {
|
||||
color: #ec7599 !important;
|
||||
color: #bc23bc !important;
|
||||
}
|
||||
[data-theme=flamingo] .bg-marginal-bg {
|
||||
background: #803a45 !important;
|
||||
background: #311231 !important;
|
||||
}
|
||||
[data-theme=flamingo] .text-marginal-bg {
|
||||
color: #803a45 !important;
|
||||
color: #311231 !important;
|
||||
}
|
||||
[data-theme=flamingo] .bg-marginal-text {
|
||||
background: rgb(255, 255, 255) !important;
|
||||
@@ -454,14 +454,6 @@ body[data-theme='monochrome'].body--dark {
|
||||
background: #0f47af !important;
|
||||
}
|
||||
|
||||
[data-theme=flamingo] .q-drawer--dark {
|
||||
background: #e75480 !important;
|
||||
}
|
||||
|
||||
[data-theme=flamingo] .q-header {
|
||||
background: #e75480 !important;
|
||||
}
|
||||
|
||||
[v-cloak] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -37,7 +37,6 @@ window.localisation.br = {
|
||||
toggle_darkmode: 'Alternar modo escuro',
|
||||
view_swagger_docs: 'Ver a documentação da API do LNbits Swagger',
|
||||
api_docs: 'Documentação da API',
|
||||
commit_version: 'Versão de commit',
|
||||
lnbits_version: 'Versão do LNbits',
|
||||
runs_on: 'Executa em',
|
||||
credit_hint: 'Pressione Enter para creditar a conta',
|
||||
@@ -63,6 +62,8 @@ window.localisation.br = {
|
||||
invoice_waiting: 'Fatura aguardando pagamento',
|
||||
payment_received: 'Pagamento Recebido',
|
||||
payment_sent: 'Pagamento Enviado',
|
||||
receive: 'receber',
|
||||
send: 'enviar',
|
||||
outgoing_payment_pending: 'Pagamento pendente de saída',
|
||||
drain_funds: 'Drenar Fundos',
|
||||
drain_funds_desc:
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
window.localisation.cn = {
|
||||
confirm: '确定',
|
||||
server: '服务器',
|
||||
theme: '主题',
|
||||
funding: '资金',
|
||||
users: '用户',
|
||||
restart: '重新启动服务器',
|
||||
save: '保存',
|
||||
save_tooltip: '保存更改',
|
||||
topup: '充值',
|
||||
topup_wallet: '给钱包充值',
|
||||
topup_hint: '使用钱包ID为任何钱包充值',
|
||||
restart_tooltip: '重新启动服务器以使更改生效',
|
||||
add_funds_tooltip: '为钱包添加资金',
|
||||
reset_defaults: '重置为默认设置',
|
||||
reset_defaults_tooltip: '删除所有设置并重置为默认设置',
|
||||
download_backup: '下载数据库备份',
|
||||
name_your_wallet: '给你的 %{name}钱包起个名字',
|
||||
paste_invoice_label: '粘贴发票,付款请求或lnurl*',
|
||||
lnbits_description:
|
||||
'LNbits 设置简单、轻量级,可以运行在任何闪电网络的版本上,目前支持 LND、Core Lightning、OpenNode、LNPay,甚至 LNbits 本身!您可以为自己运行 LNbits,或者为他人轻松提供资金托管。每个钱包都有自己的 API 密钥,你可以创建的钱包数量没有限制。能够把资金分开管理使 LNbits 成为一款有用的资金管理和开发工具。扩展程序增加了 LNbits 的额外功能,所以你可以在闪电网络上尝试各种尖端技术。我们已经尽可能简化了开发扩展程序的过程,作为一个免费和开源的项目,我们鼓励人们开发并提交自己的扩展程序。',
|
||||
export_to_phone: '通过二维码导出到手机',
|
||||
export_to_phone_desc:
|
||||
'这个二维码包含您钱包的URL。您可以使用手机扫描的方式打开您的钱包。',
|
||||
wallets: '钱包',
|
||||
add_wallet: '添加新钱包',
|
||||
delete_wallet: '删除钱包',
|
||||
delete_wallet_desc: '整个钱包将被删除,资金将无法恢复',
|
||||
rename_wallet: '重命名钱包',
|
||||
update_name: '更新名称',
|
||||
press_to_claim: '点击领取比特币',
|
||||
donate: '捐献',
|
||||
view_github: '在GitHub上查看',
|
||||
voidwallet_active: 'VoidWallet 已激活!付款功能已禁用。',
|
||||
use_with_caution: '请谨慎使用 - %{name}钱包还处于测试版阶段',
|
||||
toggle_darkmode: '切换暗黑模式',
|
||||
view_swagger_docs: '查看 LNbits Swagger API 文档',
|
||||
api_docs: 'API文档',
|
||||
lnbits_version: 'LNbits版本',
|
||||
runs_on: '可运行在',
|
||||
credit_hint: '按 Enter 键充值账户',
|
||||
credit_label: '%{denomination} 充值',
|
||||
paste_request: '粘贴请求',
|
||||
create_invoice: '创建发票',
|
||||
camera_tooltip: '用相机扫描发票/二维码',
|
||||
export_csv: '导出为CSV',
|
||||
transactions: '交易记录',
|
||||
chart_tooltip: '显示图表',
|
||||
pending: '待处理',
|
||||
copy_invoice: '复制发票',
|
||||
close: '关闭',
|
||||
cancel: '取消',
|
||||
scan: '扫描',
|
||||
read: '读取',
|
||||
pay: '付款',
|
||||
memo: '备注',
|
||||
date: '日期',
|
||||
processing_payment: '正在处理支付...',
|
||||
not_enough_funds: '资金不足!',
|
||||
search_by_tag_memo_amount: '按标签、备注、金额搜索',
|
||||
invoice_waiting: '待支付的发票',
|
||||
payment_received: '收到付款',
|
||||
payment_sent: '付款已发送',
|
||||
receive: '收款',
|
||||
send: '付款',
|
||||
outgoing_payment_pending: '付款正在等待处理',
|
||||
drain_funds: '清空资金',
|
||||
drain_funds_desc:
|
||||
'这是一个 LNURL-取款的二维码,用于从该钱包中提取全部资金。请不要与他人分享。它与 balanceCheck 和 balanceNotify 兼容,因此在第一次取款后,您的钱包还可能会持续从这里提取资金',
|
||||
i_understand: '我明白',
|
||||
copy_wallet_url: '复制钱包URL',
|
||||
disclaimer_dialog:
|
||||
'登录功能将在以后的更新中发布,请将此页面加为书签,以便将来访问您的钱包!此服务处于测试阶段,我们不对资金的丢失承担任何责任。',
|
||||
no_transactions: '尚未进行任何交易',
|
||||
manage_server: '管理服务器',
|
||||
extensions: '扩展程序',
|
||||
no_extensions: '你没有安装任何扩展程序 :(',
|
||||
created: '已创建',
|
||||
|
||||
search_extensions: '搜索扩展程序',
|
||||
warning: '警告',
|
||||
manage: '管理',
|
||||
repository: '代码库',
|
||||
confirm_continue: '你确定要继续吗?',
|
||||
manage_extension_details: '安装/卸载扩展程序',
|
||||
install: '安装',
|
||||
uninstall: '卸载',
|
||||
drop_db: '删除数据',
|
||||
open: '打开',
|
||||
enable: '启用',
|
||||
enable_extension_details: '为当前用户启用扩展程序',
|
||||
disable: '禁用',
|
||||
installed: '已安装',
|
||||
activated: '已激活',
|
||||
deactivated: '已停用',
|
||||
release_notes: '发布说明',
|
||||
activate_extension_details: '对用户开放或禁用扩展程序',
|
||||
featured: '精选',
|
||||
all: '全部',
|
||||
only_admins_can_install: '(只有管理员账户可以安装扩展)',
|
||||
admin_only: '仅限管理员',
|
||||
new_version: '新版本',
|
||||
extension_depends_on: '依赖于:',
|
||||
extension_rating_soon: '即将推出评分',
|
||||
extension_installed_version: '已安装的版本',
|
||||
extension_uninstall_warning: '您即将对所有用户删除该扩展程序。',
|
||||
uninstall_confirm: '是的,卸载',
|
||||
extension_db_drop_info: '该扩展程序的所有数据将被永久删除。此操作无法撤销!',
|
||||
extension_db_drop_warning:
|
||||
'您即将删除该扩展的所有数据。请继续输入扩展程序名称以确认操作:',
|
||||
|
||||
extension_min_lnbits_version: '此版本要求最低的 LNbits 版本为',
|
||||
|
||||
payment_hash: '付款哈希',
|
||||
fee: '费',
|
||||
amount: '金额',
|
||||
unit: '单位',
|
||||
description: '详情',
|
||||
expiry: '过期时间',
|
||||
webhook: 'Webhook',
|
||||
payment_proof: '付款证明'
|
||||
}
|
||||
@@ -40,7 +40,6 @@ window.localisation.de = {
|
||||
toggle_darkmode: 'Auf Dark Mode umschalten',
|
||||
view_swagger_docs: 'LNbits Swagger API-Dokumente',
|
||||
api_docs: 'API docs',
|
||||
commit_version: 'Commit Version',
|
||||
runs_on: 'Läuft auf',
|
||||
credit_hint: 'Klicke Enter, um das Konto zu belasten',
|
||||
credit_label: '%{denomination} zu belasten',
|
||||
@@ -66,6 +65,8 @@ window.localisation.de = {
|
||||
invoice_waiting: 'Rechnung wartend auf Zahlung',
|
||||
payment_received: 'Zahlung erhalten',
|
||||
payment_sent: 'Zahlung gesendet',
|
||||
receive: 'erhalten',
|
||||
send: 'schicken',
|
||||
outgoing_payment_pending: 'Ausgehende Zahlung wartend',
|
||||
drain_funds: 'Sats abziehen',
|
||||
drain_funds_desc:
|
||||
|
||||
@@ -1,8 +1,27 @@
|
||||
window.localisation.en = {
|
||||
confirm: 'Yes',
|
||||
server: 'Server',
|
||||
theme: 'Theme',
|
||||
funding: 'Funding',
|
||||
|
||||
users: 'Users',
|
||||
apps: 'Apps',
|
||||
channels: 'Channels',
|
||||
transactions: 'Transactions',
|
||||
dashboard: 'Dashboard',
|
||||
manage_node: 'Manage Node',
|
||||
total_capacity: 'Total Capacity',
|
||||
avg_channel_size: 'Avg. Channel Size',
|
||||
biggest_channel_size: 'Biggest Channel Size',
|
||||
smallest_channel_size: 'Smallest Channel Size',
|
||||
number_of_channels: 'Number of Channels',
|
||||
active_channels: 'Active Channels',
|
||||
connect_peer: 'Connect Peer',
|
||||
connect: 'Connect',
|
||||
open_channel: 'Open Channel',
|
||||
open: 'Open',
|
||||
close_channel: 'Close Channel',
|
||||
close: 'Close',
|
||||
restart: 'Restart server',
|
||||
save: 'Save',
|
||||
save_tooltip: 'Save your changes',
|
||||
@@ -28,6 +47,9 @@ window.localisation.en = {
|
||||
'This whole wallet will be deleted, the funds will be UNRECOVERABLE.',
|
||||
rename_wallet: 'Rename wallet',
|
||||
update_name: 'Update name',
|
||||
fiat_tracking: 'Fiat tracking',
|
||||
currency: 'Currency',
|
||||
update_currency: 'Update currency',
|
||||
press_to_claim: 'Press to claim bitcoin',
|
||||
donate: 'Donate',
|
||||
view_github: 'View on GitHub',
|
||||
@@ -36,11 +58,12 @@ window.localisation.en = {
|
||||
toggle_darkmode: 'Toggle Dark Mode',
|
||||
view_swagger_docs: 'View LNbits Swagger API docs',
|
||||
api_docs: 'Api docs',
|
||||
commit_version: 'Commit version',
|
||||
lnbits_version: 'LNbits version',
|
||||
runs_on: 'Runs on',
|
||||
credit_hint: 'Press Enter to credit account',
|
||||
credit_label: '%{denomination} to credit',
|
||||
paste: 'Paste',
|
||||
paste_from_clipboard: 'Paste from clipboard',
|
||||
paste_request: 'Paste Request',
|
||||
create_invoice: 'Create Invoice',
|
||||
camera_tooltip: 'Use camera to scan an invoice/QR',
|
||||
@@ -62,6 +85,8 @@ window.localisation.en = {
|
||||
invoice_waiting: 'Invoice waiting to be paid',
|
||||
payment_received: 'Payment Received',
|
||||
payment_sent: 'Payment Sent',
|
||||
receive: 'receive',
|
||||
send: 'send',
|
||||
outgoing_payment_pending: 'Outgoing payment pending',
|
||||
drain_funds: 'Drain Funds',
|
||||
drain_funds_desc:
|
||||
@@ -84,6 +109,7 @@ window.localisation.en = {
|
||||
manage_extension_details: 'Install/uninstall extension',
|
||||
install: 'Install',
|
||||
uninstall: 'Uninstall',
|
||||
drop_db: 'Remove Data',
|
||||
open: 'Open',
|
||||
enable: 'Enable',
|
||||
enable_extension_details: 'Enable extension for current user',
|
||||
@@ -96,6 +122,7 @@ window.localisation.en = {
|
||||
featured: 'Featured',
|
||||
all: 'All',
|
||||
only_admins_can_install: '(Only admin accounts can install extensions)',
|
||||
admin_only: 'Admin Only',
|
||||
new_version: 'New Version',
|
||||
extension_depends_on: 'Depends on:',
|
||||
extension_rating_soon: 'Ratings coming soon',
|
||||
@@ -103,6 +130,11 @@ window.localisation.en = {
|
||||
extension_uninstall_warning:
|
||||
'You are about to remove the extension for all users.',
|
||||
uninstall_confirm: 'Yes, Uninstall',
|
||||
extension_db_drop_info:
|
||||
'All data for the extension will be permanently deleted. There is no way to undo this operation!',
|
||||
extension_db_drop_warning:
|
||||
'You are about to remove all data for the extension. Please type the extension name to continue:',
|
||||
|
||||
extension_min_lnbits_version: 'This release requires at least LNbits version',
|
||||
|
||||
payment_hash: 'Payment Hash',
|
||||
@@ -112,5 +144,52 @@ window.localisation.en = {
|
||||
description: 'Description',
|
||||
expiry: 'Expiry',
|
||||
webhook: 'Webhook',
|
||||
payment_proof: 'Payment Proof'
|
||||
payment_proof: 'Payment Proof',
|
||||
update_available: 'Update %{version} available!',
|
||||
latest_update: 'You are on the latest version %{version}.',
|
||||
notifications: 'Notifications',
|
||||
no_notifications: 'No notifications',
|
||||
notifications_disabled: 'LNbits status notifications are disabled.',
|
||||
enable_notifications: 'Enable Notifications',
|
||||
enable_notifications_desc:
|
||||
'If enabled it will fetch the latest LNbits Status updates, like security incidents and updates.',
|
||||
enable_killswitch: 'Enable Killswitch',
|
||||
enable_killswitch_desc:
|
||||
'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).',
|
||||
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.',
|
||||
watchdog_interval: 'Watchdog Interval',
|
||||
watchdog_interval_desc:
|
||||
'How often the background task should check for a killswitch signal in the watchdog delta [node_balance - lnbits_balance] (in minutes).',
|
||||
watchdog_delta: 'Watchdog Delta',
|
||||
watchdog_delta_desc:
|
||||
'Limit before killswitch changes funding source to VoidWallet [lnbits_balance - node_balance > delta]',
|
||||
status: 'Status',
|
||||
notification_source: 'Notification Source',
|
||||
notification_source_label:
|
||||
'Source URL (only use the official LNbits status source, and sources you can trust)',
|
||||
more: 'more',
|
||||
releases: 'Releases',
|
||||
killswitch: 'Killswitch',
|
||||
watchdog: 'Watchdog',
|
||||
server_logs: 'Server Logs',
|
||||
ip_blocker: 'IP Blocker',
|
||||
security: 'Security',
|
||||
security_tools: 'Security tools',
|
||||
block_access_hint: 'Block access by IP',
|
||||
allow_access_hint: 'Allow access by IP (will override blocked IPs)',
|
||||
enter_ip: 'Enter IP and hit enter',
|
||||
rate_limiter: 'Rate Limiter',
|
||||
number_of_requests: 'Number of requests',
|
||||
time_unit: 'Time unit',
|
||||
minute: 'minute',
|
||||
second: 'second',
|
||||
hour: 'hour',
|
||||
disable_server_log: 'Disable Server Log',
|
||||
enable_server_log: 'Enable Server Log',
|
||||
coming_soon: 'Feature coming soon'
|
||||
}
|
||||
|
||||
@@ -38,7 +38,6 @@ window.localisation.es = {
|
||||
toggle_darkmode: 'Cambiar modo oscuro',
|
||||
view_swagger_docs: 'Ver documentos de API de LNbits Swagger',
|
||||
api_docs: 'Documentos de API',
|
||||
commit_version: 'Versión de compromiso',
|
||||
runs_on: 'Corre en',
|
||||
credit_hint: 'Presione Enter para cargar la cuenta',
|
||||
credit_label: 'Cargar %{denomination}',
|
||||
@@ -63,6 +62,8 @@ window.localisation.es = {
|
||||
invoice_waiting: 'Factura esperando pago',
|
||||
payment_received: 'Pago recibido',
|
||||
payment_sent: 'Pago enviado',
|
||||
receive: 'recibir',
|
||||
send: 'enviar',
|
||||
outgoing_payment_pending: 'Pago saliente pendiente',
|
||||
drain_funds: 'Drenar fondos',
|
||||
drain_funds_desc:
|
||||
|
||||
@@ -41,7 +41,6 @@ window.localisation.fr = {
|
||||
toggle_darkmode: 'Basculer le mode sombre',
|
||||
view_swagger_docs: "Voir les documents de l'API Swagger de LNbits",
|
||||
api_docs: "Documents de l'API",
|
||||
commit_version: 'Version de commit',
|
||||
lnbits_version: 'Version de LNbits',
|
||||
runs_on: 'Fonctionne sur',
|
||||
credit_hint: 'Appuyez sur Entrée pour créditer le compte',
|
||||
@@ -67,6 +66,8 @@ window.localisation.fr = {
|
||||
invoice_waiting: 'Facture en attente de paiement',
|
||||
payment_received: 'Paiement reçu',
|
||||
payment_sent: 'Paiement envoyé',
|
||||
receive: 'recevoir',
|
||||
send: 'envoyer',
|
||||
outgoing_payment_pending: 'Paiement sortant en attente',
|
||||
drain_funds: 'Vider les fonds',
|
||||
drain_funds_desc:
|
||||
|
||||
@@ -38,7 +38,6 @@ window.localisation.it = {
|
||||
toggle_darkmode: 'Attiva la modalità notturna',
|
||||
view_swagger_docs: "Visualizza i documenti dell'API Swagger di LNbits",
|
||||
api_docs: 'Documenti API',
|
||||
commit_version: 'Commit version',
|
||||
lnbits_version: 'Versione di LNbits',
|
||||
runs_on: 'Esegue su',
|
||||
credit_hint: 'Premere Invio per accreditare i fondi',
|
||||
@@ -64,6 +63,8 @@ window.localisation.it = {
|
||||
invoice_waiting: 'Fattura in attesa di pagamento',
|
||||
payment_received: 'Pagamento ricevuto',
|
||||
payment_sent: 'Pagamento inviato',
|
||||
receive: 'ricevere',
|
||||
send: 'inviare',
|
||||
outgoing_payment_pending: 'Pagamento in uscita in attesa',
|
||||
drain_funds: 'Fondi di drenaggio',
|
||||
drain_funds_desc:
|
||||
|
||||