Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -8,6 +8,12 @@ PORT=5000
|
|||||||
|
|
||||||
DEBUG=false
|
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)
|
# Allow users and admins by user IDs (comma separated list)
|
||||||
LNBITS_ALLOWED_USERS=""
|
LNBITS_ALLOWED_USERS=""
|
||||||
LNBITS_ADMIN_USERS=""
|
LNBITS_ADMIN_USERS=""
|
||||||
@@ -52,6 +58,9 @@ LNBITS_RESERVE_FEE_MIN=2000
|
|||||||
# value in percent
|
# value in percent
|
||||||
LNBITS_RESERVE_FEE_PERCENT=1.0
|
LNBITS_RESERVE_FEE_PERCENT=1.0
|
||||||
|
|
||||||
|
# Limit fiat currencies allowed to see in UI
|
||||||
|
# LNBITS_ALLOWED_CURRENCIES="EUR, USD"
|
||||||
|
|
||||||
# Change theme
|
# Change theme
|
||||||
LNBITS_SITE_TITLE="LNbits"
|
LNBITS_SITE_TITLE="LNbits"
|
||||||
LNBITS_SITE_TAGLINE="free and open-source lightning wallet"
|
LNBITS_SITE_TAGLINE="free and open-source lightning wallet"
|
||||||
@@ -60,15 +69,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_THEME_OPTIONS="classic, bitcoin, flamingo, freedom, mint, autumn, monochrome, salvador, cyber"
|
||||||
# LNBITS_CUSTOM_LOGO="https://lnbits.com/assets/images/logo/logo.svg"
|
# LNBITS_CUSTOM_LOGO="https://lnbits.com/assets/images/logo/logo.svg"
|
||||||
|
|
||||||
# Choose from LNPayWallet, OpenNodeWallet, ClicheWallet,
|
# which fundingsources are allowed in the admin ui
|
||||||
# LndWallet, LndRestWallet, CoreLightningWallet, EclairWallet,
|
LNBITS_ALLOWED_FUNDING_SOURCES="VoidWallet, FakeWallet, CoreLightningWallet, CoreLightningRestWallet, LndRestWallet, EclairWallet, LndWallet, LnTipsWallet, LNPayWallet, LNbitsWallet, OpenNodeWallet"
|
||||||
# LnTipsWallet, LNbitsWallet, SparkWallet, FakeWallet,
|
|
||||||
LNBITS_BACKEND_WALLET_CLASS=VoidWallet
|
LNBITS_BACKEND_WALLET_CLASS=VoidWallet
|
||||||
# VoidWallet is just a fallback that works without any actual Lightning capabilities,
|
# VoidWallet is just a fallback that works without any actual Lightning capabilities,
|
||||||
# just so you can see the UI before dealing with this file.
|
# just so you can see the UI before dealing with this file.
|
||||||
|
|
||||||
# Invoice expiry for LND, CLN, Eclair, LNbits funding sources
|
# 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:
|
# Set one of these blocks depending on the wallet kind you chose above:
|
||||||
|
|
||||||
|
|||||||
@@ -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 }}
|
||||||
@@ -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
|
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:
|
jobs:
|
||||||
LndRestWallet:
|
regtest:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ${{ inputs.os-version }}
|
||||||
strategy:
|
timeout-minutes: 10
|
||||||
matrix:
|
|
||||||
python-version: ["3.9"]
|
|
||||||
poetry-version: ["1.3.1"]
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- 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:
|
with:
|
||||||
poetry-version: ${{ matrix.poetry-version }}
|
context: .
|
||||||
- name: Set up Python ${{ matrix.python-version }}
|
push: false
|
||||||
uses: actions/setup-python@v4
|
tags: lnbitsdocker/lnbits-legend:latest
|
||||||
with:
|
cache-from: type=registry,ref=lnbitsdocker/lnbits-legend:latest
|
||||||
python-version: ${{ matrix.python-version }}
|
cache-to: type=inline
|
||||||
cache: "poetry"
|
|
||||||
- name: Setup Regtest
|
- name: Setup Regtest
|
||||||
run: |
|
run: |
|
||||||
docker build -t lnbitsdocker/lnbits-legend .
|
|
||||||
git clone https://github.com/lnbits/legend-regtest-enviroment.git docker
|
git clone https://github.com/lnbits/legend-regtest-enviroment.git docker
|
||||||
cd docker
|
cd docker
|
||||||
chmod +x ./tests
|
chmod +x ./tests
|
||||||
./tests
|
./tests
|
||||||
sudo chmod -R a+rwx .
|
sudo chmod -R a+rwx .
|
||||||
- name: Install dependencies
|
|
||||||
run: |
|
- uses: ./.github/actions/prepare
|
||||||
poetry install
|
with:
|
||||||
- name: Run tests
|
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:
|
env:
|
||||||
PYTHONUNBUFFERED: 1
|
LNBITS_DATABASE_URL: ${{ inputs.db-url }}
|
||||||
PORT: 5123
|
LNBITS_BACKEND_WALLET_CLASS: ${{ inputs.backend-wallet-class }}
|
||||||
LNBITS_DATA_FOLDER: ./data
|
|
||||||
LNBITS_BACKEND_WALLET_CLASS: LndRestWallet
|
|
||||||
LND_REST_ENDPOINT: https://localhost:8081/
|
LND_REST_ENDPOINT: https://localhost:8081/
|
||||||
LND_REST_CERT: ./docker/data/lnd-1/tls.cert
|
LND_REST_CERT: ./docker/data/lnd-3/tls.cert
|
||||||
LND_REST_MACAROON: ./docker/data/lnd-1/data/chain/bitcoin/regtest/admin.macaroon
|
LND_REST_MACAROON: ./docker/data/lnd-3/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_GRPC_ENDPOINT: localhost
|
LND_GRPC_ENDPOINT: localhost
|
||||||
LND_GRPC_PORT: 10009
|
LND_GRPC_PORT: 10009
|
||||||
LND_GRPC_CERT: docker/data/lnd-1/tls.cert
|
LND_GRPC_CERT: docker/data/lnd-3/tls.cert
|
||||||
LND_GRPC_MACAROON: docker/data/lnd-1/data/chain/bitcoin/regtest/admin.macaroon
|
LND_GRPC_MACAROON: docker/data/lnd-3/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
|
|
||||||
CORELIGHTNING_RPC: ./docker/data/clightning-1/regtest/lightning-rpc
|
CORELIGHTNING_RPC: ./docker/data/clightning-1/regtest/lightning-rpc
|
||||||
run: |
|
CORELIGHTNING_REST_URL: https://localhost:3001
|
||||||
sudo chmod -R a+rwx . && rm -rf ./data && mkdir -p ./data
|
CORELIGHTNING_REST_MACAROON: ./docker/data/clightning-2-rest/access.macaroon
|
||||||
make test-real-wallet
|
CORELIGHTNING_REST_CERT: ./docker/data/clightning-2-rest/certificate.pem
|
||||||
- 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
|
|
||||||
LNBITS_ENDPOINT: http://localhost:5001
|
LNBITS_ENDPOINT: http://localhost:5001
|
||||||
LNBITS_KEY: "d08a3313322a4514af75d488bcc27eee"
|
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_URL: http://127.0.0.1:8082
|
||||||
ECLAIR_PASS: lnbits
|
ECLAIR_PASS: lnbits
|
||||||
run: |
|
run: make test-real-wallet
|
||||||
sudo chmod -R a+rwx . && rm -rf ./data && mkdir -p ./data
|
|
||||||
make test-real-wallet
|
|
||||||
- name: Upload coverage to Codecov
|
- name: Upload coverage to Codecov
|
||||||
uses: codecov/codecov-action@v3
|
uses: codecov/codecov-action@v3
|
||||||
with:
|
with:
|
||||||
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
file: ./coverage.xml
|
file: ./coverage.xml
|
||||||
|
|||||||
@@ -1,70 +1,58 @@
|
|||||||
name: tests
|
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:
|
jobs:
|
||||||
sqlite:
|
tests:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ${{ inputs.os-version }}
|
||||||
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
|
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:latest
|
image: postgres:latest
|
||||||
env:
|
env:
|
||||||
POSTGRES_USER: postgres
|
POSTGRES_USER: lnbits
|
||||||
POSTGRES_PASSWORD: postgres
|
POSTGRES_PASSWORD: lnbits
|
||||||
POSTGRES_DB: postgres
|
POSTGRES_DB: ${{ inputs.db-name }}
|
||||||
ports:
|
ports:
|
||||||
# maps tcp port 5432 on service container to the host
|
|
||||||
- 5432:5432
|
- 5432:5432
|
||||||
options: >-
|
options: >-
|
||||||
--health-cmd pg_isready
|
--health-cmd pg_isready
|
||||||
--health-interval 10s
|
--health-interval 10s
|
||||||
--health-timeout 5s
|
--health-timeout 5s
|
||||||
--health-retries 5
|
--health-retries 5
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
python-version: ["3.9"]
|
|
||||||
poetry-version: ["1.3.1"]
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
- name: Set up Poetry ${{ matrix.poetry-version }}
|
|
||||||
uses: abatilo/actions-poetry@v2
|
- uses: ./.github/actions/prepare
|
||||||
with:
|
with:
|
||||||
poetry-version: ${{ matrix.poetry-version }}
|
python-version: ${{ inputs.python-version }}
|
||||||
- name: Set up Python ${{ matrix.python-version }}
|
|
||||||
uses: actions/setup-python@v4
|
- name: Run Tests
|
||||||
with:
|
|
||||||
python-version: ${{ matrix.python-version }}
|
|
||||||
cache: "poetry"
|
|
||||||
- name: Install dependencies
|
|
||||||
run: |
|
|
||||||
poetry install
|
|
||||||
- name: Run tests
|
|
||||||
env:
|
env:
|
||||||
LNBITS_DATABASE_URL: postgres://postgres:postgres@0.0.0.0:5432/postgres
|
LNBITS_DATABASE_URL: ${{ inputs.db-url }}
|
||||||
run: make test
|
LNBITS_BACKEND_WALLET_CLASS: FakeWallet
|
||||||
|
run: make ${{ inputs.make }}
|
||||||
|
|
||||||
- name: Upload coverage to Codecov
|
- name: Upload coverage to Codecov
|
||||||
uses: codecov/codecov-action@v3
|
uses: codecov/codecov-action@v3
|
||||||
with:
|
with:
|
||||||
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
file: ./coverage.xml
|
file: ./coverage.xml
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ tests/data/*.sqlite3
|
|||||||
*.env
|
*.env
|
||||||
.env
|
.env
|
||||||
.super_user
|
.super_user
|
||||||
|
.pre-commit-config.yaml
|
||||||
|
|
||||||
data
|
data
|
||||||
*.sqlite3
|
*.sqlite3
|
||||||
@@ -46,5 +47,5 @@ fly.toml
|
|||||||
lnbits-backup.zip
|
lnbits-backup.zip
|
||||||
|
|
||||||
# Ignore extensions (post installable extension PR)
|
# Ignore extensions (post installable extension PR)
|
||||||
extensions/
|
extensions
|
||||||
upgrades/
|
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:
|
repos:
|
||||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||||
@@ -14,21 +14,17 @@ repos:
|
|||||||
- id: mixed-line-ending
|
- id: mixed-line-ending
|
||||||
- id: check-case-conflict
|
- id: check-case-conflict
|
||||||
- repo: https://github.com/psf/black
|
- repo: https://github.com/psf/black
|
||||||
rev: 22.6.0
|
rev: 23.7.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: black
|
- id: black
|
||||||
- repo: https://github.com/pycqa/isort
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
rev: 5.12.0
|
rev: v0.0.283
|
||||||
hooks:
|
hooks:
|
||||||
- id: isort
|
- id: ruff
|
||||||
args: ['--profile', 'black']
|
args: [ --fix, --exit-non-zero-on-fix ]
|
||||||
- repo: https://github.com/pre-commit/mirrors-prettier
|
- repo: https://github.com/pre-commit/mirrors-prettier
|
||||||
rev: '50c5478ed9e10bf360335449280cf2a67f4edb7a'
|
rev: '50c5478ed9e10bf360335449280cf2a67f4edb7a'
|
||||||
hooks:
|
hooks:
|
||||||
- id: prettier
|
- id: prettier
|
||||||
|
types_or: [css, javascript, html, json]
|
||||||
args: ['lnbits']
|
args: ['lnbits']
|
||||||
- repo: https://github.com/pycqa/flake8
|
|
||||||
rev: 6.0.0
|
|
||||||
hooks:
|
|
||||||
- id: flake8
|
|
||||||
entry: poetry run flake8
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
*.yml
|
*.yml
|
||||||
|
|
||||||
**/lnbits/extensions/*
|
**/lnbits/extensions/*
|
||||||
|
**/lnbits/upgrades/*
|
||||||
|
|
||||||
**/lnbits/static/vendor
|
**/lnbits/static/vendor
|
||||||
**/lnbits/static/bundle.*
|
**/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 clean
|
||||||
RUN apt-get update
|
RUN apt-get update
|
||||||
@@ -8,7 +8,7 @@ RUN curl -sSL https://install.python-poetry.org | python3 -
|
|||||||
ENV PATH="/root/.local/bin:$PATH"
|
ENV PATH="/root/.local/bin:$PATH"
|
||||||
|
|
||||||
# needed for backups postgresql-client version 14 (pg_dump)
|
# 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 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 wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | apt-key add -
|
||||||
RUN apt-get update
|
RUN apt-get update
|
||||||
@@ -20,7 +20,6 @@ COPY . .
|
|||||||
|
|
||||||
RUN mkdir data
|
RUN mkdir data
|
||||||
|
|
||||||
RUN poetry config virtualenvs.create false
|
|
||||||
RUN poetry install --only main
|
RUN poetry install --only main
|
||||||
|
|
||||||
ENV LNBITS_PORT="5000"
|
ENV LNBITS_PORT="5000"
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
all: format check
|
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:
|
prettier:
|
||||||
poetry run ./node_modules/.bin/prettier --write lnbits
|
poetry run ./node_modules/.bin/prettier --write lnbits
|
||||||
@@ -12,20 +12,17 @@ prettier:
|
|||||||
pyright:
|
pyright:
|
||||||
poetry run ./node_modules/.bin/pyright
|
poetry run ./node_modules/.bin/pyright
|
||||||
|
|
||||||
black:
|
|
||||||
poetry run black .
|
|
||||||
|
|
||||||
flake8:
|
|
||||||
poetry run flake8
|
|
||||||
|
|
||||||
mypy:
|
mypy:
|
||||||
poetry run mypy
|
poetry run mypy
|
||||||
|
|
||||||
isort:
|
black:
|
||||||
poetry run isort .
|
poetry run black .
|
||||||
|
|
||||||
pylint:
|
ruff:
|
||||||
poetry run pylint *.py lnbits/ tools/ tests/
|
poetry run ruff check . --fix
|
||||||
|
|
||||||
|
checkruff:
|
||||||
|
poetry run ruff check .
|
||||||
|
|
||||||
checkprettier:
|
checkprettier:
|
||||||
poetry run ./node_modules/.bin/prettier --check lnbits
|
poetry run ./node_modules/.bin/prettier --check lnbits
|
||||||
@@ -33,8 +30,8 @@ checkprettier:
|
|||||||
checkblack:
|
checkblack:
|
||||||
poetry run black --check .
|
poetry run black --check .
|
||||||
|
|
||||||
checkisort:
|
dev:
|
||||||
poetry run isort --check-only .
|
poetry run lnbits --reload
|
||||||
|
|
||||||
test:
|
test:
|
||||||
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
|
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
|
||||||
@@ -69,6 +66,17 @@ test-migration:
|
|||||||
migration:
|
migration:
|
||||||
poetry run python tools/conv.py
|
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:
|
bak:
|
||||||
# LNBITS_DATABASE_URL=postgres://postgres:postgres@0.0.0.0:5432/postgres
|
# 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
|
## 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
|
## LNbits as an account system
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,30 @@ For developers
|
|||||||
Thanks for contributing :)
|
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
|
Tests
|
||||||
=====
|
=====
|
||||||
|
|
||||||
|
|||||||
@@ -55,11 +55,12 @@ Now start LNbits once in the terminal window
|
|||||||
```
|
```
|
||||||
$ poetry run lnbits
|
$ poetry run lnbits
|
||||||
```
|
```
|
||||||
It will now show you the Super User Account:
|
You can now `cat` the Super User ID:
|
||||||
|
```
|
||||||
`SUCCESS | ✔️ Access super user account at: https://127.0.0.1:5000/wallet?usr=5711d7..`
|
$ cat .super_user
|
||||||
|
123de4bfdddddbbeb48c8bc8382fe123
|
||||||
The `/wallet?usr=..` is your super user account. You just have to append it to your normal LNbits web domain.
|
```
|
||||||
|
You can access your super user account at `/wallet?usr=super_user_id`. You just have to append it to your normal LNbits web domain.
|
||||||
|
|
||||||
After that you will find the __`Admin` / `Manage Server`__ between `Wallets` and `Extensions`
|
After that you will find the __`Admin` / `Manage Server`__ between `Wallets` and `Extensions`
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ allow-self-payment=1
|
|||||||
|
|
||||||
</details>
|
</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>
|
<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>
|
||||||
|
|
||||||
@@ -73,7 +73,7 @@ allow-self-payment=1
|
|||||||
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>
|
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>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>
|
</ul>
|
||||||
</details>
|
</details>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
|
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 |
@@ -7,21 +7,30 @@ import shutil
|
|||||||
import signal
|
import signal
|
||||||
import sys
|
import sys
|
||||||
import traceback
|
import traceback
|
||||||
|
from hashlib import sha256
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
from typing import Callable, List
|
from typing import Callable, List
|
||||||
|
|
||||||
from fastapi import FastAPI, Request
|
from fastapi import FastAPI, HTTPException, Request
|
||||||
from fastapi.exceptions import HTTPException, RequestValidationError
|
from fastapi.exceptions import RequestValidationError
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.middleware.gzip import GZipMiddleware
|
|
||||||
from fastapi.responses import JSONResponse
|
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from loguru import logger
|
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.crud import get_installed_extensions
|
||||||
from lnbits.core.helpers import migrate_extension_database
|
from lnbits.core.helpers import migrate_extension_database
|
||||||
from lnbits.core.tasks import register_task_listeners
|
from lnbits.core.services import websocketUpdater
|
||||||
from lnbits.settings import get_wallet_class, set_wallet_class, settings
|
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 .commands import db_versions, load_disabled_extension_list, migrate_databases
|
||||||
from .core import (
|
from .core import (
|
||||||
@@ -34,10 +43,15 @@ from .core.services import check_admin_settings
|
|||||||
from .core.views.generic import core_html_routes
|
from .core.views.generic import core_html_routes
|
||||||
from .extension_manager import Extension, InstallableExtension, get_valid_extensions
|
from .extension_manager import Extension, InstallableExtension, get_valid_extensions
|
||||||
from .helpers import template_renderer
|
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 .requestvars import g
|
||||||
from .tasks import (
|
from .tasks import (
|
||||||
catch_everything_and_restart,
|
|
||||||
check_pending_payments,
|
check_pending_payments,
|
||||||
internal_invoice_listener,
|
internal_invoice_listener,
|
||||||
invoice_listener,
|
invoice_listener,
|
||||||
@@ -46,12 +60,13 @@ from .tasks import (
|
|||||||
|
|
||||||
|
|
||||||
def create_app() -> FastAPI:
|
def create_app() -> FastAPI:
|
||||||
|
|
||||||
configure_logger()
|
configure_logger()
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="LNbits API",
|
title=settings.lnbits_title,
|
||||||
description="API for LNbits, the free and open source bitcoin wallet and accounts system with plugins.",
|
description=(
|
||||||
|
"API for LNbits, the free and open source bitcoin wallet and "
|
||||||
|
"accounts system with plugins."
|
||||||
|
),
|
||||||
version=settings.version,
|
version=settings.version,
|
||||||
license_info={
|
license_info={
|
||||||
"name": "MIT License",
|
"name": "MIT License",
|
||||||
@@ -72,7 +87,9 @@ def create_app() -> FastAPI:
|
|||||||
CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]
|
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
|
# order of these two middlewares is important
|
||||||
app.add_middleware(InstalledExtensionMiddleware)
|
app.add_middleware(InstalledExtensionMiddleware)
|
||||||
@@ -82,19 +99,22 @@ def create_app() -> FastAPI:
|
|||||||
register_routes(app)
|
register_routes(app)
|
||||||
register_async_tasks(app)
|
register_async_tasks(app)
|
||||||
register_exception_handlers(app)
|
register_exception_handlers(app)
|
||||||
|
register_shutdown(app)
|
||||||
|
|
||||||
# Allow registering new extensions routes without direct access to the `app` object
|
# 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_ext_routes", register_new_ext_routes(app))
|
||||||
|
setattr(core_app_extra, "register_new_ratelimiter", register_new_ratelimiter(app))
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
async def check_funding_source() -> None:
|
async def check_funding_source() -> None:
|
||||||
|
|
||||||
original_sigint_handler = signal.getsignal(signal.SIGINT)
|
original_sigint_handler = signal.getsignal(signal.SIGINT)
|
||||||
|
|
||||||
def signal_handler(signal, frame):
|
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)
|
sys.exit(1)
|
||||||
|
|
||||||
signal.signal(signal.SIGINT, signal_handler)
|
signal.signal(signal.SIGINT, signal_handler)
|
||||||
@@ -116,17 +136,16 @@ async def check_funding_source() -> None:
|
|||||||
break
|
break
|
||||||
|
|
||||||
logger.error(
|
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,
|
RuntimeWarning,
|
||||||
)
|
)
|
||||||
except:
|
except Exception as e:
|
||||||
|
logger.error(f"Error connecting to {WALLET.__class__.__name__}: {e}")
|
||||||
pass
|
pass
|
||||||
|
|
||||||
if settings.lnbits_admin_ui and retry_counter == timeout:
|
if settings.lnbits_admin_ui and retry_counter == timeout:
|
||||||
logger.warning(
|
set_void_wallet_class()
|
||||||
f"Fallback to VoidWallet, because the backend for {WALLET.__class__.__name__} isn't working properly"
|
|
||||||
)
|
|
||||||
set_wallet_class("VoidWallet")
|
|
||||||
WALLET = get_wallet_class()
|
WALLET = get_wallet_class()
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
@@ -137,16 +156,25 @@ async def check_funding_source() -> None:
|
|||||||
signal.signal(signal.SIGINT, original_sigint_handler)
|
signal.signal(signal.SIGINT, original_sigint_handler)
|
||||||
|
|
||||||
logger.info(
|
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):
|
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.
|
Check extensions that have been installed, but for some reason no longer present in
|
||||||
One reason might be a docker-container that was re-created.
|
the 'lnbits/extensions' directory. One reason might be a docker-container that was
|
||||||
The 'data' directory (where the '.zip' files live) is expected to persist state.
|
re-created. The 'data' directory (where the '.zip' files live) is expected to
|
||||||
Zips that are missing will be re-downloaded.
|
persist state. Zips that are missing will be re-downloaded.
|
||||||
"""
|
"""
|
||||||
shutil.rmtree(os.path.join("lnbits", "upgrades"), True)
|
shutil.rmtree(os.path.join("lnbits", "upgrades"), True)
|
||||||
await load_disabled_extension_list()
|
await load_disabled_extension_list()
|
||||||
@@ -154,11 +182,12 @@ async def check_installed_extensions(app: FastAPI):
|
|||||||
|
|
||||||
for ext in installed_extensions:
|
for ext in installed_extensions:
|
||||||
try:
|
try:
|
||||||
installed = check_installed_extension(ext)
|
installed = check_installed_extension_files(ext)
|
||||||
if not installed:
|
if not installed:
|
||||||
await restore_installed_extension(app, ext)
|
await restore_installed_extension(app, ext)
|
||||||
logger.info(
|
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:
|
except Exception as e:
|
||||||
logger.warning(e)
|
logger.warning(e)
|
||||||
@@ -166,6 +195,10 @@ async def check_installed_extensions(app: FastAPI):
|
|||||||
f"Failed to re-install extension: {ext.id} ({ext.installed_version})"
|
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]:
|
async def build_all_installed_extensions_list() -> List[InstallableExtension]:
|
||||||
"""
|
"""
|
||||||
@@ -187,13 +220,11 @@ async def build_all_installed_extensions_list() -> List[InstallableExtension]:
|
|||||||
id=ext_id, name=ext_id, installed_release=release, icon=release.icon
|
id=ext_id, name=ext_id, installed_release=release, icon=release.icon
|
||||||
)
|
)
|
||||||
installed_extensions.append(ext_info)
|
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
|
return installed_extensions
|
||||||
|
|
||||||
|
|
||||||
def check_installed_extension(ext: InstallableExtension) -> bool:
|
def check_installed_extension_files(ext: InstallableExtension) -> bool:
|
||||||
if ext.has_installed_version:
|
if ext.has_installed_version:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -209,6 +240,9 @@ def check_installed_extension(ext: InstallableExtension) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
async def restore_installed_extension(app: FastAPI, ext: InstallableExtension):
|
async def restore_installed_extension(app: FastAPI, ext: InstallableExtension):
|
||||||
|
await add_installed_extension(ext)
|
||||||
|
await update_installed_extension_state(ext_id=ext.id, active=True)
|
||||||
|
|
||||||
extension = Extension.from_installable_ext(ext)
|
extension = Extension.from_installable_ext(ext)
|
||||||
register_ext_routes(app, extension)
|
register_ext_routes(app, extension)
|
||||||
|
|
||||||
@@ -238,13 +272,27 @@ def register_routes(app: FastAPI) -> None:
|
|||||||
|
|
||||||
def register_new_ext_routes(app: FastAPI) -> Callable:
|
def register_new_ext_routes(app: FastAPI) -> Callable:
|
||||||
# Returns a function that registers new routes for an extension.
|
# 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):
|
def register_new_ext_routes_fn(ext: Extension):
|
||||||
register_ext_routes(app, ext)
|
register_ext_routes(app, ext)
|
||||||
|
|
||||||
return register_new_ext_routes_fn
|
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:
|
def register_ext_routes(app: FastAPI, ext: Extension) -> None:
|
||||||
"""Register FastAPI routes for extension."""
|
"""Register FastAPI routes for extension."""
|
||||||
ext_module = importlib.import_module(ext.module_name)
|
ext_module = importlib.import_module(ext.module_name)
|
||||||
@@ -278,7 +326,6 @@ def register_ext_routes(app: FastAPI, ext: Extension) -> None:
|
|||||||
def register_startup(app: FastAPI):
|
def register_startup(app: FastAPI):
|
||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
async def lnbits_startup():
|
async def lnbits_startup():
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# wait till migration is done
|
# wait till migration is done
|
||||||
await migrate_databases()
|
await migrate_databases()
|
||||||
@@ -288,8 +335,19 @@ def register_startup(app: FastAPI):
|
|||||||
|
|
||||||
log_server_info()
|
log_server_info()
|
||||||
|
|
||||||
|
# adds security middleware
|
||||||
|
add_ratelimit_middleware(app)
|
||||||
|
add_ip_block_middleware(app)
|
||||||
|
|
||||||
# initialize WALLET
|
# 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
|
# initialize funding source
|
||||||
await check_funding_source()
|
await check_funding_source()
|
||||||
@@ -297,11 +355,42 @@ def register_startup(app: FastAPI):
|
|||||||
# check extensions after restart
|
# check extensions after restart
|
||||||
await check_installed_extensions(app)
|
await check_installed_extensions(app)
|
||||||
|
|
||||||
|
if settings.lnbits_admin_ui:
|
||||||
|
initialize_server_logger()
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(str(e))
|
logger.error(str(e))
|
||||||
raise ImportError("Failed to run 'startup' event.")
|
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():
|
def log_server_info():
|
||||||
logger.info("Starting LNbits")
|
logger.info("Starting LNbits")
|
||||||
logger.info(f"Version: {settings.version}")
|
logger.info(f"Version: {settings.version}")
|
||||||
@@ -322,9 +411,11 @@ def get_db_vendor_name():
|
|||||||
return (
|
return (
|
||||||
"PostgreSQL"
|
"PostgreSQL"
|
||||||
if db_url and db_url.startswith("postgres://")
|
if db_url and db_url.startswith("postgres://")
|
||||||
else "CockroachDB"
|
else (
|
||||||
if db_url and db_url.startswith("cockroachdb://")
|
"CockroachDB"
|
||||||
else "SQLite"
|
if db_url and db_url.startswith("cockroachdb://")
|
||||||
|
else "SQLite"
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -335,17 +426,14 @@ def register_async_tasks(app):
|
|||||||
|
|
||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
async def listeners():
|
async def listeners():
|
||||||
loop = asyncio.get_event_loop()
|
create_permanent_task(check_pending_payments)
|
||||||
loop.create_task(catch_everything_and_restart(check_pending_payments))
|
create_permanent_task(invoice_listener)
|
||||||
loop.create_task(catch_everything_and_restart(invoice_listener))
|
create_permanent_task(internal_invoice_listener)
|
||||||
loop.create_task(catch_everything_and_restart(internal_invoice_listener))
|
create_permanent_task(cache.invalidate_forever)
|
||||||
await register_task_listeners()
|
register_task_listeners()
|
||||||
|
register_killswitch()
|
||||||
# await run_deferred_async() # calle: doesn't do anyting?
|
# await run_deferred_async() # calle: doesn't do anyting?
|
||||||
|
|
||||||
@app.on_event("shutdown")
|
|
||||||
async def stop_listeners():
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def register_exception_handlers(app: FastAPI):
|
def register_exception_handlers(app: FastAPI):
|
||||||
@app.exception_handler(Exception)
|
@app.exception_handler(Exception)
|
||||||
@@ -421,26 +509,32 @@ def configure_logger() -> None:
|
|||||||
logger.remove()
|
logger.remove()
|
||||||
log_level: str = "DEBUG" if settings.debug else "INFO"
|
log_level: str = "DEBUG" if settings.debug else "INFO"
|
||||||
formatter = Formatter()
|
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").handlers = [InterceptHandler()]
|
||||||
logging.getLogger("uvicorn.access").handlers = [InterceptHandler()]
|
logging.getLogger("uvicorn.access").handlers = [InterceptHandler()]
|
||||||
|
logging.getLogger("uvicorn.error").handlers = [InterceptHandler()]
|
||||||
|
logging.getLogger("uvicorn.error").propagate = False
|
||||||
|
|
||||||
|
|
||||||
class Formatter:
|
class Formatter:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.padding = 0
|
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:
|
if settings.debug:
|
||||||
self.fmt: str = (
|
self.fmt = (
|
||||||
"<green>{time:YYYY-MM-DD HH:mm:ss.SS}</green> | <level>{level: <4}</level> | "
|
"<green>{time:YYYY-MM-DD HH:mm:ss.SS}</green> | "
|
||||||
"<cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> | <level>{message}</level>\n"
|
"<level>{level: <4}</level> | "
|
||||||
|
"<cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> | "
|
||||||
|
"<level>{message}</level>\n"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
self.fmt: str = self.minimal_fmt
|
self.fmt = self.minimal_fmt
|
||||||
|
|
||||||
def format(self, record):
|
def format(self, record):
|
||||||
function = "{function}".format(**record) # pylint: disable=C0209
|
function = "{function}".format(**record)
|
||||||
if function == "emit": # uvicorn logs
|
if function == "emit": # uvicorn logs
|
||||||
return self.minimal_fmt
|
return self.minimal_fmt
|
||||||
return self.fmt
|
return self.fmt
|
||||||
|
|||||||
@@ -182,7 +182,6 @@ def lnencode(addr, privkey):
|
|||||||
tags_set = set()
|
tags_set = set()
|
||||||
|
|
||||||
for k, v in addr.tags:
|
for k, v in addr.tags:
|
||||||
|
|
||||||
# BOLT #11:
|
# BOLT #11:
|
||||||
#
|
#
|
||||||
# A writer MUST NOT include more than one `d`, `h`, `n` or `x` fields,
|
# A writer MUST NOT include more than one `d`, `h`, `n` or `x` fields,
|
||||||
|
|||||||
@@ -13,8 +13,40 @@ from .db import COCKROACH, POSTGRES, SQLITE
|
|||||||
from .extension_manager import get_valid_extensions
|
from .extension_manager import get_valid_extensions
|
||||||
|
|
||||||
|
|
||||||
@click.command("migrate")
|
@click.group()
|
||||||
def db_migrate():
|
def command_group():
|
||||||
|
"""
|
||||||
|
Python CLI for LNbits
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@click.command("superuser")
|
||||||
|
def superuser():
|
||||||
|
"""Prints the superuser"""
|
||||||
|
with open(".super_user", "r") as file:
|
||||||
|
print(f"http://{settings.host}:{settings.port}/wallet?usr={file.readline()}")
|
||||||
|
|
||||||
|
|
||||||
|
@click.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())
|
||||||
|
|
||||||
|
|
||||||
|
@click.command("database-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())
|
asyncio.create_task(migrate_databases())
|
||||||
|
|
||||||
|
|
||||||
@@ -29,7 +61,8 @@ async def migrate_databases():
|
|||||||
)
|
)
|
||||||
elif conn.type in {POSTGRES, COCKROACH}:
|
elif conn.type in {POSTGRES, COCKROACH}:
|
||||||
exists = await conn.fetchone(
|
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:
|
if not exists:
|
||||||
@@ -46,13 +79,33 @@ async def migrate_databases():
|
|||||||
logger.info("✔️ All migrations done.")
|
logger.info("✔️ All migrations done.")
|
||||||
|
|
||||||
|
|
||||||
|
@click.command("database-versions")
|
||||||
|
def database_versions():
|
||||||
|
"""Show current database versions"""
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
loop.run_until_complete(db_versions())
|
||||||
|
|
||||||
|
|
||||||
async def db_versions():
|
async def db_versions():
|
||||||
|
"""Show current database versions"""
|
||||||
async with core_db.connect() as conn:
|
async with core_db.connect() as conn:
|
||||||
current_versions = await get_dbversions(conn)
|
return await get_dbversions(conn)
|
||||||
return current_versions
|
|
||||||
|
|
||||||
|
|
||||||
async def load_disabled_extension_list() -> None:
|
async def load_disabled_extension_list() -> None:
|
||||||
"""Update list of extensions that have been explicitly disabled"""
|
"""Update list of extensions that have been explicitly disabled"""
|
||||||
inactive_extensions = await get_inactive_extensions()
|
inactive_extensions = await get_inactive_extensions()
|
||||||
settings.lnbits_deactivated_extensions += inactive_extensions
|
settings.lnbits_deactivated_extensions += inactive_extensions
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""main function"""
|
||||||
|
command_group.add_command(superuser)
|
||||||
|
command_group.add_command(delete_settings)
|
||||||
|
command_group.add_command(database_migrate)
|
||||||
|
command_group.add_command(database_versions)
|
||||||
|
command_group()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ from lnbits.db import Database
|
|||||||
|
|
||||||
db = Database("database")
|
db = Database("database")
|
||||||
|
|
||||||
core_app: APIRouter = APIRouter()
|
core_app: APIRouter = APIRouter(tags=["Core"])
|
||||||
|
|
||||||
core_app_extra: CoreAppExtra = CoreAppExtra()
|
core_app_extra: CoreAppExtra = CoreAppExtra()
|
||||||
|
|
||||||
from .views.admin_api import * # noqa: F401,F403
|
from .views.admin_api import *
|
||||||
from .views.api import * # noqa: F401,F403
|
from .views.api import *
|
||||||
from .views.generic import * # noqa: F401,F403
|
from .views.generic import *
|
||||||
from .views.public_api import * # noqa: F401,F403
|
from .views.public_api import *
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ from uuid import UUID, uuid4
|
|||||||
import shortuuid
|
import shortuuid
|
||||||
|
|
||||||
from lnbits import bolt11
|
from lnbits import bolt11
|
||||||
from lnbits.db import Connection, Filters, Page
|
from lnbits.core.models import WalletType
|
||||||
|
from lnbits.db import Connection, Database, Filters, Page
|
||||||
from lnbits.extension_manager import InstallableExtension
|
from lnbits.extension_manager import InstallableExtension
|
||||||
from lnbits.settings import AdminSettings, EditableSettings, SuperSettings, settings
|
from lnbits.settings import AdminSettings, EditableSettings, SuperSettings, settings
|
||||||
|
|
||||||
@@ -57,7 +58,9 @@ async def get_user(user_id: str, conn: Optional[Connection] = None) -> Optional[
|
|||||||
)
|
)
|
||||||
wallets = await (conn or db).fetchall(
|
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
|
FROM wallets
|
||||||
WHERE "user" = ?
|
WHERE "user" = ?
|
||||||
""",
|
""",
|
||||||
@@ -75,6 +78,7 @@ async def get_user(user_id: str, conn: Optional[Connection] = None) -> Optional[
|
|||||||
wallets=[Wallet(**w) for w in wallets],
|
wallets=[Wallet(**w) for w in wallets],
|
||||||
admin=user["id"] == settings.super_user
|
admin=user["id"] == settings.super_user
|
||||||
or user["id"] in settings.lnbits_admin_users,
|
or user["id"] in settings.lnbits_admin_users,
|
||||||
|
super_user=user["id"] == settings.super_user,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -87,9 +91,9 @@ async def add_installed_extension(
|
|||||||
conn: Optional[Connection] = None,
|
conn: Optional[Connection] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
meta = {
|
meta = {
|
||||||
"installed_release": dict(ext.installed_release)
|
"installed_release": (
|
||||||
if ext.installed_release
|
dict(ext.installed_release) if ext.installed_release else None
|
||||||
else None,
|
),
|
||||||
"dependencies": ext.dependencies,
|
"dependencies": ext.dependencies,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,9 +101,11 @@ async def add_installed_extension(
|
|||||||
|
|
||||||
await (conn or db).execute(
|
await (conn or db).execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO installed_extensions (id, version, name, short_description, icon, stars, meta) VALUES (?, ?, ?, ?, ?, ?, ?)
|
INSERT INTO installed_extensions
|
||||||
ON CONFLICT (id) DO
|
(id, version, name, short_description, icon, stars, meta)
|
||||||
UPDATE SET (version, name, active, short_description, icon, stars, meta) = (?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT (id) DO UPDATE SET
|
||||||
|
(version, name, active, short_description, icon, stars, meta) =
|
||||||
|
(?, ?, ?, ?, ?, ?, ?)
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
ext.id,
|
ext.id,
|
||||||
@@ -142,6 +148,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):
|
async def get_installed_extension(ext_id: str, conn: Optional[Connection] = None):
|
||||||
row = await (conn or db).fetchone(
|
row = await (conn or db).fetchone(
|
||||||
"SELECT * FROM installed_extensions WHERE id = ?",
|
"SELECT * FROM installed_extensions WHERE id = ?",
|
||||||
@@ -213,15 +238,25 @@ async def create_wallet(
|
|||||||
|
|
||||||
|
|
||||||
async def update_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]:
|
) -> 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(
|
await (conn or db).execute(
|
||||||
"""
|
f"""
|
||||||
UPDATE wallets SET
|
UPDATE wallets SET {', '.join(set_clause)} WHERE id = ?
|
||||||
name = ?
|
|
||||||
WHERE id = ?
|
|
||||||
""",
|
""",
|
||||||
(new_name, wallet_id),
|
tuple(values),
|
||||||
)
|
)
|
||||||
wallet = await get_wallet(wallet_id=wallet_id, conn=conn)
|
wallet = await get_wallet(wallet_id=wallet_id, conn=conn)
|
||||||
assert wallet, "updated created wallet couldn't be retrieved"
|
assert wallet, "updated created wallet couldn't be retrieved"
|
||||||
@@ -249,9 +284,8 @@ async def get_wallet(
|
|||||||
) -> Optional[Wallet]:
|
) -> Optional[Wallet]:
|
||||||
row = await (conn or db).fetchone(
|
row = await (conn or db).fetchone(
|
||||||
"""
|
"""
|
||||||
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)
|
||||||
FROM wallets
|
AS balance_msat FROM wallets WHERE id = ?
|
||||||
WHERE id = ?
|
|
||||||
""",
|
""",
|
||||||
(wallet_id,),
|
(wallet_id,),
|
||||||
)
|
)
|
||||||
@@ -260,13 +294,14 @@ async def get_wallet(
|
|||||||
|
|
||||||
|
|
||||||
async def get_wallet_for_key(
|
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]:
|
) -> Optional[Wallet]:
|
||||||
row = await (conn or db).fetchone(
|
row = await (conn or db).fetchone(
|
||||||
"""
|
"""
|
||||||
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)
|
||||||
FROM wallets
|
AS balance_msat FROM wallets WHERE adminkey = ? OR inkey = ?
|
||||||
WHERE adminkey = ? OR inkey = ?
|
|
||||||
""",
|
""",
|
||||||
(key, key),
|
(key, key),
|
||||||
)
|
)
|
||||||
@@ -274,7 +309,7 @@ async def get_wallet_for_key(
|
|||||||
if not row:
|
if not row:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if key_type == "admin" and row["adminkey"] != key:
|
if key_type == WalletType.admin and row["adminkey"] != key:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return Wallet(**row)
|
return Wallet(**row)
|
||||||
@@ -285,6 +320,11 @@ async def get_total_balance(conn: Optional[Connection] = None):
|
|||||||
return 0 if row[0] is None else row[0]
|
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
|
# wallet payments
|
||||||
# ---------------
|
# ---------------
|
||||||
|
|
||||||
@@ -489,13 +529,13 @@ async def create_payment(
|
|||||||
conn: Optional[Connection] = None,
|
conn: Optional[Connection] = None,
|
||||||
) -> Payment:
|
) -> Payment:
|
||||||
# todo: add this when tests are fixed
|
# todo: add this when tests are fixed
|
||||||
# previous_payment = await get_wallet_payment(wallet_id, payment_hash, conn=conn)
|
previous_payment = await get_wallet_payment(wallet_id, payment_hash, conn=conn)
|
||||||
# assert previous_payment is None, "Payment already exists"
|
assert previous_payment is None, "Payment already exists"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
invoice = bolt11.decode(payment_request)
|
invoice = bolt11.decode(payment_request)
|
||||||
expiration_date = datetime.datetime.fromtimestamp(invoice.date + invoice.expiry)
|
expiration_date = datetime.datetime.fromtimestamp(invoice.date + invoice.expiry)
|
||||||
except:
|
except Exception:
|
||||||
# assume maximum bolt11 expiry of 31 days to be on the safe side
|
# assume maximum bolt11 expiry of 31 days to be on the safe side
|
||||||
expiration_date = datetime.datetime.now() + datetime.timedelta(days=31)
|
expiration_date = datetime.datetime.now() + datetime.timedelta(days=31)
|
||||||
|
|
||||||
@@ -516,9 +556,11 @@ async def create_payment(
|
|||||||
pending,
|
pending,
|
||||||
memo,
|
memo,
|
||||||
fee,
|
fee,
|
||||||
json.dumps(extra)
|
(
|
||||||
if extra and extra != {} and type(extra) is dict
|
json.dumps(extra)
|
||||||
else None,
|
if extra and extra != {} and type(extra) is dict
|
||||||
|
else None
|
||||||
|
),
|
||||||
webhook,
|
webhook,
|
||||||
db.datetime_to_timestamp(expiration_date),
|
db.datetime_to_timestamp(expiration_date),
|
||||||
),
|
),
|
||||||
@@ -580,7 +622,8 @@ async def update_payment_extra(
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Only update the `extra` field for the payment.
|
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"
|
amount_clause = "AND amount < 0" if outgoing else "AND amount > 0"
|
||||||
@@ -634,7 +677,10 @@ async def check_internal(
|
|||||||
async def check_internal_pending(
|
async def check_internal_pending(
|
||||||
payment_hash: str, conn: Optional[Connection] = None
|
payment_hash: str, conn: Optional[Connection] = None
|
||||||
) -> bool:
|
) -> 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(
|
row = await (conn or db).fetchone(
|
||||||
"""
|
"""
|
||||||
SELECT pending FROM apipayments
|
SELECT pending FROM apipayments
|
||||||
@@ -781,6 +827,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
|
# tinyurl
|
||||||
# -------
|
# -------
|
||||||
|
|
||||||
|
|||||||
@@ -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).
|
Stop background work for extension (like asyncio.Tasks, WebSockets, etc).
|
||||||
Extensions SHOULD expose a DELETE enpoint at the root level of their API.
|
Extensions SHOULD expose a 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:
|
async with httpx.AsyncClient() as client:
|
||||||
try:
|
try:
|
||||||
@@ -71,7 +72,7 @@ def to_valid_user_id(user_id: str) -> UUID:
|
|||||||
raise ValueError("User ID must have at least 128 bits")
|
raise ValueError("User ID must have at least 128 bits")
|
||||||
try:
|
try:
|
||||||
int(user_id, 16)
|
int(user_id, 16)
|
||||||
except:
|
except Exception:
|
||||||
raise ValueError("Invalid hex string for User ID.")
|
raise ValueError("Invalid hex string for User ID.")
|
||||||
|
|
||||||
return UUID(hex=user_id[:32], version=4)
|
return UUID(hex=user_id[:32], version=4)
|
||||||
|
|||||||
@@ -239,7 +239,8 @@ async def m007_set_invoice_expiries(db):
|
|||||||
invoice.date + invoice.expiry
|
invoice.date + invoice.expiry
|
||||||
)
|
)
|
||||||
logger.info(
|
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(
|
await db.execute(
|
||||||
"""
|
"""
|
||||||
@@ -251,7 +252,7 @@ async def m007_set_invoice_expiries(db):
|
|||||||
checking_id,
|
checking_id,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
except:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
except OperationalError:
|
except OperationalError:
|
||||||
# this is necessary now because it may be the case that this migration will
|
# this is necessary now because it may be the case that this migration will
|
||||||
@@ -301,3 +302,28 @@ 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
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|||||||
@@ -3,17 +3,21 @@ import hashlib
|
|||||||
import hmac
|
import hmac
|
||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from enum import Enum
|
||||||
from sqlite3 import Row
|
from sqlite3 import Row
|
||||||
from typing import Callable, Dict, List, Optional
|
from typing import Callable, Dict, List, Optional
|
||||||
|
|
||||||
from ecdsa import SECP256k1, SigningKey
|
from ecdsa import SECP256k1, SigningKey
|
||||||
|
from fastapi import Query
|
||||||
from lnurl import encode as lnurl_encode
|
from lnurl import encode as lnurl_encode
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from lnbits.db import Connection, FilterModel, FromRowModel
|
from lnbits.db import Connection, FilterModel, FromRowModel
|
||||||
from lnbits.helpers import url_for
|
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
|
from lnbits.wallets.base import PaymentStatus
|
||||||
|
|
||||||
|
|
||||||
@@ -23,6 +27,7 @@ class Wallet(BaseModel):
|
|||||||
user: str
|
user: str
|
||||||
adminkey: str
|
adminkey: str
|
||||||
inkey: str
|
inkey: str
|
||||||
|
currency: Optional[str]
|
||||||
balance_msat: int
|
balance_msat: int
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -37,11 +42,10 @@ class Wallet(BaseModel):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def lnurlwithdraw_full(self) -> str:
|
def lnurlwithdraw_full(self) -> str:
|
||||||
|
|
||||||
url = url_for("/withdraw", external=True, usr=self.user, wal=self.id)
|
url = url_for("/withdraw", external=True, usr=self.user, wal=self.id)
|
||||||
try:
|
try:
|
||||||
return lnurl_encode(url)
|
return lnurl_encode(url)
|
||||||
except:
|
except Exception:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
def lnurlauth_key(self, domain: str) -> SigningKey:
|
def lnurlauth_key(self, domain: str) -> SigningKey:
|
||||||
@@ -58,6 +62,22 @@ class Wallet(BaseModel):
|
|||||||
return await get_standalone_payment(payment_hash)
|
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):
|
class User(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
email: Optional[str] = None
|
email: Optional[str] = None
|
||||||
@@ -169,6 +189,8 @@ class Payment(FromRowModel):
|
|||||||
async def set_pending(self, pending: bool) -> None:
|
async def set_pending(self, pending: bool) -> None:
|
||||||
from .crud import update_payment_status
|
from .crud import update_payment_status
|
||||||
|
|
||||||
|
self.pending = pending
|
||||||
|
|
||||||
await update_payment_status(self.checking_id, pending)
|
await update_payment_status(self.checking_id, pending)
|
||||||
|
|
||||||
async def check_status(
|
async def check_status(
|
||||||
@@ -179,7 +201,8 @@ class Payment(FromRowModel):
|
|||||||
return PaymentStatus(None)
|
return PaymentStatus(None)
|
||||||
|
|
||||||
logger.debug(
|
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()
|
WALLET = get_wallet_class()
|
||||||
@@ -193,7 +216,8 @@ class Payment(FromRowModel):
|
|||||||
if self.is_in and status.pending and self.is_expired and self.expiry:
|
if self.is_in and status.pending and self.is_expired and self.expiry:
|
||||||
expiration_date = datetime.datetime.fromtimestamp(self.expiry)
|
expiration_date = datetime.datetime.fromtimestamp(self.expiry)
|
||||||
logger.debug(
|
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)
|
await self.delete(conn)
|
||||||
elif self.is_out and status.failed:
|
elif self.is_out and status.failed:
|
||||||
@@ -203,7 +227,8 @@ class Payment(FromRowModel):
|
|||||||
await self.delete(conn)
|
await self.delete(conn)
|
||||||
elif not status.pending:
|
elif not status.pending:
|
||||||
logger.info(
|
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)
|
await self.update_status(status, conn=conn)
|
||||||
return status
|
return status
|
||||||
@@ -244,6 +269,7 @@ class BalanceCheck(BaseModel):
|
|||||||
|
|
||||||
class CoreAppExtra:
|
class CoreAppExtra:
|
||||||
register_new_ext_routes: Callable
|
register_new_ext_routes: Callable
|
||||||
|
register_new_ratelimiter: Callable
|
||||||
|
|
||||||
|
|
||||||
class TinyURL(BaseModel):
|
class TinyURL(BaseModel):
|
||||||
@@ -256,3 +282,50 @@ class TinyURL(BaseModel):
|
|||||||
@classmethod
|
@classmethod
|
||||||
def from_row(cls, row: Row):
|
def from_row(cls, row: Row):
|
||||||
return cls(**dict(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
|
||||||
|
|||||||
@@ -15,14 +15,14 @@ from lnbits.db import Connection
|
|||||||
from lnbits.decorators import WalletTypeInfo, require_admin_key
|
from lnbits.decorators import WalletTypeInfo, require_admin_key
|
||||||
from lnbits.helpers import url_for
|
from lnbits.helpers import url_for
|
||||||
from lnbits.settings import (
|
from lnbits.settings import (
|
||||||
FAKE_WALLET,
|
|
||||||
EditableSettings,
|
EditableSettings,
|
||||||
SuperSettings,
|
SuperSettings,
|
||||||
get_wallet_class,
|
|
||||||
readonly_variables,
|
readonly_variables,
|
||||||
send_admin_user_to_saas,
|
send_admin_user_to_saas,
|
||||||
settings,
|
settings,
|
||||||
)
|
)
|
||||||
|
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis, satoshis_amount_as_fiat
|
||||||
|
from lnbits.wallets import FAKE_WALLET, get_wallet_class, set_wallet_class
|
||||||
from lnbits.wallets.base import PaymentResponse, PaymentStatus
|
from lnbits.wallets.base import PaymentResponse, PaymentStatus
|
||||||
|
|
||||||
from . import db
|
from . import db
|
||||||
@@ -37,6 +37,7 @@ from .crud import (
|
|||||||
get_account,
|
get_account,
|
||||||
get_standalone_payment,
|
get_standalone_payment,
|
||||||
get_super_settings,
|
get_super_settings,
|
||||||
|
get_total_balance,
|
||||||
get_wallet,
|
get_wallet,
|
||||||
get_wallet_payment,
|
get_wallet_payment,
|
||||||
update_payment_details,
|
update_payment_details,
|
||||||
@@ -44,7 +45,7 @@ from .crud import (
|
|||||||
update_super_user,
|
update_super_user,
|
||||||
)
|
)
|
||||||
from .helpers import to_valid_user_id
|
from .helpers import to_valid_user_id
|
||||||
from .models import Payment
|
from .models import Payment, Wallet
|
||||||
|
|
||||||
|
|
||||||
class PaymentFailure(Exception):
|
class PaymentFailure(Exception):
|
||||||
@@ -55,10 +56,47 @@ class InvoiceFailure(Exception):
|
|||||||
pass
|
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 for {wallet}: {extra=}")
|
||||||
|
|
||||||
|
return amount_sat, extra
|
||||||
|
|
||||||
|
|
||||||
async def create_invoice(
|
async def create_invoice(
|
||||||
*,
|
*,
|
||||||
wallet_id: str,
|
wallet_id: str,
|
||||||
amount: int, # in satoshis
|
amount: float,
|
||||||
|
currency: Optional[str] = "sat",
|
||||||
memo: str,
|
memo: str,
|
||||||
description_hash: Optional[bytes] = None,
|
description_hash: Optional[bytes] = None,
|
||||||
unhashed_description: Optional[bytes] = None,
|
unhashed_description: Optional[bytes] = None,
|
||||||
@@ -76,19 +114,23 @@ async def create_invoice(
|
|||||||
# use the fake wallet if the invoice is for internal use only
|
# use the fake wallet if the invoice is for internal use only
|
||||||
wallet = FAKE_WALLET if internal else get_wallet_class()
|
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(
|
ok, checking_id, payment_request, error_message = await wallet.create_invoice(
|
||||||
amount=amount,
|
amount=amount_sat,
|
||||||
memo=invoice_memo,
|
memo=invoice_memo,
|
||||||
description_hash=description_hash,
|
description_hash=description_hash,
|
||||||
unhashed_description=unhashed_description,
|
unhashed_description=unhashed_description,
|
||||||
expiry=expiry or settings.lightning_invoice_expiry,
|
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.")
|
raise InvoiceFailure(error_message or "unexpected backend error.")
|
||||||
|
|
||||||
invoice = bolt11.decode(payment_request)
|
invoice = bolt11.decode(payment_request)
|
||||||
|
|
||||||
amount_msat = amount * 1000
|
amount_msat = 1000 * amount_sat
|
||||||
await create_payment(
|
await create_payment(
|
||||||
wallet_id=wallet_id,
|
wallet_id=wallet_id,
|
||||||
checking_id=checking_id,
|
checking_id=checking_id,
|
||||||
@@ -115,16 +157,17 @@ async def pay_invoice(
|
|||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Pay a Lightning invoice.
|
Pay a Lightning invoice.
|
||||||
First, we create a temporary payment in the database with fees set to the reserve fee.
|
First, we create a temporary payment in the database with fees set to the reserve
|
||||||
We then check whether the balance of the payer would go negative.
|
fee. We then check whether the balance of the payer would go negative.
|
||||||
We then attempt to pay the invoice through the backend.
|
We then attempt to pay the invoice through the backend. If the payment is
|
||||||
If the payment is successful, we update the payment in the database with the payment details.
|
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 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)
|
invoice = bolt11.decode(payment_request)
|
||||||
fee_reserve_msat = fee_reserve(invoice.amount_msat)
|
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
|
temp_id = invoice.payment_hash
|
||||||
internal_id = f"internal_{invoice.payment_hash}"
|
internal_id = f"internal_{invoice.payment_hash}"
|
||||||
|
|
||||||
@@ -133,6 +176,10 @@ async def pay_invoice(
|
|||||||
if max_sat and invoice.amount_msat > max_sat * 1000:
|
if max_sat and invoice.amount_msat > max_sat * 1000:
|
||||||
raise ValueError("Amount in invoice is too high.")
|
raise ValueError("Amount in invoice is too high.")
|
||||||
|
|
||||||
|
_, extra = await calculate_fiat_amounts(
|
||||||
|
invoice.amount_msat, wallet_id, extra=extra, conn=conn
|
||||||
|
)
|
||||||
|
|
||||||
# put all parameters that don't change here
|
# put all parameters that don't change here
|
||||||
class PaymentKwargs(TypedDict):
|
class PaymentKwargs(TypedDict):
|
||||||
wallet_id: str
|
wallet_id: str
|
||||||
@@ -151,11 +198,13 @@ async def pay_invoice(
|
|||||||
extra=extra,
|
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):
|
if not await check_internal_pending(invoice.payment_hash, conn=conn):
|
||||||
raise PaymentFailure("Internal invoice already paid.")
|
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)
|
internal_checking_id = await check_internal(invoice.payment_hash, conn=conn)
|
||||||
if internal_checking_id:
|
if internal_checking_id:
|
||||||
# perform additional checks on the internal payment
|
# perform additional checks on the internal payment
|
||||||
@@ -172,7 +221,7 @@ async def pay_invoice(
|
|||||||
|
|
||||||
logger.debug(f"creating temporary internal payment with id {internal_id}")
|
logger.debug(f"creating temporary internal payment with id {internal_id}")
|
||||||
# create a new payment from this wallet
|
# create a new payment from this wallet
|
||||||
await create_payment(
|
new_payment = await create_payment(
|
||||||
checking_id=internal_id,
|
checking_id=internal_id,
|
||||||
fee=0,
|
fee=0,
|
||||||
pending=False,
|
pending=False,
|
||||||
@@ -184,7 +233,7 @@ async def pay_invoice(
|
|||||||
# create a temporary payment here so we can check if
|
# create a temporary payment here so we can check if
|
||||||
# the balance is enough in the next step
|
# the balance is enough in the next step
|
||||||
try:
|
try:
|
||||||
await create_payment(
|
new_payment = await create_payment(
|
||||||
checking_id=temp_id,
|
checking_id=temp_id,
|
||||||
fee=-fee_reserve_msat,
|
fee=-fee_reserve_msat,
|
||||||
conn=conn,
|
conn=conn,
|
||||||
@@ -202,7 +251,8 @@ async def pay_invoice(
|
|||||||
logger.debug("balance is too low, deleting temporary payment")
|
logger.debug("balance is too low, deleting temporary payment")
|
||||||
if not internal_checking_id and wallet.balance_msat > -fee_reserve_msat:
|
if not internal_checking_id and wallet.balance_msat > -fee_reserve_msat:
|
||||||
raise PaymentFailure(
|
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.")
|
raise PermissionError("Insufficient balance.")
|
||||||
|
|
||||||
@@ -215,6 +265,7 @@ async def pay_invoice(
|
|||||||
await update_payment_status(
|
await update_payment_status(
|
||||||
checking_id=internal_checking_id, pending=False, conn=conn
|
checking_id=internal_checking_id, pending=False, conn=conn
|
||||||
)
|
)
|
||||||
|
await send_payment_notification(wallet, new_payment)
|
||||||
|
|
||||||
# notify receiver asynchronously
|
# notify receiver asynchronously
|
||||||
from lnbits.tasks import internal_invoice_queue
|
from lnbits.tasks import internal_invoice_queue
|
||||||
@@ -231,7 +282,8 @@ async def pay_invoice(
|
|||||||
|
|
||||||
if payment.checking_id and payment.checking_id != temp_id:
|
if payment.checking_id and payment.checking_id != temp_id:
|
||||||
logger.warning(
|
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}")
|
logger.debug(f"backend: pay_invoice finished {temp_id}")
|
||||||
@@ -248,14 +300,11 @@ async def pay_invoice(
|
|||||||
conn=conn,
|
conn=conn,
|
||||||
)
|
)
|
||||||
wallet = await get_wallet(wallet_id, conn=conn)
|
wallet = await get_wallet(wallet_id, conn=conn)
|
||||||
if wallet:
|
updated = await get_wallet_payment(
|
||||||
await websocketUpdater(
|
wallet_id, payment.checking_id, conn=conn
|
||||||
wallet_id,
|
)
|
||||||
{
|
if wallet and updated:
|
||||||
"wallet_balance": wallet.balance or None,
|
await send_payment_notification(wallet, updated)
|
||||||
"payment": payment._asdict(),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
logger.debug(f"payment successful {payment.checking_id}")
|
logger.debug(f"payment successful {payment.checking_id}")
|
||||||
elif payment.checking_id is None and payment.ok is False:
|
elif payment.checking_id is None and payment.ok is False:
|
||||||
# payment failed
|
# payment failed
|
||||||
@@ -269,7 +318,8 @@ async def pay_invoice(
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
logger.warning(
|
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
|
return invoice.payment_hash
|
||||||
@@ -301,9 +351,10 @@ async def redeem_lnurl_withdraw(
|
|||||||
extra=extra,
|
extra=extra,
|
||||||
conn=conn,
|
conn=conn,
|
||||||
)
|
)
|
||||||
except:
|
except Exception:
|
||||||
logger.warning(
|
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
|
return None
|
||||||
|
|
||||||
@@ -422,13 +473,26 @@ async def check_transaction_status(
|
|||||||
return 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:
|
def fee_reserve(amount_msat: int) -> int:
|
||||||
reserve_min = settings.lnbits_reserve_fee_min
|
reserve_min = settings.lnbits_reserve_fee_min
|
||||||
reserve_percent = settings.lnbits_reserve_fee_percent
|
reserve_percent = settings.lnbits_reserve_fee_percent
|
||||||
return max(int(reserve_min), int(amount_msat * reserve_percent / 100.0))
|
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):
|
async def update_wallet_balance(wallet_id: str, amount: int):
|
||||||
payment_hash, _ = await create_invoice(
|
payment_hash, _ = await create_invoice(
|
||||||
wallet_id=wallet_id,
|
wallet_id=wallet_id,
|
||||||
@@ -464,12 +528,7 @@ async def check_admin_settings():
|
|||||||
|
|
||||||
update_cached_settings(settings_db.dict())
|
update_cached_settings(settings_db.dict())
|
||||||
|
|
||||||
# printing settings for debugging
|
admin_url = f'{settings.lnbits_baseurl}wallet?usr=<ID from ".super_user" file>'
|
||||||
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}")
|
logger.success(f"✔️ Access super user account at: {admin_url}")
|
||||||
|
|
||||||
# saving it to .super_user file
|
# saving it to .super_user file
|
||||||
@@ -490,7 +549,7 @@ def update_cached_settings(sets_dict: dict):
|
|||||||
if key not in readonly_variables:
|
if key not in readonly_variables:
|
||||||
try:
|
try:
|
||||||
setattr(settings, key, value)
|
setattr(settings, key, value)
|
||||||
except:
|
except Exception:
|
||||||
logger.warning(f"Failed overriding setting: {key}, value: {value}")
|
logger.warning(f"Failed overriding setting: {key}, value: {value}")
|
||||||
if "super_user" in sets_dict:
|
if "super_user" in sets_dict:
|
||||||
setattr(settings, "super_user", sets_dict["super_user"])
|
setattr(settings, "super_user", sets_dict["super_user"])
|
||||||
@@ -511,12 +570,12 @@ async def init_admin_settings(super_user: Optional[str] = None) -> SuperSettings
|
|||||||
|
|
||||||
|
|
||||||
class WebsocketConnectionManager:
|
class WebsocketConnectionManager:
|
||||||
def __init__(self):
|
def __init__(self) -> None:
|
||||||
self.active_connections: List[WebSocket] = []
|
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()
|
await websocket.accept()
|
||||||
logger.debug(websocket)
|
|
||||||
self.active_connections.append(websocket)
|
self.active_connections.append(websocket)
|
||||||
|
|
||||||
def disconnect(self, websocket: WebSocket):
|
def disconnect(self, websocket: WebSocket):
|
||||||
@@ -533,3 +592,20 @@ websocketManager = WebsocketConnectionManager()
|
|||||||
|
|
||||||
async def websocketUpdater(item_id, data):
|
async def websocketUpdater(item_id, data):
|
||||||
return await websocketManager.send_data(f"{data}", item_id)
|
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]
|
|
||||||
})
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
// update cache version every time there is a new deployment
|
// update cache version every time there is a new deployment
|
||||||
// so the service worker reinitializes the cache
|
// so the service worker reinitializes the cache
|
||||||
const CACHE_VERSION = 30
|
const CACHE_VERSION = 53
|
||||||
const CURRENT_CACHE = `lnbits-${CACHE_VERSION}-`
|
const CURRENT_CACHE = `lnbits-${CACHE_VERSION}-`
|
||||||
|
|
||||||
const getApiKey = request => {
|
const getApiKey = request => {
|
||||||
|
|||||||
@@ -137,6 +137,9 @@ new Vue({
|
|||||||
comment: ''
|
comment: ''
|
||||||
},
|
},
|
||||||
paymentChecker: null,
|
paymentChecker: null,
|
||||||
|
copy: {
|
||||||
|
show: false
|
||||||
|
},
|
||||||
camera: {
|
camera: {
|
||||||
show: false,
|
show: false,
|
||||||
camera: 'auto'
|
camera: 'auto'
|
||||||
@@ -148,14 +151,7 @@ new Vue({
|
|||||||
{
|
{
|
||||||
name: 'time',
|
name: 'time',
|
||||||
align: 'left',
|
align: 'left',
|
||||||
label: this.$t('date'),
|
label: this.$t('memo') + '/' + this.$t('date'),
|
||||||
field: 'date',
|
|
||||||
sortable: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'memo',
|
|
||||||
align: 'left',
|
|
||||||
label: this.$t('memo'),
|
|
||||||
field: 'date',
|
field: 'date',
|
||||||
sortable: true
|
sortable: true
|
||||||
},
|
},
|
||||||
@@ -177,6 +173,80 @@ new Vue({
|
|||||||
filter: null,
|
filter: null,
|
||||||
loading: false
|
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: {
|
paymentsChart: {
|
||||||
show: false
|
show: false
|
||||||
},
|
},
|
||||||
@@ -186,7 +256,10 @@ new Vue({
|
|||||||
},
|
},
|
||||||
balance: 0,
|
balance: 0,
|
||||||
credit: 0,
|
credit: 0,
|
||||||
newName: ''
|
update: {
|
||||||
|
name: null,
|
||||||
|
currency: null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -243,7 +316,6 @@ new Vue({
|
|||||||
this.receive.data.amount = null
|
this.receive.data.amount = null
|
||||||
this.receive.data.memo = null
|
this.receive.data.memo = null
|
||||||
this.receive.unit = 'sat'
|
this.receive.unit = 'sat'
|
||||||
this.receive.paymentChecker = null
|
|
||||||
this.receive.minMax = [0, 2100000000000000]
|
this.receive.minMax = [0, 2100000000000000]
|
||||||
this.receive.lnurl = null
|
this.receive.lnurl = null
|
||||||
this.focusInput('setAmount')
|
this.focusInput('setAmount')
|
||||||
@@ -253,6 +325,8 @@ new Vue({
|
|||||||
this.parse.invoice = null
|
this.parse.invoice = null
|
||||||
this.parse.lnurlpay = null
|
this.parse.lnurlpay = null
|
||||||
this.parse.lnurlauth = null
|
this.parse.lnurlauth = null
|
||||||
|
this.parse.copy.show =
|
||||||
|
window.isSecureContext && navigator.clipboard?.readText !== undefined
|
||||||
this.parse.data.request = ''
|
this.parse.data.request = ''
|
||||||
this.parse.data.comment = ''
|
this.parse.data.comment = ''
|
||||||
this.parse.data.paymentChecker = null
|
this.parse.data.paymentChecker = null
|
||||||
@@ -285,11 +359,6 @@ new Vue({
|
|||||||
LNbits.utils.notifyApiError(error)
|
LNbits.utils.notifyApiError(error)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
closeReceiveDialog: function () {
|
|
||||||
setTimeout(() => {
|
|
||||||
clearInterval(this.receive.paymentChecker)
|
|
||||||
}, 10000)
|
|
||||||
},
|
|
||||||
closeParseDialog: function () {
|
closeParseDialog: function () {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
clearInterval(this.parse.paymentChecker)
|
clearInterval(this.parse.paymentChecker)
|
||||||
@@ -302,7 +371,6 @@ new Vue({
|
|||||||
if (this.receive.paymentHash === paymentHash) {
|
if (this.receive.paymentHash === paymentHash) {
|
||||||
this.receive.show = false
|
this.receive.show = false
|
||||||
this.receive.paymentHash = null
|
this.receive.paymentHash = null
|
||||||
clearInterval(this.receive.paymentChecker)
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
createInvoice: function () {
|
createInvoice: function () {
|
||||||
@@ -347,19 +415,7 @@ new Vue({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
clearInterval(this.receive.paymentChecker)
|
this.fetchPayments()
|
||||||
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)
|
|
||||||
})
|
})
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
LNbits.utils.notifyApiError(err)
|
LNbits.utils.notifyApiError(err)
|
||||||
@@ -664,16 +720,12 @@ new Vue({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
updateWalletName: function () {
|
updateWallet: function (data) {
|
||||||
let newName = this.newName
|
|
||||||
let adminkey = this.g.wallet.adminkey
|
|
||||||
if (!newName || !newName.length) return
|
|
||||||
LNbits.api
|
LNbits.api
|
||||||
.request('PUT', '/api/v1/wallet/' + newName, adminkey, {})
|
.request('PATCH', '/api/v1/wallet', this.g.wallet.adminkey, data)
|
||||||
.then(res => {
|
.then(res => {
|
||||||
this.newName = ''
|
|
||||||
this.$q.notify({
|
this.$q.notify({
|
||||||
message: `Wallet named updated.`,
|
message: `Wallet updated.`,
|
||||||
type: 'positive',
|
type: 'positive',
|
||||||
timeout: 3500
|
timeout: 3500
|
||||||
})
|
})
|
||||||
@@ -684,7 +736,6 @@ new Vue({
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
this.newName = ''
|
|
||||||
LNbits.utils.notifyApiError(err)
|
LNbits.utils.notifyApiError(err)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@@ -738,14 +789,15 @@ new Vue({
|
|||||||
// status is important for export but it is not in paymentsTable
|
// status is important for export but it is not in paymentsTable
|
||||||
// because it is manually added with payment detail link and icons
|
// because it is manually added with payment detail link and icons
|
||||||
// and would cause duplication in the list
|
// and would cause duplication in the list
|
||||||
let columns = structuredClone(this.paymentsTable.columns)
|
LNbits.api.getPayments(this.g.wallet, {}).then(response => {
|
||||||
columns.unshift({
|
const payments = response.data.data.map(LNbits.map.payment)
|
||||||
name: 'pending',
|
LNbits.utils.exportCSV(this.paymentsCSV.columns, payments)
|
||||||
align: 'left',
|
})
|
||||||
label: 'Pending',
|
},
|
||||||
field: 'pending'
|
pasteToTextArea: function () {
|
||||||
|
navigator.clipboard.readText().then(text => {
|
||||||
|
this.$refs.textArea.value = text
|
||||||
})
|
})
|
||||||
LNbits.utils.exportCSV(columns, this.payments)
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
@@ -757,6 +809,9 @@ new Vue({
|
|||||||
this.fetchBalance()
|
this.fetchBalance()
|
||||||
this.fetchPayments()
|
this.fetchPayments()
|
||||||
|
|
||||||
|
this.update.name = this.g.wallet.name
|
||||||
|
this.update.currency = this.g.wallet.currency
|
||||||
|
|
||||||
LNbits.api
|
LNbits.api
|
||||||
.request('GET', '/api/v1/currencies')
|
.request('GET', '/api/v1/currencies')
|
||||||
.then(response => {
|
.then(response => {
|
||||||
|
|||||||
@@ -4,34 +4,98 @@ from typing import Dict
|
|||||||
import httpx
|
import httpx
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from lnbits.tasks import SseListenersDict, register_invoice_listener
|
from lnbits.settings import get_wallet_class, settings
|
||||||
|
from lnbits.tasks import (
|
||||||
|
SseListenersDict,
|
||||||
|
create_permanent_task,
|
||||||
|
create_task,
|
||||||
|
register_invoice_listener,
|
||||||
|
)
|
||||||
|
|
||||||
from . import db
|
from . import db
|
||||||
from .crud import get_balance_notify, get_wallet
|
from .crud import get_balance_notify, get_wallet
|
||||||
from .models import Payment
|
from .models import Payment
|
||||||
from .services import websocketUpdater
|
from .services import get_balance_delta, send_payment_notification, switch_to_voidwallet
|
||||||
|
|
||||||
api_invoice_listeners: Dict[str, asyncio.Queue] = SseListenersDict(
|
api_invoice_listeners: Dict[str, asyncio.Queue] = SseListenersDict(
|
||||||
"api_invoice_listeners"
|
"api_invoice_listeners"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def register_task_listeners():
|
def register_killswitch():
|
||||||
"""
|
"""
|
||||||
Registers an invoice listener queue for the core tasks.
|
Registers a killswitch which will check lnbits-status repository for a signal from
|
||||||
Incoming payaments in this queue will eventually trigger the signals sent to all other extensions
|
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.
|
and fulfill other core tasks such as dispatching webhooks.
|
||||||
"""
|
"""
|
||||||
invoice_paid_queue = asyncio.Queue(5)
|
invoice_paid_queue = asyncio.Queue(5)
|
||||||
# we register invoice_paid_queue to receive all incoming invoices
|
# we register invoice_paid_queue to receive all incoming invoices
|
||||||
register_invoice_listener(invoice_paid_queue, "core/tasks.py")
|
register_invoice_listener(invoice_paid_queue, "core/tasks.py")
|
||||||
# register a worker that will react to invoices
|
# 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):
|
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:
|
while True:
|
||||||
payment = await invoice_paid_queue.get()
|
payment = await invoice_paid_queue.get()
|
||||||
@@ -40,13 +104,7 @@ async def wait_for_paid_invoices(invoice_paid_queue: asyncio.Queue):
|
|||||||
await dispatch_api_invoice_listeners(payment)
|
await dispatch_api_invoice_listeners(payment)
|
||||||
wallet = await get_wallet(payment.wallet_id)
|
wallet = await get_wallet(payment.wallet_id)
|
||||||
if wallet:
|
if wallet:
|
||||||
await websocketUpdater(
|
await send_payment_notification(wallet, payment)
|
||||||
payment.wallet_id,
|
|
||||||
{
|
|
||||||
"wallet_balance": wallet.balance or None,
|
|
||||||
"payment": payment._asdict(),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
# dispatch webhook
|
# dispatch webhook
|
||||||
if payment.webhook and not payment.webhook_status:
|
if payment.webhook and not payment.webhook_status:
|
||||||
await dispatch_webhook(payment)
|
await dispatch_webhook(payment)
|
||||||
@@ -79,11 +137,15 @@ async def dispatch_webhook(payment: Payment):
|
|||||||
"""
|
"""
|
||||||
Dispatches the webhook to the webhook url.
|
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:
|
async with httpx.AsyncClient() as client:
|
||||||
data = payment.dict()
|
data = payment.dict()
|
||||||
try:
|
try:
|
||||||
logger.debug("sending webhook", payment.webhook)
|
r = await client.post(payment.webhook, json=data, timeout=40)
|
||||||
r = await client.post(payment.webhook, json=data, timeout=40) # type: ignore
|
|
||||||
await mark_webhook_sent(payment, r.status_code)
|
await mark_webhook_sent(payment, r.status_code)
|
||||||
except (httpx.ConnectError, httpx.RequestError):
|
except (httpx.ConnectError, httpx.RequestError):
|
||||||
await mark_webhook_sent(payment, -1)
|
await mark_webhook_sent(payment, -1)
|
||||||
|
|||||||
@@ -9,7 +9,18 @@
|
|||||||
<ul>
|
<ul>
|
||||||
{%raw%}
|
{%raw%}
|
||||||
<li>Funding Source: {{settings.lnbits_backend_wallet_class}}</li>
|
<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%}
|
{%endraw%}
|
||||||
</ul>
|
</ul>
|
||||||
<br />
|
<br />
|
||||||
@@ -36,7 +47,8 @@
|
|||||||
<p>Invoice Expiry</p>
|
<p>Invoice Expiry</p>
|
||||||
<q-input
|
<q-input
|
||||||
filled
|
filled
|
||||||
v-model="formData.lightning_invoice_expiry"
|
v-model.number="formData.lightning_invoice_expiry"
|
||||||
|
type="number"
|
||||||
label="Invoice expiry (seconds)"
|
label="Invoice expiry (seconds)"
|
||||||
mask="#######"
|
mask="#######"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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="checknark"></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 />
|
<br />
|
||||||
</div>
|
</div>
|
||||||
</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="row q-col-gutter-md">
|
||||||
<div class="col-12 col-md-6">
|
<div class="col-12 col-md-6">
|
||||||
<p>Admin Extensions</p>
|
<p>Admin Extensions</p>
|
||||||
|
|||||||
@@ -88,6 +88,12 @@
|
|||||||
@update="val => tab = val.name"
|
@update="val => tab = val.name"
|
||||||
></q-tab>
|
></q-tab>
|
||||||
|
|
||||||
|
<q-tab
|
||||||
|
name="security"
|
||||||
|
:label="$t('security')"
|
||||||
|
@update="val => tab = val.name"
|
||||||
|
></q-tab>
|
||||||
|
|
||||||
<q-tab
|
<q-tab
|
||||||
name="theme"
|
name="theme"
|
||||||
:label="$t('theme')"
|
:label="$t('theme')"
|
||||||
@@ -101,7 +107,8 @@
|
|||||||
<q-tab-panels v-model="tab" animated>
|
<q-tab-panels v-model="tab" animated>
|
||||||
{% include "admin/_tab_funding.html" %} {% include
|
{% include "admin/_tab_funding.html" %} {% include
|
||||||
"admin/_tab_users.html" %} {% include "admin/_tab_server.html" %} {%
|
"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-tab-panels>
|
||||||
</q-form>
|
</q-form>
|
||||||
</q-card>
|
</q-card>
|
||||||
@@ -164,6 +171,8 @@
|
|||||||
data: function () {
|
data: function () {
|
||||||
return {
|
return {
|
||||||
settings: {},
|
settings: {},
|
||||||
|
logs: [],
|
||||||
|
serverlogEnabled: false,
|
||||||
lnbits_theme_options: [
|
lnbits_theme_options: [
|
||||||
'classic',
|
'classic',
|
||||||
'bitcoin',
|
'bitcoin',
|
||||||
@@ -175,10 +184,30 @@
|
|||||||
'monochrome',
|
'monochrome',
|
||||||
'salvador'
|
'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: {},
|
formData: {},
|
||||||
formAddAdmin: '',
|
formAddAdmin: '',
|
||||||
formAddUser: '',
|
formAddUser: '',
|
||||||
formAddExtensionsManifest: '',
|
formAddExtensionsManifest: '',
|
||||||
|
formAllowedIPs: '',
|
||||||
|
formBlockedIPs: '',
|
||||||
isSuperUser: false,
|
isSuperUser: false,
|
||||||
wallet: {},
|
wallet: {},
|
||||||
cancel: {},
|
cancel: {},
|
||||||
@@ -207,6 +236,23 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
'CoreLightningRestWallet',
|
||||||
|
{
|
||||||
|
corelightning_rest_url: {
|
||||||
|
value: null,
|
||||||
|
label: 'Endpoint'
|
||||||
|
},
|
||||||
|
corelightning_rest_cert: {
|
||||||
|
value: null,
|
||||||
|
label: 'Certificate'
|
||||||
|
},
|
||||||
|
corelightning_rest_macaroon: {
|
||||||
|
value: null,
|
||||||
|
label: 'Macaroon'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
[
|
[
|
||||||
'LndRestWallet',
|
'LndRestWallet',
|
||||||
{
|
{
|
||||||
@@ -347,13 +393,20 @@
|
|||||||
])
|
])
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
created: function () {
|
created() {
|
||||||
this.getSettings()
|
this.getSettings()
|
||||||
|
this.getAudit()
|
||||||
this.balance = +'{{ balance|safe }}'
|
this.balance = +'{{ balance|safe }}'
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
lnbitsVersion() {
|
||||||
|
return LNBITS_VERSION
|
||||||
|
},
|
||||||
checkChanges() {
|
checkChanges() {
|
||||||
return !_.isEqual(this.settings, this.formData)
|
return !_.isEqual(this.settings, this.formData)
|
||||||
|
},
|
||||||
|
updateAvailable() {
|
||||||
|
return LNBITS_VERSION !== this.statusData.version
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@@ -364,7 +417,6 @@
|
|||||||
//admin_users = [...admin_users, addUser]
|
//admin_users = [...admin_users, addUser]
|
||||||
this.formData.lnbits_admin_users = [...admin_users, addUser]
|
this.formData.lnbits_admin_users = [...admin_users, addUser]
|
||||||
this.formAddAdmin = ''
|
this.formAddAdmin = ''
|
||||||
//console.log(this.checkChanges)
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
removeAdminUser(user) {
|
removeAdminUser(user) {
|
||||||
@@ -406,6 +458,68 @@
|
|||||||
m => m !== manifest
|
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() {
|
restartServer() {
|
||||||
LNbits.api
|
LNbits.api
|
||||||
.request('GET', '/admin/api/v1/restart/?usr=' + this.g.user.id)
|
.request('GET', '/admin/api/v1/restart/?usr=' + this.g.user.id)
|
||||||
@@ -449,12 +563,43 @@
|
|||||||
this.settings.lnbits_allowed_funding_sources.map(f => {
|
this.settings.lnbits_allowed_funding_sources.map(f => {
|
||||||
let opts = this.funding_sources.get(f)
|
let opts = this.funding_sources.get(f)
|
||||||
if (!opts) return
|
if (!opts) return
|
||||||
|
|
||||||
Object.keys(opts).forEach(e => {
|
Object.keys(opts).forEach(e => {
|
||||||
opts[e].value = this.settings[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() {
|
getSettings() {
|
||||||
LNbits.api
|
LNbits.api
|
||||||
.request(
|
.request(
|
||||||
@@ -465,8 +610,9 @@
|
|||||||
.then(response => {
|
.then(response => {
|
||||||
this.isSuperUser = response.data.is_super_user || false
|
this.isSuperUser = response.data.is_super_user || false
|
||||||
this.settings = response.data
|
this.settings = response.data
|
||||||
this.formData = _.clone(this.settings)
|
this.formData = {...this.settings}
|
||||||
this.updateFundingData()
|
this.updateFundingData()
|
||||||
|
this.getNotifications()
|
||||||
})
|
})
|
||||||
.catch(function (error) {
|
.catch(function (error) {
|
||||||
LNbits.utils.notifyApiError(error)
|
LNbits.utils.notifyApiError(error)
|
||||||
@@ -487,13 +633,17 @@
|
|||||||
.then(response => {
|
.then(response => {
|
||||||
this.needsRestart =
|
this.needsRestart =
|
||||||
this.settings.lnbits_backend_wallet_class !==
|
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.settings = this.formData
|
||||||
this.formData = _.clone(this.settings)
|
this.formData = _.clone(this.settings)
|
||||||
this.updateFundingData()
|
this.updateFundingData()
|
||||||
this.$q.notify({
|
this.$q.notify({
|
||||||
type: 'positive',
|
type: 'positive',
|
||||||
message: 'Success! Settings changed!',
|
message: `Success! Settings changed! ${
|
||||||
|
this.needsRestart ? 'Restart required!' : ''
|
||||||
|
}`,
|
||||||
icon: null
|
icon: null
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -108,7 +108,8 @@
|
|||||||
class="text-subtitle2 gt-sm"
|
class="text-subtitle2 gt-sm"
|
||||||
style="font-size: 11px; height: 34px"
|
style="font-size: 11px; height: 34px"
|
||||||
>
|
>
|
||||||
{{ extension.shortDescription }}
|
{{ extension.shortDescription ||
|
||||||
|
extension.installedRelease?.description }}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-subtitle1 lt-md q-mt-sm q-mb-xs">
|
<div class="text-subtitle1 lt-md q-mt-sm q-mb-xs">
|
||||||
{{ extension.name }}
|
{{ extension.name }}
|
||||||
@@ -184,7 +185,7 @@
|
|||||||
flat
|
flat
|
||||||
color="primary"
|
color="primary"
|
||||||
type="a"
|
type="a"
|
||||||
:href="[extension.id, '?usr=', g.user.id].join('')"
|
:href="[extension.id, '/?usr=', g.user.id].join('')"
|
||||||
>{%raw%}{{ $t('open') }}{%endraw%}</q-btn
|
>{%raw%}{{ $t('open') }}{%endraw%}</q-btn
|
||||||
>
|
>
|
||||||
<q-btn
|
<q-btn
|
||||||
@@ -198,8 +199,11 @@
|
|||||||
>
|
>
|
||||||
{%raw%}{{ $t('disable') }}{%endraw%}</q-btn
|
{%raw%}{{ $t('disable') }}{%endraw%}</q-btn
|
||||||
>
|
>
|
||||||
|
<q-badge v-if="extension.isAdminOnly && !user.admin">
|
||||||
|
{%raw%}{{ $t('admin_only') }}{%endraw%}
|
||||||
|
</q-badge>
|
||||||
<q-btn
|
<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
|
flat
|
||||||
color="primary"
|
color="primary"
|
||||||
type="a"
|
type="a"
|
||||||
@@ -255,6 +259,17 @@
|
|||||||
{%raw%}{{ $t('confirm_continue') }}{%endraw%}
|
{%raw%}{{ $t('confirm_continue') }}{%endraw%}
|
||||||
</p>
|
</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">
|
<div class="row q-mt-lg">
|
||||||
<q-btn outline color="grey" @click="uninstallExtension()"
|
<q-btn outline color="grey" @click="uninstallExtension()"
|
||||||
>{%raw%}{{ $t('uninstall_confirm') }}{%endraw%}</q-btn
|
>{%raw%}{{ $t('uninstall_confirm') }}{%endraw%}</q-btn
|
||||||
@@ -266,6 +281,32 @@
|
|||||||
</q-card>
|
</q-card>
|
||||||
</q-dialog>
|
</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-dialog v-model="showUpgradeDialog">
|
||||||
<q-card class="q-pa-lg lnbits__dialog-card">
|
<q-card class="q-pa-lg lnbits__dialog-card">
|
||||||
<q-card-section>
|
<q-card-section>
|
||||||
@@ -300,6 +341,11 @@
|
|||||||
{%raw%}{{ $t('repository') }}{%endraw%}
|
{%raw%}{{ $t('repository') }}{%endraw%}
|
||||||
<br />
|
<br />
|
||||||
<small v-text="repoName"></small>
|
<small v-text="repoName"></small>
|
||||||
|
<q-tooltip
|
||||||
|
><span
|
||||||
|
v-text="selectedExtensionRepos[repoName].repo"
|
||||||
|
></span
|
||||||
|
></q-tooltip>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-2"></div>
|
<div class="col-2"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -389,6 +435,13 @@
|
|||||||
>
|
>
|
||||||
{%raw%}{{ $t('uninstall') }}{%endraw%}</q-btn
|
{%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">
|
<q-btn v-close-popup flat color="grey" class="q-ml-auto">
|
||||||
{%raw%}{{ $t('close') }}{%endraw%}</q-btn
|
{%raw%}{{ $t('close') }}{%endraw%}</q-btn
|
||||||
>
|
>
|
||||||
@@ -407,8 +460,11 @@
|
|||||||
filteredExtensions: null,
|
filteredExtensions: null,
|
||||||
showUninstallDialog: false,
|
showUninstallDialog: false,
|
||||||
showUpgradeDialog: false,
|
showUpgradeDialog: false,
|
||||||
|
showDropDbDialog: false,
|
||||||
|
dropDbExtensionId: '',
|
||||||
selectedExtension: null,
|
selectedExtension: null,
|
||||||
selectedExtensionRepos: null,
|
selectedExtensionRepos: null,
|
||||||
|
uninstallAndDropDb: false,
|
||||||
maxStars: 5,
|
maxStars: 5,
|
||||||
user: null
|
user: null
|
||||||
}
|
}
|
||||||
@@ -497,6 +553,40 @@
|
|||||||
this.filteredExtensions = this.extensions.concat([])
|
this.filteredExtensions = this.extensions.concat([])
|
||||||
this.handleTabChanged('installed')
|
this.handleTabChanged('installed')
|
||||||
this.tab = 'installed'
|
this.tab = 'installed'
|
||||||
|
this.$q.notify({
|
||||||
|
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 => {
|
.catch(err => {
|
||||||
LNbits.utils.notifyApiError(err)
|
LNbits.utils.notifyApiError(err)
|
||||||
@@ -525,6 +615,11 @@
|
|||||||
showUninstall: function () {
|
showUninstall: function () {
|
||||||
this.showUpgradeDialog = false
|
this.showUpgradeDialog = false
|
||||||
this.showUninstallDialog = true
|
this.showUninstallDialog = true
|
||||||
|
this.uninstallAndDropDb = false
|
||||||
|
},
|
||||||
|
|
||||||
|
showDropDb: function () {
|
||||||
|
this.showDropDbDialog = true
|
||||||
},
|
},
|
||||||
|
|
||||||
showUpgrade: async function (extension) {
|
showUpgrade: async function (extension) {
|
||||||
@@ -541,7 +636,8 @@
|
|||||||
this.selectedExtensionRepos = data.reduce((repos, release) => {
|
this.selectedExtensionRepos = data.reduce((repos, release) => {
|
||||||
repos[release.source_repo] = repos[release.source_repo] || {
|
repos[release.source_repo] = repos[release.source_repo] || {
|
||||||
releases: [],
|
releases: [],
|
||||||
isInstalled: false
|
isInstalled: false,
|
||||||
|
repo: release.repo
|
||||||
}
|
}
|
||||||
release.inProgress = false
|
release.inProgress = false
|
||||||
release.error = null
|
release.error = null
|
||||||
|
|||||||
@@ -124,6 +124,16 @@
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="col q-pl-md">
|
<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/">
|
<a href="https://getumbrel.com/">
|
||||||
<q-img
|
<q-img
|
||||||
contain
|
contain
|
||||||
@@ -131,9 +141,7 @@
|
|||||||
></q-img>
|
></q-img>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="col q-pl-md">
|
||||||
<div class="row">
|
|
||||||
<div class="col">
|
|
||||||
<a href="https://mynodebtc.com">
|
<a href="https://mynodebtc.com">
|
||||||
<q-img
|
<q-img
|
||||||
contain
|
contain
|
||||||
@@ -141,7 +149,9 @@
|
|||||||
></q-img>
|
></q-img>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="col q-pl-md">
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col">
|
||||||
<a href="https://github.com/shesek/spark-wallet">
|
<a href="https://github.com/shesek/spark-wallet">
|
||||||
<q-img
|
<q-img
|
||||||
contain
|
contain
|
||||||
@@ -149,9 +159,7 @@
|
|||||||
></q-img>
|
></q-img>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="col q-pl-md">
|
||||||
<div class="row">
|
|
||||||
<div class="col">
|
|
||||||
<a href="https://voltage.cloud">
|
<a href="https://voltage.cloud">
|
||||||
<q-img
|
<q-img
|
||||||
contain
|
contain
|
||||||
@@ -159,7 +167,6 @@
|
|||||||
></q-img>
|
></q-img>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="col q-pl-md"></div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -149,6 +149,7 @@
|
|||||||
{% raw %}
|
{% raw %}
|
||||||
<template v-slot:header="props">
|
<template v-slot:header="props">
|
||||||
<q-tr :props="props">
|
<q-tr :props="props">
|
||||||
|
<q-th auto-width></q-th>
|
||||||
<q-th v-for="col in props.cols" :key="col.name" :props="props"
|
<q-th v-for="col in props.cols" :key="col.name" :props="props"
|
||||||
>{{ col.label }}</q-th
|
>{{ col.label }}</q-th
|
||||||
>
|
>
|
||||||
@@ -174,7 +175,7 @@
|
|||||||
</q-icon>
|
</q-icon>
|
||||||
</q-td>
|
</q-td>
|
||||||
<q-td
|
<q-td
|
||||||
key="memo"
|
key="time"
|
||||||
:props="props"
|
:props="props"
|
||||||
style="white-space: normal; word-break: break-all"
|
style="white-space: normal; word-break: break-all"
|
||||||
>
|
>
|
||||||
@@ -192,8 +193,12 @@
|
|||||||
</q-badge>
|
</q-badge>
|
||||||
{{ props.row.memo }}
|
{{ props.row.memo }}
|
||||||
<br />
|
<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>
|
</q-td>
|
||||||
|
|
||||||
{% endraw %}
|
{% endraw %}
|
||||||
@@ -206,10 +211,17 @@
|
|||||||
parseFloat(String(props.row.fsat).replaceAll(",", "")) / 100
|
parseFloat(String(props.row.fsat).replaceAll(",", "")) / 100
|
||||||
}}
|
}}
|
||||||
</q-td>
|
</q-td>
|
||||||
|
|
||||||
<q-td auto-width key="amount" v-else :props="props">
|
<q-td auto-width key="amount" v-else :props="props">
|
||||||
{{ props.row.fsat }}<br />
|
{{ 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-td>
|
||||||
</q-tr>
|
</q-tr>
|
||||||
|
|
||||||
@@ -356,23 +368,54 @@
|
|||||||
<div class="" style="max-width: 320px">
|
<div class="" style="max-width: 320px">
|
||||||
<q-input
|
<q-input
|
||||||
filled
|
filled
|
||||||
v-model.trim="newName"
|
v-model.trim="update.name"
|
||||||
label="Label"
|
label="Name"
|
||||||
dense="dense"
|
dense
|
||||||
@update:model-value="(e) => console.log(e)"
|
@update:model-value="(e) => console.log(e)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<q-btn
|
<q-btn
|
||||||
:disable="!newName.length"
|
:disable="!update.name.length"
|
||||||
unelevated
|
unelevated
|
||||||
class="q-mt-sm"
|
class="q-mt-sm"
|
||||||
color="primary"
|
color="primary"
|
||||||
:label="$t('update_name')"
|
:label="$t('update_name')"
|
||||||
@click="updateWalletName()"
|
@click="updateWallet({ name: update.name })"
|
||||||
></q-btn>
|
></q-btn>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-card>
|
</q-card>
|
||||||
</q-expansion-item>
|
</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-separator></q-separator>
|
||||||
<q-expansion-item
|
<q-expansion-item
|
||||||
group="extras"
|
group="extras"
|
||||||
@@ -423,7 +466,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<q-dialog v-model="receive.show" @hide="closeReceiveDialog">
|
<q-dialog v-model="receive.show">
|
||||||
{% raw %}
|
{% raw %}
|
||||||
<q-card
|
<q-card
|
||||||
v-if="!receive.paymentReq"
|
v-if="!receive.paymentReq"
|
||||||
@@ -706,6 +749,7 @@
|
|||||||
v-model.trim="parse.data.request"
|
v-model.trim="parse.data.request"
|
||||||
type="textarea"
|
type="textarea"
|
||||||
:label="$t('paste_invoice_label')"
|
:label="$t('paste_invoice_label')"
|
||||||
|
ref="textArea"
|
||||||
>
|
>
|
||||||
</q-input>
|
</q-input>
|
||||||
<div class="row q-mt-lg">
|
<div class="row q-mt-lg">
|
||||||
@@ -716,6 +760,17 @@
|
|||||||
type="submit"
|
type="submit"
|
||||||
:label="$t('read')"
|
:label="$t('read')"
|
||||||
></q-btn>
|
></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
|
<q-btn
|
||||||
v-close-popup
|
v-close-popup
|
||||||
flat
|
flat
|
||||||
@@ -783,10 +838,14 @@
|
|||||||
indicator-color="transparent"
|
indicator-color="transparent"
|
||||||
align="justify"
|
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>
|
||||||
|
|
||||||
<q-tab @click="showParseDialog" icon="file_upload" label="Send">
|
<q-tab @click="showParseDialog" icon="file_upload" :label="$t('send')">
|
||||||
</q-tab>
|
</q-tab>
|
||||||
<q-btn
|
<q-btn
|
||||||
round
|
round
|
||||||
|
|||||||
@@ -6,21 +6,46 @@ from subprocess import Popen
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from fastapi import Body, Depends
|
from fastapi import Depends
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from starlette.exceptions import HTTPException
|
from starlette.exceptions import HTTPException
|
||||||
|
|
||||||
from lnbits.core.crud import get_wallet
|
from lnbits.core.crud import get_wallet
|
||||||
from lnbits.core.models import User
|
from lnbits.core.models import CreateTopup, User
|
||||||
from lnbits.core.services import update_cached_settings, update_wallet_balance
|
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.decorators import check_admin, check_super_user
|
||||||
from lnbits.server import server_restart
|
from lnbits.server import server_restart
|
||||||
from lnbits.settings import AdminSettings, EditableSettings, settings
|
from lnbits.settings import AdminSettings, EditableSettings, settings
|
||||||
|
|
||||||
from .. import core_app
|
from .. import core_app, core_app_extra
|
||||||
from ..crud import delete_admin_settings, get_admin_settings, update_admin_settings
|
from ..crud import delete_admin_settings, get_admin_settings, update_admin_settings
|
||||||
|
|
||||||
|
|
||||||
|
@core_app.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.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@core_app.get("/admin/api/v1/settings/")
|
@core_app.get("/admin/api/v1/settings/")
|
||||||
async def api_get_settings(
|
async def api_get_settings(
|
||||||
user: User = Depends(check_admin),
|
user: User = Depends(check_admin),
|
||||||
@@ -40,6 +65,7 @@ async def api_update_settings(
|
|||||||
admin_settings = await get_admin_settings(user.super_user)
|
admin_settings = await get_admin_settings(user.super_user)
|
||||||
assert admin_settings, "Updated admin settings not found."
|
assert admin_settings, "Updated admin settings not found."
|
||||||
update_cached_settings(admin_settings.dict())
|
update_cached_settings(admin_settings.dict())
|
||||||
|
core_app_extra.register_new_ratelimiter()
|
||||||
return {"status": "Success"}
|
return {"status": "Success"}
|
||||||
|
|
||||||
|
|
||||||
@@ -65,20 +91,24 @@ async def api_restart_server() -> dict[str, str]:
|
|||||||
|
|
||||||
@core_app.put(
|
@core_app.put(
|
||||||
"/admin/api/v1/topup/",
|
"/admin/api/v1/topup/",
|
||||||
|
name="Topup",
|
||||||
status_code=HTTPStatus.OK,
|
status_code=HTTPStatus.OK,
|
||||||
dependencies=[Depends(check_super_user)],
|
dependencies=[Depends(check_super_user)],
|
||||||
)
|
)
|
||||||
async def api_topup_balance(
|
async def api_topup_balance(data: CreateTopup) -> dict[str, str]:
|
||||||
id: str = Body(...), amount: int = Body(...)
|
|
||||||
) -> dict[str, str]:
|
|
||||||
try:
|
try:
|
||||||
await get_wallet(id)
|
await get_wallet(data.id)
|
||||||
except:
|
except Exception:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.FORBIDDEN, detail="wallet does not exist."
|
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"}
|
return {"status": "Success"}
|
||||||
|
|
||||||
@@ -100,10 +130,10 @@ async def api_download_backup() -> FileResponse:
|
|||||||
p = urlparse(db_url)
|
p = urlparse(db_url)
|
||||||
command = (
|
command = (
|
||||||
f"pg_dump --host={p.hostname} "
|
f"pg_dump --host={p.hostname} "
|
||||||
f'--dbname={p.path.replace("/", "")} '
|
f"--dbname={p.path.replace('/', '')} "
|
||||||
f"--username={p.username} "
|
f"--username={p.username} "
|
||||||
f"--no-password "
|
"--no-password "
|
||||||
f"--format=c "
|
"--format=c "
|
||||||
f"--file={pg_backup_filename}"
|
f"--file={pg_backup_filename}"
|
||||||
)
|
)
|
||||||
proc = Popen(
|
proc = Popen(
|
||||||
|
|||||||
@@ -1,21 +1,18 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import time
|
|
||||||
import uuid
|
import uuid
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from typing import Dict, List, Optional, Tuple, Union
|
from typing import Dict, List, Optional, Union
|
||||||
from urllib.parse import ParseResult, parse_qs, urlencode, urlparse, urlunparse
|
from urllib.parse import ParseResult, parse_qs, urlencode, urlparse, urlunparse
|
||||||
|
|
||||||
import async_timeout
|
|
||||||
import httpx
|
import httpx
|
||||||
import pyqrcode
|
import pyqrcode
|
||||||
from fastapi import (
|
from fastapi import (
|
||||||
Body,
|
Body,
|
||||||
Depends,
|
Depends,
|
||||||
Header,
|
Header,
|
||||||
Query,
|
|
||||||
Request,
|
Request,
|
||||||
Response,
|
Response,
|
||||||
WebSocket,
|
WebSocket,
|
||||||
@@ -23,8 +20,6 @@ from fastapi import (
|
|||||||
)
|
)
|
||||||
from fastapi.exceptions import HTTPException
|
from fastapi.exceptions import HTTPException
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from pydantic import BaseModel
|
|
||||||
from pydantic.fields import Field
|
|
||||||
from sse_starlette.sse import EventSourceResponse
|
from sse_starlette.sse import EventSourceResponse
|
||||||
from starlette.responses import RedirectResponse, StreamingResponse
|
from starlette.responses import RedirectResponse, StreamingResponse
|
||||||
|
|
||||||
@@ -33,7 +28,18 @@ from lnbits.core.helpers import (
|
|||||||
migrate_extension_database,
|
migrate_extension_database,
|
||||||
stop_extension_background_work,
|
stop_extension_background_work,
|
||||||
)
|
)
|
||||||
from lnbits.core.models import Payment, PaymentFilters, User, Wallet
|
from lnbits.core.models import (
|
||||||
|
ConversionData,
|
||||||
|
CreateInvoice,
|
||||||
|
CreateLnurl,
|
||||||
|
CreateLnurlAuth,
|
||||||
|
DecodePayment,
|
||||||
|
Payment,
|
||||||
|
PaymentFilters,
|
||||||
|
User,
|
||||||
|
Wallet,
|
||||||
|
WalletType,
|
||||||
|
)
|
||||||
from lnbits.db import Filters, Page
|
from lnbits.db import Filters, Page
|
||||||
from lnbits.decorators import (
|
from lnbits.decorators import (
|
||||||
WalletTypeInfo,
|
WalletTypeInfo,
|
||||||
@@ -52,7 +58,7 @@ from lnbits.extension_manager import (
|
|||||||
get_valid_extensions,
|
get_valid_extensions,
|
||||||
)
|
)
|
||||||
from lnbits.helpers import generate_filter_params_openapi, url_for
|
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 (
|
from lnbits.utils.exchange_rates import (
|
||||||
currencies,
|
currencies,
|
||||||
fiat_amount_as_satoshis,
|
fiat_amount_as_satoshis,
|
||||||
@@ -63,15 +69,16 @@ from .. import core_app, core_app_extra, db
|
|||||||
from ..crud import (
|
from ..crud import (
|
||||||
add_installed_extension,
|
add_installed_extension,
|
||||||
create_tinyurl,
|
create_tinyurl,
|
||||||
|
delete_dbversion,
|
||||||
delete_installed_extension,
|
delete_installed_extension,
|
||||||
delete_tinyurl,
|
delete_tinyurl,
|
||||||
|
drop_extension_db,
|
||||||
get_dbversions,
|
get_dbversions,
|
||||||
get_payments,
|
get_payments,
|
||||||
get_payments_paginated,
|
get_payments_paginated,
|
||||||
get_standalone_payment,
|
get_standalone_payment,
|
||||||
get_tinyurl,
|
get_tinyurl,
|
||||||
get_tinyurl_by_url,
|
get_tinyurl_by_url,
|
||||||
get_total_balance,
|
|
||||||
get_wallet_for_key,
|
get_wallet_for_key,
|
||||||
save_balance_check,
|
save_balance_check,
|
||||||
update_wallet,
|
update_wallet,
|
||||||
@@ -96,7 +103,7 @@ async def health():
|
|||||||
|
|
||||||
@core_app.get("/api/v1/wallet")
|
@core_app.get("/api/v1/wallet")
|
||||||
async def api_wallet(wallet: WalletTypeInfo = Depends(get_key_type)):
|
async def api_wallet(wallet: WalletTypeInfo = Depends(get_key_type)):
|
||||||
if wallet.wallet_type == 0:
|
if wallet.wallet_type == WalletType.admin:
|
||||||
return {
|
return {
|
||||||
"id": wallet.wallet.id,
|
"id": wallet.wallet.id,
|
||||||
"name": wallet.wallet.name,
|
"name": wallet.wallet.name,
|
||||||
@@ -107,7 +114,7 @@ async def api_wallet(wallet: WalletTypeInfo = Depends(get_key_type)):
|
|||||||
|
|
||||||
|
|
||||||
@core_app.put("/api/v1/wallet/{new_name}")
|
@core_app.put("/api/v1/wallet/{new_name}")
|
||||||
async def api_update_wallet(
|
async def api_update_wallet_name(
|
||||||
new_name: str, wallet: WalletTypeInfo = Depends(require_admin_key)
|
new_name: str, wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||||
):
|
):
|
||||||
await update_wallet(wallet.wallet.id, new_name)
|
await update_wallet(wallet.wallet.id, new_name)
|
||||||
@@ -118,6 +125,15 @@ async def api_update_wallet(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@core_app.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)
|
||||||
|
|
||||||
|
|
||||||
@core_app.get(
|
@core_app.get(
|
||||||
"/api/v1/payments",
|
"/api/v1/payments",
|
||||||
name="Payment List",
|
name="Payment List",
|
||||||
@@ -179,57 +195,37 @@ async def api_payments_paginated(
|
|||||||
return page
|
return page
|
||||||
|
|
||||||
|
|
||||||
class CreateInvoiceData(BaseModel):
|
async def api_payments_create_invoice(data: CreateInvoice, wallet: Wallet):
|
||||||
out: Optional[bool] = True
|
description_hash = b""
|
||||||
amount: float = Query(None, ge=0)
|
unhashed_description = b""
|
||||||
memo: Optional[str] = None
|
memo = data.memo or settings.lnbits_site_title
|
||||||
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):
|
|
||||||
if data.description_hash or data.unhashed_description:
|
if data.description_hash or data.unhashed_description:
|
||||||
try:
|
if data.description_hash:
|
||||||
description_hash = (
|
try:
|
||||||
bytes.fromhex(data.description_hash) if data.description_hash else b""
|
description_hash = bytes.fromhex(data.description_hash)
|
||||||
)
|
except ValueError:
|
||||||
unhashed_description = (
|
raise HTTPException(
|
||||||
bytes.fromhex(data.unhashed_description)
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
if data.unhashed_description
|
detail="'description_hash' must be a valid hex string",
|
||||||
else b""
|
)
|
||||||
)
|
if data.unhashed_description:
|
||||||
except ValueError:
|
try:
|
||||||
raise HTTPException(
|
unhashed_description = bytes.fromhex(data.unhashed_description)
|
||||||
status_code=HTTPStatus.BAD_REQUEST,
|
except ValueError:
|
||||||
detail="'description_hash' and 'unhashed_description' must be a valid hex strings",
|
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 = ""
|
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:
|
async with db.connect() as conn:
|
||||||
try:
|
try:
|
||||||
_, payment_request = await create_invoice(
|
payment_hash, payment_request = await create_invoice(
|
||||||
wallet_id=wallet.id,
|
wallet_id=wallet.id,
|
||||||
amount=amount,
|
amount=data.amount,
|
||||||
memo=memo,
|
memo=memo,
|
||||||
|
currency=data.unit,
|
||||||
description_hash=description_hash,
|
description_hash=description_hash,
|
||||||
unhashed_description=unhashed_description,
|
unhashed_description=unhashed_description,
|
||||||
expiry=data.expiry,
|
expiry=data.expiry,
|
||||||
@@ -238,6 +234,12 @@ async def api_payments_create_invoice(data: CreateInvoiceData, wallet: Wallet):
|
|||||||
internal=data.internal,
|
internal=data.internal,
|
||||||
conn=conn,
|
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:
|
except InvoiceFailure as e:
|
||||||
raise HTTPException(status_code=520, detail=str(e))
|
raise HTTPException(status_code=520, detail=str(e))
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -280,7 +282,7 @@ async def api_payments_create_invoice(data: CreateInvoiceData, wallet: Wallet):
|
|||||||
"payment_hash": invoice.payment_hash,
|
"payment_hash": invoice.payment_hash,
|
||||||
"payment_request": payment_request,
|
"payment_request": payment_request,
|
||||||
# maintain backwards compatibility with API clients:
|
# maintain backwards compatibility with API clients:
|
||||||
"checking_id": invoice.payment_hash,
|
"checking_id": checking_id,
|
||||||
"lnurl_response": lnurl_response,
|
"lnurl_response": lnurl_response,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -306,13 +308,22 @@ async def api_payments_pay_invoice(bolt11: str, wallet: Wallet):
|
|||||||
|
|
||||||
@core_app.post(
|
@core_app.post(
|
||||||
"/api/v1/payments",
|
"/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,
|
status_code=HTTPStatus.CREATED,
|
||||||
)
|
)
|
||||||
async def api_payments_create(
|
async def api_payments_create(
|
||||||
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
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:
|
if not invoiceData.bolt11:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.BAD_REQUEST,
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
@@ -331,17 +342,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")
|
@core_app.post("/api/v1/payments/lnurl")
|
||||||
async def api_payments_pay_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
|
domain = urlparse(data.callback).netloc
|
||||||
|
|
||||||
@@ -377,13 +380,23 @@ async def api_payments_pay_lnurl(
|
|||||||
if invoice.amount_msat != data.amount:
|
if invoice.amount_msat != data.amount:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.BAD_REQUEST,
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
detail=f"{domain} returned an invalid invoice. Expected {data.amount} msat, got {invoice.amount_msat}.",
|
detail=(
|
||||||
|
(
|
||||||
|
f"{domain} returned an invalid invoice. Expected"
|
||||||
|
f" {data.amount} msat, got {invoice.amount_msat}."
|
||||||
|
),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
if invoice.description_hash != data.description_hash:
|
if invoice.description_hash != data.description_hash:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.BAD_REQUEST,
|
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 description_hash"
|
||||||
|
f" == {data.description_hash}, got {invoice.description_hash}."
|
||||||
|
),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
extra = {}
|
extra = {}
|
||||||
@@ -421,34 +434,18 @@ async def subscribe_wallet_invoices(request: Request, wallet: Wallet):
|
|||||||
logger.debug(f"adding sse listener for wallet: {uid}")
|
logger.debug(f"adding sse listener for wallet: {uid}")
|
||||||
api_invoice_listeners[uid] = payment_queue
|
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:
|
try:
|
||||||
while True:
|
while True:
|
||||||
if await request.is_disconnected():
|
if await request.is_disconnected():
|
||||||
await request.close()
|
await request.close()
|
||||||
break
|
break
|
||||||
typ, data = await send_queue.get()
|
payment: Payment = await payment_queue.get()
|
||||||
if data:
|
if payment.wallet_id == this_wallet_id:
|
||||||
jdata = json.dumps(dict(data.dict(), pending=False))
|
logger.debug("sse listener: payment received", payment)
|
||||||
yield dict(data=jdata, event=typ)
|
yield dict(data=payment.json(), event="payment-received")
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
logger.debug(f"removing listener for wallet {uid}")
|
logger.debug(f"removing listener for wallet {uid}")
|
||||||
api_invoice_listeners.pop(uid)
|
api_invoice_listeners.pop(uid)
|
||||||
task.cancel()
|
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
||||||
@@ -468,10 +465,11 @@ async def api_payments_sse(
|
|||||||
async def api_payment(payment_hash, X_Api_Key: Optional[str] = Header(None)):
|
async def api_payment(payment_hash, X_Api_Key: Optional[str] = Header(None)):
|
||||||
# We use X_Api_Key here because we want this call to work with and without keys
|
# 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
|
# 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
|
wallet = await get_wallet_for_key(X_Api_Key) if isinstance(X_Api_Key, str) else None
|
||||||
|
|
||||||
# we have to specify the wallet id here, because postgres and sqlite return internal payments in different order
|
# we have to specify the wallet id here, because postgres and sqlite return
|
||||||
# and get_standalone_payment otherwise just fetches the first one, causing unpredictable results
|
# internal payments in different order and get_standalone_payment otherwise
|
||||||
|
# just fetches the first one, causing unpredictable results
|
||||||
payment = await get_standalone_payment(
|
payment = await get_standalone_payment(
|
||||||
payment_hash, wallet_id=wallet.id if wallet else None
|
payment_hash, wallet_id=wallet.id if wallet else None
|
||||||
)
|
)
|
||||||
@@ -513,7 +511,7 @@ async def api_lnurlscan(code: str, wallet: WalletTypeInfo = Depends(get_key_type
|
|||||||
try:
|
try:
|
||||||
url = lnurl.decode(code)
|
url = lnurl.decode(code)
|
||||||
domain = urlparse(url).netloc
|
domain = urlparse(url).netloc
|
||||||
except:
|
except Exception:
|
||||||
# parse internet identifier (user@domain.com)
|
# parse internet identifier (user@domain.com)
|
||||||
name_domain = code.split("@")
|
name_domain = code.split("@")
|
||||||
if len(name_domain) == 2 and len(name_domain[1].split(".")) >= 2:
|
if len(name_domain) == 2 and len(name_domain[1].split(".")) >= 2:
|
||||||
@@ -541,8 +539,9 @@ async def api_lnurlscan(code: str, wallet: WalletTypeInfo = Depends(get_key_type
|
|||||||
assert lnurlauth_key.verifying_key
|
assert lnurlauth_key.verifying_key
|
||||||
params.update(pubkey=lnurlauth_key.verifying_key.to_string("compressed").hex())
|
params.update(pubkey=lnurlauth_key.verifying_key.to_string("compressed").hex())
|
||||||
else:
|
else:
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient(follow_redirects=True) as client:
|
||||||
r = await client.get(url, timeout=5)
|
r = await client.get(url, timeout=5)
|
||||||
|
r.raise_for_status()
|
||||||
if r.is_error:
|
if r.is_error:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.SERVICE_UNAVAILABLE,
|
status_code=HTTPStatus.SERVICE_UNAVAILABLE,
|
||||||
@@ -622,10 +621,6 @@ async def api_lnurlscan(code: str, wallet: WalletTypeInfo = Depends(get_key_type
|
|||||||
return params
|
return params
|
||||||
|
|
||||||
|
|
||||||
class DecodePayment(BaseModel):
|
|
||||||
data: str
|
|
||||||
|
|
||||||
|
|
||||||
@core_app.post("/api/v1/payments/decode", status_code=HTTPStatus.OK)
|
@core_app.post("/api/v1/payments/decode", status_code=HTTPStatus.OK)
|
||||||
async def api_payments_decode(data: DecodePayment, response: Response):
|
async def api_payments_decode(data: DecodePayment, response: Response):
|
||||||
payment_str = data.data
|
payment_str = data.data
|
||||||
@@ -647,20 +642,16 @@ async def api_payments_decode(data: DecodePayment, response: Response):
|
|||||||
"route_hints": invoice.route_hints,
|
"route_hints": invoice.route_hints,
|
||||||
"min_final_cltv_expiry": invoice.min_final_cltv_expiry,
|
"min_final_cltv_expiry": invoice.min_final_cltv_expiry,
|
||||||
}
|
}
|
||||||
except:
|
except Exception:
|
||||||
response.status_code = HTTPStatus.BAD_REQUEST
|
response.status_code = HTTPStatus.BAD_REQUEST
|
||||||
return {"message": "Failed to decode"}
|
return {"message": "Failed to decode"}
|
||||||
|
|
||||||
|
|
||||||
class Callback(BaseModel):
|
|
||||||
callback: str = Query(...)
|
|
||||||
|
|
||||||
|
|
||||||
@core_app.post("/api/v1/lnurlauth")
|
@core_app.post("/api/v1/lnurlauth")
|
||||||
async def api_perform_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:
|
if err:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.SERVICE_UNAVAILABLE, detail=err.reason
|
status_code=HTTPStatus.SERVICE_UNAVAILABLE, detail=err.reason
|
||||||
@@ -670,15 +661,15 @@ async def api_perform_lnurlauth(
|
|||||||
|
|
||||||
@core_app.get("/api/v1/currencies")
|
@core_app.get("/api/v1/currencies")
|
||||||
async def api_list_currencies_available():
|
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())
|
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")
|
@core_app.post("/api/v1/conversion")
|
||||||
async def api_fiat_as_sats(data: ConversionData):
|
async def api_fiat_as_sats(data: ConversionData):
|
||||||
output = {}
|
output = {}
|
||||||
@@ -698,7 +689,7 @@ async def api_fiat_as_sats(data: ConversionData):
|
|||||||
|
|
||||||
|
|
||||||
@core_app.get("/api/v1/qrcode/{data}", response_class=StreamingResponse)
|
@core_app.get("/api/v1/qrcode/{data}", response_class=StreamingResponse)
|
||||||
async def img(request: Request, data):
|
async def img(data):
|
||||||
qr = pyqrcode.create(data)
|
qr = pyqrcode.create(data)
|
||||||
stream = BytesIO()
|
stream = BytesIO()
|
||||||
qr.svg(stream, scale=3)
|
qr.svg(stream, scale=3)
|
||||||
@@ -718,31 +709,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}")
|
@core_app.websocket("/api/v1/ws/{item_id}")
|
||||||
async def websocket_connect(websocket: WebSocket, item_id: str):
|
async def websocket_connect(websocket: WebSocket, item_id: str):
|
||||||
await websocketManager.connect(websocket)
|
await websocketManager.connect(websocket, item_id)
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
await websocket.receive_text()
|
await websocket.receive_text()
|
||||||
@@ -755,7 +724,7 @@ async def websocket_update_post(item_id: str, data: str):
|
|||||||
try:
|
try:
|
||||||
await websocketUpdater(item_id, data)
|
await websocketUpdater(item_id, data)
|
||||||
return {"sent": True, "data": data}
|
return {"sent": True, "data": data}
|
||||||
except:
|
except Exception:
|
||||||
return {"sent": False, "data": data}
|
return {"sent": False, "data": data}
|
||||||
|
|
||||||
|
|
||||||
@@ -764,7 +733,7 @@ async def websocket_update_get(item_id: str, data: str):
|
|||||||
try:
|
try:
|
||||||
await websocketUpdater(item_id, data)
|
await websocketUpdater(item_id, data)
|
||||||
return {"sent": True, "data": data}
|
return {"sent": True, "data": data}
|
||||||
except:
|
except Exception:
|
||||||
return {"sent": False, "data": data}
|
return {"sent": False, "data": data}
|
||||||
|
|
||||||
|
|
||||||
@@ -820,7 +789,10 @@ async def api_install_extension(
|
|||||||
ext_info.clean_extension_files()
|
ext_info.clean_extension_files()
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
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})."
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -843,7 +815,10 @@ async def api_uninstall_extension(ext_id: str, user: User = Depends(check_admin)
|
|||||||
if installed_ext and ext_id in installed_ext.dependencies:
|
if installed_ext and ext_id in installed_ext.dependencies:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.BAD_REQUEST,
|
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:
|
try:
|
||||||
@@ -902,10 +877,40 @@ async def get_extension_release(org: str, repo: str, tag_name: str):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@core_app.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}'",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# TINYURL
|
# TINYURL
|
||||||
|
|
||||||
|
|
||||||
@core_app.post("/api/v1/tinyurl")
|
@core_app.post(
|
||||||
|
"/api/v1/tinyurl",
|
||||||
|
name="Tinyurl",
|
||||||
|
description="creates a tinyurl",
|
||||||
|
)
|
||||||
async def api_create_tinyurl(
|
async def api_create_tinyurl(
|
||||||
url: str, endless: bool = False, wallet: WalletTypeInfo = Depends(get_key_type)
|
url: str, endless: bool = False, wallet: WalletTypeInfo = Depends(get_key_type)
|
||||||
):
|
):
|
||||||
@@ -916,13 +921,17 @@ async def api_create_tinyurl(
|
|||||||
if tinyurl.wallet == wallet.wallet.inkey:
|
if tinyurl.wallet == wallet.wallet.inkey:
|
||||||
return tinyurl
|
return tinyurl
|
||||||
return await create_tinyurl(url, endless, wallet.wallet.inkey)
|
return await create_tinyurl(url, endless, wallet.wallet.inkey)
|
||||||
except:
|
except Exception:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.BAD_REQUEST, detail="Unable to create tinyurl"
|
status_code=HTTPStatus.BAD_REQUEST, detail="Unable to create tinyurl"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@core_app.get("/api/v1/tinyurl/{tinyurl_id}")
|
@core_app.get(
|
||||||
|
"/api/v1/tinyurl/{tinyurl_id}",
|
||||||
|
name="Tinyurl",
|
||||||
|
description="get a tinyurl by id",
|
||||||
|
)
|
||||||
async def api_get_tinyurl(
|
async def api_get_tinyurl(
|
||||||
tinyurl_id: str, wallet: WalletTypeInfo = Depends(get_key_type)
|
tinyurl_id: str, wallet: WalletTypeInfo = Depends(get_key_type)
|
||||||
):
|
):
|
||||||
@@ -934,13 +943,17 @@ async def api_get_tinyurl(
|
|||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.FORBIDDEN, detail="Wrong key provided."
|
status_code=HTTPStatus.FORBIDDEN, detail="Wrong key provided."
|
||||||
)
|
)
|
||||||
except:
|
except Exception:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.NOT_FOUND, detail="Unable to fetch tinyurl"
|
status_code=HTTPStatus.NOT_FOUND, detail="Unable to fetch tinyurl"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@core_app.delete("/api/v1/tinyurl/{tinyurl_id}")
|
@core_app.delete(
|
||||||
|
"/api/v1/tinyurl/{tinyurl_id}",
|
||||||
|
name="Tinyurl",
|
||||||
|
description="delete a tinyurl by id",
|
||||||
|
)
|
||||||
async def api_delete_tinyurl(
|
async def api_delete_tinyurl(
|
||||||
tinyurl_id: str, wallet: WalletTypeInfo = Depends(get_key_type)
|
tinyurl_id: str, wallet: WalletTypeInfo = Depends(get_key_type)
|
||||||
):
|
):
|
||||||
@@ -953,22 +966,23 @@ async def api_delete_tinyurl(
|
|||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.FORBIDDEN, detail="Wrong key provided."
|
status_code=HTTPStatus.FORBIDDEN, detail="Wrong key provided."
|
||||||
)
|
)
|
||||||
except:
|
except Exception:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.BAD_REQUEST, detail="Unable to delete"
|
status_code=HTTPStatus.BAD_REQUEST, detail="Unable to delete"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@core_app.get("/t/{tinyurl_id}")
|
@core_app.get(
|
||||||
|
"/t/{tinyurl_id}",
|
||||||
|
name="Tinyurl",
|
||||||
|
description="redirects a tinyurl by id",
|
||||||
|
)
|
||||||
async def api_tinyurl(tinyurl_id: str):
|
async def api_tinyurl(tinyurl_id: str):
|
||||||
try:
|
tinyurl = await get_tinyurl(tinyurl_id)
|
||||||
tinyurl = await get_tinyurl(tinyurl_id)
|
if tinyurl:
|
||||||
if tinyurl:
|
response = RedirectResponse(url=tinyurl.url)
|
||||||
response = RedirectResponse(url=tinyurl.url)
|
return response
|
||||||
return response
|
else:
|
||||||
else:
|
|
||||||
return
|
|
||||||
except:
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.NOT_FOUND, detail="unable to find tinyurl"
|
status_code=HTTPStatus.NOT_FOUND, detail="unable to find tinyurl"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -15,14 +15,17 @@ from lnbits.core.helpers import to_valid_user_id
|
|||||||
from lnbits.core.models import User
|
from lnbits.core.models import User
|
||||||
from lnbits.decorators import check_admin, check_user_exists
|
from lnbits.decorators import check_admin, check_user_exists
|
||||||
from lnbits.helpers import template_renderer, url_for
|
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 ...extension_manager import InstallableExtension, get_valid_extensions
|
||||||
|
from ...utils.exchange_rates import currencies
|
||||||
from ..crud import (
|
from ..crud import (
|
||||||
create_account,
|
create_account,
|
||||||
create_wallet,
|
create_wallet,
|
||||||
delete_wallet,
|
delete_wallet,
|
||||||
get_balance_check,
|
get_balance_check,
|
||||||
|
get_dbversions,
|
||||||
get_inactive_extensions,
|
get_inactive_extensions,
|
||||||
get_installed_extensions,
|
get_installed_extensions,
|
||||||
get_user,
|
get_user,
|
||||||
@@ -32,7 +35,9 @@ from ..crud import (
|
|||||||
)
|
)
|
||||||
from ..services import pay_invoice, redeem_lnurl_withdraw
|
from ..services import pay_invoice, redeem_lnurl_withdraw
|
||||||
|
|
||||||
core_html_routes: APIRouter = APIRouter(tags=["Core NON-API Website Routes"])
|
core_html_routes: APIRouter = APIRouter(
|
||||||
|
tags=["Core NON-API Website Routes"], include_in_schema=False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@core_html_routes.get("/favicon.ico", response_class=FileResponse)
|
@core_html_routes.get("/favicon.ico", response_class=FileResponse)
|
||||||
@@ -113,6 +118,7 @@ async def extensions_install(
|
|||||||
|
|
||||||
all_extensions = list(map(lambda e: e.code, get_valid_extensions()))
|
all_extensions = list(map(lambda e: e.code, get_valid_extensions()))
|
||||||
inactive_extensions = await get_inactive_extensions()
|
inactive_extensions = await get_inactive_extensions()
|
||||||
|
db_version = await get_dbversions()
|
||||||
extensions = list(
|
extensions = list(
|
||||||
map(
|
map(
|
||||||
lambda ext: {
|
lambda ext: {
|
||||||
@@ -124,14 +130,16 @@ async def extensions_install(
|
|||||||
"isFeatured": ext.featured,
|
"isFeatured": ext.featured,
|
||||||
"dependencies": ext.dependencies,
|
"dependencies": ext.dependencies,
|
||||||
"isInstalled": ext.id in installed_exts_ids,
|
"isInstalled": ext.id in installed_exts_ids,
|
||||||
|
"hasDatabaseTables": ext.id in db_version,
|
||||||
"isAvailable": ext.id in all_extensions,
|
"isAvailable": ext.id in all_extensions,
|
||||||
|
"isAdminOnly": ext.id in settings.lnbits_admin_extensions,
|
||||||
"isActive": ext.id not in inactive_extensions,
|
"isActive": ext.id not in inactive_extensions,
|
||||||
"latestRelease": dict(ext.latest_release)
|
"latestRelease": (
|
||||||
if ext.latest_release
|
dict(ext.latest_release) if ext.latest_release else None
|
||||||
else None,
|
),
|
||||||
"installedRelease": dict(ext.installed_release)
|
"installedRelease": (
|
||||||
if ext.installed_release
|
dict(ext.installed_release) if ext.installed_release else None
|
||||||
else None,
|
),
|
||||||
},
|
},
|
||||||
installable_exts,
|
installable_exts,
|
||||||
)
|
)
|
||||||
@@ -154,13 +162,13 @@ async def extensions_install(
|
|||||||
"/wallet",
|
"/wallet",
|
||||||
response_class=HTMLResponse,
|
response_class=HTMLResponse,
|
||||||
description="""
|
description="""
|
||||||
Args:
|
just **wallet_name**: create a new user, then create a new wallet
|
||||||
|
for user with wallet_name
|
||||||
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
|
||||||
just **user_id**: return the first user wallet or create one if none found (with default wallet_name)<br>
|
(with default wallet_name)
|
||||||
**user_id** and **wallet_name**: create a new wallet for user with wallet_name<br>
|
**user_id** and **wallet_name**: create a new wallet for user with wallet_name
|
||||||
**user_id** and **wallet_id**: return that wallet if user is the owner<br>
|
**user_id** and **wallet_id**: return that wallet if user is the owner
|
||||||
nothing: create everything<br>
|
nothing: create everything
|
||||||
""",
|
""",
|
||||||
)
|
)
|
||||||
async def wallet(
|
async def wallet(
|
||||||
@@ -204,7 +212,8 @@ async def wallet(
|
|||||||
else:
|
else:
|
||||||
wallet = await create_wallet(user_id=user.id, wallet_name=wallet_name)
|
wallet = await create_wallet(user_id=user.id, wallet_name=wallet_name)
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Created new wallet {wallet_name if wallet_name else '(no name)'} for user {user.id}"
|
f"Created new wallet {wallet_name if wallet_name else '(no name)'} for"
|
||||||
|
f" user {user.id}"
|
||||||
)
|
)
|
||||||
|
|
||||||
return RedirectResponse(
|
return RedirectResponse(
|
||||||
@@ -213,7 +222,9 @@ async def wallet(
|
|||||||
)
|
)
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"Access {'user '+ user.id + ' ' if user else ''} {'wallet ' + wallet_name if wallet_name else ''}"
|
"Access "
|
||||||
|
f"{'user '+ user.id + ' ' if user else ''} "
|
||||||
|
f"{'wallet ' + wallet_name if wallet_name else ''}"
|
||||||
)
|
)
|
||||||
userwallet = user.get_wallet(wallet_id)
|
userwallet = user.get_wallet(wallet_id)
|
||||||
if not userwallet:
|
if not userwallet:
|
||||||
@@ -249,7 +260,9 @@ async def lnurl_full_withdraw(request: Request):
|
|||||||
"k1": "0",
|
"k1": "0",
|
||||||
"minWithdrawable": 1000 if wallet.withdrawable_balance else 0,
|
"minWithdrawable": 1000 if wallet.withdrawable_balance else 0,
|
||||||
"maxWithdrawable": wallet.withdrawable_balance,
|
"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),
|
"balanceCheck": url_for("/withdraw", external=True, usr=user.id, wal=wallet.id),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,7 +282,7 @@ async def lnurl_full_withdraw_callback(request: Request):
|
|||||||
async def pay():
|
async def pay():
|
||||||
try:
|
try:
|
||||||
await pay_invoice(wallet_id=wallet.id, payment_request=pr)
|
await pay_invoice(wallet_id=wallet.id, payment_request=pr)
|
||||||
except:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
asyncio.create_task(pay())
|
asyncio.create_task(pay())
|
||||||
@@ -356,9 +369,11 @@ async def manifest(usr: str):
|
|||||||
"name": settings.lnbits_site_title + " Wallet",
|
"name": settings.lnbits_site_title + " Wallet",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": settings.lnbits_custom_logo
|
"src": (
|
||||||
if settings.lnbits_custom_logo
|
settings.lnbits_custom_logo
|
||||||
else "https://cdn.jsdelivr.net/gh/lnbits/lnbits@0.3.0/docs/logos/lnbits.png",
|
if settings.lnbits_custom_logo
|
||||||
|
else "https://cdn.jsdelivr.net/gh/lnbits/lnbits@0.3.0/docs/logos/lnbits.png"
|
||||||
|
),
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"sizes": "900x900",
|
"sizes": "900x900",
|
||||||
}
|
}
|
||||||
@@ -396,6 +411,7 @@ async def index(request: Request, user: User = Depends(check_admin)):
|
|||||||
"user": user.dict(),
|
"user": user.dict(),
|
||||||
"settings": settings.dict(),
|
"settings": settings.dict(),
|
||||||
"balance": balance,
|
"balance": balance,
|
||||||
|
"currencies": list(currencies.keys()),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ async def api_public_payment_longpolling(payment_hash):
|
|||||||
expiration = datetime.datetime.fromtimestamp(invoice.date + invoice.expiry)
|
expiration = datetime.datetime.fromtimestamp(invoice.date + invoice.expiry)
|
||||||
if expiration < datetime.datetime.now():
|
if expiration < datetime.datetime.now():
|
||||||
return {"status": "expired"}
|
return {"status": "expired"}
|
||||||
except:
|
except Exception:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.BAD_REQUEST, detail="Invalid bolt11 invoice."
|
status_code=HTTPStatus.BAD_REQUEST, detail="Invalid bolt11 invoice."
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -47,15 +47,6 @@ if settings.lnbits_database_url:
|
|||||||
lambda value, curs: float(value) if value is not None else None,
|
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))
|
register_type(new_type((1184, 1114), "TIMESTAMP2INT", _parse_timestamp))
|
||||||
else:
|
else:
|
||||||
@@ -68,6 +59,15 @@ else:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def compat_timestamp_placeholder():
|
||||||
|
if DB_TYPE == POSTGRES:
|
||||||
|
return "to_timestamp(?)"
|
||||||
|
elif DB_TYPE == COCKROACH:
|
||||||
|
return "cast(? AS timestamp)"
|
||||||
|
else:
|
||||||
|
return "?"
|
||||||
|
|
||||||
|
|
||||||
class Compat:
|
class Compat:
|
||||||
type: Optional[str] = "<inherited>"
|
type: Optional[str] = "<inherited>"
|
||||||
schema: Optional[str] = "<inherited>"
|
schema: Optional[str] = "<inherited>"
|
||||||
@@ -116,15 +116,9 @@ class Compat:
|
|||||||
return "BIGINT"
|
return "BIGINT"
|
||||||
return "INT"
|
return "INT"
|
||||||
|
|
||||||
@classmethod
|
|
||||||
@property
|
@property
|
||||||
def timestamp_placeholder(cls):
|
def timestamp_placeholder(self) -> str:
|
||||||
if DB_TYPE == POSTGRES:
|
return compat_timestamp_placeholder()
|
||||||
return "to_timestamp(?)"
|
|
||||||
elif DB_TYPE == COCKROACH:
|
|
||||||
return "cast(? AS timestamp)"
|
|
||||||
else:
|
|
||||||
return "?"
|
|
||||||
|
|
||||||
|
|
||||||
class Connection(Compat):
|
class Connection(Compat):
|
||||||
@@ -303,6 +297,21 @@ class Database(Compat):
|
|||||||
async def reuse_conn(self, conn: Connection):
|
async def reuse_conn(self, conn: Connection):
|
||||||
yield conn
|
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):
|
class Operator(Enum):
|
||||||
GT = "gt"
|
GT = "gt"
|
||||||
@@ -360,7 +369,6 @@ class Page(BaseModel, Generic[T]):
|
|||||||
|
|
||||||
class Filter(BaseModel, Generic[TFilterModel]):
|
class Filter(BaseModel, Generic[TFilterModel]):
|
||||||
field: str
|
field: str
|
||||||
nested: Optional[List[str]]
|
|
||||||
op: Operator = Operator.EQ
|
op: Operator = Operator.EQ
|
||||||
values: list[Any]
|
values: list[Any]
|
||||||
|
|
||||||
@@ -375,55 +383,36 @@ class Filter(BaseModel, Generic[TFilterModel]):
|
|||||||
split = key[:-1].split("[")
|
split = key[:-1].split("[")
|
||||||
if len(split) != 2:
|
if len(split) != 2:
|
||||||
raise ValueError("Invalid key")
|
raise ValueError("Invalid key")
|
||||||
field_names = split[0].split(".")
|
field = split[0]
|
||||||
op = Operator(split[1])
|
op = Operator(split[1])
|
||||||
else:
|
else:
|
||||||
field_names = key.split(".")
|
field = key
|
||||||
op = Operator("eq")
|
op = Operator("eq")
|
||||||
|
|
||||||
field = field_names[0]
|
|
||||||
nested = field_names[1:]
|
|
||||||
|
|
||||||
if field in model.__fields__:
|
if field in model.__fields__:
|
||||||
compare_field = model.__fields__[field]
|
compare_field = model.__fields__[field]
|
||||||
values = []
|
values = []
|
||||||
for raw_value in raw_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")
|
validated, errors = compare_field.validate(raw_value, {}, loc="none")
|
||||||
if errors:
|
if errors:
|
||||||
raise ValidationError(errors=[errors], model=model)
|
raise ValidationError(errors=[errors], model=model)
|
||||||
|
|
||||||
for name in nested:
|
|
||||||
if isinstance(validated, dict):
|
|
||||||
validated = validated[name]
|
|
||||||
else:
|
|
||||||
validated = getattr(validated, name)
|
|
||||||
|
|
||||||
values.append(validated)
|
values.append(validated)
|
||||||
else:
|
else:
|
||||||
raise ValueError("Unknown filter field")
|
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
|
@property
|
||||||
def statement(self):
|
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:
|
if self.model and self.model.__fields__[self.field].type_ == datetime.datetime:
|
||||||
placeholder = Compat.timestamp_placeholder
|
placeholder = compat_timestamp_placeholder()
|
||||||
else:
|
else:
|
||||||
placeholder = "?"
|
placeholder = "?"
|
||||||
if self.op in (Operator.INCLUDE, Operator.EXCLUDE):
|
if self.op in (Operator.INCLUDE, Operator.EXCLUDE):
|
||||||
placeholders = ", ".join([placeholder] * len(self.values))
|
placeholders = ", ".join([placeholder] * len(self.values))
|
||||||
stmt = [f"{accessor} {self.op.as_sql} ({placeholders})"]
|
stmt = [f"{self.field} {self.op.as_sql} ({placeholders})"]
|
||||||
else:
|
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)
|
return " OR ".join(stmt)
|
||||||
|
|
||||||
|
|
||||||
@@ -432,8 +421,8 @@ class Filters(BaseModel, Generic[TFilterModel]):
|
|||||||
Generic helper class for filtering and sorting data.
|
Generic helper class for filtering and sorting data.
|
||||||
For usage in an api endpoint, use the `parse_filters` dependency.
|
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.
|
When constructing this class manually always make sure to pass a model so that
|
||||||
Otherwise, make sure to validate the inputs manually.
|
the values can be validated. Otherwise, make sure to validate the inputs manually.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
filters: List[Filter[TFilterModel]] = []
|
filters: List[Filter[TFilterModel]] = []
|
||||||
@@ -476,7 +465,7 @@ class Filters(BaseModel, Generic[TFilterModel]):
|
|||||||
if self.search and self.model:
|
if self.search and self.model:
|
||||||
if DB_TYPE == POSTGRES:
|
if DB_TYPE == POSTGRES:
|
||||||
where_stmts.append(
|
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:
|
elif DB_TYPE == SQLITE:
|
||||||
where_stmts.append(
|
where_stmts.append(
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
from typing import Literal, Optional, Type
|
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.exceptions import HTTPException
|
||||||
from fastapi.openapi.models import APIKey, APIKeyIn
|
from fastapi.openapi.models import APIKey, APIKeyIn
|
||||||
from fastapi.security import APIKeyHeader, APIKeyQuery
|
from fastapi.security import APIKeyHeader, APIKeyQuery
|
||||||
@@ -9,7 +9,7 @@ from fastapi.security.base import SecurityBase
|
|||||||
from pydantic.types import UUID4
|
from pydantic.types import UUID4
|
||||||
|
|
||||||
from lnbits.core.crud import get_user, get_wallet_for_key
|
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.db import Filter, Filters, TFilterModel
|
||||||
from lnbits.requestvars import g
|
from lnbits.requestvars import g
|
||||||
from lnbits.settings import settings
|
from lnbits.settings import settings
|
||||||
@@ -25,7 +25,7 @@ class KeyChecker(SecurityBase):
|
|||||||
):
|
):
|
||||||
self.scheme_name = scheme_name or self.__class__.__name__
|
self.scheme_name = scheme_name or self.__class__.__name__
|
||||||
self.auto_error = auto_error
|
self.auto_error = auto_error
|
||||||
self._key_type = "invoice"
|
self._key_type = WalletType.invoice
|
||||||
self._api_key = api_key
|
self._api_key = api_key
|
||||||
if api_key:
|
if api_key:
|
||||||
key = APIKey(
|
key = APIKey(
|
||||||
@@ -39,7 +39,7 @@ class KeyChecker(SecurityBase):
|
|||||||
name="X-API-KEY",
|
name="X-API-KEY",
|
||||||
description="Wallet API Key - HEADER",
|
description="Wallet API Key - HEADER",
|
||||||
)
|
)
|
||||||
self.wallet = None # type: ignore
|
self.wallet = None
|
||||||
self.model: APIKey = key
|
self.model: APIKey = key
|
||||||
|
|
||||||
async def __call__(self, request: Request):
|
async def __call__(self, request: Request):
|
||||||
@@ -49,16 +49,16 @@ class KeyChecker(SecurityBase):
|
|||||||
if self._api_key
|
if self._api_key
|
||||||
else request.headers.get("X-API-KEY") or request.query_params["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.
|
# FIXME: Find another way to validate the key. A fetch from DB should be
|
||||||
# Also, we should not return the wallet here - thats silly.
|
# avoided here. Also, we should not return the wallet here - thats
|
||||||
# Possibly store it in a Redis DB
|
# silly. Possibly store it in a Redis DB
|
||||||
self.wallet = await get_wallet_for_key(key_value, self._key_type) # type: ignore
|
wallet = await get_wallet_for_key(key_value, self._key_type)
|
||||||
if not self.wallet:
|
self.wallet = wallet # type: ignore
|
||||||
|
if not wallet:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.UNAUTHORIZED,
|
status_code=HTTPStatus.UNAUTHORIZED,
|
||||||
detail="Invalid key or expired key.",
|
detail="Invalid key or expired key.",
|
||||||
)
|
)
|
||||||
|
|
||||||
except KeyError:
|
except KeyError:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.BAD_REQUEST, detail="`X-API-KEY` header missing."
|
status_code=HTTPStatus.BAD_REQUEST, detail="`X-API-KEY` header missing."
|
||||||
@@ -81,7 +81,7 @@ class WalletInvoiceKeyChecker(KeyChecker):
|
|||||||
api_key: Optional[str] = None,
|
api_key: Optional[str] = None,
|
||||||
):
|
):
|
||||||
super().__init__(scheme_name, auto_error, api_key)
|
super().__init__(scheme_name, auto_error, api_key)
|
||||||
self._key_type = "invoice"
|
self._key_type = WalletType.invoice
|
||||||
|
|
||||||
|
|
||||||
class WalletAdminKeyChecker(KeyChecker):
|
class WalletAdminKeyChecker(KeyChecker):
|
||||||
@@ -100,16 +100,7 @@ class WalletAdminKeyChecker(KeyChecker):
|
|||||||
api_key: Optional[str] = None,
|
api_key: Optional[str] = None,
|
||||||
):
|
):
|
||||||
super().__init__(scheme_name, auto_error, api_key)
|
super().__init__(scheme_name, auto_error, api_key)
|
||||||
self._key_type = "admin"
|
self._key_type = WalletType.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
|
|
||||||
|
|
||||||
|
|
||||||
api_key_header = APIKeyHeader(
|
api_key_header = APIKeyHeader(
|
||||||
@@ -129,11 +120,6 @@ async def get_key_type(
|
|||||||
api_key_header: str = Security(api_key_header),
|
api_key_header: str = Security(api_key_header),
|
||||||
api_key_query: str = Security(api_key_query),
|
api_key_query: str = Security(api_key_query),
|
||||||
) -> WalletTypeInfo:
|
) -> WalletTypeInfo:
|
||||||
# 0: admin
|
|
||||||
# 1: invoice
|
|
||||||
# 2: invalid
|
|
||||||
pathname = r["path"].split("/")[1]
|
|
||||||
|
|
||||||
token = api_key_header or api_key_query
|
token = api_key_header or api_key_query
|
||||||
|
|
||||||
if not token:
|
if not token:
|
||||||
@@ -142,38 +128,40 @@ async def get_key_type(
|
|||||||
detail="Invoice (or Admin) key required.",
|
detail="Invoice (or Admin) key required.",
|
||||||
)
|
)
|
||||||
|
|
||||||
for typenr, WalletChecker in zip(
|
for wallet_type, WalletChecker in zip(
|
||||||
[0, 1], [WalletAdminKeyChecker, WalletInvoiceKeyChecker]
|
[WalletType.admin, WalletType.invoice],
|
||||||
|
[WalletAdminKeyChecker, WalletInvoiceKeyChecker],
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
checker = WalletChecker(api_key=token)
|
checker = WalletChecker(api_key=token)
|
||||||
await checker.__call__(r)
|
await checker.__call__(r)
|
||||||
wallet = WalletTypeInfo(typenr, checker.wallet) # type: ignore
|
if checker.wallet is None:
|
||||||
if wallet is None or wallet.wallet is None:
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.NOT_FOUND, detail="Wallet does not exist."
|
status_code=HTTPStatus.NOT_FOUND, detail="Wallet does not exist."
|
||||||
)
|
)
|
||||||
|
wallet = WalletTypeInfo(wallet_type, checker.wallet)
|
||||||
if (
|
if (
|
||||||
wallet.wallet.user != settings.super_user
|
wallet.wallet.user != settings.super_user
|
||||||
and wallet.wallet.user not in settings.lnbits_admin_users
|
and wallet.wallet.user not in settings.lnbits_admin_users
|
||||||
) and (
|
) and (
|
||||||
settings.lnbits_admin_extensions
|
settings.lnbits_admin_extensions
|
||||||
and pathname in settings.lnbits_admin_extensions
|
and r["path"].split("/")[1] in settings.lnbits_admin_extensions
|
||||||
):
|
):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.FORBIDDEN,
|
status_code=HTTPStatus.FORBIDDEN,
|
||||||
detail="User not authorized for this extension.",
|
detail="User not authorized for this extension.",
|
||||||
)
|
)
|
||||||
return wallet
|
return wallet
|
||||||
except HTTPException as e:
|
except HTTPException as exc:
|
||||||
if e.status_code == HTTPStatus.BAD_REQUEST:
|
if exc.status_code == HTTPStatus.BAD_REQUEST:
|
||||||
raise
|
raise
|
||||||
elif e.status_code == HTTPStatus.UNAUTHORIZED:
|
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
|
# 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
|
pass
|
||||||
else:
|
else:
|
||||||
raise
|
raise
|
||||||
except:
|
except Exception:
|
||||||
raise
|
raise
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.NOT_FOUND, detail="Wallet does not exist."
|
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
|
# If wallet type is not admin then return the unauthorized status
|
||||||
# This also covers when the user passes an invalid key type
|
# This also covers when the user passes an invalid key type
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED, detail="Admin key required."
|
status_code=HTTPStatus.UNAUTHORIZED, detail="Admin key required."
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
return wallet
|
return wallet
|
||||||
@@ -220,11 +208,12 @@ async def require_invoice_key(
|
|||||||
|
|
||||||
wallet = await get_key_type(r, token)
|
wallet = await get_key_type(r, token)
|
||||||
|
|
||||||
if wallet.wallet_type > 1:
|
if (
|
||||||
# If wallet type is not invoice then return the unauthorized status
|
wallet.wallet_type != WalletType.admin
|
||||||
# This also covers when the user passes an invalid key type
|
and wallet.wallet_type != WalletType.invoice
|
||||||
|
):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=HTTPStatus.UNAUTHORIZED,
|
||||||
detail="Invoice (or Admin) key required.",
|
detail="Invoice (or Admin) key required.",
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
@@ -259,10 +248,6 @@ async def check_admin(usr: UUID4) -> User:
|
|||||||
status_code=HTTPStatus.UNAUTHORIZED,
|
status_code=HTTPStatus.UNAUTHORIZED,
|
||||||
detail="User not authorized. No admin privileges.",
|
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
|
return user
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ class ExplicitRelease(BaseModel):
|
|||||||
archive: str
|
archive: str
|
||||||
hash: str
|
hash: str
|
||||||
dependencies: List[str] = []
|
dependencies: List[str] = []
|
||||||
|
repo: Optional[str]
|
||||||
icon: Optional[str]
|
icon: Optional[str]
|
||||||
short_description: Optional[str]
|
short_description: Optional[str]
|
||||||
min_lnbits_version: Optional[str]
|
min_lnbits_version: Optional[str]
|
||||||
@@ -98,18 +99,18 @@ async def fetch_github_repo_info(
|
|||||||
) -> Tuple[GitHubRepo, GitHubRepoRelease, ExtensionConfig]:
|
) -> Tuple[GitHubRepo, GitHubRepoRelease, ExtensionConfig]:
|
||||||
repo_url = f"https://api.github.com/repos/{org}/{repository}"
|
repo_url = f"https://api.github.com/repos/{org}/{repository}"
|
||||||
error_msg = "Cannot fetch extension repo"
|
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)
|
github_repo = GitHubRepo.parse_obj(repo)
|
||||||
|
|
||||||
lates_release_url = (
|
lates_release_url = (
|
||||||
f"https://api.github.com/repos/{org}/{repository}/releases/latest"
|
f"https://api.github.com/repos/{org}/{repository}/releases/latest"
|
||||||
)
|
)
|
||||||
error_msg = "Cannot fetch extension releases"
|
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"
|
config_url = f"https://raw.githubusercontent.com/{org}/{repository}/{github_repo.default_branch}/config.json"
|
||||||
error_msg = "Cannot fetch config for extension"
|
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 (
|
return (
|
||||||
github_repo,
|
github_repo,
|
||||||
@@ -120,14 +121,14 @@ async def fetch_github_repo_info(
|
|||||||
|
|
||||||
async def fetch_manifest(url) -> Manifest:
|
async def fetch_manifest(url) -> Manifest:
|
||||||
error_msg = "Cannot fetch extensions 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)
|
return Manifest.parse_obj(manifest)
|
||||||
|
|
||||||
|
|
||||||
async def fetch_github_releases(org: str, repo: str) -> List[GitHubRepoRelease]:
|
async def fetch_github_releases(org: str, repo: str) -> List[GitHubRepoRelease]:
|
||||||
releases_url = f"https://api.github.com/repos/{org}/{repo}/releases"
|
releases_url = f"https://api.github.com/repos/{org}/{repo}/releases"
|
||||||
error_msg = "Cannot fetch extension 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]
|
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"
|
f"https://raw.githubusercontent.com/{org}/{repo}/{tag_name}/config.json"
|
||||||
)
|
)
|
||||||
error_msg = "Cannot fetch GitHub extension config"
|
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)
|
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:
|
async with httpx.AsyncClient() as client:
|
||||||
headers = (
|
headers = (
|
||||||
{"Authorization": "Bearer " + settings.lnbits_ext_github_token}
|
{"Authorization": "Bearer " + settings.lnbits_ext_github_token}
|
||||||
@@ -203,7 +204,7 @@ class Extension(NamedTuple):
|
|||||||
|
|
||||||
|
|
||||||
class ExtensionManager:
|
class ExtensionManager:
|
||||||
def __init__(self):
|
def __init__(self) -> None:
|
||||||
p = Path(settings.lnbits_path, "extensions")
|
p = Path(settings.lnbits_path, "extensions")
|
||||||
Path(p).mkdir(parents=True, exist_ok=True)
|
Path(p).mkdir(parents=True, exist_ok=True)
|
||||||
self._extension_folders: List[Path] = [f for f in p.iterdir() if f.is_dir()]
|
self._extension_folders: List[Path] = [f for f in p.iterdir() if f.is_dir()]
|
||||||
@@ -254,6 +255,7 @@ class ExtensionRelease(BaseModel):
|
|||||||
html_url: Optional[str] = None
|
html_url: Optional[str] = None
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
warning: Optional[str] = None
|
warning: Optional[str] = None
|
||||||
|
repo: Optional[str] = None
|
||||||
icon: Optional[str] = None
|
icon: Optional[str] = None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -267,7 +269,7 @@ class ExtensionRelease(BaseModel):
|
|||||||
archive=r.zipball_url,
|
archive=r.zipball_url,
|
||||||
source_repo=source_repo,
|
source_repo=source_repo,
|
||||||
is_github_release=True,
|
is_github_release=True,
|
||||||
# description=r.body, # bad for JSON
|
repo=f"https://github.com/{source_repo}",
|
||||||
html_url=r.html_url,
|
html_url=r.html_url,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -286,6 +288,7 @@ class ExtensionRelease(BaseModel):
|
|||||||
is_version_compatible=e.is_version_compatible(),
|
is_version_compatible=e.is_version_compatible(),
|
||||||
warning=e.warning,
|
warning=e.warning,
|
||||||
html_url=e.html_url,
|
html_url=e.html_url,
|
||||||
|
repo=e.repo,
|
||||||
icon=e.icon,
|
icon=e.icon,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -423,7 +426,10 @@ class InstallableExtension(BaseModel):
|
|||||||
logger.success(f"Extension {self.name} ({self.installed_version}) installed.")
|
logger.success(f"Extension {self.name} ({self.installed_version}) installed.")
|
||||||
|
|
||||||
def nofiy_upgrade(self) -> None:
|
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(
|
clean_upgraded_exts = list(
|
||||||
filter(
|
filter(
|
||||||
|
|||||||
@@ -4,11 +4,7 @@ from typing import Any, List, Optional, Type
|
|||||||
|
|
||||||
import jinja2
|
import jinja2
|
||||||
import shortuuid
|
import shortuuid
|
||||||
from pydantic.schema import (
|
from pydantic.schema import field_schema
|
||||||
field_schema,
|
|
||||||
get_flat_models_from_fields,
|
|
||||||
get_model_name_map,
|
|
||||||
)
|
|
||||||
|
|
||||||
from lnbits.jinja2_templating import Jinja2Templates
|
from lnbits.jinja2_templating import Jinja2Templates
|
||||||
from lnbits.requestvars import g
|
from lnbits.requestvars import g
|
||||||
@@ -90,32 +86,25 @@ def get_current_extension_name() -> str:
|
|||||||
with open(config_path) as json_file:
|
with open(config_path) as json_file:
|
||||||
config = json.load(json_file)
|
config = json.load(json_file)
|
||||||
ext_name = config["name"]
|
ext_name = config["name"]
|
||||||
except:
|
except Exception:
|
||||||
ext_name = extension_director_name
|
ext_name = extension_director_name
|
||||||
return ext_name
|
return ext_name
|
||||||
|
|
||||||
|
|
||||||
def generate_filter_params_openapi(model: Type[FilterModel], keep_optional=False):
|
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 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())
|
fields = list(model.__fields__.values())
|
||||||
models = get_flat_models_from_fields(fields, set())
|
|
||||||
namemap = get_model_name_map(models)
|
|
||||||
params = []
|
params = []
|
||||||
for field in fields:
|
for field in fields:
|
||||||
schema, definitions, _ = field_schema(field, model_name_map=namemap)
|
schema, _, _ = field_schema(field, model_name_map={})
|
||||||
|
|
||||||
# Support nested definition
|
|
||||||
if "$ref" in schema:
|
|
||||||
name = schema["$ref"].split("/")[-1]
|
|
||||||
schema = definitions[name]
|
|
||||||
|
|
||||||
description = "Supports Filtering"
|
description = "Supports Filtering"
|
||||||
if schema["type"] == "object":
|
|
||||||
description += f". Nested attributes can be filtered too, e.g. `{field.alias}.[additional].[attributes]`"
|
|
||||||
if (
|
if (
|
||||||
hasattr(model, "__search_fields__")
|
hasattr(model, "__search_fields__")
|
||||||
and field.name in model.__search_fields__
|
and field.name in model.__search_fields__
|
||||||
|
|||||||
@@ -2,9 +2,15 @@ from http import HTTPStatus
|
|||||||
from typing import Any, List, Tuple, Union
|
from typing import Any, List, Tuple, Union
|
||||||
from urllib.parse import parse_qs
|
from urllib.parse import parse_qs
|
||||||
|
|
||||||
|
from fastapi import FastAPI, Request, Response
|
||||||
from fastapi.responses import HTMLResponse, JSONResponse
|
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 starlette.types import ASGIApp, Receive, Scope, Send
|
||||||
|
|
||||||
|
from lnbits.core import core_app_extra
|
||||||
from lnbits.helpers import template_renderer
|
from lnbits.helpers import template_renderer
|
||||||
from lnbits.settings import settings
|
from lnbits.settings import settings
|
||||||
|
|
||||||
@@ -12,7 +18,8 @@ from lnbits.settings import settings
|
|||||||
class InstalledExtensionMiddleware:
|
class InstalledExtensionMiddleware:
|
||||||
# This middleware class intercepts calls made to the extensions API and:
|
# This middleware class intercepts calls made to the extensions API and:
|
||||||
# - it blocks the calls if the extension has been disabled or uninstalled.
|
# - 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
|
# - otherwise it has no effect
|
||||||
def __init__(self, app: ASGIApp) -> None:
|
def __init__(self, app: ASGIApp) -> None:
|
||||||
self.app = app
|
self.app = app
|
||||||
@@ -83,9 +90,10 @@ class InstalledExtensionMiddleware:
|
|||||||
self, headers: List[Any], msg: str, status_code: HTTPStatus
|
self, headers: List[Any], msg: str, status_code: HTTPStatus
|
||||||
) -> Union[HTMLResponse, JSONResponse]:
|
) -> Union[HTMLResponse, JSONResponse]:
|
||||||
"""
|
"""
|
||||||
Build an HTTP response containing the `msg` as HTTP body and the `status_code` as HTTP code.
|
Build an HTTP response containing the `msg` as HTTP body and the `status_code`
|
||||||
If the `accept` HTTP header is present int the request and contains the value of `text/html`
|
as HTTP code. If the `accept` HTTP header is present int the request and
|
||||||
then return an `HTMLResponse`, otherwise return an `JSONResponse`.
|
contains the value of `text/html` then return an `HTMLResponse`,
|
||||||
|
otherwise return an `JSONResponse`.
|
||||||
"""
|
"""
|
||||||
accept_header: str = next(
|
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:
|
class ExtensionsRedirectMiddleware:
|
||||||
# Extensions are allowed to specify redirect paths.
|
# Extensions are allowed to specify redirect paths. A call to a path outside the
|
||||||
# A call to a path outside the scope of the extension can be redirected to one of the extension's endpoints.
|
# 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`
|
# Eg: redirect `GET /.well-known` to `GET /lnurlp/api/v1/well-known`
|
||||||
|
|
||||||
def __init__(self, app: ASGIApp) -> None:
|
def __init__(self, app: ASGIApp) -> None:
|
||||||
@@ -189,3 +209,39 @@ class ExtensionsRedirectMiddleware:
|
|||||||
]
|
]
|
||||||
|
|
||||||
return "/" + "/".join(elements)
|
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)
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
import uvloop
|
|
||||||
|
|
||||||
uvloop.install()
|
|
||||||
|
|
||||||
import multiprocessing as mp
|
import multiprocessing as mp
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import click
|
import click
|
||||||
import uvicorn
|
import uvicorn
|
||||||
|
from uvicorn.supervisors import ChangeReload
|
||||||
|
|
||||||
from lnbits.settings import set_cli_settings, settings
|
from lnbits.settings import set_cli_settings, settings
|
||||||
|
|
||||||
@@ -41,9 +38,13 @@ def main(
|
|||||||
# create data dir if it does not exist
|
# create data dir if it does not exist
|
||||||
Path(settings.lnbits_data_folder).mkdir(parents=True, exist_ok=True)
|
Path(settings.lnbits_data_folder).mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# create extension dir if it does not exist
|
||||||
|
Path(settings.lnbits_path, "extensions").mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
set_cli_settings(host=host, port=port, forwarded_allow_ips=forwarded_allow_ips)
|
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()
|
d = dict()
|
||||||
for a in ctx.args:
|
for a in ctx.args:
|
||||||
item = a.split("=")
|
item = a.split("=")
|
||||||
@@ -70,7 +71,14 @@ def main(
|
|||||||
)
|
)
|
||||||
|
|
||||||
server = uvicorn.Server(config=config)
|
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()
|
process.start()
|
||||||
server_restart.wait()
|
server_restart.wait()
|
||||||
server_restart.clear()
|
server_restart.clear()
|
||||||
|
|||||||
@@ -12,21 +12,21 @@ from loguru import logger
|
|||||||
from pydantic import BaseSettings, Extra, Field, validator
|
from pydantic import BaseSettings, Extra, Field, validator
|
||||||
|
|
||||||
|
|
||||||
def list_parse_fallback(v):
|
def list_parse_fallback(v: str):
|
||||||
try:
|
v = v.replace(" ", "")
|
||||||
return json.loads(v)
|
if len(v) > 0:
|
||||||
except Exception:
|
if v.startswith("[") or v.startswith("{"):
|
||||||
replaced = v.replace(" ", "")
|
return json.loads(v)
|
||||||
if replaced:
|
|
||||||
return replaced.split(",")
|
|
||||||
else:
|
else:
|
||||||
return []
|
return v.split(",")
|
||||||
|
else:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
class LNbitsSettings(BaseSettings):
|
class LNbitsSettings(BaseSettings):
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate(cls, val):
|
def validate(cls, val):
|
||||||
if type(val) == str:
|
if isinstance(val, str):
|
||||||
val = val.split(",") if val else []
|
val = val.split(",") if val else []
|
||||||
return val
|
return val
|
||||||
|
|
||||||
@@ -89,6 +89,8 @@ class ThemesSettings(LNbitsSettings):
|
|||||||
default="https://shop.lnbits.com/;/static/images/lnbits-shop-light.png;/static/images/lnbits-shop-dark.png"
|
default="https://shop.lnbits.com/;/static/images/lnbits-shop-light.png;/static/images/lnbits-shop-dark.png"
|
||||||
) # sneaky sneaky
|
) # sneaky sneaky
|
||||||
lnbits_ad_space_enabled: bool = Field(default=False)
|
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):
|
class OpsSettings(LNbitsSettings):
|
||||||
@@ -100,6 +102,24 @@ class OpsSettings(LNbitsSettings):
|
|||||||
lnbits_denomination: str = Field(default="sats")
|
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):
|
class FakeWalletFundingSource(LNbitsSettings):
|
||||||
fake_wallet_secret: str = Field(default="ToTheMoon1")
|
fake_wallet_secret: str = Field(default="ToTheMoon1")
|
||||||
|
|
||||||
@@ -120,6 +140,12 @@ class CoreLightningFundingSource(LNbitsSettings):
|
|||||||
clightning_rpc: Optional[str] = Field(default=None)
|
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):
|
class EclairFundingSource(LNbitsSettings):
|
||||||
eclair_url: Optional[str] = Field(default=None)
|
eclair_url: Optional[str] = Field(default=None)
|
||||||
eclair_pass: Optional[str] = Field(default=None)
|
eclair_pass: Optional[str] = Field(default=None)
|
||||||
@@ -182,7 +208,7 @@ class BoltzExtensionSettings(LNbitsSettings):
|
|||||||
|
|
||||||
|
|
||||||
class LightningSettings(LNbitsSettings):
|
class LightningSettings(LNbitsSettings):
|
||||||
lightning_invoice_expiry: int = Field(default=600)
|
lightning_invoice_expiry: int = Field(default=3600)
|
||||||
|
|
||||||
|
|
||||||
class FundingSourcesSettings(
|
class FundingSourcesSettings(
|
||||||
@@ -190,6 +216,7 @@ class FundingSourcesSettings(
|
|||||||
LNbitsFundingSource,
|
LNbitsFundingSource,
|
||||||
ClicheFundingSource,
|
ClicheFundingSource,
|
||||||
CoreLightningFundingSource,
|
CoreLightningFundingSource,
|
||||||
|
CoreLightningRestFundingSource,
|
||||||
EclairFundingSource,
|
EclairFundingSource,
|
||||||
LndRestFundingSource,
|
LndRestFundingSource,
|
||||||
LndGrpcFundingSource,
|
LndGrpcFundingSource,
|
||||||
@@ -206,6 +233,7 @@ class EditableSettings(
|
|||||||
ExtensionsSettings,
|
ExtensionsSettings,
|
||||||
ThemesSettings,
|
ThemesSettings,
|
||||||
OpsSettings,
|
OpsSettings,
|
||||||
|
SecuritySettings,
|
||||||
FundingSourcesSettings,
|
FundingSourcesSettings,
|
||||||
BoltzExtensionSettings,
|
BoltzExtensionSettings,
|
||||||
LightningSettings,
|
LightningSettings,
|
||||||
@@ -227,6 +255,13 @@ class EditableSettings(
|
|||||||
**{k: v for k, v in d.items() if k in inspect.signature(cls).parameters}
|
**{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 EnvSettings(LNbitsSettings):
|
class EnvSettings(LNbitsSettings):
|
||||||
debug: bool = Field(default=False)
|
debug: bool = Field(default=False)
|
||||||
@@ -234,6 +269,7 @@ class EnvSettings(LNbitsSettings):
|
|||||||
host: str = Field(default="127.0.0.1")
|
host: str = Field(default="127.0.0.1")
|
||||||
port: int = Field(default=5000)
|
port: int = Field(default=5000)
|
||||||
forwarded_allow_ips: str = Field(default="*")
|
forwarded_allow_ips: str = Field(default="*")
|
||||||
|
lnbits_title: str = Field(default="LNbits API")
|
||||||
lnbits_path: str = Field(default=".")
|
lnbits_path: str = Field(default=".")
|
||||||
lnbits_commit: str = Field(default="unknown")
|
lnbits_commit: str = Field(default="unknown")
|
||||||
super_user: str = Field(default="")
|
super_user: str = Field(default="")
|
||||||
@@ -257,6 +293,7 @@ class SuperUserSettings(LNbitsSettings):
|
|||||||
"VoidWallet",
|
"VoidWallet",
|
||||||
"FakeWallet",
|
"FakeWallet",
|
||||||
"CoreLightningWallet",
|
"CoreLightningWallet",
|
||||||
|
"CoreLightningRestWallet",
|
||||||
"LndRestWallet",
|
"LndRestWallet",
|
||||||
"EclairWallet",
|
"EclairWallet",
|
||||||
"LndWallet",
|
"LndWallet",
|
||||||
@@ -323,19 +360,6 @@ def set_cli_settings(**kwargs):
|
|||||||
setattr(settings, key, value)
|
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():
|
def send_admin_user_to_saas():
|
||||||
if settings.lnbits_saas_callback:
|
if settings.lnbits_saas_callback:
|
||||||
with httpx.Client() as client:
|
with httpx.Client() as client:
|
||||||
@@ -356,7 +380,8 @@ def send_admin_user_to_saas():
|
|||||||
logger.success("sent super_user to saas application")
|
logger.success("sent super_user to saas application")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(
|
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)}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -376,20 +401,16 @@ try:
|
|||||||
.strip()
|
.strip()
|
||||||
.decode("ascii")
|
.decode("ascii")
|
||||||
)
|
)
|
||||||
except:
|
except Exception:
|
||||||
settings.lnbits_commit = "docker"
|
settings.lnbits_commit = "docker"
|
||||||
|
|
||||||
settings.version = importlib.metadata.version("lnbits")
|
settings.version = importlib.metadata.version("lnbits")
|
||||||
|
|
||||||
# printing environment variable for debugging
|
|
||||||
if not settings.lnbits_admin_ui:
|
|
||||||
logger.debug("Environment Settings:")
|
|
||||||
for key, value in settings.dict(exclude_none=True).items():
|
|
||||||
logger.debug(f"{key}: {value}")
|
|
||||||
|
|
||||||
|
def get_wallet_class():
|
||||||
|
"""
|
||||||
|
Backwards compatibility
|
||||||
|
"""
|
||||||
|
from lnbits.wallets import get_wallet_class
|
||||||
|
|
||||||
wallets_module = importlib.import_module("lnbits.wallets")
|
return get_wallet_class()
|
||||||
FAKE_WALLET = getattr(wallets_module, "FakeWallet")()
|
|
||||||
|
|
||||||
# initialize as fake wallet
|
|
||||||
WALLET = FAKE_WALLET
|
|
||||||
|
|||||||
@@ -325,7 +325,7 @@ body[data-theme='autumn'].body--dark {
|
|||||||
[data-theme=flamingo] .q-drawer--dark,
|
[data-theme=flamingo] .q-drawer--dark,
|
||||||
body[data-theme=flamingo].body--dark,
|
body[data-theme=flamingo].body--dark,
|
||||||
[data-theme=flamingo] .q-menu--dark {
|
[data-theme=flamingo] .q-menu--dark {
|
||||||
background: #803a45 !important;
|
background: #2f032f !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* IF WANTING TO SET A DARKER BG COLOR IN THE FUTURE
|
/* 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-card--dark,
|
||||||
[data-theme=flamingo] .q-stepper--dark {
|
[data-theme=flamingo] .q-stepper--dark {
|
||||||
background: #ec7599 !important;
|
background: #bc23bc !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme=flamingo] .bg-primary {
|
[data-theme=flamingo] .bg-primary {
|
||||||
background: #d11d53 !important;
|
background: #ff00ff !important;
|
||||||
}
|
}
|
||||||
[data-theme=flamingo] .text-primary {
|
[data-theme=flamingo] .text-primary {
|
||||||
color: #d11d53 !important;
|
color: #ff00ff !important;
|
||||||
}
|
}
|
||||||
[data-theme=flamingo] .bg-secondary {
|
[data-theme=flamingo] .bg-secondary {
|
||||||
background: #db3e6d !important;
|
background: #fda3fd !important;
|
||||||
}
|
}
|
||||||
[data-theme=flamingo] .text-secondary {
|
[data-theme=flamingo] .text-secondary {
|
||||||
color: #db3e6d !important;
|
color: #fda3fd !important;
|
||||||
}
|
}
|
||||||
[data-theme=flamingo] .bg-dark {
|
[data-theme=flamingo] .bg-dark {
|
||||||
background: #803a45 !important;
|
background: #2f032f !important;
|
||||||
}
|
}
|
||||||
[data-theme=flamingo] .text-dark {
|
[data-theme=flamingo] .text-dark {
|
||||||
color: #803a45 !important;
|
color: #2f032f !important;
|
||||||
}
|
}
|
||||||
[data-theme=flamingo] .bg-info {
|
[data-theme=flamingo] .bg-info {
|
||||||
background: #ec7599 !important;
|
background: #bc23bc !important;
|
||||||
}
|
}
|
||||||
[data-theme=flamingo] .text-info {
|
[data-theme=flamingo] .text-info {
|
||||||
color: #ec7599 !important;
|
color: #bc23bc !important;
|
||||||
}
|
}
|
||||||
[data-theme=flamingo] .bg-marginal-bg {
|
[data-theme=flamingo] .bg-marginal-bg {
|
||||||
background: #803a45 !important;
|
background: #311231 !important;
|
||||||
}
|
}
|
||||||
[data-theme=flamingo] .text-marginal-bg {
|
[data-theme=flamingo] .text-marginal-bg {
|
||||||
color: #803a45 !important;
|
color: #311231 !important;
|
||||||
}
|
}
|
||||||
[data-theme=flamingo] .bg-marginal-text {
|
[data-theme=flamingo] .bg-marginal-text {
|
||||||
background: rgb(255, 255, 255) !important;
|
background: rgb(255, 255, 255) !important;
|
||||||
@@ -454,14 +454,6 @@ body[data-theme='monochrome'].body--dark {
|
|||||||
background: #0f47af !important;
|
background: #0f47af !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme=flamingo] .q-drawer--dark {
|
|
||||||
background: #e75480 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-theme=flamingo] .q-header {
|
|
||||||
background: #e75480 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
[v-cloak] {
|
[v-cloak] {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,6 +63,8 @@ window.localisation.br = {
|
|||||||
invoice_waiting: 'Fatura aguardando pagamento',
|
invoice_waiting: 'Fatura aguardando pagamento',
|
||||||
payment_received: 'Pagamento Recebido',
|
payment_received: 'Pagamento Recebido',
|
||||||
payment_sent: 'Pagamento Enviado',
|
payment_sent: 'Pagamento Enviado',
|
||||||
|
receive: 'receber',
|
||||||
|
send: 'enviar',
|
||||||
outgoing_payment_pending: 'Pagamento pendente de saída',
|
outgoing_payment_pending: 'Pagamento pendente de saída',
|
||||||
drain_funds: 'Drenar Fundos',
|
drain_funds: 'Drenar Fundos',
|
||||||
drain_funds_desc:
|
drain_funds_desc:
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
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文档',
|
||||||
|
commit_version: '提交版本',
|
||||||
|
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: '付款证明'
|
||||||
|
}
|
||||||
@@ -66,6 +66,8 @@ window.localisation.de = {
|
|||||||
invoice_waiting: 'Rechnung wartend auf Zahlung',
|
invoice_waiting: 'Rechnung wartend auf Zahlung',
|
||||||
payment_received: 'Zahlung erhalten',
|
payment_received: 'Zahlung erhalten',
|
||||||
payment_sent: 'Zahlung gesendet',
|
payment_sent: 'Zahlung gesendet',
|
||||||
|
receive: 'erhalten',
|
||||||
|
send: 'schicken',
|
||||||
outgoing_payment_pending: 'Ausgehende Zahlung wartend',
|
outgoing_payment_pending: 'Ausgehende Zahlung wartend',
|
||||||
drain_funds: 'Sats abziehen',
|
drain_funds: 'Sats abziehen',
|
||||||
drain_funds_desc:
|
drain_funds_desc:
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
window.localisation.en = {
|
window.localisation.en = {
|
||||||
|
confirm: 'Yes',
|
||||||
server: 'Server',
|
server: 'Server',
|
||||||
theme: 'Theme',
|
theme: 'Theme',
|
||||||
funding: 'Funding',
|
funding: 'Funding',
|
||||||
@@ -28,6 +29,9 @@ window.localisation.en = {
|
|||||||
'This whole wallet will be deleted, the funds will be UNRECOVERABLE.',
|
'This whole wallet will be deleted, the funds will be UNRECOVERABLE.',
|
||||||
rename_wallet: 'Rename wallet',
|
rename_wallet: 'Rename wallet',
|
||||||
update_name: 'Update name',
|
update_name: 'Update name',
|
||||||
|
fiat_tracking: 'Fiat tracking',
|
||||||
|
currency: 'Currency',
|
||||||
|
update_currency: 'Update currency',
|
||||||
press_to_claim: 'Press to claim bitcoin',
|
press_to_claim: 'Press to claim bitcoin',
|
||||||
donate: 'Donate',
|
donate: 'Donate',
|
||||||
view_github: 'View on GitHub',
|
view_github: 'View on GitHub',
|
||||||
@@ -41,6 +45,8 @@ window.localisation.en = {
|
|||||||
runs_on: 'Runs on',
|
runs_on: 'Runs on',
|
||||||
credit_hint: 'Press Enter to credit account',
|
credit_hint: 'Press Enter to credit account',
|
||||||
credit_label: '%{denomination} to credit',
|
credit_label: '%{denomination} to credit',
|
||||||
|
paste: 'Paste',
|
||||||
|
paste_from_clipboard: 'Paste from clipboard',
|
||||||
paste_request: 'Paste Request',
|
paste_request: 'Paste Request',
|
||||||
create_invoice: 'Create Invoice',
|
create_invoice: 'Create Invoice',
|
||||||
camera_tooltip: 'Use camera to scan an invoice/QR',
|
camera_tooltip: 'Use camera to scan an invoice/QR',
|
||||||
@@ -62,6 +68,8 @@ window.localisation.en = {
|
|||||||
invoice_waiting: 'Invoice waiting to be paid',
|
invoice_waiting: 'Invoice waiting to be paid',
|
||||||
payment_received: 'Payment Received',
|
payment_received: 'Payment Received',
|
||||||
payment_sent: 'Payment Sent',
|
payment_sent: 'Payment Sent',
|
||||||
|
receive: 'receive',
|
||||||
|
send: 'send',
|
||||||
outgoing_payment_pending: 'Outgoing payment pending',
|
outgoing_payment_pending: 'Outgoing payment pending',
|
||||||
drain_funds: 'Drain Funds',
|
drain_funds: 'Drain Funds',
|
||||||
drain_funds_desc:
|
drain_funds_desc:
|
||||||
@@ -84,6 +92,7 @@ window.localisation.en = {
|
|||||||
manage_extension_details: 'Install/uninstall extension',
|
manage_extension_details: 'Install/uninstall extension',
|
||||||
install: 'Install',
|
install: 'Install',
|
||||||
uninstall: 'Uninstall',
|
uninstall: 'Uninstall',
|
||||||
|
drop_db: 'Remove Data',
|
||||||
open: 'Open',
|
open: 'Open',
|
||||||
enable: 'Enable',
|
enable: 'Enable',
|
||||||
enable_extension_details: 'Enable extension for current user',
|
enable_extension_details: 'Enable extension for current user',
|
||||||
@@ -96,6 +105,7 @@ window.localisation.en = {
|
|||||||
featured: 'Featured',
|
featured: 'Featured',
|
||||||
all: 'All',
|
all: 'All',
|
||||||
only_admins_can_install: '(Only admin accounts can install extensions)',
|
only_admins_can_install: '(Only admin accounts can install extensions)',
|
||||||
|
admin_only: 'Admin Only',
|
||||||
new_version: 'New Version',
|
new_version: 'New Version',
|
||||||
extension_depends_on: 'Depends on:',
|
extension_depends_on: 'Depends on:',
|
||||||
extension_rating_soon: 'Ratings coming soon',
|
extension_rating_soon: 'Ratings coming soon',
|
||||||
@@ -103,6 +113,11 @@ window.localisation.en = {
|
|||||||
extension_uninstall_warning:
|
extension_uninstall_warning:
|
||||||
'You are about to remove the extension for all users.',
|
'You are about to remove the extension for all users.',
|
||||||
uninstall_confirm: 'Yes, Uninstall',
|
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',
|
extension_min_lnbits_version: 'This release requires at least LNbits version',
|
||||||
|
|
||||||
payment_hash: 'Payment Hash',
|
payment_hash: 'Payment Hash',
|
||||||
@@ -112,5 +127,52 @@ window.localisation.en = {
|
|||||||
description: 'Description',
|
description: 'Description',
|
||||||
expiry: 'Expiry',
|
expiry: 'Expiry',
|
||||||
webhook: 'Webhook',
|
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'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,6 +63,8 @@ window.localisation.es = {
|
|||||||
invoice_waiting: 'Factura esperando pago',
|
invoice_waiting: 'Factura esperando pago',
|
||||||
payment_received: 'Pago recibido',
|
payment_received: 'Pago recibido',
|
||||||
payment_sent: 'Pago enviado',
|
payment_sent: 'Pago enviado',
|
||||||
|
receive: 'recibir',
|
||||||
|
send: 'enviar',
|
||||||
outgoing_payment_pending: 'Pago saliente pendiente',
|
outgoing_payment_pending: 'Pago saliente pendiente',
|
||||||
drain_funds: 'Drenar fondos',
|
drain_funds: 'Drenar fondos',
|
||||||
drain_funds_desc:
|
drain_funds_desc:
|
||||||
|
|||||||
@@ -67,6 +67,8 @@ window.localisation.fr = {
|
|||||||
invoice_waiting: 'Facture en attente de paiement',
|
invoice_waiting: 'Facture en attente de paiement',
|
||||||
payment_received: 'Paiement reçu',
|
payment_received: 'Paiement reçu',
|
||||||
payment_sent: 'Paiement envoyé',
|
payment_sent: 'Paiement envoyé',
|
||||||
|
receive: 'recevoir',
|
||||||
|
send: 'envoyer',
|
||||||
outgoing_payment_pending: 'Paiement sortant en attente',
|
outgoing_payment_pending: 'Paiement sortant en attente',
|
||||||
drain_funds: 'Vider les fonds',
|
drain_funds: 'Vider les fonds',
|
||||||
drain_funds_desc:
|
drain_funds_desc:
|
||||||
|
|||||||
@@ -64,6 +64,8 @@ window.localisation.it = {
|
|||||||
invoice_waiting: 'Fattura in attesa di pagamento',
|
invoice_waiting: 'Fattura in attesa di pagamento',
|
||||||
payment_received: 'Pagamento ricevuto',
|
payment_received: 'Pagamento ricevuto',
|
||||||
payment_sent: 'Pagamento inviato',
|
payment_sent: 'Pagamento inviato',
|
||||||
|
receive: 'ricevere',
|
||||||
|
send: 'inviare',
|
||||||
outgoing_payment_pending: 'Pagamento in uscita in attesa',
|
outgoing_payment_pending: 'Pagamento in uscita in attesa',
|
||||||
drain_funds: 'Fondi di drenaggio',
|
drain_funds: 'Fondi di drenaggio',
|
||||||
drain_funds_desc:
|
drain_funds_desc:
|
||||||
|
|||||||
@@ -62,6 +62,8 @@ window.localisation.jp = {
|
|||||||
invoice_waiting: '請求書を待っています',
|
invoice_waiting: '請求書を待っています',
|
||||||
payment_received: 'お支払いありがとうございます',
|
payment_received: 'お支払いありがとうございます',
|
||||||
payment_sent: '支払いが完了しました',
|
payment_sent: '支払いが完了しました',
|
||||||
|
receive: '受け取る',
|
||||||
|
send: '送信',
|
||||||
outgoing_payment_pending: '支払い保留中',
|
outgoing_payment_pending: '支払い保留中',
|
||||||
drain_funds: '資金を排出する',
|
drain_funds: '資金を排出する',
|
||||||
drain_funds_desc: 'ウォレットの残高をすべて他のウォレットに送金します',
|
drain_funds_desc: 'ウォレットの残高をすべて他のウォレットに送金します',
|
||||||
|
|||||||
@@ -65,6 +65,8 @@ window.localisation.nl = {
|
|||||||
invoice_waiting: 'Factuur wachtend op betaling',
|
invoice_waiting: 'Factuur wachtend op betaling',
|
||||||
payment_received: 'Betaling ontvangen',
|
payment_received: 'Betaling ontvangen',
|
||||||
payment_sent: 'Betaling verzonden',
|
payment_sent: 'Betaling verzonden',
|
||||||
|
receive: 'ontvangen',
|
||||||
|
send: 'versturen',
|
||||||
voutgoing_payment_pending: 'Uitgaande betaling in behandeling',
|
voutgoing_payment_pending: 'Uitgaande betaling in behandeling',
|
||||||
drain_funds: 'Geld opnemen',
|
drain_funds: 'Geld opnemen',
|
||||||
drain_funds_desc:
|
drain_funds_desc:
|
||||||
|
|||||||
@@ -64,6 +64,8 @@ window.localisation.pi = {
|
|||||||
invoice_waiting: 'Invoice waiting to be plundered, arrr',
|
invoice_waiting: 'Invoice waiting to be plundered, arrr',
|
||||||
payment_received: 'Payment Received like a treasure, argh',
|
payment_received: 'Payment Received like a treasure, argh',
|
||||||
payment_sent: 'Payment Sent, hoist the colors! We´ve got some doubloons!',
|
payment_sent: 'Payment Sent, hoist the colors! We´ve got some doubloons!',
|
||||||
|
receive: 'booty',
|
||||||
|
send: 'hoist',
|
||||||
outgoing_payment_pending:
|
outgoing_payment_pending:
|
||||||
'Outgoing payment pending in the port, ye scurvy dog',
|
'Outgoing payment pending in the port, ye scurvy dog',
|
||||||
drain_funds: 'Plunder all the doubloons, ye buccaneer',
|
drain_funds: 'Plunder all the doubloons, ye buccaneer',
|
||||||
|
|||||||
@@ -62,6 +62,8 @@ window.localisation.pl = {
|
|||||||
invoice_waiting: 'Faktura oczekuje na zapłatę',
|
invoice_waiting: 'Faktura oczekuje na zapłatę',
|
||||||
payment_received: 'Otrzymano płatność',
|
payment_received: 'Otrzymano płatność',
|
||||||
payment_sent: 'Wysłano płatność',
|
payment_sent: 'Wysłano płatność',
|
||||||
|
receive: 'odbierać',
|
||||||
|
send: 'wysłać',
|
||||||
outgoing_payment_pending: 'Płatność wychodząca w toku',
|
outgoing_payment_pending: 'Płatność wychodząca w toku',
|
||||||
drain_funds: 'Opróżnij środki',
|
drain_funds: 'Opróżnij środki',
|
||||||
drain_funds_desc:
|
drain_funds_desc:
|
||||||
|
|||||||
@@ -63,6 +63,8 @@ window.localisation.pt = {
|
|||||||
invoice_waiting: 'Fatura aguardando pagamento',
|
invoice_waiting: 'Fatura aguardando pagamento',
|
||||||
payment_received: 'Pagamento Recebido',
|
payment_received: 'Pagamento Recebido',
|
||||||
payment_sent: 'Pagamento Enviado',
|
payment_sent: 'Pagamento Enviado',
|
||||||
|
receive: 'receber',
|
||||||
|
send: 'enviar',
|
||||||
outgoing_payment_pending: 'Pagamento de saída pendente',
|
outgoing_payment_pending: 'Pagamento de saída pendente',
|
||||||
drain_funds: 'Esvasiar carteira',
|
drain_funds: 'Esvasiar carteira',
|
||||||
drain_funds_desc:
|
drain_funds_desc:
|
||||||
|
|||||||
@@ -63,6 +63,8 @@ window.localisation.we = {
|
|||||||
invoice_waiting: 'Anfoneb yn aros i gael ei thalu',
|
invoice_waiting: 'Anfoneb yn aros i gael ei thalu',
|
||||||
payment_received: 'Taliad a Dderbyniwyd',
|
payment_received: 'Taliad a Dderbyniwyd',
|
||||||
payment_sent: 'Taliad a Anfonwyd',
|
payment_sent: 'Taliad a Anfonwyd',
|
||||||
|
receive: 'derbyn',
|
||||||
|
send: 'anfon',
|
||||||
outgoing_payment_pending: 'Taliad sy`n aros yn yr arfaeth',
|
outgoing_payment_pending: 'Taliad sy`n aros yn yr arfaeth',
|
||||||
drain_funds: 'Cronfeydd Draenio',
|
drain_funds: 'Cronfeydd Draenio',
|
||||||
drain_funds_desc:
|
drain_funds_desc:
|
||||||
|
|||||||
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
@@ -178,7 +178,8 @@ window.LNbits = {
|
|||||||
id: data.id,
|
id: data.id,
|
||||||
name: data.name,
|
name: data.name,
|
||||||
adminkey: data.adminkey,
|
adminkey: data.adminkey,
|
||||||
inkey: data.inkey
|
inkey: data.inkey,
|
||||||
|
currency: data.currency
|
||||||
}
|
}
|
||||||
newWallet.msat = data.balance_msat
|
newWallet.msat = data.balance_msat
|
||||||
newWallet.sat = Math.round(data.balance_msat / 1000)
|
newWallet.sat = Math.round(data.balance_msat / 1000)
|
||||||
@@ -203,7 +204,9 @@ window.LNbits = {
|
|||||||
extra: data.extra,
|
extra: data.extra,
|
||||||
wallet_id: data.wallet_id,
|
wallet_id: data.wallet_id,
|
||||||
webhook: data.webhook,
|
webhook: data.webhook,
|
||||||
webhook_status: data.webhook_status
|
webhook_status: data.webhook_status,
|
||||||
|
fiat_amount: data.fiat_amount,
|
||||||
|
fiat_currency: data.fiat_currency
|
||||||
}
|
}
|
||||||
|
|
||||||
obj.date = Quasar.utils.date.formatDate(
|
obj.date = Quasar.utils.date.formatDate(
|
||||||
@@ -241,6 +244,15 @@ window.LNbits = {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
digestMessage: async function (message) {
|
||||||
|
const msgUint8 = new TextEncoder().encode(message)
|
||||||
|
const hashBuffer = await crypto.subtle.digest('SHA-256', msgUint8)
|
||||||
|
const hashArray = Array.from(new Uint8Array(hashBuffer))
|
||||||
|
const hashHex = hashArray
|
||||||
|
.map(b => b.toString(16).padStart(2, '0'))
|
||||||
|
.join('')
|
||||||
|
return hashHex
|
||||||
|
},
|
||||||
formatCurrency: function (value, currency) {
|
formatCurrency: function (value, currency) {
|
||||||
return new Intl.NumberFormat(window.LOCALE, {
|
return new Intl.NumberFormat(window.LOCALE, {
|
||||||
style: 'currency',
|
style: 'currency',
|
||||||
@@ -350,6 +362,9 @@ window.windowMixin = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
methods: {
|
methods: {
|
||||||
|
activeLanguage: function (lang) {
|
||||||
|
return window.i18n.locale === lang
|
||||||
|
},
|
||||||
changeLanguage: function (newValue) {
|
changeLanguage: function (newValue) {
|
||||||
window.i18n.locale = newValue
|
window.i18n.locale = newValue
|
||||||
this.$q.localStorage.set('lnbits.lang', newValue)
|
this.$q.localStorage.set('lnbits.lang', newValue)
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ function decode(paymentRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function decodeHumanReadablePart(humanReadablePart) {
|
function decodeHumanReadablePart(humanReadablePart) {
|
||||||
let prefixes = ['lnbc', 'lntb', 'lnbcrt', 'lnsb']
|
let prefixes = ['lnbc', 'lntb', 'lnbcrt', 'lnsb', 'lntbs']
|
||||||
let prefix
|
let prefix
|
||||||
prefixes.forEach(value => {
|
prefixes.forEach(value => {
|
||||||
if (humanReadablePart.substring(0, value.length) === value) {
|
if (humanReadablePart.substring(0, value.length) === value) {
|
||||||
|
|||||||
@@ -140,6 +140,7 @@ Vue.component('lnbits-extension-list', {
|
|||||||
<q-item-label lines="1" class="text-caption" v-text="$t('extensions')"></q-item-label>
|
<q-item-label lines="1" class="text-caption" v-text="$t('extensions')"></q-item-label>
|
||||||
</q-item-section>
|
</q-item-section>
|
||||||
</q-item>
|
</q-item>
|
||||||
|
<div class="lt-md q-mt-xl q-mb-xl"></div>
|
||||||
</q-list>
|
</q-list>
|
||||||
`,
|
`,
|
||||||
computed: {
|
computed: {
|
||||||
@@ -250,17 +251,14 @@ Vue.component('lnbits-payment-details', {
|
|||||||
<b style="white-space: nowrap;" v-text="$t('memo')"></b>: {{ payment.memo }}
|
<b style="white-space: nowrap;" v-text="$t('memo')"></b>: {{ payment.memo }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row" v-if="payment.webhook">
|
<div class="text-wrap" v-if="payment.webhook">
|
||||||
<b v-text="$t('webhook')"></b>:
|
<b style="white-space: nowrap;" v-text="$t('webhook')"></b>: {{ payment.webhook }}: <q-badge :color="webhookStatusColor" text-color="white">
|
||||||
{{ payment.webhook }}
|
|
||||||
<q-badge :color="webhookStatusColor" text-color="white">
|
|
||||||
{{ webhookStatusText }}
|
{{ webhookStatusText }}
|
||||||
</q-badge>
|
</q-badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row" v-if="hasPreimage">
|
<div class="text-wrap" v-if="hasPreimage">
|
||||||
<b v-text="$t('payment_proof')"></b>:
|
<b style="white-space: nowrap;" v-text="$t('payment_proof')"></b>: {{ payment.preimage }}
|
||||||
{{ payment.preimage }}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row" v-for="entry in extras">
|
<div class="row" v-for="entry in extras">
|
||||||
|
|||||||
@@ -48,11 +48,11 @@ $themes: (
|
|||||||
marginal-text: rgb(255, 255, 255)
|
marginal-text: rgb(255, 255, 255)
|
||||||
),
|
),
|
||||||
'flamingo': (
|
'flamingo': (
|
||||||
primary: #d11d53,
|
primary: #ff00ff,
|
||||||
secondary: #db3e6d,
|
secondary: #fda3fd,
|
||||||
dark: #803a45,
|
dark: #2f032f,
|
||||||
info: #ec7599,
|
info: #bc23bc,
|
||||||
marginal-bg: #803a45,
|
marginal-bg: #311231,
|
||||||
marginal-text: rgb(255, 255, 255)
|
marginal-text: rgb(255, 255, 255)
|
||||||
),
|
),
|
||||||
'monochrome': (
|
'monochrome': (
|
||||||
@@ -122,14 +122,6 @@ $themes: (
|
|||||||
background: #0f47af !important;
|
background: #0f47af !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme='flamingo'] .q-drawer--dark {
|
|
||||||
background: #e75480 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-theme='flamingo'] .q-header {
|
|
||||||
background: #e75480 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
[v-cloak] {
|
[v-cloak] {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
"/static/i18n/fr.js",
|
"/static/i18n/fr.js",
|
||||||
"/static/i18n/it.js",
|
"/static/i18n/it.js",
|
||||||
"/static/i18n/jp.js",
|
"/static/i18n/jp.js",
|
||||||
|
"/static/i18n/cn.js",
|
||||||
"/static/i18n/nl.js",
|
"/static/i18n/nl.js",
|
||||||
"/static/i18n/pi.js",
|
"/static/i18n/pi.js",
|
||||||
"/static/i18n/pl.js",
|
"/static/i18n/pl.js",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import time
|
|||||||
import traceback
|
import traceback
|
||||||
import uuid
|
import uuid
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
from typing import Dict, Optional
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
from fastapi.exceptions import HTTPException
|
from fastapi.exceptions import HTTPException
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
@@ -15,10 +15,30 @@ from lnbits.core.crud import (
|
|||||||
get_standalone_payment,
|
get_standalone_payment,
|
||||||
)
|
)
|
||||||
from lnbits.core.services import redeem_lnurl_withdraw
|
from lnbits.core.services import redeem_lnurl_withdraw
|
||||||
from lnbits.settings import get_wallet_class
|
from lnbits.wallets import get_wallet_class
|
||||||
|
|
||||||
from .core import db
|
from .core import db
|
||||||
|
|
||||||
|
tasks: List[asyncio.Task] = []
|
||||||
|
|
||||||
|
|
||||||
|
def create_task(coro):
|
||||||
|
task = asyncio.create_task(coro)
|
||||||
|
tasks.append(task)
|
||||||
|
return task
|
||||||
|
|
||||||
|
|
||||||
|
def create_permanent_task(func):
|
||||||
|
return create_task(catch_everything_and_restart(func))
|
||||||
|
|
||||||
|
|
||||||
|
def cancel_all_tasks():
|
||||||
|
for task in tasks:
|
||||||
|
try:
|
||||||
|
task.cancel()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(f"error while cancelling task: {str(exc)}")
|
||||||
|
|
||||||
|
|
||||||
async def catch_everything_and_restart(func):
|
async def catch_everything_and_restart(func):
|
||||||
try:
|
try:
|
||||||
@@ -46,8 +66,8 @@ class SseListenersDict(dict):
|
|||||||
self.name = name or f"sse_listener_{str(uuid.uuid4())[:8]}"
|
self.name = name or f"sse_listener_{str(uuid.uuid4())[:8]}"
|
||||||
|
|
||||||
def __setitem__(self, key, value):
|
def __setitem__(self, key, value):
|
||||||
assert type(key) == str, f"{key} is not a string"
|
assert isinstance(key, str), f"{key} is not a string"
|
||||||
assert type(value) == asyncio.Queue, f"{value} is not an asyncio.Queue"
|
assert isinstance(value, asyncio.Queue), f"{value} is not an asyncio.Queue"
|
||||||
logger.trace(f"sse: adding listener {key} to {self.name}. len = {len(self)+1}")
|
logger.trace(f"sse: adding listener {key} to {self.name}. len = {len(self)+1}")
|
||||||
return super().__setitem__(key, value)
|
return super().__setitem__(key, value)
|
||||||
|
|
||||||
@@ -67,8 +87,8 @@ invoice_listeners: Dict[str, asyncio.Queue] = SseListenersDict("invoice_listener
|
|||||||
|
|
||||||
def register_invoice_listener(send_chan: asyncio.Queue, name: Optional[str] = None):
|
def register_invoice_listener(send_chan: asyncio.Queue, name: Optional[str] = None):
|
||||||
"""
|
"""
|
||||||
A method intended for extensions (and core/tasks.py) to call when they want to be notified about
|
A method intended for extensions (and core/tasks.py) to call when they want to be
|
||||||
new invoice payments incoming. Will emit all incoming payments.
|
notified about new invoice payments incoming. Will emit all incoming payments.
|
||||||
"""
|
"""
|
||||||
name_unique = f"{name or 'no_name'}_{str(uuid.uuid4())[:8]}"
|
name_unique = f"{name or 'no_name'}_{str(uuid.uuid4())[:8]}"
|
||||||
logger.trace(f"sse: registering invoice listener {name_unique}")
|
logger.trace(f"sse: registering invoice listener {name_unique}")
|
||||||
@@ -127,9 +147,10 @@ async def check_pending_payments():
|
|||||||
while True:
|
while True:
|
||||||
async with db.connect() as conn:
|
async with db.connect() as conn:
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Task: checking all pending payments (incoming={incoming}, outgoing={outgoing}) of last 15 days"
|
f"Task: checking all pending payments (incoming={incoming},"
|
||||||
|
f" outgoing={outgoing}) of last 15 days"
|
||||||
)
|
)
|
||||||
start_time: float = time.time()
|
start_time = time.time()
|
||||||
pending_payments = await get_payments(
|
pending_payments = await get_payments(
|
||||||
since=(int(time.time()) - 60 * 60 * 24 * 15), # 15 days ago
|
since=(int(time.time()) - 60 * 60 * 24 * 15), # 15 days ago
|
||||||
complete=False,
|
complete=False,
|
||||||
@@ -143,15 +164,17 @@ async def check_pending_payments():
|
|||||||
await payment.check_status(conn=conn)
|
await payment.check_status(conn=conn)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Task: pending check finished for {len(pending_payments)} payments (took {time.time() - start_time:0.3f} s)"
|
f"Task: pending check finished for {len(pending_payments)} payments"
|
||||||
|
f" (took {time.time() - start_time:0.3f} s)"
|
||||||
)
|
)
|
||||||
# we delete expired invoices once upon the first pending check
|
# we delete expired invoices once upon the first pending check
|
||||||
if incoming:
|
if incoming:
|
||||||
logger.debug("Task: deleting all expired invoices")
|
logger.debug("Task: deleting all expired invoices")
|
||||||
start_time: float = time.time()
|
start_time = time.time()
|
||||||
await delete_expired_invoices(conn=conn)
|
await delete_expired_invoices(conn=conn)
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Task: expired invoice deletion finished (took {time.time() - start_time:0.3f} s)"
|
"Task: expired invoice deletion finished (took"
|
||||||
|
f" {time.time() - start_time:0.3f} s)"
|
||||||
)
|
)
|
||||||
|
|
||||||
# after the first check we will only check outgoing, not incoming
|
# after the first check we will only check outgoing, not incoming
|
||||||
|
|||||||
@@ -82,6 +82,7 @@
|
|||||||
<q-item
|
<q-item
|
||||||
clickable
|
clickable
|
||||||
v-close-popup
|
v-close-popup
|
||||||
|
:active="activeLanguage(lang.value)"
|
||||||
@click="changeLanguage(lang.value)"
|
@click="changeLanguage(lang.value)"
|
||||||
><q-item-section>
|
><q-item-section>
|
||||||
{% raw %}
|
{% raw %}
|
||||||
@@ -238,7 +239,7 @@
|
|||||||
class="bg-transparent q-px-lg q-py-md"
|
class="bg-transparent q-px-lg q-py-md"
|
||||||
:class="{'text-dark': !$q.dark.isActive}"
|
:class="{'text-dark': !$q.dark.isActive}"
|
||||||
>
|
>
|
||||||
<q-space class="q-py-lg lt-sm"></q-space>
|
<q-space class="q-py-lg lt-md"></q-space>
|
||||||
<q-toolbar class="gt-sm">
|
<q-toolbar class="gt-sm">
|
||||||
<q-toolbar-title class="text-caption">
|
<q-toolbar-title class="text-caption">
|
||||||
{{ SITE_TITLE }}, {{SITE_TAGLINE}}
|
{{ SITE_TITLE }}, {{SITE_TAGLINE}}
|
||||||
@@ -294,6 +295,7 @@
|
|||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
const themes = {{ LNBITS_THEME_OPTIONS | tojson }}
|
const themes = {{ LNBITS_THEME_OPTIONS | tojson }}
|
||||||
const LNBITS_DENOMINATION = {{ LNBITS_DENOMINATION | tojson }}
|
const LNBITS_DENOMINATION = {{ LNBITS_DENOMINATION | tojson }}
|
||||||
|
const LNBITS_VERSION = {{ LNBITS_VERSION | tojson }}
|
||||||
if (themes && themes.length) {
|
if (themes && themes.length) {
|
||||||
window.allowedThemes = themes.map(str => str.trim())
|
window.allowedThemes = themes.map(str => str.trim())
|
||||||
}
|
}
|
||||||
@@ -302,6 +304,7 @@
|
|||||||
{value: 'de', label: 'Deutsch', display: 'DE'},
|
{value: 'de', label: 'Deutsch', display: 'DE'},
|
||||||
{value: 'es', label: 'Español', display: 'ES'},
|
{value: 'es', label: 'Español', display: 'ES'},
|
||||||
{value: 'jp', label: '日本語', display: 'JP'},
|
{value: 'jp', label: '日本語', display: 'JP'},
|
||||||
|
{value: 'cn', label: '中文', display: 'CN'},
|
||||||
{value: 'fr', label: 'Français', display: 'FR'},
|
{value: 'fr', label: 'Français', display: 'FR'},
|
||||||
{value: 'it', label: 'Italiano', display: 'IT'},
|
{value: 'it', label: 'Italiano', display: 'IT'},
|
||||||
{value: 'pi', label: 'Pirate', display: 'PI'},
|
{value: 'pi', label: 'Pirate', display: 'PI'},
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from time import time
|
||||||
|
from typing import Any, NamedTuple, Optional
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
|
||||||
|
class Cached(NamedTuple):
|
||||||
|
value: Any
|
||||||
|
expiry: float
|
||||||
|
|
||||||
|
|
||||||
|
class Cache:
|
||||||
|
"""
|
||||||
|
Small caching utility providing simple get/set interface (very much like redis)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, interval: float = 10) -> None:
|
||||||
|
self.interval = interval
|
||||||
|
self._values: dict[Any, Cached] = {}
|
||||||
|
|
||||||
|
def get(self, key: str, default=None) -> Optional[Any]:
|
||||||
|
cached = self._values.get(key)
|
||||||
|
if cached is not None:
|
||||||
|
if cached.expiry > time():
|
||||||
|
return cached.value
|
||||||
|
else:
|
||||||
|
self._values.pop(key)
|
||||||
|
return default
|
||||||
|
|
||||||
|
def set(self, key: str, value: Any, expiry: float = 10):
|
||||||
|
self._values[key] = Cached(value, time() + expiry)
|
||||||
|
|
||||||
|
def pop(self, key: str, default=None) -> Optional[Any]:
|
||||||
|
cached = self._values.pop(key, None)
|
||||||
|
if cached and cached.expiry > time():
|
||||||
|
return cached.value
|
||||||
|
return default
|
||||||
|
|
||||||
|
async def save_result(self, coro, key: str, expiry: float = 10):
|
||||||
|
"""
|
||||||
|
If `key` exists, return its value, otherwise call coro and cache its result
|
||||||
|
"""
|
||||||
|
cached = self.get(key)
|
||||||
|
if cached:
|
||||||
|
return cached
|
||||||
|
else:
|
||||||
|
value = await coro()
|
||||||
|
self.set(key, value, expiry=expiry)
|
||||||
|
return value
|
||||||
|
|
||||||
|
async def invalidate_forever(self):
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
await asyncio.sleep(self.interval)
|
||||||
|
ts = time()
|
||||||
|
expired = [k for k, v in self._values.items() if v.expiry < ts]
|
||||||
|
for k in expired:
|
||||||
|
self._values.pop(k)
|
||||||
|
except Exception:
|
||||||
|
logger.error("Error invalidating cache")
|
||||||
|
|
||||||
|
|
||||||
|
cache = Cache()
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
from typing import Callable, List, NamedTuple
|
from typing import Callable, NamedTuple
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
|
from lnbits.utils.cache import cache
|
||||||
|
|
||||||
currencies = {
|
currencies = {
|
||||||
"AED": "United Arab Emirates Dirham",
|
"AED": "United Arab Emirates Dirham",
|
||||||
"AFN": "Afghan Afghani",
|
"AFN": "Afghan Afghani",
|
||||||
@@ -181,6 +183,19 @@ class Provider(NamedTuple):
|
|||||||
|
|
||||||
|
|
||||||
exchange_rate_providers = {
|
exchange_rate_providers = {
|
||||||
|
# https://binance-docs.github.io/apidocs/spot/en/#symbol-price-ticker
|
||||||
|
"binance": Provider(
|
||||||
|
"Binance",
|
||||||
|
"binance.com",
|
||||||
|
"https://api.binance.com/api/v3/ticker/price?symbol={FROM}{TO}",
|
||||||
|
lambda data, replacements: data["price"],
|
||||||
|
),
|
||||||
|
"blockchain": Provider(
|
||||||
|
"Blockchain",
|
||||||
|
"blockchain.com",
|
||||||
|
"https://blockchain.info/tobtc?currency={TO}&value=1",
|
||||||
|
lambda data, replacements: 1 / data,
|
||||||
|
),
|
||||||
"exir": Provider(
|
"exir": Provider(
|
||||||
"Exir",
|
"Exir",
|
||||||
"exir.io",
|
"exir.io",
|
||||||
@@ -227,28 +242,6 @@ async def btc_price(currency: str) -> float:
|
|||||||
"TO": currency.upper(),
|
"TO": currency.upper(),
|
||||||
"to": currency.lower(),
|
"to": currency.lower(),
|
||||||
}
|
}
|
||||||
rates: List[float] = []
|
|
||||||
tasks: List[asyncio.Task] = []
|
|
||||||
|
|
||||||
send_channel: asyncio.Queue = asyncio.Queue()
|
|
||||||
|
|
||||||
async def controller():
|
|
||||||
failures = 0
|
|
||||||
while True:
|
|
||||||
rate = await send_channel.get()
|
|
||||||
if rate:
|
|
||||||
rates.append(rate)
|
|
||||||
else:
|
|
||||||
failures += 1
|
|
||||||
|
|
||||||
if len(rates) >= 2 or len(rates) == 1 and failures >= 2:
|
|
||||||
for t in tasks:
|
|
||||||
t.cancel()
|
|
||||||
break
|
|
||||||
if failures == len(exchange_rate_providers):
|
|
||||||
for t in tasks:
|
|
||||||
t.cancel()
|
|
||||||
break
|
|
||||||
|
|
||||||
async def fetch_price(provider: Provider):
|
async def fetch_price(provider: Provider):
|
||||||
url = provider.api_url.format(**replacements)
|
url = provider.api_url.format(**replacements)
|
||||||
@@ -257,37 +250,33 @@ async def btc_price(currency: str) -> float:
|
|||||||
r = await client.get(url, timeout=0.5)
|
r = await client.get(url, timeout=0.5)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
data = r.json()
|
data = r.json()
|
||||||
rate = float(provider.getter(data, replacements))
|
return float(provider.getter(data, replacements))
|
||||||
await send_channel.put(rate)
|
except Exception as e:
|
||||||
except (
|
logger.warning(
|
||||||
TypeError, # CoinMate returns HTTPStatus 200 but no data when a currency pair is not found
|
f"Failed to fetch Bitcoin price "
|
||||||
KeyError, # Kraken's response dictionary doesn't include keys we look up for
|
f"for {currency} from {provider.name}: {e}"
|
||||||
httpx.ConnectTimeout,
|
)
|
||||||
httpx.ConnectError,
|
raise
|
||||||
httpx.ReadTimeout,
|
|
||||||
httpx.HTTPStatusError, # Some providers throw a 404 when a currency pair is not found
|
|
||||||
):
|
|
||||||
await send_channel.put(None)
|
|
||||||
|
|
||||||
asyncio.create_task(controller())
|
results = await asyncio.gather(
|
||||||
for _, provider in exchange_rate_providers.items():
|
*[fetch_price(provider) for provider in exchange_rate_providers.values()],
|
||||||
tasks.append(asyncio.create_task(fetch_price(provider)))
|
return_exceptions=True,
|
||||||
|
)
|
||||||
try:
|
rates = [r for r in results if not isinstance(r, Exception)]
|
||||||
await asyncio.gather(*tasks)
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if not rates:
|
if not rates:
|
||||||
return 9999999999
|
return 9999999999
|
||||||
elif len(rates) == 1:
|
elif len(rates) == 1:
|
||||||
logger.warning("Could only fetch one Bitcoin price.")
|
logger.warning("Could only fetch one Bitcoin price.")
|
||||||
|
|
||||||
return sum([rate for rate in rates]) / len(rates)
|
return sum(rates) / len(rates)
|
||||||
|
|
||||||
|
|
||||||
async def get_fiat_rate_satoshis(currency: str) -> float:
|
async def get_fiat_rate_satoshis(currency: str) -> float:
|
||||||
return float(100_000_000 / (await btc_price(currency)))
|
price = await cache.save_result(
|
||||||
|
lambda: btc_price(currency), f"btc-price-{currency}"
|
||||||
|
)
|
||||||
|
return float(100_000_000 / price)
|
||||||
|
|
||||||
|
|
||||||
async def fiat_amount_as_satoshis(amount: float, currency: str) -> int:
|
async def fiat_amount_as_satoshis(amount: float, currency: str) -> int:
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
# flake8: noqa: F401
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from lnbits.settings import settings
|
||||||
|
from lnbits.wallets.base import Wallet
|
||||||
|
|
||||||
from .cliche import ClicheWallet
|
from .cliche import ClicheWallet
|
||||||
from .cln import CoreLightningWallet
|
from .corelightning import CoreLightningWallet
|
||||||
from .cln import CoreLightningWallet as CLightningWallet
|
from .corelightning import CoreLightningWallet as CLightningWallet
|
||||||
|
from .corelightningrest import CoreLightningRestWallet
|
||||||
from .eclair import EclairWallet
|
from .eclair import EclairWallet
|
||||||
from .fake import FakeWallet
|
from .fake import FakeWallet
|
||||||
from .lnbits import LNbitsWallet
|
from .lnbits import LNbitsWallet
|
||||||
@@ -12,3 +20,21 @@ from .lntips import LnTipsWallet
|
|||||||
from .opennode import OpenNodeWallet
|
from .opennode import OpenNodeWallet
|
||||||
from .spark import SparkWallet
|
from .spark import SparkWallet
|
||||||
from .void import VoidWallet
|
from .void import VoidWallet
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
||||||
|
return WALLET
|
||||||
|
|
||||||
|
|
||||||
|
wallets_module = importlib.import_module("lnbits.wallets")
|
||||||
|
FAKE_WALLET: Wallet = FakeWallet()
|
||||||
|
|
||||||
|
# initialize as fake wallet
|
||||||
|
WALLET: Wallet = FAKE_WALLET
|
||||||
|
|||||||
@@ -48,6 +48,9 @@ class PaymentStatus(NamedTuple):
|
|||||||
|
|
||||||
|
|
||||||
class Wallet(ABC):
|
class Wallet(ABC):
|
||||||
|
async def cleanup(self):
|
||||||
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def status(self) -> Coroutine[None, None, StatusResponse]:
|
def status(self) -> Coroutine[None, None, StatusResponse]:
|
||||||
pass
|
pass
|
||||||
@@ -58,6 +61,7 @@ class Wallet(ABC):
|
|||||||
amount: int,
|
amount: int,
|
||||||
memo: Optional[str] = None,
|
memo: Optional[str] = None,
|
||||||
description_hash: Optional[bytes] = None,
|
description_hash: Optional[bytes] = None,
|
||||||
|
**kwargs,
|
||||||
) -> Coroutine[None, None, InvoiceResponse]:
|
) -> Coroutine[None, None, InvoiceResponse]:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ class ClicheWallet(Wallet):
|
|||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
data = json.loads(r)
|
data = json.loads(r)
|
||||||
except:
|
except Exception:
|
||||||
return StatusResponse(
|
return StatusResponse(
|
||||||
f"Failed to connect to {self.endpoint}, got: '{r[:200]}...'", 0
|
f"Failed to connect to {self.endpoint}, got: '{r[:200]}...'", 0
|
||||||
)
|
)
|
||||||
@@ -55,13 +55,16 @@ class ClicheWallet(Wallet):
|
|||||||
description_hash_str = (
|
description_hash_str = (
|
||||||
description_hash.hex()
|
description_hash.hex()
|
||||||
if description_hash
|
if description_hash
|
||||||
else hashlib.sha256(unhashed_description).hexdigest()
|
else (
|
||||||
if unhashed_description
|
hashlib.sha256(unhashed_description).hexdigest()
|
||||||
else None
|
if unhashed_description
|
||||||
|
else None
|
||||||
|
)
|
||||||
)
|
)
|
||||||
ws = create_connection(self.endpoint)
|
ws = create_connection(self.endpoint)
|
||||||
ws.send(
|
ws.send(
|
||||||
f"create-invoice --msatoshi {amount*1000} --description_hash {description_hash_str}"
|
f"create-invoice --msatoshi {amount*1000} --description_hash"
|
||||||
|
f" {description_hash_str}"
|
||||||
)
|
)
|
||||||
r = ws.recv()
|
r = ws.recv()
|
||||||
else:
|
else:
|
||||||
@@ -168,11 +171,12 @@ class ClicheWallet(Wallet):
|
|||||||
try:
|
try:
|
||||||
if data["result"]["status"]:
|
if data["result"]["status"]:
|
||||||
yield data["result"]["payment_hash"]
|
yield data["result"]["payment_hash"]
|
||||||
except:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error(
|
logger.error(
|
||||||
f"lost connection to cliche's invoices stream: '{exc}', retrying in 5 seconds"
|
f"lost connection to cliche's invoices stream: '{exc}', retrying in"
|
||||||
|
" 5 seconds"
|
||||||
)
|
)
|
||||||
await asyncio.sleep(5)
|
await asyncio.sleep(5)
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -1,14 +1,9 @@
|
|||||||
try:
|
|
||||||
from pyln.client import LightningRpc, RpcError # type: ignore
|
|
||||||
except ImportError: # pragma: nocover
|
|
||||||
LightningRpc = None
|
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import random
|
import random
|
||||||
from functools import partial, wraps
|
from typing import Any, AsyncGenerator, Optional
|
||||||
from typing import AsyncGenerator, Optional
|
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
from pyln.client import LightningRpc, RpcError
|
||||||
|
|
||||||
from lnbits import bolt11 as lnbits_bolt11
|
from lnbits import bolt11 as lnbits_bolt11
|
||||||
from lnbits.settings import settings
|
from lnbits.settings import settings
|
||||||
@@ -23,43 +18,23 @@ from .base import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def async_wrap(func):
|
async def run_sync(func) -> Any:
|
||||||
@wraps(func)
|
loop = asyncio.get_event_loop()
|
||||||
async def run(*args, loop=None, executor=None, **kwargs):
|
return await loop.run_in_executor(None, func)
|
||||||
if loop is None:
|
|
||||||
loop = asyncio.get_event_loop()
|
|
||||||
partial_func = partial(func, *args, **kwargs)
|
|
||||||
return await loop.run_in_executor(executor, partial_func)
|
|
||||||
|
|
||||||
return run
|
|
||||||
|
|
||||||
|
|
||||||
def _pay_invoice(ln, payload):
|
|
||||||
return ln.call("pay", payload)
|
|
||||||
|
|
||||||
|
|
||||||
def _paid_invoices_stream(ln, last_pay_index):
|
|
||||||
return ln.waitanyinvoice(last_pay_index)
|
|
||||||
|
|
||||||
|
|
||||||
class CoreLightningWallet(Wallet):
|
class CoreLightningWallet(Wallet):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
if LightningRpc is None: # pragma: nocover
|
|
||||||
raise ImportError(
|
|
||||||
"The `pyln-client` library must be installed to use `CoreLightningWallet`."
|
|
||||||
)
|
|
||||||
|
|
||||||
self.rpc = settings.corelightning_rpc or settings.clightning_rpc
|
self.rpc = settings.corelightning_rpc or settings.clightning_rpc
|
||||||
self.ln = LightningRpc(self.rpc)
|
self.ln = LightningRpc(self.rpc)
|
||||||
|
|
||||||
# check if description_hash is supported (from CLN>=v0.11.0)
|
# check if description_hash is supported (from corelightning>=v0.11.0)
|
||||||
self.supports_description_hash = (
|
command = self.ln.help("invoice")["help"][0]["command"] # type: ignore
|
||||||
"deschashonly" in self.ln.help("invoice")["help"][0]["command"]
|
self.supports_description_hash = "deschashonly" in command
|
||||||
)
|
|
||||||
|
|
||||||
# check last payindex so we can listen from that point on
|
# check last payindex so we can listen from that point on
|
||||||
self.last_pay_index = 0
|
self.last_pay_index = 0
|
||||||
invoices = self.ln.listinvoices()
|
invoices: dict = self.ln.listinvoices() # type: ignore
|
||||||
for inv in invoices["invoices"][::-1]:
|
for inv in invoices["invoices"][::-1]:
|
||||||
if "pay_index" in inv:
|
if "pay_index" in inv:
|
||||||
self.last_pay_index = inv["pay_index"]
|
self.last_pay_index = inv["pay_index"]
|
||||||
@@ -67,7 +42,7 @@ class CoreLightningWallet(Wallet):
|
|||||||
|
|
||||||
async def status(self) -> StatusResponse:
|
async def status(self) -> StatusResponse:
|
||||||
try:
|
try:
|
||||||
funds = self.ln.listfunds()
|
funds: dict = self.ln.listfunds() # type: ignore
|
||||||
return StatusResponse(
|
return StatusResponse(
|
||||||
None, sum([int(ch["our_amount_msat"]) for ch in funds["channels"]])
|
None, sum([int(ch["our_amount_msat"]) for ch in funds["channels"]])
|
||||||
)
|
)
|
||||||
@@ -88,29 +63,33 @@ class CoreLightningWallet(Wallet):
|
|||||||
try:
|
try:
|
||||||
if description_hash and not unhashed_description:
|
if description_hash and not unhashed_description:
|
||||||
raise Unsupported(
|
raise Unsupported(
|
||||||
"'description_hash' unsupported by CLN, provide 'unhashed_description'"
|
"'description_hash' unsupported by CoreLightning, provide"
|
||||||
|
" 'unhashed_description'"
|
||||||
)
|
)
|
||||||
if unhashed_description and not self.supports_description_hash:
|
if unhashed_description and not self.supports_description_hash:
|
||||||
raise Unsupported("unhashed_description")
|
raise Unsupported("unhashed_description")
|
||||||
r = self.ln.invoice(
|
r: dict = self.ln.invoice( # type: ignore
|
||||||
msatoshi=msat,
|
msatoshi=msat,
|
||||||
label=label,
|
label=label,
|
||||||
description=unhashed_description.decode()
|
description=(
|
||||||
if unhashed_description
|
unhashed_description.decode() if unhashed_description else memo
|
||||||
else memo,
|
),
|
||||||
exposeprivatechannels=True,
|
exposeprivatechannels=True,
|
||||||
deschashonly=True
|
deschashonly=(
|
||||||
if unhashed_description
|
True if unhashed_description else False
|
||||||
else False, # we can't pass None here
|
), # we can't pass None here
|
||||||
expiry=kwargs.get("expiry"),
|
expiry=kwargs.get("expiry"),
|
||||||
)
|
)
|
||||||
|
|
||||||
if r.get("code") and r.get("code") < 0:
|
if r.get("code") and r.get("code") < 0: # type: ignore
|
||||||
raise Exception(r.get("message"))
|
raise Exception(r.get("message"))
|
||||||
|
|
||||||
return InvoiceResponse(True, r["payment_hash"], r["bolt11"], "")
|
return InvoiceResponse(True, r["payment_hash"], r["bolt11"], "")
|
||||||
except RpcError as exc:
|
except RpcError as exc:
|
||||||
error_message = f"CLN method '{exc.method}' failed with '{exc.error.get('message') or exc.error}'."
|
error_message = (
|
||||||
|
f"CoreLightning method '{exc.method}' failed with"
|
||||||
|
f" '{exc.error.get('message') or exc.error}'." # type: ignore
|
||||||
|
)
|
||||||
return InvoiceResponse(False, None, None, error_message)
|
return InvoiceResponse(False, None, None, error_message)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return InvoiceResponse(False, None, None, str(e))
|
return InvoiceResponse(False, None, None, str(e))
|
||||||
@@ -123,20 +102,23 @@ class CoreLightningWallet(Wallet):
|
|||||||
return PaymentResponse(False, None, None, None, "invoice already paid")
|
return PaymentResponse(False, None, None, None, "invoice already paid")
|
||||||
|
|
||||||
fee_limit_percent = fee_limit_msat / invoice.amount_msat * 100
|
fee_limit_percent = fee_limit_msat / invoice.amount_msat * 100
|
||||||
|
# so fee_limit_percent is applied even on payments with fee < 5000 millisatoshi
|
||||||
|
# (which is default value of exemptfee)
|
||||||
payload = {
|
payload = {
|
||||||
"bolt11": bolt11,
|
"bolt11": bolt11,
|
||||||
"maxfeepercent": f"{fee_limit_percent:.11}",
|
"maxfeepercent": f"{fee_limit_percent:.11}",
|
||||||
"exemptfee": 0, # so fee_limit_percent is applied even on payments with fee < 5000 millisatoshi (which is default value of exemptfee)
|
"exemptfee": 0,
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
wrapped = async_wrap(_pay_invoice)
|
r = await run_sync(lambda: self.ln.call("pay", payload))
|
||||||
r = await wrapped(self.ln, payload)
|
|
||||||
except RpcError as exc:
|
except RpcError as exc:
|
||||||
try:
|
try:
|
||||||
error_message = exc.error["attempts"][-1]["fail_reason"]
|
error_message = exc.error["attempts"][-1]["fail_reason"] # type: ignore
|
||||||
except:
|
except Exception:
|
||||||
error_message = f"CLN method '{exc.method}' failed with '{exc.error.get('message') or exc.error}'."
|
error_message = (
|
||||||
|
f"CoreLightning method '{exc.method}' failed with"
|
||||||
|
f" '{exc.error.get('message') or exc.error}'." # type: ignore
|
||||||
|
)
|
||||||
return PaymentResponse(False, None, None, None, error_message)
|
return PaymentResponse(False, None, None, None, error_message)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return PaymentResponse(False, None, None, None, str(exc))
|
return PaymentResponse(False, None, None, None, str(exc))
|
||||||
@@ -148,8 +130,8 @@ class CoreLightningWallet(Wallet):
|
|||||||
|
|
||||||
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
|
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
|
||||||
try:
|
try:
|
||||||
r = self.ln.listinvoices(payment_hash=checking_id)
|
r: dict = self.ln.listinvoices(payment_hash=checking_id) # type: ignore
|
||||||
except:
|
except RpcError:
|
||||||
return PaymentStatus(None)
|
return PaymentStatus(None)
|
||||||
if not r["invoices"]:
|
if not r["invoices"]:
|
||||||
return PaymentStatus(None)
|
return PaymentStatus(None)
|
||||||
@@ -161,15 +143,18 @@ class CoreLightningWallet(Wallet):
|
|||||||
return PaymentStatus(True)
|
return PaymentStatus(True)
|
||||||
elif invoice_resp["status"] == "unpaid":
|
elif invoice_resp["status"] == "unpaid":
|
||||||
return PaymentStatus(None)
|
return PaymentStatus(None)
|
||||||
logger.warning(f"supplied an invalid checking_id: {checking_id}")
|
elif invoice_resp["status"] == "expired":
|
||||||
|
return PaymentStatus(False)
|
||||||
|
else:
|
||||||
|
logger.warning(f"supplied an invalid checking_id: {checking_id}")
|
||||||
return PaymentStatus(None)
|
return PaymentStatus(None)
|
||||||
|
|
||||||
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
|
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
|
||||||
try:
|
try:
|
||||||
r = self.ln.call("listpays", {"payment_hash": checking_id})
|
r: dict = self.ln.listpays(payment_hash=checking_id) # type: ignore
|
||||||
except:
|
except Exception:
|
||||||
return PaymentStatus(None)
|
return PaymentStatus(None)
|
||||||
if not r["pays"]:
|
if "pays" not in r or not r["pays"]:
|
||||||
return PaymentStatus(None)
|
return PaymentStatus(None)
|
||||||
payment_resp = r["pays"][-1]
|
payment_resp = r["pays"][-1]
|
||||||
|
|
||||||
@@ -183,19 +168,27 @@ class CoreLightningWallet(Wallet):
|
|||||||
return PaymentStatus(True, fee_msat, payment_resp["preimage"])
|
return PaymentStatus(True, fee_msat, payment_resp["preimage"])
|
||||||
elif status == "failed":
|
elif status == "failed":
|
||||||
return PaymentStatus(False)
|
return PaymentStatus(False)
|
||||||
return PaymentStatus(None)
|
else:
|
||||||
logger.warning(f"supplied an invalid checking_id: {checking_id}")
|
return PaymentStatus(None)
|
||||||
|
else:
|
||||||
|
logger.warning(f"supplied an invalid checking_id: {checking_id}")
|
||||||
return PaymentStatus(None)
|
return PaymentStatus(None)
|
||||||
|
|
||||||
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
|
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
wrapped = async_wrap(_paid_invoices_stream)
|
paid = await run_sync(
|
||||||
paid = await wrapped(self.ln, self.last_pay_index)
|
lambda: self.ln.waitanyinvoice(self.last_pay_index, timeout=2)
|
||||||
|
)
|
||||||
self.last_pay_index = paid["pay_index"]
|
self.last_pay_index = paid["pay_index"]
|
||||||
yield paid["payment_hash"]
|
yield paid["payment_hash"]
|
||||||
|
except RpcError as exc:
|
||||||
|
# only raise if not a timeout
|
||||||
|
if exc.error["code"] != 904: # type: ignore
|
||||||
|
raise
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error(
|
logger.error(
|
||||||
f"lost connection to cln invoices stream: '{exc}', retrying in 5 seconds"
|
f"lost connection to corelightning invoices stream: '{exc}', "
|
||||||
|
"retrying in 5 seconds"
|
||||||
)
|
)
|
||||||
await asyncio.sleep(5)
|
await asyncio.sleep(5)
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import random
|
||||||
|
from typing import AsyncGenerator, Dict, Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from lnbits import bolt11 as lnbits_bolt11
|
||||||
|
from lnbits.settings import settings
|
||||||
|
|
||||||
|
from .base import (
|
||||||
|
InvoiceResponse,
|
||||||
|
PaymentResponse,
|
||||||
|
PaymentStatus,
|
||||||
|
StatusResponse,
|
||||||
|
Unsupported,
|
||||||
|
Wallet,
|
||||||
|
)
|
||||||
|
from .macaroon import load_macaroon
|
||||||
|
|
||||||
|
|
||||||
|
class CoreLightningRestWallet(Wallet):
|
||||||
|
def __init__(self):
|
||||||
|
macaroon = settings.corelightning_rest_macaroon
|
||||||
|
assert macaroon, "missing cln-rest macaroon"
|
||||||
|
|
||||||
|
self.macaroon = load_macaroon(macaroon)
|
||||||
|
|
||||||
|
url = settings.corelightning_rest_url
|
||||||
|
if not url:
|
||||||
|
raise Exception("missing url for corelightning-rest")
|
||||||
|
if not macaroon:
|
||||||
|
raise Exception("missing macaroon for corelightning-rest")
|
||||||
|
|
||||||
|
self.url = url[:-1] if url.endswith("/") else url
|
||||||
|
self.url = (
|
||||||
|
f"https://{self.url}" if not self.url.startswith("http") else self.url
|
||||||
|
)
|
||||||
|
self.auth = {
|
||||||
|
"macaroon": self.macaroon,
|
||||||
|
"encodingtype": "hex",
|
||||||
|
"accept": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
self.cert = settings.corelightning_rest_cert or False
|
||||||
|
self.client = httpx.AsyncClient(verify=self.cert, headers=self.auth)
|
||||||
|
self.last_pay_index = 0
|
||||||
|
self.statuses = {
|
||||||
|
"paid": True,
|
||||||
|
"complete": True,
|
||||||
|
"failed": False,
|
||||||
|
"pending": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def cleanup(self):
|
||||||
|
try:
|
||||||
|
await self.client.aclose()
|
||||||
|
except RuntimeError as e:
|
||||||
|
logger.warning(f"Error closing wallet connection: {e}")
|
||||||
|
|
||||||
|
async def status(self) -> StatusResponse:
|
||||||
|
r = await self.client.get(f"{self.url}/v1/channel/localremotebal", timeout=5)
|
||||||
|
r.raise_for_status()
|
||||||
|
if r.is_error or "error" in r.json():
|
||||||
|
try:
|
||||||
|
data = r.json()
|
||||||
|
error_message = data["error"]
|
||||||
|
except Exception:
|
||||||
|
error_message = r.text
|
||||||
|
return StatusResponse(
|
||||||
|
f"Failed to connect to {self.url}, got: '{error_message}...'", 0
|
||||||
|
)
|
||||||
|
|
||||||
|
data = r.json()
|
||||||
|
if len(data) == 0:
|
||||||
|
return StatusResponse("no data", 0)
|
||||||
|
|
||||||
|
return StatusResponse(None, int(data.get("localBalance") * 1000))
|
||||||
|
|
||||||
|
async def create_invoice(
|
||||||
|
self,
|
||||||
|
amount: int,
|
||||||
|
memo: Optional[str] = None,
|
||||||
|
description_hash: Optional[bytes] = None,
|
||||||
|
unhashed_description: Optional[bytes] = None,
|
||||||
|
**kwargs,
|
||||||
|
) -> InvoiceResponse:
|
||||||
|
label = f"lbl{random.random()}"
|
||||||
|
data: Dict = {
|
||||||
|
"amount": amount * 1000,
|
||||||
|
"description": memo,
|
||||||
|
"label": label,
|
||||||
|
}
|
||||||
|
if description_hash and not unhashed_description:
|
||||||
|
raise Unsupported(
|
||||||
|
"'description_hash' unsupported by CoreLightningRest, "
|
||||||
|
"provide 'unhashed_description'"
|
||||||
|
)
|
||||||
|
|
||||||
|
if unhashed_description:
|
||||||
|
data["description"] = unhashed_description.decode("utf-8")
|
||||||
|
|
||||||
|
if kwargs.get("expiry"):
|
||||||
|
data["expiry"] = kwargs["expiry"]
|
||||||
|
|
||||||
|
if kwargs.get("preimage"):
|
||||||
|
data["preimage"] = kwargs["preimage"]
|
||||||
|
|
||||||
|
r = await self.client.post(
|
||||||
|
f"{self.url}/v1/invoice/genInvoice",
|
||||||
|
data=data,
|
||||||
|
)
|
||||||
|
|
||||||
|
if r.is_error or "error" in r.json():
|
||||||
|
try:
|
||||||
|
data = r.json()
|
||||||
|
error_message = data["error"]
|
||||||
|
except Exception:
|
||||||
|
error_message = r.text
|
||||||
|
|
||||||
|
return InvoiceResponse(False, None, None, error_message)
|
||||||
|
|
||||||
|
data = r.json()
|
||||||
|
assert "payment_hash" in data
|
||||||
|
assert "bolt11" in data
|
||||||
|
# NOTE: use payment_hash when corelightning-rest updates and supports it
|
||||||
|
# return InvoiceResponse(True, data["payment_hash"], data["bolt11"], None)
|
||||||
|
return InvoiceResponse(True, label, data["bolt11"], None)
|
||||||
|
|
||||||
|
async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
|
||||||
|
invoice = lnbits_bolt11.decode(bolt11)
|
||||||
|
fee_limit_percent = fee_limit_msat / invoice.amount_msat * 100
|
||||||
|
r = await self.client.post(
|
||||||
|
f"{self.url}/v1/pay",
|
||||||
|
data={
|
||||||
|
"invoice": bolt11,
|
||||||
|
"maxfeepercent": f"{fee_limit_percent:.11}",
|
||||||
|
"exemptfee": 0, # so fee_limit_percent is applied even on payments
|
||||||
|
# with fee < 5000 millisatoshi (which is default value of exemptfee)
|
||||||
|
},
|
||||||
|
timeout=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
if r.is_error or "error" in r.json():
|
||||||
|
try:
|
||||||
|
data = r.json()
|
||||||
|
error_message = data["error"]
|
||||||
|
except Exception:
|
||||||
|
error_message = r.text
|
||||||
|
return PaymentResponse(False, None, None, None, error_message)
|
||||||
|
|
||||||
|
data = r.json()
|
||||||
|
|
||||||
|
if data["status"] != "complete":
|
||||||
|
return PaymentResponse(False, None, None, None, "payment failed")
|
||||||
|
|
||||||
|
checking_id = data["payment_hash"]
|
||||||
|
preimage = data["payment_preimage"]
|
||||||
|
fee_msat = data["msatoshi_sent"] - data["msatoshi"]
|
||||||
|
|
||||||
|
return PaymentResponse(
|
||||||
|
self.statuses.get(data["status"]), checking_id, fee_msat, preimage, None
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
|
||||||
|
# get invoice bolt11 from checking_id
|
||||||
|
# corelightning-rest wants the "label" here....
|
||||||
|
# NOTE: We can get rid of all labels and use payment_hash when
|
||||||
|
# corelightning-rest updates and supports it
|
||||||
|
r = await self.client.get(
|
||||||
|
f"{self.url}/v1/invoice/listInvoices",
|
||||||
|
params={"label": checking_id},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
r.raise_for_status()
|
||||||
|
data = r.json()
|
||||||
|
|
||||||
|
if r.is_error or "error" in data or data.get("invoices") is None:
|
||||||
|
raise Exception("error in cln response")
|
||||||
|
return PaymentStatus(self.statuses.get(data["invoices"][0]["status"]))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting invoice status: {e}")
|
||||||
|
return PaymentStatus(None)
|
||||||
|
|
||||||
|
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
|
||||||
|
from lnbits.core import get_standalone_payment
|
||||||
|
|
||||||
|
payment = await get_standalone_payment(checking_id)
|
||||||
|
if not payment:
|
||||||
|
raise ValueError(f"Payment with checking_id {checking_id} not found")
|
||||||
|
r = await self.client.get(
|
||||||
|
f"{self.url}/v1/pay/listPays",
|
||||||
|
params={"invoice": payment.bolt11},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
r.raise_for_status()
|
||||||
|
data = r.json()
|
||||||
|
|
||||||
|
if r.is_error or "error" in data or not data.get("pays"):
|
||||||
|
raise Exception("error in corelightning-rest response")
|
||||||
|
|
||||||
|
pay = data["pays"][0]
|
||||||
|
|
||||||
|
fee_msat, preimage = None, None
|
||||||
|
if self.statuses[pay["status"]]:
|
||||||
|
# cut off "msat" and convert to int
|
||||||
|
fee_msat = -int(pay["amount_sent_msat"][:-4]) - int(
|
||||||
|
pay["amount_msat"][:-4]
|
||||||
|
)
|
||||||
|
preimage = pay["preimage"]
|
||||||
|
|
||||||
|
return PaymentStatus(self.statuses.get(pay["status"]), fee_msat, preimage)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting payment status: {e}")
|
||||||
|
return PaymentStatus(None)
|
||||||
|
|
||||||
|
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
url = f"{self.url}/v1/invoice/waitAnyInvoice/{self.last_pay_index}"
|
||||||
|
async with self.client.stream("GET", url, timeout=None) as r:
|
||||||
|
async for line in r.aiter_lines():
|
||||||
|
inv = json.loads(line)
|
||||||
|
if "error" in inv and "message" in inv["error"]:
|
||||||
|
logger.error("Error in paid_invoices_stream:", inv)
|
||||||
|
raise Exception(inv["error"]["message"])
|
||||||
|
try:
|
||||||
|
paid = inv["status"] == "paid"
|
||||||
|
self.last_pay_index = inv["pay_index"]
|
||||||
|
if not paid:
|
||||||
|
continue
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
logger.trace(f"paid invoice: {inv}")
|
||||||
|
yield inv["label"]
|
||||||
|
# NOTE: use payment_hash when corelightning-rest updates
|
||||||
|
# and supports it
|
||||||
|
# payment_hash = inv["payment_hash"]
|
||||||
|
# yield payment_hash
|
||||||
|
# hack to return payment_hash if the above shouldn't work
|
||||||
|
# r = await self.client.get(
|
||||||
|
# f"{self.url}/v1/invoice/listInvoices",
|
||||||
|
# params={"label": inv["label"]},
|
||||||
|
# )
|
||||||
|
# paid_invoce = r.json()
|
||||||
|
# logger.trace(f"paid invoice: {paid_invoce}")
|
||||||
|
# yield paid_invoce["invoices"][0]["payment_hash"]
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug(
|
||||||
|
f"lost connection to corelightning-rest invoices stream: '{exc}', "
|
||||||
|
"reconnecting..."
|
||||||
|
)
|
||||||
|
await asyncio.sleep(0.02)
|
||||||
@@ -3,7 +3,7 @@ import base64
|
|||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
from typing import AsyncGenerator, Dict, Optional
|
from typing import Any, AsyncGenerator, Dict, Optional
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
@@ -41,15 +41,19 @@ class EclairWallet(Wallet):
|
|||||||
encodedAuth = base64.b64encode(f":{passw}".encode())
|
encodedAuth = base64.b64encode(f":{passw}".encode())
|
||||||
auth = str(encodedAuth, "utf-8")
|
auth = str(encodedAuth, "utf-8")
|
||||||
self.auth = {"Authorization": f"Basic {auth}"}
|
self.auth = {"Authorization": f"Basic {auth}"}
|
||||||
|
self.client = httpx.AsyncClient(base_url=self.url, headers=self.auth)
|
||||||
|
|
||||||
|
async def cleanup(self):
|
||||||
|
try:
|
||||||
|
await self.client.aclose()
|
||||||
|
except RuntimeError as e:
|
||||||
|
logger.warning(f"Error closing wallet connection: {e}")
|
||||||
|
|
||||||
async def status(self) -> StatusResponse:
|
async def status(self) -> StatusResponse:
|
||||||
async with httpx.AsyncClient() as client:
|
r = await self.client.post("/globalbalance", timeout=5)
|
||||||
r = await client.post(
|
|
||||||
f"{self.url}/globalbalance", headers=self.auth, timeout=5
|
|
||||||
)
|
|
||||||
try:
|
try:
|
||||||
data = r.json()
|
data = r.json()
|
||||||
except:
|
except Exception:
|
||||||
return StatusResponse(
|
return StatusResponse(
|
||||||
f"Failed to connect to {self.url}, got: '{r.text[:200]}...'", 0
|
f"Failed to connect to {self.url}, got: '{r.text[:200]}...'", 0
|
||||||
)
|
)
|
||||||
@@ -69,30 +73,27 @@ class EclairWallet(Wallet):
|
|||||||
unhashed_description: Optional[bytes] = None,
|
unhashed_description: Optional[bytes] = None,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
) -> InvoiceResponse:
|
) -> InvoiceResponse:
|
||||||
|
data: Dict[str, Any] = {
|
||||||
data: Dict = {
|
|
||||||
"amountMsat": amount * 1000,
|
"amountMsat": amount * 1000,
|
||||||
"description_hash": b"",
|
|
||||||
"description": memo,
|
|
||||||
}
|
}
|
||||||
if kwargs.get("expiry"):
|
if kwargs.get("expiry"):
|
||||||
data["expireIn"] = kwargs["expiry"]
|
data["expireIn"] = kwargs["expiry"]
|
||||||
|
|
||||||
|
# Either 'description' (string) or 'descriptionHash' must be supplied
|
||||||
if description_hash:
|
if description_hash:
|
||||||
data["descriptionHash"] = description_hash.hex()
|
data["descriptionHash"] = description_hash.hex()
|
||||||
elif unhashed_description:
|
elif unhashed_description:
|
||||||
data["descriptionHash"] = hashlib.sha256(unhashed_description).hexdigest()
|
data["descriptionHash"] = hashlib.sha256(unhashed_description).hexdigest()
|
||||||
|
else:
|
||||||
|
data["description"] = memo
|
||||||
|
|
||||||
async with httpx.AsyncClient() as client:
|
r = await self.client.post("/createinvoice", data=data, timeout=40)
|
||||||
r = await client.post(
|
|
||||||
f"{self.url}/createinvoice", headers=self.auth, data=data, timeout=40
|
|
||||||
)
|
|
||||||
|
|
||||||
if r.is_error:
|
if r.is_error:
|
||||||
try:
|
try:
|
||||||
data = r.json()
|
data = r.json()
|
||||||
error_message = data["error"]
|
error_message = data["error"]
|
||||||
except:
|
except Exception:
|
||||||
error_message = r.text
|
error_message = r.text
|
||||||
|
|
||||||
return InvoiceResponse(False, None, None, error_message)
|
return InvoiceResponse(False, None, None, error_message)
|
||||||
@@ -101,19 +102,17 @@ class EclairWallet(Wallet):
|
|||||||
return InvoiceResponse(True, data["paymentHash"], data["serialized"], None)
|
return InvoiceResponse(True, data["paymentHash"], data["serialized"], None)
|
||||||
|
|
||||||
async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
|
async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
|
||||||
async with httpx.AsyncClient() as client:
|
r = await self.client.post(
|
||||||
r = await client.post(
|
"/payinvoice",
|
||||||
f"{self.url}/payinvoice",
|
data={"invoice": bolt11, "blocking": True},
|
||||||
headers=self.auth,
|
timeout=None,
|
||||||
data={"invoice": bolt11, "blocking": True},
|
)
|
||||||
timeout=None,
|
|
||||||
)
|
|
||||||
|
|
||||||
if "error" in r.json():
|
if "error" in r.json():
|
||||||
try:
|
try:
|
||||||
data = r.json()
|
data = r.json()
|
||||||
error_message = data["error"]
|
error_message = data["error"]
|
||||||
except:
|
except Exception:
|
||||||
error_message = r.text
|
error_message = r.text
|
||||||
return PaymentResponse(False, None, None, None, error_message)
|
return PaymentResponse(False, None, None, None, error_message)
|
||||||
|
|
||||||
@@ -127,19 +126,17 @@ class EclairWallet(Wallet):
|
|||||||
|
|
||||||
# We do all this again to get the fee:
|
# We do all this again to get the fee:
|
||||||
|
|
||||||
async with httpx.AsyncClient() as client:
|
r = await self.client.post(
|
||||||
r = await client.post(
|
"/getsentinfo",
|
||||||
f"{self.url}/getsentinfo",
|
data={"paymentHash": checking_id},
|
||||||
headers=self.auth,
|
timeout=40,
|
||||||
data={"paymentHash": checking_id},
|
)
|
||||||
timeout=40,
|
|
||||||
)
|
|
||||||
|
|
||||||
if "error" in r.json():
|
if "error" in r.json():
|
||||||
try:
|
try:
|
||||||
data = r.json()
|
data = r.json()
|
||||||
error_message = data["error"]
|
error_message = data["error"]
|
||||||
except:
|
except Exception:
|
||||||
error_message = r.text
|
error_message = r.text
|
||||||
return PaymentResponse(None, checking_id, None, preimage, error_message)
|
return PaymentResponse(None, checking_id, None, preimage, error_message)
|
||||||
|
|
||||||
@@ -161,12 +158,10 @@ class EclairWallet(Wallet):
|
|||||||
|
|
||||||
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
|
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient() as client:
|
r = await self.client.post(
|
||||||
r = await client.post(
|
"/getreceivedinfo",
|
||||||
f"{self.url}/getreceivedinfo",
|
data={"paymentHash": checking_id},
|
||||||
headers=self.auth,
|
)
|
||||||
data={"paymentHash": checking_id},
|
|
||||||
)
|
|
||||||
|
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
data = r.json()
|
data = r.json()
|
||||||
@@ -180,18 +175,16 @@ class EclairWallet(Wallet):
|
|||||||
"pending": None,
|
"pending": None,
|
||||||
}
|
}
|
||||||
return PaymentStatus(statuses.get(data["status"]["type"]))
|
return PaymentStatus(statuses.get(data["status"]["type"]))
|
||||||
except:
|
except Exception:
|
||||||
return PaymentStatus(None)
|
return PaymentStatus(None)
|
||||||
|
|
||||||
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
|
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient() as client:
|
r = await self.client.post(
|
||||||
r = await client.post(
|
"/getsentinfo",
|
||||||
f"{self.url}/getsentinfo",
|
data={"paymentHash": checking_id},
|
||||||
headers=self.auth,
|
timeout=40,
|
||||||
data={"paymentHash": checking_id},
|
)
|
||||||
timeout=40,
|
|
||||||
)
|
|
||||||
|
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
|
|
||||||
@@ -213,7 +206,7 @@ class EclairWallet(Wallet):
|
|||||||
return PaymentStatus(
|
return PaymentStatus(
|
||||||
statuses.get(data["status"]["type"]), fee_msat, preimage
|
statuses.get(data["status"]["type"]), fee_msat, preimage
|
||||||
)
|
)
|
||||||
except:
|
except Exception:
|
||||||
return PaymentStatus(None)
|
return PaymentStatus(None)
|
||||||
|
|
||||||
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
|
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
|
||||||
@@ -232,6 +225,7 @@ class EclairWallet(Wallet):
|
|||||||
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error(
|
logger.error(
|
||||||
f"lost connection to eclair invoices stream: '{exc}', retrying in 5 seconds"
|
f"lost connection to eclair invoices stream: '{exc}'"
|
||||||
|
"retrying in 5 seconds"
|
||||||
)
|
)
|
||||||
await asyncio.sleep(5)
|
await asyncio.sleep(5)
|
||||||
|
|||||||
@@ -31,7 +31,8 @@ class FakeWallet(Wallet):
|
|||||||
|
|
||||||
async def status(self) -> StatusResponse:
|
async def status(self) -> StatusResponse:
|
||||||
logger.info(
|
logger.info(
|
||||||
"FakeWallet funding source is for using LNbits as a centralised, stand-alone payment system with brrrrrr."
|
"FakeWallet funding source is for using LNbits as a centralised,"
|
||||||
|
" stand-alone payment system with brrrrrr."
|
||||||
)
|
)
|
||||||
return StatusResponse(None, 1000000000)
|
return StatusResponse(None, 1000000000)
|
||||||
|
|
||||||
@@ -61,7 +62,7 @@ class FakeWallet(Wallet):
|
|||||||
data["tags_set"] = ["h"]
|
data["tags_set"] = ["h"]
|
||||||
data["description_hash"] = description_hash
|
data["description_hash"] = description_hash
|
||||||
elif unhashed_description:
|
elif unhashed_description:
|
||||||
data["tags_set"] = ["d"]
|
data["tags_set"] = ["h"]
|
||||||
data["description_hash"] = hashlib.sha256(unhashed_description).digest()
|
data["description_hash"] = hashlib.sha256(unhashed_description).digest()
|
||||||
else:
|
else:
|
||||||
data["tags_set"] = ["d"]
|
data["tags_set"] = ["d"]
|
||||||
|
|||||||
@@ -29,21 +29,25 @@ class LNbitsWallet(Wallet):
|
|||||||
if not self.endpoint or not key:
|
if not self.endpoint or not key:
|
||||||
raise Exception("cannot initialize lnbits wallet")
|
raise Exception("cannot initialize lnbits wallet")
|
||||||
self.key = {"X-Api-Key": key}
|
self.key = {"X-Api-Key": key}
|
||||||
|
self.client = httpx.AsyncClient(base_url=self.endpoint, headers=self.key)
|
||||||
|
|
||||||
|
async def cleanup(self):
|
||||||
|
try:
|
||||||
|
await self.client.aclose()
|
||||||
|
except RuntimeError as e:
|
||||||
|
logger.warning(f"Error closing wallet connection: {e}")
|
||||||
|
|
||||||
async def status(self) -> StatusResponse:
|
async def status(self) -> StatusResponse:
|
||||||
async with httpx.AsyncClient() as client:
|
try:
|
||||||
try:
|
r = await self.client.get(url="/api/v1/wallet", timeout=15)
|
||||||
r = await client.get(
|
except Exception as exc:
|
||||||
url=f"{self.endpoint}/api/v1/wallet", headers=self.key, timeout=15
|
return StatusResponse(
|
||||||
)
|
f"Failed to connect to {self.endpoint} due to: {exc}", 0
|
||||||
except Exception as exc:
|
)
|
||||||
return StatusResponse(
|
|
||||||
f"Failed to connect to {self.endpoint} due to: {exc}", 0
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
data = r.json()
|
data = r.json()
|
||||||
except:
|
except Exception:
|
||||||
return StatusResponse(
|
return StatusResponse(
|
||||||
f"Failed to connect to {self.endpoint}, got: '{r.text[:200]}...'", 0
|
f"Failed to connect to {self.endpoint}, got: '{r.text[:200]}...'", 0
|
||||||
)
|
)
|
||||||
@@ -69,10 +73,7 @@ class LNbitsWallet(Wallet):
|
|||||||
if unhashed_description:
|
if unhashed_description:
|
||||||
data["unhashed_description"] = unhashed_description.hex()
|
data["unhashed_description"] = unhashed_description.hex()
|
||||||
|
|
||||||
async with httpx.AsyncClient() as client:
|
r = await self.client.post(url="/api/v1/payments", json=data)
|
||||||
r = await client.post(
|
|
||||||
url=f"{self.endpoint}/api/v1/payments", headers=self.key, json=data
|
|
||||||
)
|
|
||||||
ok, checking_id, payment_request, error_message = (
|
ok, checking_id, payment_request, error_message = (
|
||||||
not r.is_error,
|
not r.is_error,
|
||||||
None,
|
None,
|
||||||
@@ -89,24 +90,16 @@ class LNbitsWallet(Wallet):
|
|||||||
return InvoiceResponse(ok, checking_id, payment_request, error_message)
|
return InvoiceResponse(ok, checking_id, payment_request, error_message)
|
||||||
|
|
||||||
async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
|
async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
|
||||||
async with httpx.AsyncClient() as client:
|
r = await self.client.post(
|
||||||
r = await client.post(
|
url="/api/v1/payments",
|
||||||
url=f"{self.endpoint}/api/v1/payments",
|
json={"out": True, "bolt11": bolt11},
|
||||||
headers=self.key,
|
timeout=None,
|
||||||
json={"out": True, "bolt11": bolt11},
|
|
||||||
timeout=None,
|
|
||||||
)
|
|
||||||
ok, checking_id, _, _, error_message = (
|
|
||||||
not r.is_error,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
)
|
)
|
||||||
|
ok = not r.is_error
|
||||||
|
|
||||||
if r.is_error:
|
if r.is_error:
|
||||||
error_message = r.json()["detail"]
|
error_message = r.json()["detail"]
|
||||||
return PaymentResponse(None, None, None, None, error_message)
|
return PaymentResponse(False, None, None, None, error_message)
|
||||||
else:
|
else:
|
||||||
data = r.json()
|
data = r.json()
|
||||||
checking_id = data["payment_hash"]
|
checking_id = data["payment_hash"]
|
||||||
@@ -118,25 +111,20 @@ class LNbitsWallet(Wallet):
|
|||||||
|
|
||||||
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
|
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient() as client:
|
r = await self.client.get(
|
||||||
r = await client.get(
|
url=f"/api/v1/payments/{checking_id}",
|
||||||
url=f"{self.endpoint}/api/v1/payments/{checking_id}",
|
)
|
||||||
headers=self.key,
|
|
||||||
)
|
|
||||||
if r.is_error:
|
if r.is_error:
|
||||||
return PaymentStatus(None)
|
return PaymentStatus(None)
|
||||||
return PaymentStatus(r.json()["paid"])
|
return PaymentStatus(r.json()["paid"])
|
||||||
except:
|
except Exception:
|
||||||
return PaymentStatus(None)
|
return PaymentStatus(None)
|
||||||
|
|
||||||
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
|
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
|
||||||
async with httpx.AsyncClient() as client:
|
r = await self.client.get(url=f"/api/v1/payments/{checking_id}")
|
||||||
r = await client.get(
|
|
||||||
url=f"{self.endpoint}/api/v1/payments/{checking_id}", headers=self.key
|
|
||||||
)
|
|
||||||
|
|
||||||
if r.is_error:
|
if r.is_error:
|
||||||
return PaymentStatus(None)
|
return PaymentStatus(False)
|
||||||
data = r.json()
|
data = r.json()
|
||||||
if "paid" not in data and "details" not in data:
|
if "paid" not in data and "details" not in data:
|
||||||
return PaymentStatus(None)
|
return PaymentStatus(None)
|
||||||
|
|||||||
@@ -1,10 +1,3 @@
|
|||||||
imports_ok = True
|
|
||||||
try:
|
|
||||||
import grpc
|
|
||||||
from grpc import RpcError
|
|
||||||
except ImportError: # pragma: nocover
|
|
||||||
imports_ok = False
|
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import base64
|
import base64
|
||||||
import hashlib
|
import hashlib
|
||||||
@@ -13,14 +6,6 @@ from typing import AsyncGenerator, Dict, Optional
|
|||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from .macaroon import AESCipher, load_macaroon
|
|
||||||
|
|
||||||
if imports_ok:
|
|
||||||
import lnbits.wallets.lnd_grpc_files.lightning_pb2 as ln
|
|
||||||
import lnbits.wallets.lnd_grpc_files.lightning_pb2_grpc as lnrpc
|
|
||||||
import lnbits.wallets.lnd_grpc_files.router_pb2 as router
|
|
||||||
import lnbits.wallets.lnd_grpc_files.router_pb2_grpc as routerrpc
|
|
||||||
|
|
||||||
from lnbits.settings import settings
|
from lnbits.settings import settings
|
||||||
|
|
||||||
from .base import (
|
from .base import (
|
||||||
@@ -30,6 +15,20 @@ from .base import (
|
|||||||
StatusResponse,
|
StatusResponse,
|
||||||
Wallet,
|
Wallet,
|
||||||
)
|
)
|
||||||
|
from .macaroon import AESCipher, load_macaroon
|
||||||
|
|
||||||
|
imports_ok = True
|
||||||
|
try:
|
||||||
|
import grpc
|
||||||
|
from grpc import RpcError
|
||||||
|
except ImportError: # pragma: nocover
|
||||||
|
imports_ok = False
|
||||||
|
|
||||||
|
if imports_ok:
|
||||||
|
import lnbits.wallets.lnd_grpc_files.lightning_pb2 as ln
|
||||||
|
import lnbits.wallets.lnd_grpc_files.lightning_pb2_grpc as lnrpc
|
||||||
|
import lnbits.wallets.lnd_grpc_files.router_pb2 as router
|
||||||
|
import lnbits.wallets.lnd_grpc_files.router_pb2_grpc as routerrpc
|
||||||
|
|
||||||
|
|
||||||
def get_ssl_context(cert_path: str):
|
def get_ssl_context(cert_path: str):
|
||||||
@@ -83,7 +82,7 @@ def hex_to_b64(hex_str: str) -> str:
|
|||||||
def hex_to_bytes(hex_str: str) -> bytes:
|
def hex_to_bytes(hex_str: str) -> bytes:
|
||||||
try:
|
try:
|
||||||
return bytes.fromhex(hex_str)
|
return bytes.fromhex(hex_str)
|
||||||
except:
|
except Exception:
|
||||||
return b""
|
return b""
|
||||||
|
|
||||||
|
|
||||||
@@ -101,7 +100,8 @@ class LndWallet(Wallet):
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
if not imports_ok: # pragma: nocover
|
if not imports_ok: # pragma: nocover
|
||||||
raise ImportError(
|
raise ImportError(
|
||||||
"The `grpcio` and `protobuf` library must be installed to use `GRPC LndWallet`. Alternatively try using the LndRESTWallet."
|
"The `grpcio` and `protobuf` library must be installed to use `GRPC"
|
||||||
|
" LndWallet`. Alternatively try using the LndRESTWallet."
|
||||||
)
|
)
|
||||||
|
|
||||||
endpoint = settings.lnd_grpc_endpoint
|
endpoint = settings.lnd_grpc_endpoint
|
||||||
@@ -289,7 +289,7 @@ class LndWallet(Wallet):
|
|||||||
bytes_to_hex(payment.htlcs[-1].preimage),
|
bytes_to_hex(payment.htlcs[-1].preimage),
|
||||||
)
|
)
|
||||||
return PaymentStatus(statuses[payment.status])
|
return PaymentStatus(statuses[payment.status])
|
||||||
except: # most likely the payment wasn't found
|
except Exception: # most likely the payment wasn't found
|
||||||
return PaymentStatus(None)
|
return PaymentStatus(None)
|
||||||
|
|
||||||
return PaymentStatus(None)
|
return PaymentStatus(None)
|
||||||
@@ -306,6 +306,7 @@ class LndWallet(Wallet):
|
|||||||
yield checking_id
|
yield checking_id
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error(
|
logger.error(
|
||||||
f"lost connection to lnd invoices stream: '{exc}', retrying in 5 seconds"
|
f"lost connection to lnd invoices stream: '{exc}', "
|
||||||
|
"retrying in 5 seconds"
|
||||||
)
|
)
|
||||||
await asyncio.sleep(5)
|
await asyncio.sleep(5)
|
||||||
|
|||||||