Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9e69d9d17 | ||
|
|
2c5ccc0b1b | ||
|
|
ea58b51619 | ||
|
|
bfda0b62da | ||
|
|
b0a8e0d942 | ||
|
|
2161b2511c | ||
|
|
1b2a9c54b6 | ||
|
|
209e2c5bff | ||
|
|
820882db28 | ||
|
|
d64239f1ad | ||
|
|
2066fff994 | ||
|
|
e0b7d2f739 | ||
|
|
cd66b7d70c | ||
|
|
dc74f5816f | ||
|
|
c67fcf0e45 | ||
|
|
073b117528 | ||
|
|
ef6bc4c3ae | ||
|
|
468121a453 | ||
|
|
4679d321de | ||
|
|
c9af15b320 | ||
|
|
9b5bb1b07a | ||
|
|
aaa6573272 | ||
|
|
741ecac78b | ||
|
|
1dd096213e | ||
|
|
ed44244291 | ||
|
|
0b22c41d55 | ||
|
|
25c6ee40f7 | ||
|
|
cdc102af07 | ||
|
|
b9f0af0e79 | ||
|
|
86821f4606 | ||
|
|
0ef9c4db10 | ||
|
|
c101b85054 | ||
|
|
fe3b00292a | ||
|
|
10fe113099 | ||
|
|
1bf5f10f53 | ||
|
|
e2bbcaabbd | ||
|
|
4265915201 | ||
|
|
7e3c511027 | ||
|
|
7783f34998 | ||
|
|
7dcb2fcdd5 | ||
|
|
033a57cad4 | ||
|
|
299228b7b5 | ||
|
|
5b022e2ef3 | ||
|
|
c8818f5774 | ||
|
|
1398857688 | ||
|
|
2a1505bc0d | ||
|
|
4bba6ecc82 | ||
|
|
522c4df44c | ||
|
|
c03b81d2ea | ||
|
|
43a797444a | ||
|
|
d0cb961b49 | ||
|
|
d208e68885 | ||
|
|
8dcb53aea0 | ||
|
|
241b286e21 | ||
|
|
17839f6a25 | ||
|
|
e4d3faefa0 | ||
|
|
116ca7f011 | ||
|
|
d44339b018 | ||
|
|
352fd23c0b | ||
|
|
65b8868c36 | ||
|
|
54dec171f9 | ||
|
|
5b4398911a | ||
|
|
4c0bd132b1 | ||
|
|
7ce4eddb0e | ||
|
|
14519135d8 | ||
|
|
60839481ad | ||
|
|
3ef1941fc0 | ||
|
|
16cb1a8026 | ||
|
|
e3b9bd6a70 | ||
|
|
a1ea04acf8 | ||
|
|
3e341a3555 | ||
|
|
cb5c9b03bf | ||
|
|
884a1b9d6f | ||
|
|
e8aa498683 | ||
|
|
1b7efd854a | ||
|
|
8b32c3dcb6 | ||
|
|
0821b28eac | ||
|
|
4c71d5ac42 | ||
|
|
a8e0b5a5ac | ||
|
|
9b9014a042 | ||
|
|
96b42b1784 | ||
|
|
26d854ebe8 | ||
|
|
ed36d01b40 | ||
|
|
646556a457 | ||
|
|
d20a35eddc | ||
|
|
d69946db8a | ||
|
|
ddab4075d2 | ||
|
|
d6c8ad1d0d | ||
|
|
54c6faa4b6 | ||
|
|
c88f05c5c0 | ||
|
|
e419c74ebb | ||
|
|
b6dc66b070 |
+8
-1
@@ -28,7 +28,7 @@ PORT=5000
|
||||
######################################
|
||||
|
||||
# which fundingsources are allowed in the admin ui
|
||||
LNBITS_ALLOWED_FUNDING_SOURCES="VoidWallet, FakeWallet, CoreLightningWallet, CoreLightningRestWallet, LndRestWallet, EclairWallet, LndWallet, LnTipsWallet, LNPayWallet, LNbitsWallet, AlbyWallet, OpenNodeWallet"
|
||||
LNBITS_ALLOWED_FUNDING_SOURCES="VoidWallet, FakeWallet, CoreLightningWallet, CoreLightningRestWallet, LndRestWallet, EclairWallet, LndWallet, LnTipsWallet, LNPayWallet, LNbitsWallet, AlbyWallet, ZBDWallet, OpenNodeWallet"
|
||||
|
||||
LNBITS_BACKEND_WALLET_CLASS=VoidWallet
|
||||
# VoidWallet is just a fallback that works without any actual Lightning capabilities,
|
||||
@@ -84,6 +84,10 @@ LNPAY_WALLET_KEY=LNPAY_ADMIN_KEY
|
||||
ALBY_API_ENDPOINT=https://api.getalby.com/
|
||||
ALBY_ACCESS_TOKEN=ALBY_ACCESS_TOKEN
|
||||
|
||||
# ZBDWallet
|
||||
ZBD_API_ENDPOINT=https://api.zebedee.io/v0/
|
||||
ZBD_API_KEY=ZBD_ACCESS_TOKEN
|
||||
|
||||
# OpenNodeWallet
|
||||
OPENNODE_API_ENDPOINT=https://api.opennode.com/
|
||||
OPENNODE_KEY=OPENNODE_ADMIN_KEY
|
||||
@@ -132,6 +136,8 @@ KEYCLOAK_DISCOVERY_URL=""
|
||||
######################################
|
||||
|
||||
# uvicorn variable, uncomment to allow https behind a proxy
|
||||
# IMPORTANT: this also needs the webserver to be configured to forward the headers
|
||||
# http://docs.lnbits.org/guide/installation.html#running-behind-an-apache2-reverse-proxy-over-https
|
||||
# FORWARDED_ALLOW_IPS="*"
|
||||
|
||||
# Server security, rate limiting ips, blocked ips, allowed ips
|
||||
@@ -222,6 +228,7 @@ LNBITS_RESERVE_FEE_PERCENT=1.0
|
||||
######################################
|
||||
|
||||
DEBUG=false
|
||||
DEBUG_DATABASE=false
|
||||
BUNDLE_ASSETS=true
|
||||
|
||||
# logging into LNBITS_DATA_FOLDER/logs/
|
||||
|
||||
@@ -10,7 +10,7 @@ inputs:
|
||||
default: "1.7.0"
|
||||
node-version:
|
||||
description: "Node Version"
|
||||
default: "18.x"
|
||||
default: "20.x"
|
||||
npm:
|
||||
description: "use npm"
|
||||
default: false
|
||||
@@ -21,7 +21,7 @@ runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Set up Python ${{ inputs.python-version }}
|
||||
uses: actions/setup-python@v4
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ inputs.python-version }}
|
||||
# cache poetry install via pip
|
||||
@@ -38,7 +38,7 @@ runs:
|
||||
poetry config virtualenvs.create true --local
|
||||
poetry config virtualenvs.in-project true --local
|
||||
|
||||
- uses: actions/cache@v3
|
||||
- uses: actions/cache@v4
|
||||
name: Define a cache for the virtual environment based on the dependencies lock file
|
||||
with:
|
||||
path: ./.venv
|
||||
@@ -50,11 +50,11 @@ runs:
|
||||
|
||||
- name: Use Node.js ${{ inputs.node-version }}
|
||||
if: ${{ (inputs.npm == 'true') }}
|
||||
uses: actions/setup-node@v3
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ inputs.node-version }}
|
||||
|
||||
- uses: actions/cache@v3
|
||||
- uses: actions/cache@v4
|
||||
if: ${{ (inputs.npm == 'true') }}
|
||||
name: Define a cache for the npm based on the dependencies lock file
|
||||
with:
|
||||
|
||||
@@ -21,16 +21,18 @@ jobs:
|
||||
uses: ./.github/workflows/tests.yml
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
db-url: ${{ matrix.db-url }}
|
||||
secrets:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
migrations:
|
||||
migration:
|
||||
needs: [ lint ]
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.9"]
|
||||
uses: ./.github/workflows/tests.yml
|
||||
python-version: ["3.9", "3.10"]
|
||||
uses: ./.github/workflows/migration.yml
|
||||
with:
|
||||
make: test-migration
|
||||
db-name: migration
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
openapi:
|
||||
needs: [ lint ]
|
||||
@@ -48,3 +50,16 @@ jobs:
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
backend-wallet-class: ${{ matrix.backend-wallet-class }}
|
||||
secrets:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
jmeter:
|
||||
needs: [ lint ]
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.9"]
|
||||
poetry-version: ["1.5.1"]
|
||||
uses: ./.github/workflows/jmeter.yml
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
poetry-version: ${{ matrix.poetry-version }}
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 2
|
||||
- run: git checkout HEAD^2
|
||||
|
||||
@@ -11,6 +11,11 @@ on:
|
||||
tag:
|
||||
default: latest
|
||||
type: string
|
||||
secrets:
|
||||
DOCKER_USERNAME:
|
||||
required: true
|
||||
DOCKER_PASSWORD:
|
||||
required: true
|
||||
|
||||
jobs:
|
||||
push_to_dockerhub:
|
||||
@@ -46,13 +51,3 @@ jobs:
|
||||
platforms: linux/amd64,linux/arm64
|
||||
cache-from: type=local,src=/tmp/.buildx-cache
|
||||
cache-to: type=local,dest=/tmp/.buildx-cache
|
||||
|
||||
- name: Build and push latest tag
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ secrets.DOCKER_USERNAME }}/lnbits:latest
|
||||
platforms: linux/amd64,linux/arm64
|
||||
cache-from: type=local,src=/tmp/.buildx-cache
|
||||
cache-to: type=local,dest=/tmp/.buildx-cache
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
name: JMeter Extension Tests
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
python-version:
|
||||
description: "Python Version"
|
||||
required: true
|
||||
default: "3.9"
|
||||
type: string
|
||||
poetry-version:
|
||||
description: "Poetry Version"
|
||||
required: true
|
||||
default: "1.5.1"
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
jmeter:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: ./.github/actions/prepare
|
||||
with:
|
||||
python-version: ${{ inputs.python-version }}
|
||||
|
||||
- name: run LNbits
|
||||
env:
|
||||
LNBITS_ADMIN_UI: true
|
||||
LNBITS_EXTENSIONS_DEFAULT_INSTALL: "watchonly, satspay, tipjar, tpos, lnurlp, withdraw"
|
||||
LNBITS_BACKEND_WALLET_CLASS: FakeWallet
|
||||
run: |
|
||||
poetry run lnbits &
|
||||
sleep 5
|
||||
|
||||
- name: clone lnbits-extensions, install jmeter and run tests
|
||||
run: |
|
||||
git clone https://github.com/lnbits/lnbits-extensions
|
||||
cd lnbits-extensions
|
||||
mkdir logs
|
||||
mkdir reports
|
||||
make install-jmeter
|
||||
make start-mirror-server
|
||||
make test
|
||||
|
||||
- name: upload jmeter test results
|
||||
uses: actions/upload-artifact@v4
|
||||
if: ${{ always() }}
|
||||
with:
|
||||
name: jmeter-extension-test-results
|
||||
path: |
|
||||
lnbits-extensions/reports/
|
||||
lnbits-extensions/logs/
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
node-version: ["18.x"]
|
||||
runs-on: ${{ matrix.os-version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ./.github/actions/prepare
|
||||
with:
|
||||
python-version: ${{ inputs.python-version }}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
name: migration
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
python-version:
|
||||
description: "python version"
|
||||
type: string
|
||||
default: "3.10"
|
||||
|
||||
jobs:
|
||||
make:
|
||||
name: migration (${{ inputs.python-version }})
|
||||
strategy:
|
||||
matrix:
|
||||
os-version: ["ubuntu-latest"]
|
||||
runs-on: ${{ matrix.os-version }}
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:latest
|
||||
env:
|
||||
POSTGRES_USER: lnbits
|
||||
POSTGRES_PASSWORD: lnbits
|
||||
POSTGRES_DB: migration
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ./.github/actions/prepare
|
||||
with:
|
||||
python-version: ${{ inputs.python-version }}
|
||||
- run: make test-migration
|
||||
@@ -15,21 +15,24 @@ on:
|
||||
backend-wallet-class:
|
||||
required: true
|
||||
type: string
|
||||
secrets:
|
||||
CODECOV_TOKEN:
|
||||
required: true
|
||||
|
||||
jobs:
|
||||
regtest:
|
||||
runs-on: ${{ inputs.os-version }}
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
if: ${{ inputs.backend-wallet-class == 'LNbitsWallet' }}
|
||||
uses: docker/setup-buildx-action@v2
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build and push
|
||||
if: ${{ inputs.backend-wallet-class == 'LNbitsWallet' }}
|
||||
uses: docker/build-push-action@v4
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: false
|
||||
@@ -53,7 +56,8 @@ jobs:
|
||||
if: ${{ inputs.backend-wallet-class == 'LNbitsWallet' }}
|
||||
run: docker exec lnbits-lnbits-1 poetry run python tools/create_fake_admin.py
|
||||
|
||||
- name: Run Tests
|
||||
- name: Run pytest
|
||||
uses: pavelzw/pytest-action@v2
|
||||
env:
|
||||
LNBITS_DATABASE_URL: ${{ inputs.db-url }}
|
||||
LNBITS_BACKEND_WALLET_CLASS: ${{ inputs.backend-wallet-class }}
|
||||
@@ -72,10 +76,20 @@ jobs:
|
||||
LNBITS_KEY: "d08a3313322a4514af75d488bcc27eee"
|
||||
ECLAIR_URL: http://127.0.0.1:8082
|
||||
ECLAIR_PASS: lnbits
|
||||
run: make test-real-wallet
|
||||
LNBITS_DATA_FOLDER: "./tests/data"
|
||||
PYTHONUNBUFFERED: 1
|
||||
DEBUG: true
|
||||
with:
|
||||
verbose: false
|
||||
job-summary: true
|
||||
emoji: false
|
||||
click-to-expand: false
|
||||
custom-pytest: poetry run pytest
|
||||
report-title: "regtest (${{ inputs.python-version }}, ${{ inputs.backend-wallet-class }}"
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v3
|
||||
uses: codecov/codecov-action@v4
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
file: ./coverage.xml
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
verbose: true
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
- name: Create github release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -26,3 +26,6 @@ jobs:
|
||||
uses: ./.github/workflows/docker.yml
|
||||
with:
|
||||
tag: ${{ github.ref_name }}
|
||||
secrets:
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
||||
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
@@ -3,9 +3,6 @@ name: tests
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
make:
|
||||
default: test
|
||||
type: string
|
||||
python-version:
|
||||
default: "3.9"
|
||||
type: string
|
||||
@@ -18,6 +15,9 @@ on:
|
||||
db-name:
|
||||
default: "lnbits"
|
||||
type: string
|
||||
secrets:
|
||||
CODECOV_TOKEN:
|
||||
required: true
|
||||
|
||||
jobs:
|
||||
tests:
|
||||
@@ -39,20 +39,32 @@ jobs:
|
||||
--health-retries 5
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: ./.github/actions/prepare
|
||||
with:
|
||||
python-version: ${{ inputs.python-version }}
|
||||
|
||||
- name: Run Tests
|
||||
- name: Run pytest
|
||||
uses: pavelzw/pytest-action@v2
|
||||
env:
|
||||
LNBITS_DATABASE_URL: ${{ inputs.db-url }}
|
||||
LNBITS_BACKEND_WALLET_CLASS: FakeWallet
|
||||
run: make ${{ inputs.make }}
|
||||
FAKE_WALLET_SECRET: "ToTheMoon1"
|
||||
LNBITS_DATA_FOLDER: "./tests/data"
|
||||
PYTHONUNBUFFERED: 1
|
||||
DEBUG: true
|
||||
with:
|
||||
verbose: false
|
||||
job-summary: true
|
||||
emoji: false
|
||||
click-to-expand: false
|
||||
custom-pytest: poetry run pytest
|
||||
report-title: "test (${{ inputs.python-version }}, ${{ inputs.db-url }})"
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v3
|
||||
uses: codecov/codecov-action@v4
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
file: ./coverage.xml
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
verbose: false
|
||||
|
||||
@@ -11,6 +11,7 @@ __pycache__
|
||||
*.egg
|
||||
*.egg-info
|
||||
.coverage
|
||||
.coverage.*
|
||||
.pytest_cache
|
||||
.webassets-cache
|
||||
htmlcov
|
||||
|
||||
@@ -14,11 +14,11 @@ repos:
|
||||
- id: mixed-line-ending
|
||||
- id: check-case-conflict
|
||||
- repo: https://github.com/psf/black
|
||||
rev: 23.7.0
|
||||
rev: 24.2.0
|
||||
hooks:
|
||||
- id: black
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.0.283
|
||||
rev: v0.3.2
|
||||
hooks:
|
||||
- id: ruff
|
||||
args: [ --fix, --exit-non-zero-on-fix ]
|
||||
|
||||
@@ -86,7 +86,7 @@ bak:
|
||||
sass:
|
||||
npm run sass
|
||||
|
||||
bundle_no_bump:
|
||||
bundle:
|
||||
npm install
|
||||
npm run sass
|
||||
npm run vendor_copy
|
||||
@@ -97,16 +97,10 @@ bundle_no_bump:
|
||||
npm run vendor_bundle_js
|
||||
npm run vendor_minify_js
|
||||
|
||||
bundle:
|
||||
make bundle_no_bump
|
||||
# increment serviceworker version
|
||||
awk '/CACHE_VERSION =/ {sub(/[0-9]+$$/, $$NF+1)} 1' lnbits/static/js/service-worker.js > lnbits/static/js/service-worker.js.new
|
||||
mv lnbits/static/js/service-worker.js.new lnbits/static/js/service-worker.js
|
||||
|
||||
checkbundle:
|
||||
cp lnbits/static/bundle.min.js lnbits/static/bundle.min.js.old
|
||||
cp lnbits/static/bundle.min.css lnbits/static/bundle.min.css.old
|
||||
make bundle_no_bump
|
||||
make bundle
|
||||
diff -q lnbits/static/bundle.min.js lnbits/static/bundle.min.js.old || exit 1
|
||||
diff -q lnbits/static/bundle.min.css lnbits/static/bundle.min.css.old || exit 1
|
||||
@echo "Bundle is OK"
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
# LNbits BETA
|
||||
|
||||
[![license-badge]](LICENSE)
|
||||
[![docs-badge]][docs]
|
||||
<picture >
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://i.imgur.com/QE6SIrs.png" style="width:300px">
|
||||
<img src="https://i.imgur.com/fyKPgVT.png" style="width:300px">
|
||||
</picture>
|
||||
|
||||

|
||||
<b>(BETA)</b>
|
||||
|
||||
# Free and Open-Source Lightning Wallet Accounts System
|
||||
[![license-badge]](LICENSE) [![docs-badge]][docs]
|
||||
|
||||

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

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

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

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

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

|
||||
## LNbits across all your devices
|
||||
|
||||
As well as working great in a browser, LNbits has native IoS and Android apps as well as a chrome extension. So you can enjoy the same UI across ALL your devices.
|
||||
|
||||
<img src="https://i.imgur.com/J96EbRf.png" style="width:800px">
|
||||
|
||||
## Tip us
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ Extension structure explained
|
||||
Adding new dependencies
|
||||
-----------------------
|
||||
|
||||
DO NOT ADD NEW DEPENDENCIES. Try to use the dependencies that are availabe in `pyproject.toml`. Getting the LNbits project to accept a new dependency is time consuming and uncertain, and may result in your extension NOT being made available to others.
|
||||
DO NOT ADD NEW DEPENDENCIES. Try to use the dependencies that are available in `pyproject.toml`. Getting the LNbits project to accept a new dependency is time consuming and uncertain, and may result in your extension NOT being made available to others.
|
||||
|
||||
If for some reason your extensions must have a new python package to work, and its nees are not met in `pyproject.toml`, you can add a new package using `poerty`:
|
||||
|
||||
@@ -51,7 +51,7 @@ $ poetry add <package>
|
||||
```
|
||||
|
||||
**But we need an extra step to make sure LNbits doesn't break in production.**
|
||||
Dependencies need to be added to `pyproject.toml`, then tested by running on `poetry` compatability can be tested with `nix build .#checks.x86_64-linux.vmTest`.
|
||||
Dependencies need to be added to `pyproject.toml`, then tested by running on `poetry` compatibility can be tested with `nix build .#checks.x86_64-linux.vmTest`.
|
||||
|
||||
|
||||
SQLite to PostgreSQL migration
|
||||
|
||||
@@ -33,7 +33,7 @@ There is also the possibility of posting the super user via webhook to another s
|
||||
Admin Users
|
||||
===========
|
||||
environment variable: `LNBITS_ADMIN_USERS`, comma-separated list of user ids
|
||||
Admin Users can change settings in the admin ui as well, with the exception of funding source settings, because they require e server restart and could potentially make the server inaccessable. Also they have access to all the extension defined in `LNBITS_ADMIN_EXTENSIONS`.
|
||||
Admin Users can change settings in the admin ui as well, with the exception of funding source settings, because they require e server restart and could potentially make the server inaccessible. Also they have access to all the extension defined in `LNBITS_ADMIN_EXTENSIONS`.
|
||||
|
||||
|
||||
Allowed Users
|
||||
|
||||
+2
-2
@@ -100,7 +100,7 @@ allow-self-payment=1
|
||||
|
||||
<details><summary>Configure a comment that people see when paying to my LNURLp QR</summary>
|
||||
<p>When you create a LNURL-p, by default the comment box is not filled. That means comments are not allowed to be attached to payments.<p>
|
||||
<p>In order to allow comments, add the characters lenght of the box, from 1 to 250. Once you put a number there,
|
||||
<p>In order to allow comments, add the characters length of the box, from 1 to 250. Once you put a number there,
|
||||
the comment box will be displayed in the payment process. You can also edit a LNURL-p already created and add that number.</p>
|
||||
|
||||

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

|
||||
</p>
|
||||
</details>
|
||||
|
||||
@@ -13,21 +13,29 @@ By default, LNbits will use SQLite as its database. You can also use PostgreSQL
|
||||
## Option 1 (recommended): poetry
|
||||
|
||||
Mininum poetry version has is ^1.2, but it is recommended to use latest poetry. (including OSX)
|
||||
Make sure you have Python 3.9 or 3.10 installed.
|
||||
|
||||
### install python on ubuntu
|
||||
```sh
|
||||
# for making sure python 3.9 is installed, skip if installed. To check your installed version: python3 --version
|
||||
sudo apt update
|
||||
sudo apt install software-properties-common
|
||||
sudo add-apt-repository ppa:deadsnakes/ppa
|
||||
sudo apt install python3.9 python3.9-distutils
|
||||
```
|
||||
|
||||
### install poetry
|
||||
```sh
|
||||
curl -sSL https://install.python-poetry.org | python3 -
|
||||
# Once the above poetry install is completed, use the installation path printed to terminal and replace in the following command
|
||||
export PATH="/home/user/.local/bin:$PATH"
|
||||
```
|
||||
|
||||
```sh
|
||||
git clone https://github.com/lnbits/lnbits.git
|
||||
cd lnbits
|
||||
git checkout main
|
||||
|
||||
# for making sure python 3.9 is installed, skip if installed. To check your installed version: python3 --version
|
||||
sudo apt update
|
||||
sudo apt install software-properties-common
|
||||
sudo add-apt-repository ppa:deadsnakes/ppa
|
||||
sudo apt install python3.9 python3.9-distutils
|
||||
|
||||
curl -sSL https://install.python-poetry.org | python3 -
|
||||
# Once the above poetry install is completed, use the installation path printed to terminal and replace in the following command
|
||||
export PATH="/home/user/.local/bin:$PATH"
|
||||
# Next command, you can exchange with python3.10 or newer versions.
|
||||
# Identify your version with python3 --version and specify in the next line
|
||||
# command is only needed when your default python is not ^3.9 or ^3.10
|
||||
|
||||
@@ -8,7 +8,7 @@ nav_order: 3
|
||||
Backend wallets
|
||||
===============
|
||||
|
||||
LNbits can run on top of many lightning-network funding sources with more being added regularly.
|
||||
LNbits can run on top of many Lightning Network funding sources with more being added regularly.
|
||||
|
||||
A backend wallet can be configured using the following LNbits environment variables:
|
||||
|
||||
@@ -87,6 +87,14 @@ For the invoice to work you must have a publicly accessible URL in your LNbits.
|
||||
- `ALBY_API_ENDPOINT`: https://api.getalby.com/
|
||||
- `ALBY_ACCESS_TOKEN`: AlbyAccessToken
|
||||
|
||||
### ZBD
|
||||
|
||||
For the invoice to work you must have a publicly accessible URL in your LNbits. No manual webhook setting is necessary. You can generate an ZBD API Key here: https://zbd.dev/docs/dashboard/projects/api
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: **ZBDWallet**
|
||||
- `ZBD_API_ENDPOINT`: https://api.zebedee.io/v0/
|
||||
- `ZBD_API_KEY`: ZBDApiKey
|
||||
|
||||
### Cliche Wallet
|
||||
|
||||
- `CLICHE_ENDPOINT`: ws://127.0.0.1:12000
|
||||
|
||||
+2
-2
@@ -5,13 +5,13 @@ nav_order: 1
|
||||
---
|
||||
|
||||
|
||||
LNbits, free and open-source lightning-network wallet/accounts system
|
||||
LNbits, free and open-source Lightning Network wallet/accounts system
|
||||
=====================================================================
|
||||
|
||||
LNbits is a very simple Python application that sits on top of any funding source, and can be used as:
|
||||
|
||||
* Accounts system to mitigate the risk of exposing applications to your full balance, via unique API keys for each wallet
|
||||
* Extendable platform for exploring lightning-network functionality via LNbits extension framework
|
||||
* Extendable platform for exploring Lightning Network functionality via LNbits extension framework
|
||||
* Part of a development stack via LNbits API
|
||||
* Fallback wallet for the LNURL scheme
|
||||
* Instant wallet for LN demonstrations
|
||||
|
||||
@@ -33,6 +33,13 @@
|
||||
protobuf = prev.protobuf.override { preferWheel = true; };
|
||||
ruff = prev.ruff.override { preferWheel = true; };
|
||||
wallycore = prev.wallycore.override { preferWheel = true; };
|
||||
# remove the following override when https://github.com/nix-community/poetry2nix/pull/1563 is merged
|
||||
asgi-lifespan = prev.asgi-lifespan.overridePythonAttrs (
|
||||
old: { buildInputs = (old.buildInputs or []) ++ [ prev.setuptools ]; }
|
||||
);
|
||||
pytest-md = prev.pytest-md.overridePythonAttrs (
|
||||
old: { buildInputs = (old.buildInputs or []) ++ [ prev.setuptools ]; }
|
||||
);
|
||||
});
|
||||
};
|
||||
});
|
||||
|
||||
+109
-114
@@ -4,9 +4,9 @@ import importlib
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import signal
|
||||
import sys
|
||||
import traceback
|
||||
from contextlib import asynccontextmanager
|
||||
from hashlib import sha256
|
||||
from http import HTTPStatus
|
||||
from pathlib import Path
|
||||
@@ -26,12 +26,16 @@ from starlette.responses import JSONResponse
|
||||
from lnbits.core.crud import get_dbversions, get_installed_extensions
|
||||
from lnbits.core.helpers import migrate_extension_database
|
||||
from lnbits.core.services import websocketUpdater
|
||||
from lnbits.core.tasks import ( # register_watchdog,; unregister_watchdog,
|
||||
register_killswitch,
|
||||
register_task_listeners,
|
||||
from lnbits.core.tasks import ( # watchdog_task
|
||||
killswitch_task,
|
||||
wait_for_paid_invoices,
|
||||
)
|
||||
from lnbits.settings import settings
|
||||
from lnbits.tasks import cancel_all_tasks, create_permanent_task
|
||||
from lnbits.tasks import (
|
||||
cancel_all_tasks,
|
||||
create_permanent_task,
|
||||
register_invoice_listener,
|
||||
)
|
||||
from lnbits.utils.cache import cache
|
||||
from lnbits.wallets import get_wallet_class, set_wallet_class
|
||||
|
||||
@@ -39,7 +43,7 @@ from .commands import migrate_databases
|
||||
from .core import init_core_routers
|
||||
from .core.db import core_app_extra
|
||||
from .core.services import check_admin_settings, check_webpush_settings
|
||||
from .core.views.api import add_installed_extension
|
||||
from .core.views.extension_api import add_installed_extension
|
||||
from .core.views.generic import update_installed_extension_state
|
||||
from .extension_manager import (
|
||||
Extension,
|
||||
@@ -61,10 +65,62 @@ from .tasks import (
|
||||
check_pending_payments,
|
||||
internal_invoice_listener,
|
||||
invoice_listener,
|
||||
webhook_handler,
|
||||
)
|
||||
|
||||
|
||||
async def startup(app: FastAPI):
|
||||
|
||||
# wait till migration is done
|
||||
await migrate_databases()
|
||||
|
||||
# setup admin settings
|
||||
await check_admin_settings()
|
||||
await check_webpush_settings()
|
||||
|
||||
log_server_info()
|
||||
|
||||
# initialize WALLET
|
||||
try:
|
||||
set_wallet_class()
|
||||
except Exception as e:
|
||||
logger.error(f"Error initializing {settings.lnbits_backend_wallet_class}: {e}")
|
||||
set_void_wallet_class()
|
||||
|
||||
# initialize funding source
|
||||
await check_funding_source()
|
||||
|
||||
# register core routes
|
||||
init_core_routers(app)
|
||||
|
||||
# check extensions after restart
|
||||
if not settings.lnbits_extensions_deactivate_all:
|
||||
await check_installed_extensions(app)
|
||||
register_all_ext_routes(app)
|
||||
|
||||
if settings.lnbits_admin_ui:
|
||||
initialize_server_logger()
|
||||
|
||||
# initialize tasks
|
||||
register_async_tasks()
|
||||
|
||||
|
||||
async def shutdown():
|
||||
# shutdown event
|
||||
cancel_all_tasks()
|
||||
|
||||
# wait a bit to allow them to finish, so that cleanup can run without problems
|
||||
await asyncio.sleep(0.1)
|
||||
WALLET = get_wallet_class()
|
||||
await WALLET.cleanup()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
await startup(app)
|
||||
yield
|
||||
await shutdown()
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
configure_logger()
|
||||
app = FastAPI(
|
||||
@@ -74,6 +130,7 @@ def create_app() -> FastAPI:
|
||||
"accounts system with plugins."
|
||||
),
|
||||
version=settings.version,
|
||||
lifespan=lifespan,
|
||||
license_info={
|
||||
"name": "MIT License",
|
||||
"url": "https://raw.githubusercontent.com/lnbits/lnbits/main/LICENSE",
|
||||
@@ -114,41 +171,30 @@ def create_app() -> FastAPI:
|
||||
add_ip_block_middleware(app)
|
||||
add_ratelimit_middleware(app)
|
||||
|
||||
register_startup(app)
|
||||
register_async_tasks(app)
|
||||
register_exception_handlers(app)
|
||||
register_shutdown(app)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
async def check_funding_source() -> None:
|
||||
original_sigint_handler = signal.getsignal(signal.SIGINT)
|
||||
|
||||
def signal_handler(signal, frame):
|
||||
logger.debug(
|
||||
f"SIGINT received, terminating LNbits. signal: {signal}, frame: {frame}"
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
signal.signal(signal.SIGINT, signal_handler)
|
||||
|
||||
WALLET = get_wallet_class()
|
||||
|
||||
# fallback to void after 30 seconds of failures
|
||||
sleep_time = 5
|
||||
timeout = int(30 / sleep_time)
|
||||
|
||||
balance = 0
|
||||
max_retries = 5
|
||||
retry_counter = 0
|
||||
|
||||
while True:
|
||||
try:
|
||||
logger.info(f"Connecting to backend {WALLET.__class__.__name__}...")
|
||||
error_message, balance = await WALLET.status()
|
||||
if not error_message:
|
||||
retry_counter = 0
|
||||
logger.success(
|
||||
f"✔️ Backend {WALLET.__class__.__name__} connected "
|
||||
f"and with a balance of {balance} msat."
|
||||
)
|
||||
break
|
||||
|
||||
logger.error(
|
||||
f"The backend for {WALLET.__class__.__name__} isn't "
|
||||
f"working properly: '{error_message}'",
|
||||
@@ -156,23 +202,18 @@ async def check_funding_source() -> None:
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error connecting to {WALLET.__class__.__name__}: {e}")
|
||||
pass
|
||||
|
||||
if settings.lnbits_admin_ui and retry_counter == timeout:
|
||||
if retry_counter == max_retries:
|
||||
set_void_wallet_class()
|
||||
WALLET = get_wallet_class()
|
||||
break
|
||||
else:
|
||||
logger.warning(f"Retrying connection to backend in {sleep_time} seconds...")
|
||||
retry_counter += 1
|
||||
await asyncio.sleep(sleep_time)
|
||||
|
||||
signal.signal(signal.SIGINT, original_sigint_handler)
|
||||
|
||||
logger.success(
|
||||
f"✔️ Backend {WALLET.__class__.__name__} connected "
|
||||
f"and with a balance of {balance} msat."
|
||||
)
|
||||
retry_counter += 1
|
||||
logger.warning(
|
||||
f"Retrying connection to backend in {sleep_time} seconds... "
|
||||
f"({retry_counter}/{max_retries})"
|
||||
)
|
||||
await asyncio.sleep(sleep_time)
|
||||
|
||||
|
||||
def set_void_wallet_class():
|
||||
@@ -195,7 +236,7 @@ async def check_installed_extensions(app: FastAPI):
|
||||
|
||||
for ext in installed_extensions:
|
||||
try:
|
||||
installed = check_installed_extension_files(ext)
|
||||
installed = await check_installed_extension_files(ext)
|
||||
if not installed:
|
||||
await restore_installed_extension(app, ext)
|
||||
logger.info(
|
||||
@@ -253,14 +294,14 @@ async def build_all_installed_extensions_list(
|
||||
]
|
||||
|
||||
|
||||
def check_installed_extension_files(ext: InstallableExtension) -> bool:
|
||||
async def check_installed_extension_files(ext: InstallableExtension) -> bool:
|
||||
if ext.has_installed_version:
|
||||
return True
|
||||
|
||||
zip_files = glob.glob(os.path.join(settings.lnbits_data_folder, "zips", "*.zip"))
|
||||
|
||||
if f"./{str(ext.zip_path)}" not in zip_files:
|
||||
ext.download_archive()
|
||||
await ext.download_archive()
|
||||
ext.extract_archive()
|
||||
|
||||
return False
|
||||
@@ -279,18 +320,7 @@ async def restore_installed_extension(app: FastAPI, ext: InstallableExtension):
|
||||
# mount routes for the new version
|
||||
core_app_extra.register_new_ext_routes(extension)
|
||||
if extension.upgrade_hash:
|
||||
ext.nofiy_upgrade()
|
||||
|
||||
|
||||
def register_routes(app: FastAPI) -> None:
|
||||
"""Register FastAPI routes / LNbits extensions."""
|
||||
init_core_routers(app)
|
||||
|
||||
for ext in get_valid_extensions(False):
|
||||
try:
|
||||
register_ext_routes(app, ext)
|
||||
except Exception as e:
|
||||
logger.error(f"Could not load extension `{ext.code}`: {str(e)}")
|
||||
ext.notify_upgrade()
|
||||
|
||||
|
||||
def register_custom_extensions_path():
|
||||
@@ -368,53 +398,12 @@ def register_ext_routes(app: FastAPI, ext: Extension) -> None:
|
||||
app.include_router(router=ext_route, prefix=prefix)
|
||||
|
||||
|
||||
def register_startup(app: FastAPI):
|
||||
@app.on_event("startup")
|
||||
async def lnbits_startup():
|
||||
def register_all_ext_routes(app: FastAPI):
|
||||
for ext in get_valid_extensions(False):
|
||||
try:
|
||||
# wait till migration is done
|
||||
await migrate_databases()
|
||||
|
||||
# setup admin settings
|
||||
await check_admin_settings()
|
||||
await check_webpush_settings()
|
||||
|
||||
log_server_info()
|
||||
|
||||
# initialize WALLET
|
||||
try:
|
||||
set_wallet_class()
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error initializing {settings.lnbits_backend_wallet_class}: {e}"
|
||||
)
|
||||
set_void_wallet_class()
|
||||
|
||||
# initialize funding source
|
||||
await check_funding_source()
|
||||
|
||||
# check extensions after restart
|
||||
await check_installed_extensions(app)
|
||||
|
||||
# register core and extension routes
|
||||
register_routes(app)
|
||||
|
||||
if settings.lnbits_admin_ui:
|
||||
initialize_server_logger()
|
||||
|
||||
register_ext_routes(app, ext)
|
||||
except Exception as e:
|
||||
logger.error(str(e))
|
||||
raise ImportError("Failed to run 'startup' event.")
|
||||
|
||||
|
||||
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()
|
||||
logger.error(f"Could not load extension `{ext.code}`: {str(e)}")
|
||||
|
||||
|
||||
def initialize_server_logger():
|
||||
@@ -464,20 +453,20 @@ def get_db_vendor_name():
|
||||
)
|
||||
|
||||
|
||||
def register_async_tasks(app):
|
||||
@app.route("/wallet/webhook")
|
||||
async def webhook_listener():
|
||||
return await webhook_handler()
|
||||
def register_async_tasks():
|
||||
create_permanent_task(check_pending_payments)
|
||||
create_permanent_task(invoice_listener)
|
||||
create_permanent_task(internal_invoice_listener)
|
||||
create_permanent_task(cache.invalidate_forever)
|
||||
|
||||
@app.on_event("startup")
|
||||
async def listeners():
|
||||
create_permanent_task(check_pending_payments)
|
||||
create_permanent_task(invoice_listener)
|
||||
create_permanent_task(internal_invoice_listener)
|
||||
create_permanent_task(cache.invalidate_forever)
|
||||
register_task_listeners()
|
||||
register_killswitch()
|
||||
# await run_deferred_async() # calle: doesn't do anyting?
|
||||
# core invoice listener
|
||||
invoice_queue = asyncio.Queue(5)
|
||||
register_invoice_listener(invoice_queue, "core")
|
||||
create_permanent_task(lambda: wait_for_paid_invoices(invoice_queue))
|
||||
|
||||
# TODO: implement watchdog properly
|
||||
# create_permanent_task(watchdog_task)
|
||||
create_permanent_task(killswitch_task)
|
||||
|
||||
|
||||
def register_exception_handlers(app: FastAPI):
|
||||
@@ -494,7 +483,7 @@ def register_exception_handlers(app: FastAPI):
|
||||
and "text/html" in request.headers["accept"]
|
||||
):
|
||||
return template_renderer().TemplateResponse(
|
||||
"error.html", {"request": request, "err": f"Error: {str(exc)}"}
|
||||
request, "error.html", {"err": f"Error: {str(exc)}"}
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
@@ -516,8 +505,9 @@ def register_exception_handlers(app: FastAPI):
|
||||
and "text/html" in request.headers["accept"]
|
||||
):
|
||||
return template_renderer().TemplateResponse(
|
||||
request,
|
||||
"error.html",
|
||||
{"request": request, "err": f"Error: {str(exc)}"},
|
||||
{"err": f"Error: {str(exc)}"},
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
@@ -540,12 +530,11 @@ def register_exception_handlers(app: FastAPI):
|
||||
response = RedirectResponse("/")
|
||||
response.delete_cookie("cookie_access_token")
|
||||
response.delete_cookie("is_lnbits_user_authorized")
|
||||
response.set_cookie(
|
||||
"is_access_token_expired", "true", samesite="none", secure=True
|
||||
)
|
||||
response.set_cookie("is_access_token_expired", "true")
|
||||
return response
|
||||
|
||||
return template_renderer().TemplateResponse(
|
||||
request,
|
||||
"error.html",
|
||||
{
|
||||
"request": request,
|
||||
@@ -586,6 +575,12 @@ def configure_logger() -> None:
|
||||
logging.getLogger("uvicorn.error").handlers = [InterceptHandler()]
|
||||
logging.getLogger("uvicorn.error").propagate = False
|
||||
|
||||
logging.getLogger("sqlalchemy").handlers = [InterceptHandler()]
|
||||
logging.getLogger("sqlalchemy.engine.base").handlers = [InterceptHandler()]
|
||||
logging.getLogger("sqlalchemy.engine.base").propagate = False
|
||||
logging.getLogger("sqlalchemy.engine.base.Engine").handlers = [InterceptHandler()]
|
||||
logging.getLogger("sqlalchemy.engine.base.Engine").propagate = False
|
||||
|
||||
|
||||
class Formatter:
|
||||
def __init__(self):
|
||||
|
||||
+137
-10
@@ -1,7 +1,9 @@
|
||||
import asyncio
|
||||
import importlib
|
||||
import time
|
||||
from functools import wraps
|
||||
from pathlib import Path
|
||||
from typing import Optional, Tuple
|
||||
from typing import List, Optional, Tuple
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import click
|
||||
@@ -10,21 +12,29 @@ from fastapi.exceptions import HTTPException
|
||||
from loguru import logger
|
||||
from packaging import version
|
||||
|
||||
from lnbits.core.models import User
|
||||
from lnbits.core.models import Payment, User
|
||||
from lnbits.core.services import check_admin_settings
|
||||
from lnbits.core.views.api import api_install_extension, api_uninstall_extension
|
||||
from lnbits.core.views.extension_api import (
|
||||
api_install_extension,
|
||||
api_uninstall_extension,
|
||||
)
|
||||
from lnbits.settings import settings
|
||||
from lnbits.wallets.base import Wallet
|
||||
|
||||
from .core import db as core_db
|
||||
from .core import migrations as core_migrations
|
||||
from .core.crud import (
|
||||
delete_accounts_no_wallets,
|
||||
delete_unused_wallets,
|
||||
delete_wallet_by_id,
|
||||
delete_wallet_payment,
|
||||
get_dbversions,
|
||||
get_inactive_extensions,
|
||||
get_installed_extension,
|
||||
get_installed_extensions,
|
||||
get_payments,
|
||||
remove_deleted_wallets,
|
||||
update_payment_status,
|
||||
)
|
||||
from .core.helpers import migrate_extension_database, run_migration
|
||||
from .db import COCKROACH, POSTGRES, SQLITE
|
||||
@@ -180,6 +190,37 @@ async def database_cleanup_deleted_wallets():
|
||||
await remove_deleted_wallets(conn)
|
||||
|
||||
|
||||
@db.command("delete-wallet")
|
||||
@click.option("-w", "--wallet", required=True, help="ID of wallet to be deleted.")
|
||||
@coro
|
||||
async def database_delete_wallet(wallet: str):
|
||||
"""Mark wallet as deleted"""
|
||||
async with core_db.connect() as conn:
|
||||
count = await delete_wallet_by_id(wallet_id=wallet, conn=conn)
|
||||
click.echo(f"Marked as deleted '{count}' rows.")
|
||||
|
||||
|
||||
@db.command("delete-wallet-payment")
|
||||
@click.option("-w", "--wallet", required=True, help="ID of wallet to be deleted.")
|
||||
@click.option("-c", "--checking-id", required=True, help="Payment checking Id.")
|
||||
@coro
|
||||
async def database_delete_wallet_payment(wallet: str, checking_id: str):
|
||||
"""Mark wallet as deleted"""
|
||||
async with core_db.connect() as conn:
|
||||
await delete_wallet_payment(
|
||||
wallet_id=wallet, checking_id=checking_id, conn=conn
|
||||
)
|
||||
|
||||
|
||||
@db.command("mark-payment-pending")
|
||||
@click.option("-c", "--checking-id", required=True, help="Payment checking Id.")
|
||||
@coro
|
||||
async def database_revert_payment(checking_id: str, pending: bool = True):
|
||||
"""Mark wallet as deleted"""
|
||||
async with core_db.connect() as conn:
|
||||
await update_payment_status(pending=pending, checking_id=checking_id, conn=conn)
|
||||
|
||||
|
||||
@db.command("cleanup-accounts")
|
||||
@click.argument("days", type=int, required=False)
|
||||
@coro
|
||||
@@ -191,6 +232,86 @@ async def database_cleanup_accounts(days: Optional[int] = None):
|
||||
await delete_accounts_no_wallets(delta, conn)
|
||||
|
||||
|
||||
@db.command("check-payments")
|
||||
@click.option("-d", "--days", help="Maximum age of payments in days.")
|
||||
@click.option("-l", "--limit", help="Maximum number of payments to be checked.")
|
||||
@click.option("-w", "--wallet", help="Only check for this wallet.")
|
||||
@click.option("-v", "--verbose", is_flag=True, help="Detailed log.")
|
||||
@coro
|
||||
async def check_invalid_payments(
|
||||
days: Optional[int] = None,
|
||||
limit: Optional[int] = None,
|
||||
wallet: Optional[str] = None,
|
||||
verbose: Optional[bool] = False,
|
||||
):
|
||||
"""Check payments that are settled in the DB but pending on the Funding Source"""
|
||||
await check_admin_settings()
|
||||
settled_db_payments = []
|
||||
|
||||
if verbose:
|
||||
click.echo(f"Get Payments: days={days}, limit={limit}, wallet={wallet}")
|
||||
async with core_db.connect() as conn:
|
||||
delta = int(days) if days else 3 # default to 3 days
|
||||
limit = int(limit) if limit else 1000
|
||||
since = int(time.time()) - delta * 24 * 60 * 60
|
||||
|
||||
settled_db_payments = await get_payments(
|
||||
complete=True,
|
||||
incoming=True,
|
||||
exclude_uncheckable=True,
|
||||
since=since,
|
||||
limit=limit,
|
||||
wallet_id=wallet,
|
||||
conn=conn,
|
||||
)
|
||||
|
||||
click.echo("Settled Payments: " + str(len(settled_db_payments)))
|
||||
|
||||
wallets_module = importlib.import_module("lnbits.wallets")
|
||||
wallet_class = getattr(wallets_module, settings.lnbits_backend_wallet_class)
|
||||
|
||||
funding_source: Wallet = wallet_class()
|
||||
|
||||
click.echo("Funding source: " + str(funding_source))
|
||||
|
||||
# payments that are settled in the DB, but not at the Funding source level
|
||||
invalid_payments: List[Payment] = []
|
||||
invalid_wallets = {}
|
||||
for db_payment in settled_db_payments:
|
||||
if verbose:
|
||||
click.echo(
|
||||
f"Checking Payment: '{db_payment.checking_id}' for wallet"
|
||||
+ f" '{db_payment.wallet_id}'."
|
||||
)
|
||||
payment_status = await funding_source.get_invoice_status(db_payment.checking_id)
|
||||
|
||||
if payment_status.pending:
|
||||
invalid_payments.append(db_payment)
|
||||
if db_payment.wallet_id not in invalid_wallets:
|
||||
invalid_wallets[f"{db_payment.wallet_id}"] = [0, 0]
|
||||
invalid_wallets[f"{db_payment.wallet_id}"][0] += 1
|
||||
invalid_wallets[f"{db_payment.wallet_id}"][1] += db_payment.amount
|
||||
|
||||
click.echo(
|
||||
"Invalid Payment: '"
|
||||
+ " ".join(
|
||||
[
|
||||
db_payment.checking_id,
|
||||
db_payment.wallet_id,
|
||||
str(db_payment.amount / 1000).ljust(10),
|
||||
db_payment.memo or "",
|
||||
]
|
||||
)
|
||||
+ "'"
|
||||
)
|
||||
|
||||
click.echo("Invalid Payments: " + str(len(invalid_payments)))
|
||||
click.echo("\nInvalid Wallets: " + str(len(invalid_wallets)))
|
||||
for w in invalid_wallets:
|
||||
data = invalid_wallets[f"{w}"]
|
||||
click.echo(" ".join([w, str(data[0]), str(data[1] / 1000).ljust(10)]))
|
||||
|
||||
|
||||
async def load_disabled_extension_list() -> None:
|
||||
"""Update list of extensions that have been explicitly disabled"""
|
||||
inactive_extensions = await get_inactive_extensions()
|
||||
@@ -212,7 +333,7 @@ async def extensions_list():
|
||||
|
||||
@extensions.command("update")
|
||||
@click.argument("extension", required=False)
|
||||
@click.option("-a", "--all", is_flag=True, help="Update all extensions.")
|
||||
@click.option("-a", "--all-extensions", is_flag=True, help="Update all extensions.")
|
||||
@click.option(
|
||||
"-i", "--repo-index", help="Select the index of the repository to be used."
|
||||
)
|
||||
@@ -239,7 +360,7 @@ async def extensions_list():
|
||||
@coro
|
||||
async def extensions_update(
|
||||
extension: Optional[str] = None,
|
||||
all: Optional[bool] = False,
|
||||
all_extensions: Optional[bool] = False,
|
||||
repo_index: Optional[str] = None,
|
||||
source_repo: Optional[str] = None,
|
||||
url: Optional[str] = None,
|
||||
@@ -249,11 +370,11 @@ async def extensions_update(
|
||||
Update extension to the latest version.
|
||||
If an extension is not present it will be instaled.
|
||||
"""
|
||||
if not extension and not all:
|
||||
if not extension and not all_extensions:
|
||||
click.echo("Extension ID is required.")
|
||||
click.echo("Or specify the '--all' flag to update all extensions")
|
||||
click.echo("Or specify the '--all-extensions' flag to update all extensions")
|
||||
return
|
||||
if extension and all:
|
||||
if extension and all_extensions:
|
||||
click.echo("Only one of extension ID or the '--all' flag must be specified")
|
||||
return
|
||||
if url and not _is_url(url):
|
||||
@@ -397,7 +518,10 @@ async def install_extension(
|
||||
return False, "No release selected"
|
||||
|
||||
data = CreateExtension(
|
||||
ext_id=extension, archive=release.archive, source_repo=release.source_repo
|
||||
ext_id=extension,
|
||||
archive=release.archive,
|
||||
source_repo=release.source_repo,
|
||||
version=release.version,
|
||||
)
|
||||
await _call_install_extension(data, url, admin_user)
|
||||
click.echo(f"Extension '{extension}' ({release.version}) installed.")
|
||||
@@ -445,7 +569,10 @@ async def update_extension(
|
||||
click.echo(f"Updating '{extension}' extension to version: {release.version }")
|
||||
|
||||
data = CreateExtension(
|
||||
ext_id=extension, archive=release.archive, source_repo=release.source_repo
|
||||
ext_id=extension,
|
||||
archive=release.archive,
|
||||
source_repo=release.source_repo,
|
||||
version=release.version,
|
||||
)
|
||||
|
||||
await _call_install_extension(data, url, admin_user)
|
||||
|
||||
+14
-6
@@ -1,30 +1,38 @@
|
||||
from fastapi import APIRouter
|
||||
from fastapi import APIRouter, FastAPI
|
||||
|
||||
from .db import core_app_extra, db
|
||||
from .views.admin_api import admin_router
|
||||
from .views.api import api_router
|
||||
from .views.auth_api import auth_router
|
||||
from .views.extension_api import extension_router
|
||||
|
||||
# this compat is needed for usermanager extension
|
||||
from .views.generic import generic_router, update_user_extension
|
||||
from .views.node_api import node_router, public_node_router, super_node_router
|
||||
from .views.payment_api import payment_router
|
||||
from .views.public_api import public_router
|
||||
from .views.tinyurl_api import tinyurl_router
|
||||
from .views.wallet_api import wallet_router
|
||||
from .views.webpush_api import webpush_router
|
||||
from .views.websocket_api import websocket_router
|
||||
|
||||
# backwards compatibility for extensions
|
||||
core_app = APIRouter(tags=["Core"])
|
||||
|
||||
|
||||
def init_core_routers(app):
|
||||
def init_core_routers(app: FastAPI):
|
||||
app.include_router(core_app)
|
||||
app.include_router(generic_router)
|
||||
app.include_router(public_router)
|
||||
app.include_router(api_router)
|
||||
app.include_router(auth_router)
|
||||
app.include_router(admin_router)
|
||||
app.include_router(node_router)
|
||||
app.include_router(extension_router)
|
||||
app.include_router(super_node_router)
|
||||
app.include_router(public_node_router)
|
||||
app.include_router(admin_router)
|
||||
app.include_router(public_router)
|
||||
app.include_router(payment_router)
|
||||
app.include_router(wallet_router)
|
||||
app.include_router(api_router)
|
||||
app.include_router(websocket_router)
|
||||
app.include_router(tinyurl_router)
|
||||
app.include_router(webpush_router)
|
||||
app.include_router(auth_router)
|
||||
|
||||
+29
-1
@@ -322,6 +322,7 @@ async def add_installed_extension(
|
||||
dict(ext.installed_release) if ext.installed_release else None
|
||||
),
|
||||
"dependencies": ext.dependencies,
|
||||
"payments": [dict(p) for p in ext.payments] if ext.payments else None,
|
||||
}
|
||||
|
||||
version = ext.installed_release.version if ext.installed_release else ""
|
||||
@@ -512,6 +513,21 @@ async def delete_wallet(
|
||||
)
|
||||
|
||||
|
||||
async def delete_wallet_by_id(
|
||||
*, wallet_id: str, conn: Optional[Connection] = None
|
||||
) -> Optional[int]:
|
||||
now = int(time())
|
||||
result = await (conn or db).execute(
|
||||
f"""
|
||||
UPDATE wallets
|
||||
SET deleted = true, updated_at = {db.timestamp_placeholder}
|
||||
WHERE id = ?
|
||||
""",
|
||||
(now, wallet_id),
|
||||
)
|
||||
return result.rowcount
|
||||
|
||||
|
||||
async def remove_deleted_wallets(conn: Optional[Connection] = None) -> None:
|
||||
await (conn or db).execute("DELETE FROM wallets WHERE deleted = true")
|
||||
|
||||
@@ -553,7 +569,8 @@ async def get_wallet_for_key(
|
||||
row = await (conn or db).fetchone(
|
||||
"""
|
||||
SELECT *, COALESCE((SELECT balance FROM balances WHERE wallet = wallets.id), 0)
|
||||
AS balance_msat FROM wallets WHERE adminkey = ? OR inkey = ?
|
||||
AS balance_msat FROM wallets
|
||||
WHERE (adminkey = ? OR inkey = ?) AND deleted = false
|
||||
""",
|
||||
(key, key),
|
||||
)
|
||||
@@ -601,6 +618,7 @@ async def get_standalone_payment(
|
||||
SELECT *
|
||||
FROM apipayments
|
||||
WHERE {clause}
|
||||
ORDER BY amount
|
||||
LIMIT 1
|
||||
""",
|
||||
tuple(values),
|
||||
@@ -1012,6 +1030,16 @@ async def check_internal_pending(
|
||||
return row["pending"]
|
||||
|
||||
|
||||
async def mark_webhook_sent(payment_hash: str, status: int) -> None:
|
||||
await db.execute(
|
||||
"""
|
||||
UPDATE apipayments SET webhook_status = ?
|
||||
WHERE hash = ?
|
||||
""",
|
||||
(status, payment_hash),
|
||||
)
|
||||
|
||||
|
||||
# balance_check
|
||||
# -------------
|
||||
|
||||
|
||||
@@ -18,17 +18,20 @@ from lnbits.helpers import url_for
|
||||
from lnbits.lnurl import encode as lnurl_encode
|
||||
from lnbits.settings import settings
|
||||
from lnbits.wallets import get_wallet_class
|
||||
from lnbits.wallets.base import PaymentStatus
|
||||
from lnbits.wallets.base import PaymentPendingStatus, PaymentStatus
|
||||
|
||||
|
||||
class Wallet(BaseModel):
|
||||
class BaseWallet(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
user: str
|
||||
adminkey: str
|
||||
inkey: str
|
||||
currency: Optional[str]
|
||||
balance_msat: int
|
||||
|
||||
|
||||
class Wallet(BaseWallet):
|
||||
user: str
|
||||
currency: Optional[str]
|
||||
deleted: bool
|
||||
created_at: Optional[int] = None
|
||||
updated_at: Optional[int] = None
|
||||
@@ -255,7 +258,7 @@ class Payment(FromRowModel):
|
||||
conn: Optional[Connection] = None,
|
||||
) -> PaymentStatus:
|
||||
if self.is_uncheckable:
|
||||
return PaymentStatus(None)
|
||||
return PaymentPendingStatus()
|
||||
|
||||
logger.debug(
|
||||
f"Checking {'outgoing' if self.is_out else 'incoming'} "
|
||||
|
||||
+22
-16
@@ -31,7 +31,12 @@ from lnbits.settings import (
|
||||
)
|
||||
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 (
|
||||
PaymentPendingStatus,
|
||||
PaymentResponse,
|
||||
PaymentStatus,
|
||||
PaymentSuccessStatus,
|
||||
)
|
||||
|
||||
from .crud import (
|
||||
check_internal,
|
||||
@@ -189,12 +194,15 @@ async def pay_invoice(
|
||||
If the payment is still in flight, we hope that some other process
|
||||
will regularly check for the payment.
|
||||
"""
|
||||
invoice = bolt11_decode(payment_request)
|
||||
try:
|
||||
invoice = bolt11_decode(payment_request)
|
||||
except Exception:
|
||||
raise InvoiceFailure("Bolt11 decoding failed.")
|
||||
|
||||
if not invoice.amount_msat or not invoice.amount_msat > 0:
|
||||
raise ValueError("Amountless invoices not supported.")
|
||||
raise InvoiceFailure("Amountless invoices not supported.")
|
||||
if max_sat and invoice.amount_msat > max_sat * 1000:
|
||||
raise ValueError("Amount in invoice is too high.")
|
||||
raise InvoiceFailure("Amount in invoice is too high.")
|
||||
|
||||
await check_wallet_limits(wallet_id, conn, invoice.amount_msat)
|
||||
|
||||
@@ -202,11 +210,6 @@ async def pay_invoice(
|
||||
temp_id = invoice.payment_hash
|
||||
internal_id = f"internal_{invoice.payment_hash}"
|
||||
|
||||
if invoice.amount_msat == 0:
|
||||
raise ValueError("Amountless invoices not supported.")
|
||||
if max_sat and invoice.amount_msat > max_sat * 1000:
|
||||
raise ValueError("Amount in invoice is too high.")
|
||||
|
||||
_, extra = await calculate_fiat_amounts(
|
||||
invoice.amount_msat / 1000, wallet_id, extra=extra, conn=conn
|
||||
)
|
||||
@@ -579,10 +582,10 @@ async def check_transaction_status(
|
||||
wallet_id, payment_hash, conn=conn
|
||||
)
|
||||
if not payment:
|
||||
return PaymentStatus(None)
|
||||
return PaymentPendingStatus()
|
||||
if not payment.pending:
|
||||
# note: before, we still checked the status of the payment again
|
||||
return PaymentStatus(True, fee_msat=payment.fee)
|
||||
return PaymentSuccessStatus(fee_msat=payment.fee)
|
||||
|
||||
status: PaymentStatus = await payment.check_status()
|
||||
return status
|
||||
@@ -711,11 +714,14 @@ async def check_webpush_settings():
|
||||
|
||||
def update_cached_settings(sets_dict: dict):
|
||||
for key, value in sets_dict.items():
|
||||
if key not in readonly_variables:
|
||||
try:
|
||||
setattr(settings, key, value)
|
||||
except Exception:
|
||||
logger.warning(f"Failed overriding setting: {key}, value: {value}")
|
||||
if key in readonly_variables:
|
||||
continue
|
||||
if key not in settings.dict().keys():
|
||||
continue
|
||||
try:
|
||||
setattr(settings, key, value)
|
||||
except Exception:
|
||||
logger.warning(f"Failed overriding setting: {key}, value: {value}")
|
||||
if "super_user" in sets_dict:
|
||||
setattr(settings, "super_user", sets_dict["super_user"])
|
||||
|
||||
|
||||
+41
-58
@@ -8,8 +8,8 @@ from lnbits.core.crud import (
|
||||
get_balance_notify,
|
||||
get_wallet,
|
||||
get_webpush_subscriptions_for_user,
|
||||
mark_webhook_sent,
|
||||
)
|
||||
from lnbits.core.db import db
|
||||
from lnbits.core.models import Payment
|
||||
from lnbits.core.services import (
|
||||
get_balance_delta,
|
||||
@@ -17,26 +17,16 @@ from lnbits.core.services import (
|
||||
switch_to_voidwallet,
|
||||
)
|
||||
from lnbits.settings import get_wallet_class, settings
|
||||
from lnbits.tasks import (
|
||||
create_permanent_task,
|
||||
create_task,
|
||||
register_invoice_listener,
|
||||
send_push_notification,
|
||||
)
|
||||
from lnbits.tasks import send_push_notification
|
||||
|
||||
api_invoice_listeners: Dict[str, asyncio.Queue] = {}
|
||||
|
||||
|
||||
def register_killswitch():
|
||||
async def killswitch_task():
|
||||
"""
|
||||
Registers a killswitch which will check lnbits-status repository for a signal from
|
||||
killswitch will check lnbits-status repository for a signal from
|
||||
LNbits and will switch to VoidWallet if the killswitch is triggered.
|
||||
"""
|
||||
logger.debug("Starting killswitch task")
|
||||
create_permanent_task(killswitch_task)
|
||||
|
||||
|
||||
async def killswitch_task():
|
||||
while True:
|
||||
WALLET = get_wallet_class()
|
||||
if settings.lnbits_killswitch and WALLET.__class__.__name__ != "VoidWallet":
|
||||
@@ -51,7 +41,7 @@ async def killswitch_task():
|
||||
"Switching to VoidWallet. Killswitch triggered."
|
||||
)
|
||||
await switch_to_voidwallet()
|
||||
except (httpx.ConnectError, httpx.RequestError):
|
||||
except (httpx.RequestError, httpx.HTTPStatusError):
|
||||
logger.error(
|
||||
"Cannot fetch lnbits status manifest."
|
||||
f" {settings.lnbits_status_manifest}"
|
||||
@@ -59,17 +49,11 @@ async def killswitch_task():
|
||||
await asyncio.sleep(settings.lnbits_killswitch_interval * 60)
|
||||
|
||||
|
||||
async def register_watchdog():
|
||||
async def watchdog_task():
|
||||
"""
|
||||
Registers a watchdog which will check lnbits balance and nodebalance
|
||||
and will switch to VoidWallet if the watchdog delta is reached.
|
||||
"""
|
||||
# TODO: implement watchdog properly
|
||||
# logger.debug("Starting watchdog task")
|
||||
# create_permanent_task(watchdog_task)
|
||||
|
||||
|
||||
async def watchdog_task():
|
||||
while True:
|
||||
WALLET = get_wallet_class()
|
||||
if settings.lnbits_watchdog and WALLET.__class__.__name__ != "VoidWallet":
|
||||
@@ -84,36 +68,23 @@ async def watchdog_task():
|
||||
await asyncio.sleep(settings.lnbits_watchdog_interval * 60)
|
||||
|
||||
|
||||
def register_task_listeners():
|
||||
"""
|
||||
Registers an invoice listener queue for the core tasks. Incoming payments in this
|
||||
queue will eventually trigger the signals sent to all other extensions
|
||||
and fulfill other core tasks such as dispatching webhooks.
|
||||
"""
|
||||
invoice_paid_queue = asyncio.Queue(5)
|
||||
# we register invoice_paid_queue to receive all incoming invoices
|
||||
register_invoice_listener(invoice_paid_queue, "core/tasks.py")
|
||||
# register a worker that will react to invoices
|
||||
create_task(wait_for_paid_invoices(invoice_paid_queue))
|
||||
|
||||
|
||||
async def wait_for_paid_invoices(invoice_paid_queue: asyncio.Queue):
|
||||
"""
|
||||
This worker dispatches events to all extensions,
|
||||
dispatches webhooks and balance notifys.
|
||||
This task dispatches events to all api_invoice_listeners,
|
||||
webhooks, push notifications and balance notifications.
|
||||
"""
|
||||
while True:
|
||||
payment = await invoice_paid_queue.get()
|
||||
logger.trace("received invoice paid event")
|
||||
# send information to sse channel
|
||||
# dispatch api_invoice_listeners
|
||||
await dispatch_api_invoice_listeners(payment)
|
||||
# payment notification
|
||||
wallet = await get_wallet(payment.wallet_id)
|
||||
if wallet:
|
||||
await send_payment_notification(wallet, payment)
|
||||
# dispatch webhook
|
||||
if payment.webhook and not payment.webhook_status:
|
||||
await dispatch_webhook(payment)
|
||||
|
||||
# dispatch balance_notify
|
||||
url = await get_balance_notify(payment.wallet_id)
|
||||
if url:
|
||||
@@ -121,10 +92,22 @@ async def wait_for_paid_invoices(invoice_paid_queue: asyncio.Queue):
|
||||
async with httpx.AsyncClient(headers=headers) as client:
|
||||
try:
|
||||
r = await client.post(url, timeout=4)
|
||||
await mark_webhook_sent(payment, r.status_code)
|
||||
except (httpx.ConnectError, httpx.RequestError):
|
||||
pass
|
||||
r.raise_for_status()
|
||||
await mark_webhook_sent(payment.payment_hash, r.status_code)
|
||||
except httpx.HTTPStatusError as exc:
|
||||
status_code = exc.response.status_code
|
||||
await mark_webhook_sent(payment.payment_hash, status_code)
|
||||
logger.warning(
|
||||
f"balance_notify returned a bad status_code: {status_code} "
|
||||
f"while requesting {exc.request.url!r}."
|
||||
)
|
||||
logger.warning(exc)
|
||||
except httpx.RequestError as exc:
|
||||
await mark_webhook_sent(payment.payment_hash, -1)
|
||||
logger.warning(f"Could not send balance_notify to {url}")
|
||||
logger.warning(exc)
|
||||
|
||||
# dispatch push notification
|
||||
await send_payment_push_notification(payment)
|
||||
|
||||
|
||||
@@ -134,10 +117,12 @@ async def dispatch_api_invoice_listeners(payment: Payment):
|
||||
"""
|
||||
for chan_name, send_channel in api_invoice_listeners.items():
|
||||
try:
|
||||
logger.debug(f"sending invoice paid event to {chan_name}")
|
||||
logger.debug(f"api invoice listener: sending paid event to {chan_name}")
|
||||
send_channel.put_nowait(payment)
|
||||
except asyncio.QueueFull:
|
||||
logger.error(f"removing sse listener {send_channel}:{chan_name}")
|
||||
logger.error(
|
||||
f"api invoice listener: QueueFull, removing {send_channel}:{chan_name}"
|
||||
)
|
||||
api_invoice_listeners.pop(chan_name)
|
||||
|
||||
|
||||
@@ -148,26 +133,24 @@ async def dispatch_webhook(payment: Payment):
|
||||
logger.debug("sending webhook", payment.webhook)
|
||||
|
||||
if not payment.webhook:
|
||||
return await mark_webhook_sent(payment, -1)
|
||||
return await mark_webhook_sent(payment.payment_hash, -1)
|
||||
|
||||
headers = {"User-Agent": settings.user_agent}
|
||||
async with httpx.AsyncClient(headers=headers) as client:
|
||||
data = payment.dict()
|
||||
try:
|
||||
r = await client.post(payment.webhook, json=data, timeout=40)
|
||||
await mark_webhook_sent(payment, r.status_code)
|
||||
except (httpx.ConnectError, httpx.RequestError):
|
||||
await mark_webhook_sent(payment, -1)
|
||||
|
||||
|
||||
async def mark_webhook_sent(payment: Payment, status: int) -> None:
|
||||
await db.execute(
|
||||
"""
|
||||
UPDATE apipayments SET webhook_status = ?
|
||||
WHERE hash = ?
|
||||
""",
|
||||
(status, payment.payment_hash),
|
||||
)
|
||||
r.raise_for_status()
|
||||
await mark_webhook_sent(payment.payment_hash, r.status_code)
|
||||
except httpx.HTTPStatusError as exc:
|
||||
await mark_webhook_sent(payment.payment_hash, exc.response.status_code)
|
||||
logger.warning(
|
||||
f"webhook returned a bad status_code: {exc.response.status_code} "
|
||||
f"while requesting {exc.request.url!r}."
|
||||
)
|
||||
except httpx.RequestError:
|
||||
await mark_webhook_sent(payment.payment_hash, -1)
|
||||
logger.warning(f"Could not send webhook to {payment.webhook}")
|
||||
|
||||
|
||||
async def send_payment_push_notification(payment: Payment):
|
||||
|
||||
@@ -7,21 +7,21 @@
|
||||
<div class="col">
|
||||
<p>Funding Source Info</p>
|
||||
<ul>
|
||||
{%raw%}
|
||||
<li>Funding Source: {{settings.lnbits_backend_wallet_class}}</li>
|
||||
<li>
|
||||
Node Balance: {{(auditData.node_balance_msats /
|
||||
1000).toLocaleString()}} sats
|
||||
</li>
|
||||
<li>
|
||||
LNbits Balance: {{(auditData.lnbits_balance_msats /
|
||||
1000).toLocaleString()}} sats
|
||||
</li>
|
||||
<li>
|
||||
Reserve Percent: {{(auditData.node_balance_msats /
|
||||
auditData.lnbits_balance_msats * 100).toFixed(2)}} %
|
||||
</li>
|
||||
{%endraw%}
|
||||
<li
|
||||
v-text="'Funding Source: '+ settings.lnbits_backend_wallet_class"
|
||||
></li>
|
||||
<li
|
||||
v-text="'Node Balance: ' + (auditData.node_balance_msats /
|
||||
1000).toLocaleString() + ' sats'"
|
||||
></li>
|
||||
<li
|
||||
v-text="'LNbits Balance: ' + (auditData.lnbits_balance_msats /
|
||||
1000).toLocaleString() + ' sats'"
|
||||
></li>
|
||||
<li
|
||||
v-text="'Reserve Percent: ' + (auditData.node_balance_msats /
|
||||
auditData.lnbits_balance_msats * 100).toFixed(2) + ' %'"
|
||||
></li>
|
||||
</ul>
|
||||
<br />
|
||||
</div>
|
||||
|
||||
@@ -131,7 +131,7 @@
|
||||
style="padding: 10px; color: #fafafa; height: 320px"
|
||||
>
|
||||
<small v-for="log in logs"
|
||||
>{% raw %}{{ log }}{% endraw %}<br
|
||||
><span v-text="log"></span><br
|
||||
/></small>
|
||||
</q-scroll-area>
|
||||
</div>
|
||||
@@ -166,7 +166,6 @@
|
||||
></q-btn>
|
||||
</q-input>
|
||||
<div>
|
||||
{%raw%}
|
||||
<q-chip
|
||||
v-for="blocked_ip in formData.lnbits_blocked_ips"
|
||||
:key="blocked_ip"
|
||||
@@ -174,10 +173,8 @@
|
||||
@remove="removeBlockedIPs(blocked_ip)"
|
||||
color="primary"
|
||||
text-color="white"
|
||||
>
|
||||
{{ blocked_ip }}
|
||||
</q-chip>
|
||||
{%endraw%}
|
||||
v-text="blocked_ip"
|
||||
></q-chip>
|
||||
</div>
|
||||
<br />
|
||||
</div>
|
||||
@@ -198,7 +195,6 @@
|
||||
></q-btn>
|
||||
</q-input>
|
||||
<div>
|
||||
{%raw%}
|
||||
<q-chip
|
||||
v-for="allowed_ip in formData.lnbits_allowed_ips"
|
||||
:key="allowed_ip"
|
||||
@@ -206,10 +202,8 @@
|
||||
@remove="removeAllowedIPs(allowed_ip)"
|
||||
color="primary"
|
||||
text-color="white"
|
||||
>
|
||||
{{ allowed_ip }}
|
||||
</q-chip>
|
||||
{%endraw%}
|
||||
v-text="allowed_ip"
|
||||
></q-chip>
|
||||
</div>
|
||||
<br />
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
{% raw %}
|
||||
<q-banner v-if="updateAvailable" class="bg-primary text-white">
|
||||
<q-icon size="28px" name="update"></q-icon>
|
||||
|
||||
@@ -30,9 +29,12 @@
|
||||
<template v-slot:header="props">
|
||||
<q-tr :props="props">
|
||||
<q-th auto-width> </q-th>
|
||||
<q-th v-for="col in props.cols" :key="col.name" :props="props"
|
||||
>{{ col.label }}</q-th
|
||||
>
|
||||
<q-th
|
||||
v-for="col in props.cols"
|
||||
:key="col.name"
|
||||
:props="props"
|
||||
v-text="col.label"
|
||||
></q-th>
|
||||
</q-tr>
|
||||
</template>
|
||||
<template v-slot:body="props">
|
||||
@@ -51,12 +53,16 @@
|
||||
color="red"
|
||||
></q-icon>
|
||||
</q-td>
|
||||
<q-td auto-width key="date" :props="props">
|
||||
{{ formatDate(props.row.date) }}
|
||||
<q-td
|
||||
auto-width
|
||||
key="date"
|
||||
:props="props"
|
||||
v-text="formatDate(props.row.date)"
|
||||
>
|
||||
</q-td>
|
||||
<q-td key="message" :props="props"
|
||||
>{{ props.row.message }}
|
||||
<a
|
||||
><span v-text="props.row.message"></span
|
||||
><a
|
||||
v-if="props.row.link"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
@@ -69,4 +75,3 @@
|
||||
</q-table>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
{% endraw %}
|
||||
|
||||
@@ -7,14 +7,14 @@
|
||||
<div class="col">
|
||||
<p>Server Info</p>
|
||||
<ul>
|
||||
{%raw%}
|
||||
<li v-if="settings.lnbits_data_folder">
|
||||
SQlite: {{settings.lnbits_data_folder}}
|
||||
</li>
|
||||
<li v-if="settings.lnbits_database_url">
|
||||
Postgres: {{settings.lnbits_database_url}}
|
||||
</li>
|
||||
{%endraw%}
|
||||
<li
|
||||
v-if="settings.lnbits_data_folder"
|
||||
v-text="'SQlite: ' + settings.lnbits_data_folder"
|
||||
></li>
|
||||
<li
|
||||
v-if="settings.lnbits_database_url"
|
||||
v-text="'Postgres: ' + settings.lnbits_database_url"
|
||||
></li>
|
||||
</ul>
|
||||
<br />
|
||||
</div>
|
||||
@@ -60,6 +60,21 @@
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<p>Miscellaneous</p>
|
||||
<q-item tag="label" v-ripple>
|
||||
<q-item-section>
|
||||
<q-item-label>Disable Extensions</q-item-label>
|
||||
<q-item-label caption>Disables all extensions</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section avatar>
|
||||
<q-toggle
|
||||
size="md"
|
||||
v-model="formData.lnbits_extensions_deactivate_all"
|
||||
checked-icon="check"
|
||||
color="green"
|
||||
unchecked-icon="clear"
|
||||
/>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item tag="label" v-ripple>
|
||||
<q-item-section>
|
||||
<q-item-label>Hide API</q-item-label>
|
||||
@@ -154,7 +169,6 @@
|
||||
<q-btn @click="addExtensionsManifest" dense flat icon="add"></q-btn>
|
||||
</q-input>
|
||||
<div>
|
||||
{%raw%}
|
||||
<q-chip
|
||||
v-for="manifestUrl in formData.lnbits_extensions_manifests"
|
||||
:key="manifestUrl"
|
||||
@@ -162,10 +176,8 @@
|
||||
@remove="removeExtensionsManifest(manifestUrl)"
|
||||
color="primary"
|
||||
text-color="white"
|
||||
>
|
||||
{{ manifestUrl }}
|
||||
</q-chip>
|
||||
{%endraw%}
|
||||
><span v-text="manifestUrl"></span
|
||||
></q-chip>
|
||||
</div>
|
||||
<br />
|
||||
</div>
|
||||
|
||||
@@ -23,8 +23,8 @@
|
||||
@remove="removeAdminUser(user)"
|
||||
color="primary"
|
||||
text-color="white"
|
||||
v-text="user"
|
||||
>
|
||||
<span v-text="user"></span>
|
||||
</q-chip>
|
||||
</div>
|
||||
<br />
|
||||
@@ -49,8 +49,8 @@
|
||||
@remove="removeAllowedUser(user)"
|
||||
color="primary"
|
||||
text-color="white"
|
||||
v-text="user"
|
||||
>
|
||||
<span v-text="user" />
|
||||
</q-chip>
|
||||
</div>
|
||||
<br />
|
||||
|
||||
@@ -8,9 +8,9 @@
|
||||
@click="updateSettings"
|
||||
:disabled="!checkChanges"
|
||||
>
|
||||
<q-tooltip v-if="checkChanges"
|
||||
>{%raw%}{{ $t('save_tooltip') }}{%endraw%}</q-tooltip
|
||||
>
|
||||
<q-tooltip v-if="checkChanges">
|
||||
<span v-text="$t('save_tooltip')"></span>
|
||||
</q-tooltip>
|
||||
|
||||
<q-badge
|
||||
v-if="checkChanges"
|
||||
@@ -27,9 +27,9 @@
|
||||
color="primary"
|
||||
@click="restartServer"
|
||||
>
|
||||
<q-tooltip v-if="needsRestart"
|
||||
>{%raw%}{{ $t('restart_tooltip') }}{%endraw%}</q-tooltip
|
||||
>
|
||||
<q-tooltip v-if="needsRestart">
|
||||
<span v-text="$t('restart_tooltip')"></span>
|
||||
</q-tooltip>
|
||||
|
||||
<q-badge
|
||||
v-if="needsRestart"
|
||||
@@ -46,7 +46,9 @@
|
||||
color="primary"
|
||||
@click="topUpDialog.show = true"
|
||||
>
|
||||
<q-tooltip>{%raw%}{{ $t('add_funds_tooltip') }}{%endraw%}</q-tooltip>
|
||||
<q-tooltip>
|
||||
<span v-text="$t('add_funds_tooltip')"></span>
|
||||
</q-tooltip>
|
||||
</q-btn>
|
||||
|
||||
<q-btn :label="$t('download_backup')" flat @click="downloadBackup"></q-btn>
|
||||
@@ -59,7 +61,9 @@
|
||||
@click="deleteSettings"
|
||||
class="float-right"
|
||||
>
|
||||
<q-tooltip>{%raw%}{{ $t('reset_defaults_tooltip') }}{%endraw%}</q-tooltip>
|
||||
<q-tooltip>
|
||||
<span v-text="$t('reset_defaults_tooltip')"></span>
|
||||
</q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
%} {{ window_vars(user, extensions) }}{% block page %}
|
||||
<div class="row q-col-gutter-md q-mb-md">
|
||||
<div class="col-sm-9 col-xs-12">
|
||||
<p class="text-h4 gt-sm">{%raw%}{{ $t('extensions') }}{%endraw%}</p>
|
||||
<p class="text-h4 gt-sm" v-text="$t('extensions')"></p>
|
||||
</div>
|
||||
|
||||
<div class="col-sm-3 col-xs-12 q-ml-auto">
|
||||
@@ -43,9 +43,10 @@
|
||||
:label="$t('featured')"
|
||||
@update="val => tab = val.name"
|
||||
></q-tab>
|
||||
<i v-if="!g.user.admin && tab != 'installed'"
|
||||
>{%raw%}{{ $t('only_admins_can_install') }}{%endraw%}</i
|
||||
>
|
||||
<i
|
||||
v-if="!g.user.admin && tab != 'installed'"
|
||||
v-text="$t('only_admins_can_install')"
|
||||
></i>
|
||||
</q-tabs>
|
||||
</div>
|
||||
</div>
|
||||
@@ -89,45 +90,42 @@
|
||||
color="green"
|
||||
class="float-right"
|
||||
>
|
||||
<small>{%raw%}{{ $t('new_version') }}{%endraw%}</small>
|
||||
<small v-text="$t('new_version')"></small>
|
||||
<q-tooltip
|
||||
><span v-text="extension.latestRelease.version"></span
|
||||
></q-tooltip>
|
||||
</q-badge>
|
||||
{% raw %}
|
||||
<div class="text-h5 gt-sm q-mt-sm q-mb-xs gt-sm">
|
||||
{{ extension.name }}
|
||||
</div>
|
||||
<div
|
||||
class="text-h5 gt-sm q-mt-sm q-mb-xs gt-sm"
|
||||
v-text="extension.name"
|
||||
></div>
|
||||
<div
|
||||
class="text-h5 gt-sm q-mt-sm q-mb-xs lt-md"
|
||||
style="min-height: 60px"
|
||||
>
|
||||
{{ extension.name }}
|
||||
</div>
|
||||
v-text="extension.name"
|
||||
></div>
|
||||
<div
|
||||
class="text-subtitle2 gt-sm"
|
||||
style="font-size: 11px; height: 34px"
|
||||
>
|
||||
{{ extension.shortDescription ||
|
||||
extension.installedRelease?.description }}
|
||||
</div>
|
||||
<div class="text-subtitle1 lt-md q-mt-sm q-mb-xs">
|
||||
{{ extension.name }}
|
||||
</div>
|
||||
v-text="extension.shortDescription || extension.installedRelease?.description"
|
||||
></div>
|
||||
<div
|
||||
class="text-subtitle1 lt-md q-mt-sm q-mb-xs"
|
||||
v-text="extension.name"
|
||||
></div>
|
||||
<div
|
||||
class="text-subtitle2 lt-md"
|
||||
style="font-size: 9px; height: 34px"
|
||||
>
|
||||
{{ extension.shortDescription }}
|
||||
</div>
|
||||
{% endraw %}
|
||||
v-text="extension.shortDescription"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-pt-sm">
|
||||
<div class="col">
|
||||
<small v-if="extension.dependencies?.length"
|
||||
>{%raw%}{{ $t('extension_depends_on') }}{%endraw%}</small
|
||||
>
|
||||
<small
|
||||
v-if="extension.dependencies?.length"
|
||||
v-text="$t('extension_depends_on')"
|
||||
></small>
|
||||
<small v-else> </small>
|
||||
<q-badge
|
||||
v-for="dep in extension.dependencies"
|
||||
@@ -148,20 +146,18 @@
|
||||
size="1.5em"
|
||||
:max="5"
|
||||
color="primary"
|
||||
><q-tooltip
|
||||
>{%raw%}{{ $t('extension_rating_soon') }}{%endraw%}</q-tooltip
|
||||
></q-rating
|
||||
>
|
||||
><q-tooltip>
|
||||
<span v-text="$t('extension_rating_soon')"></span> </q-tooltip
|
||||
></q-rating>
|
||||
<q-rating
|
||||
v-model="maxStars"
|
||||
class="lt-md"
|
||||
size="1.5em"
|
||||
:max="5"
|
||||
color="primary"
|
||||
><q-tooltip
|
||||
>{%raw%}{{ $t('extension_rating_soon') }}{%endraw%}</q-tooltip
|
||||
></q-rating
|
||||
>
|
||||
><q-tooltip>
|
||||
<span v-text="$t('extension_rating_soon')"></span> </q-tooltip
|
||||
></q-rating>
|
||||
<q-toggle
|
||||
v-if="extension.isAvailable && extension.isInstalled && g.user.admin"
|
||||
:label="extension.isActive ? $t('activated'): $t('deactivated') "
|
||||
@@ -169,11 +165,11 @@
|
||||
style="max-height: 21px"
|
||||
v-model="extension.isActive"
|
||||
@input="toggleExtension(extension)"
|
||||
><q-tooltip
|
||||
>{%raw%}{{ $t('activate_extension_details')
|
||||
}}{%endraw%}</q-tooltip
|
||||
></q-toggle
|
||||
>
|
||||
><q-tooltip>
|
||||
<span
|
||||
v-text="$t('activate_extension_details')"
|
||||
></span> </q-tooltip
|
||||
></q-toggle>
|
||||
</div>
|
||||
</q-card-section>
|
||||
<q-separator></q-separator>
|
||||
@@ -186,8 +182,8 @@
|
||||
color="primary"
|
||||
type="a"
|
||||
:href="extension.id + '/'"
|
||||
>{%raw%}{{ $t('open') }}{%endraw%}</q-btn
|
||||
>
|
||||
:label="$t('open')"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
v-if="user.extensions.includes(extension.id) && extension.isActive && extension.isInstalled"
|
||||
flat
|
||||
@@ -196,11 +192,12 @@
|
||||
:href="['{{
|
||||
url_for('install.extensions')
|
||||
}}', '?disable=', extension.id].join('')"
|
||||
:label="$t('disable')"
|
||||
></q-btn>
|
||||
<q-badge
|
||||
v-if="extension.isAdminOnly && !user.admin"
|
||||
v-text="$t('admin_only')"
|
||||
>
|
||||
{%raw%}{{ $t('disable') }}{%endraw%}</q-btn
|
||||
>
|
||||
<q-badge v-if="extension.isAdminOnly && !user.admin">
|
||||
{%raw%}{{ $t('admin_only') }}{%endraw%}
|
||||
</q-badge>
|
||||
<q-btn
|
||||
v-else-if="extension.isInstalled && extension.isActive && !user.extensions.includes(extension.id)"
|
||||
@@ -210,8 +207,8 @@
|
||||
:href="['{{
|
||||
url_for('install.extensions')
|
||||
}}', '?enable=', extension.id].join('')"
|
||||
:label="$t('enable')"
|
||||
>
|
||||
{%raw%}{{ $t('enable') }}{%endraw%}
|
||||
<q-tooltip>
|
||||
<span v-text="$t('enable_extension_details')">
|
||||
</span> </q-tooltip
|
||||
@@ -222,12 +219,11 @@
|
||||
flat
|
||||
color="primary"
|
||||
v-if="g.user.admin"
|
||||
>
|
||||
{%raw%}{{ $t('manage') }}{%endraw%}<q-tooltip
|
||||
>{%raw%}{{ $t('manage_extension_details')
|
||||
}}{%endraw%}</q-tooltip
|
||||
></q-btn
|
||||
>
|
||||
:label="$t('manage')"
|
||||
><q-tooltip
|
||||
><span v-text="$t('manage_extension_details')"></span
|
||||
></q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
<div v-else>
|
||||
<q-spinner color="primary" size="2.55em"></q-spinner>
|
||||
@@ -240,7 +236,7 @@
|
||||
class="float-right"
|
||||
>
|
||||
<q-badge>
|
||||
{% raw %}{{ extension.installedRelease.version }}{% endraw %}
|
||||
<span v-text="extension.installedRelease.version"></span>
|
||||
<q-tooltip>
|
||||
<span v-text="$t('extension_installed_version')"></span>
|
||||
</q-tooltip>
|
||||
@@ -252,10 +248,10 @@
|
||||
</div>
|
||||
<q-dialog v-model="showUninstallDialog">
|
||||
<q-card class="q-pa-lg">
|
||||
<h6 class="q-my-md text-primary">{%raw%}{{ $t('warning') }}{%endraw%}</h6>
|
||||
<h6 class="q-my-md text-primary" v-text="$t('warning')"></h6>
|
||||
<p>
|
||||
{%raw%}{{ $t('extension_uninstall_warning') }}{%endraw%} <br />
|
||||
{%raw%}{{ $t('confirm_continue') }}{%endraw%}
|
||||
<span v-text="$t('extension_uninstall_warning')"></span><br />
|
||||
<span v-text="$t('confirm_continue')"></span>
|
||||
</p>
|
||||
|
||||
<div class="row q-mt-lg">
|
||||
@@ -264,32 +260,39 @@
|
||||
value="false"
|
||||
label="Cleanup database tables"
|
||||
>
|
||||
<q-tooltip class="bg-grey-8" anchor="bottom left" self="top left">
|
||||
{%raw%}{{ $t('extension_db_drop_info') }}{%endraw%}
|
||||
<q-tooltip class="bg-grey-8" anchor="bottom left" self="top left"
|
||||
><span v-text="$t('extension_db_drop_info')"></span>
|
||||
</q-tooltip>
|
||||
</q-checkbox>
|
||||
</div>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn outline color="grey" @click="uninstallExtension()"
|
||||
>{%raw%}{{ $t('uninstall_confirm') }}{%endraw%}</q-btn
|
||||
>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
|
||||
>{%raw%}{{ $t('cancel') }}{%endraw%}</q-btn
|
||||
>
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
@click="uninstallExtension()"
|
||||
v-text="$t('uninstall_confirm')"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
v-close-popup
|
||||
flat
|
||||
color="grey"
|
||||
class="q-ml-auto"
|
||||
v-text="$t('cancel')"
|
||||
></q-btn>
|
||||
</div>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<q-dialog v-model="showDropDbDialog">
|
||||
<q-card v-if="selectedExtension" class="q-pa-lg">
|
||||
<h6 class="q-my-md text-primary">{%raw%}{{ $t('warning') }}{%endraw%}</h6>
|
||||
<p>{%raw%}{{ $t('extension_db_drop_warning') }}{%endraw%} <br /></p>
|
||||
<h6 class="q-my-md text-primary" v-text="$t('warning')"></h6>
|
||||
<p><span v-text="$t('extension_db_drop_warning')"></span><br /></p>
|
||||
<q-input
|
||||
v-model="dropDbExtensionId"
|
||||
:label="selectedExtension.id"
|
||||
></q-input>
|
||||
<br />
|
||||
<p>{%raw%}{{ $t('confirm_continue') }}{%endraw%}</p>
|
||||
<p v-text="$t('confirm_continue')"></p>
|
||||
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
@@ -297,17 +300,58 @@
|
||||
outline
|
||||
color="red"
|
||||
@click="dropExtensionDb()"
|
||||
>{%raw%}{{ $t('confirm') }}{%endraw%}</q-btn
|
||||
>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
|
||||
>{%raw%}{{ $t('cancel') }}{%endraw%}</q-btn
|
||||
>
|
||||
v-text="$t('confirm')"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
v-close-popup
|
||||
flat
|
||||
color="grey"
|
||||
class="q-ml-auto"
|
||||
v-text="$t('cancel')"
|
||||
></q-btn>
|
||||
</div>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<q-dialog v-model="showUpgradeDialog">
|
||||
<q-card class="q-pa-lg lnbits__dialog-card">
|
||||
<q-card v-if="selectedRelease" class="q-pa-lg lnbits__dialog-card">
|
||||
<q-card-section>
|
||||
<div v-if="selectedRelease.paymentRequest">
|
||||
<a :href="'lightning:' + selectedRelease.paymentRequest">
|
||||
<q-responsive :ratio="1" class="q-mx-xl">
|
||||
<lnbits-qrcode
|
||||
:value="'lightning:' + selectedRelease.paymentRequest.toUpperCase()"
|
||||
></lnbits-qrcode>
|
||||
</q-responsive>
|
||||
</a>
|
||||
</div>
|
||||
<div v-else>
|
||||
<q-spinner color="primary" size="2.55em"></q-spinner>
|
||||
</div>
|
||||
</q-card-section>
|
||||
|
||||
<div class="row q-mt-lg">
|
||||
<div class="col">
|
||||
<q-btn
|
||||
v-if="selectedRelease.paymentRequest"
|
||||
outline
|
||||
color="grey"
|
||||
@click="copyText(selectedRelease.paymentRequest)"
|
||||
:label="$t('copy_invoice')"
|
||||
></q-btn>
|
||||
</div>
|
||||
<div class="col">
|
||||
<q-btn
|
||||
v-close-popup
|
||||
flat
|
||||
color="grey"
|
||||
class="float-right q-ml-lg"
|
||||
v-text="$t('close')"
|
||||
></q-btn>
|
||||
</div>
|
||||
</div>
|
||||
</q-card>
|
||||
<q-card v-else class="q-pa-lg lnbits__dialog-card">
|
||||
<q-card-section>
|
||||
<div class="text-h6" v-text="selectedExtension?.name"></div>
|
||||
</q-card-section>
|
||||
@@ -337,7 +381,7 @@
|
||||
<q-item-section>
|
||||
<div class="row">
|
||||
<div class="col-10">
|
||||
{%raw%}{{ $t('repository') }}{%endraw%}
|
||||
<span v-text="$t('repository')"></span>
|
||||
<br />
|
||||
<small v-text="repoName"></small>
|
||||
<q-tooltip
|
||||
@@ -376,19 +420,104 @@
|
||||
color="pink"
|
||||
size="70px"
|
||||
></q-icon>
|
||||
Cannot get the release details.
|
||||
<span v-text="$t('release_details_error')"></span>
|
||||
</div>
|
||||
<q-card v-else>
|
||||
<q-card-section v-if="release.is_version_compatible">
|
||||
<q-btn
|
||||
v-if="!release.isInstalled"
|
||||
@click="installExtension(release)"
|
||||
color="primary unelevated mt-lg pt-lg"
|
||||
>{%raw%}{{ $t('install') }}{%endraw%}</q-btn
|
||||
>
|
||||
<q-btn v-else @click="showUninstall()" flat color="red">
|
||||
{%raw%}{{ $t('uninstall') }}{%endraw%}</q-btn
|
||||
<span
|
||||
v-if="release.requiresPayment && !release.paid_sats"
|
||||
v-text="$t('extension_cost', {cost: release.cost_sats})"
|
||||
class="q-mb-lg"
|
||||
></span>
|
||||
<span
|
||||
v-if="release.requiresPayment && release.paid_sats"
|
||||
class="q-mb-lg"
|
||||
v-text="$t('extension_paid_sats', {paid_sats: release.paid_sats})"
|
||||
></span>
|
||||
<div
|
||||
v-if="!release.requiresPayment || (release.requiresPayment && release.paid_sats)"
|
||||
>
|
||||
<q-btn
|
||||
v-if="!release.isInstalled"
|
||||
@click="installExtension(release)"
|
||||
color="primary unelevated mt-lg pt-lg"
|
||||
:label="$t('install')"
|
||||
></q-btn>
|
||||
</div>
|
||||
|
||||
<div v-if="release.requiresPayment && !release.paid_sats">
|
||||
<div v-if="!release.payment_hash">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.number="release.paidAmount"
|
||||
type="number"
|
||||
:min="release.cost_sats"
|
||||
suffix="sat"
|
||||
class="q-mt-sm"
|
||||
>
|
||||
</q-input>
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
emit-value
|
||||
v-model="release.wallet"
|
||||
:options="g.user.walletOptions"
|
||||
label="Wallet *"
|
||||
class="q-mt-sm"
|
||||
>
|
||||
</q-select>
|
||||
<q-btn
|
||||
unelevated
|
||||
color="primary"
|
||||
@click="payAndInstall(release)"
|
||||
:disabled="!release.wallet"
|
||||
class="q-mt-sm"
|
||||
:label="$t('pay_from_wallet')"
|
||||
></q-btn>
|
||||
|
||||
<q-btn
|
||||
unelevated
|
||||
color="primary"
|
||||
@click="showQRCode(release)"
|
||||
class="q-mt-sm float-right"
|
||||
:label="$t('show_qr')"
|
||||
></q-btn>
|
||||
</div>
|
||||
<div v-else>
|
||||
<br />
|
||||
<span
|
||||
class="q-mb-lg q-mt-lg"
|
||||
v-text="'There is a previous pending invoice for this release.'"
|
||||
></span>
|
||||
<br />
|
||||
<q-btn
|
||||
unelevated
|
||||
@click="installExtension(release)"
|
||||
color="primary"
|
||||
class="q-mt-sm"
|
||||
:label="$t('retry_install')"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
unelevated
|
||||
@click="clearHangingInvoice(release)"
|
||||
color="primary"
|
||||
class="q-mt-sm float-right"
|
||||
:label="$t('new_payment')"
|
||||
></q-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<q-btn
|
||||
v-if="release.isInstalled"
|
||||
@click="showUninstall()"
|
||||
:label="$t('uninstall')"
|
||||
flat
|
||||
color="red"
|
||||
></q-btn>
|
||||
</div>
|
||||
|
||||
<a
|
||||
v-if="release.html_url"
|
||||
class="text-secondary float-right"
|
||||
@@ -396,11 +525,11 @@
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style="color: inherit"
|
||||
>{%raw%}{{ $t('release_notes') }}{%endraw%}</a
|
||||
>
|
||||
v-text="$t('release_notes')"
|
||||
></a>
|
||||
</q-card-section>
|
||||
<q-card-section v-else>
|
||||
{%raw%}{{ $t('extension_min_lnbits_version') }}{%endraw%}
|
||||
<span v-text="$t('extension_min_lnbits_version')"></span>
|
||||
<strong>
|
||||
<span v-text="release.min_lnbits_version"></span>
|
||||
</strong>
|
||||
@@ -408,8 +537,11 @@
|
||||
<q-card v-if="release.warning">
|
||||
<q-card-section>
|
||||
<div class="text-h6">
|
||||
<q-badge color="yellow" text-color="black">
|
||||
{%raw%}{{ $t('warning') }}{%endraw%}
|
||||
<q-badge
|
||||
color="yellow"
|
||||
text-color="black"
|
||||
v-text="$t('warning')"
|
||||
>
|
||||
</q-badge>
|
||||
</div>
|
||||
<div class="text-subtitle2">
|
||||
@@ -432,9 +564,8 @@
|
||||
@click="showUninstall()"
|
||||
flat
|
||||
color="red"
|
||||
>
|
||||
{%raw%}{{ $t('uninstall') }}{%endraw%}</q-btn
|
||||
>
|
||||
v-text="$t('uninstall')"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
v-else-if="selectedExtension?.hasDatabaseTables"
|
||||
@click="showDropDb()"
|
||||
@@ -442,9 +573,13 @@
|
||||
color="red"
|
||||
:label="$t('drop_db')"
|
||||
></q-btn>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto">
|
||||
{%raw%}{{ $t('close') }}{%endraw%}</q-btn
|
||||
>
|
||||
<q-btn
|
||||
v-close-popup
|
||||
flat
|
||||
color="grey"
|
||||
class="q-ml-auto"
|
||||
v-text="$t('close')"
|
||||
></q-btn>
|
||||
</div>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
@@ -464,8 +599,10 @@
|
||||
dropDbExtensionId: '',
|
||||
selectedExtension: null,
|
||||
selectedExtensionRepos: null,
|
||||
selectedRelease: null,
|
||||
uninstallAndDropDb: false,
|
||||
maxStars: 5,
|
||||
paylinkWebsocket: null,
|
||||
user: null
|
||||
}
|
||||
},
|
||||
@@ -504,10 +641,18 @@
|
||||
.filter(extensionNameContains(term))
|
||||
this.tab = tab
|
||||
},
|
||||
|
||||
installExtension: async function (release) {
|
||||
// no longer required to check if the invoice was paid
|
||||
// the install logic has been triggered one way or another
|
||||
this.unsubscribeFromPaylinkWs()
|
||||
|
||||
const extension = this.selectedExtension
|
||||
extension.inProgress = true
|
||||
this.showUpgradeDialog = false
|
||||
release.payment_hash =
|
||||
release.payment_hash || this.getPaylinkHash(release.pay_link)
|
||||
|
||||
LNbits.api
|
||||
.request(
|
||||
'POST',
|
||||
@@ -516,7 +661,9 @@
|
||||
{
|
||||
ext_id: extension.id,
|
||||
archive: release.archive,
|
||||
source_repo: release.source_repo
|
||||
source_repo: release.source_repo,
|
||||
payment_hash: release.payment_hash,
|
||||
version: release.version
|
||||
}
|
||||
)
|
||||
.then(response => {
|
||||
@@ -530,8 +677,9 @@
|
||||
this.tab = 'installed'
|
||||
})
|
||||
.catch(err => {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
console.warn(err)
|
||||
extension.inProgress = false
|
||||
LNbits.utils.notifyApiError(err)
|
||||
})
|
||||
},
|
||||
uninstallExtension: async function () {
|
||||
@@ -623,8 +771,10 @@
|
||||
|
||||
showUpgrade: async function (extension) {
|
||||
this.selectedExtension = extension
|
||||
this.showUpgradeDialog = true
|
||||
this.selectedRelease = null
|
||||
this.selectedExtensionRepos = null
|
||||
this.showUpgradeDialog = true
|
||||
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
'GET',
|
||||
@@ -648,6 +798,12 @@
|
||||
if (release.isInstalled) {
|
||||
repos[release.source_repo].isInstalled = true
|
||||
}
|
||||
if (release.pay_link) {
|
||||
release.requiresPayment = true
|
||||
release.paidAmount = release.cost_sats
|
||||
release.payment_hash = this.getPaylinkHash(release.pay_link)
|
||||
}
|
||||
|
||||
repos[release.source_repo].releases.push(release)
|
||||
return repos
|
||||
}, {})
|
||||
@@ -656,6 +812,122 @@
|
||||
extension.inProgress = false
|
||||
}
|
||||
},
|
||||
|
||||
async payAndInstall(release) {
|
||||
try {
|
||||
this.selectedExtension.inProgress = true
|
||||
this.showUpgradeDialog = false
|
||||
const paymentInfo = await this.requestPayment(
|
||||
this.selectedExtension.id,
|
||||
release
|
||||
)
|
||||
this.rememberPaylinkHash(release.pay_link, paymentInfo.payment_hash)
|
||||
const wallet = this.g.user.wallets.find(w => w.id === release.wallet)
|
||||
const {data} = await LNbits.api.payInvoice(
|
||||
wallet,
|
||||
paymentInfo.payment_request
|
||||
)
|
||||
|
||||
release.payment_hash = data.payment_hash
|
||||
|
||||
await this.installExtension(release)
|
||||
} catch (err) {
|
||||
console.warn(err)
|
||||
LNbits.utils.notifyApiError(err)
|
||||
} finally {
|
||||
this.selectedExtension.inProgress = false
|
||||
}
|
||||
},
|
||||
async showQRCode(release) {
|
||||
this.selectedRelease = release
|
||||
|
||||
try {
|
||||
const data = await this.requestPayment(
|
||||
this.selectedExtension.id,
|
||||
release
|
||||
)
|
||||
|
||||
this.selectedRelease.paymentRequest = data.payment_request
|
||||
this.selectedRelease.payment_hash = data.payment_hash
|
||||
this.selectedRelease = _.clone(this.selectedRelease)
|
||||
this.rememberPaylinkHash(
|
||||
this.selectedRelease.pay_link,
|
||||
this.selectedRelease.payment_hash
|
||||
)
|
||||
|
||||
this.subscribeToPaylinkWs(
|
||||
this.selectedRelease.pay_link,
|
||||
data.payment_hash
|
||||
)
|
||||
} catch (err) {
|
||||
console.warn(err)
|
||||
LNbits.utils.notifyApiError(err)
|
||||
}
|
||||
},
|
||||
|
||||
async requestPayment(extId, release) {
|
||||
const {data} = await LNbits.api.request(
|
||||
'PUT',
|
||||
`/api/v1/extension/invoice`,
|
||||
this.g.user.wallets[0].adminkey,
|
||||
{
|
||||
ext_id: extId,
|
||||
archive: release.archive,
|
||||
source_repo: release.source_repo,
|
||||
cost_sats: release.paidAmount,
|
||||
version: release.version
|
||||
}
|
||||
)
|
||||
return data
|
||||
},
|
||||
|
||||
clearHangingInvoice(release) {
|
||||
this.forgetPaylinkHash(release.pay_link)
|
||||
release.payment_hash = null
|
||||
},
|
||||
|
||||
rememberPaylinkHash(pay_link, payment_hash) {
|
||||
this.$q.localStorage.set(
|
||||
`lnbits.extensions.paylink.${pay_link}`,
|
||||
payment_hash
|
||||
)
|
||||
},
|
||||
getPaylinkHash(pay_link) {
|
||||
return this.$q.localStorage.getItem(
|
||||
`lnbits.extensions.paylink.${pay_link}`
|
||||
)
|
||||
},
|
||||
forgetPaylinkHash(pay_link) {
|
||||
this.$q.localStorage.remove(`lnbits.extensions.paylink.${pay_link}`)
|
||||
},
|
||||
subscribeToPaylinkWs(pay_link, payment_hash) {
|
||||
const url = new URL(`${pay_link}/${payment_hash}`)
|
||||
url.protocol = url.protocol === 'https:' ? 'wss' : 'ws'
|
||||
this.paylinkWebsocket = new WebSocket(url)
|
||||
this.paylinkWebsocket.addEventListener('message', async ({data}) => {
|
||||
const resp = JSON.parse(data)
|
||||
if (resp.paid) {
|
||||
this.$q.notify({
|
||||
type: 'positive',
|
||||
message: 'Invoice Paid!'
|
||||
})
|
||||
this.installExtension(this.selectedRelease)
|
||||
} else {
|
||||
this.$q.notify({
|
||||
type: 'warning',
|
||||
message: 'Invoice tracking lost!'
|
||||
})
|
||||
}
|
||||
})
|
||||
},
|
||||
unsubscribeFromPaylinkWs() {
|
||||
try {
|
||||
this.paylinkWebsocket && this.paylinkWebsocket.close()
|
||||
} catch (error) {
|
||||
console.warn(error)
|
||||
}
|
||||
},
|
||||
|
||||
hasNewVersion: function (extension) {
|
||||
if (extension.installedRelease && extension.latestRelease) {
|
||||
return (
|
||||
|
||||
@@ -490,7 +490,14 @@
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col q-pl-md"></div>
|
||||
<div class="col q-pl-md">
|
||||
<a href="https://zbd.gg" target="_blank" rel="noopener noreferrer">
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/zbd.png') }}' : '{{ static_url_for('static', 'images/zbdl.png') }}'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -370,7 +370,7 @@
|
||||
<q-card-section class="text-center">
|
||||
<p v-text="$t('export_to_phone_desc')"></p>
|
||||
<qrcode
|
||||
:value="'{{request.base_url}}' +'wallet?wal={{wallet.id}}'"
|
||||
:value="'{{request.base_url}}wallet?usr={{user.id}}&wal={{wallet.id}}'"
|
||||
:options="{width:240}"
|
||||
></qrcode>
|
||||
</q-card-section>
|
||||
|
||||
@@ -150,7 +150,6 @@
|
||||
Open channel
|
||||
</q-btn>
|
||||
</div>
|
||||
{% raw %}
|
||||
<div>
|
||||
<div class="text-subtitle1 col-grow">Total</div>
|
||||
<lnbits-channel-balance
|
||||
@@ -172,11 +171,12 @@
|
||||
<q-tr :props="props">
|
||||
<div class="q-pb-sm">
|
||||
<div class="row items-center q-gutter-sm">
|
||||
<div class="text-subtitle1 col-grow">
|
||||
{{props.row.name}}
|
||||
</div>
|
||||
<div
|
||||
class="text-subtitle1 col-grow"
|
||||
v-text="props.row.name"
|
||||
></div>
|
||||
<div class="text-caption" v-if="props.row.short_id">
|
||||
{{ props.row.short_id }}
|
||||
<span v-text="props.row.short_id"></span>
|
||||
<q-btn
|
||||
size="xs"
|
||||
flat
|
||||
@@ -188,9 +188,8 @@
|
||||
<q-badge
|
||||
rounded
|
||||
:color="states.find(s => s.value == props.row.state)?.color"
|
||||
v-text="states.find(s => s.value == props.row.state)?.label"
|
||||
>
|
||||
{{ states.find(s => s.value == props.row.state)?.label
|
||||
}}
|
||||
</q-badge>
|
||||
<q-btn
|
||||
:disable='props.row.state !== "active"'
|
||||
@@ -210,15 +209,12 @@
|
||||
</q-tr>
|
||||
</template>
|
||||
</q-table>
|
||||
|
||||
{% endraw %}
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
<div class="col-12 col-xl-6">
|
||||
<q-card class="full-height">
|
||||
<q-card-section class="column q-gutter-y-sm">
|
||||
{% raw %}
|
||||
<div
|
||||
class="row items-center q-mt-none justify-between q-gutter-x-md no-wrap"
|
||||
>
|
||||
@@ -254,19 +250,21 @@
|
||||
<q-tr :props="props">
|
||||
<div class="row no-wrap items-center q-gutter-sm">
|
||||
<div class="q-my-sm col-grow">
|
||||
<div class="text-subtitle1 text-bold">
|
||||
{{ props.row.alias }}
|
||||
</div>
|
||||
<div
|
||||
class="text-subtitle1 text-bold"
|
||||
v-text="props.row.alias"
|
||||
></div>
|
||||
<div class="row items-center q-gutter-sm">
|
||||
<q-badge
|
||||
:style="`background-color: #${props.row.color}`"
|
||||
class="text-bold"
|
||||
v-text="'#'+props.row.color"
|
||||
>
|
||||
#{{ props.row.color }}
|
||||
</q-badge>
|
||||
<div class="text-bold">
|
||||
{{ shortenNodeId(props.row.id) }}
|
||||
</div>
|
||||
<div
|
||||
class="text-bold"
|
||||
v-text="shortenNodeId(props.row.id)"
|
||||
></div>
|
||||
<q-btn
|
||||
size="xs"
|
||||
flat
|
||||
@@ -302,8 +300,6 @@
|
||||
</q-tr>
|
||||
</template>
|
||||
</q-table>
|
||||
|
||||
{% endraw %}
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<q-tab-panel name="dashboard">
|
||||
<q-card-section class="q-pa-none">
|
||||
{% raw %}
|
||||
<lnbits-node-info :info="this.info"></lnbits-node-info>
|
||||
<div class="row q-col-gutter-lg q-mt-sm">
|
||||
<div class="col-12 col-md-8 q-gutter-y-md">
|
||||
@@ -65,6 +64,5 @@
|
||||
></lnbits-channel-stats>
|
||||
</div>
|
||||
</div>
|
||||
{% endraw %}
|
||||
</q-card-section>
|
||||
</q-tab-panel>
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
<q-dialog v-model="transactionDetailsDialog.show">
|
||||
<q-card class="my-card">
|
||||
<q-card-section>
|
||||
{% raw %}
|
||||
<div class="text-center q-mb-lg">
|
||||
<div
|
||||
v-if="transactionDetailsDialog.data.isIn && transactionDetailsDialog.data.pending"
|
||||
@@ -18,7 +17,9 @@
|
||||
<div class="row q-my-md">
|
||||
<div class="col-3"><b v-text="$t('payment_hash')"></b>:</div>
|
||||
<div class="col-9 text-wrap mono">
|
||||
{{ transactionDetailsDialog.data.payment_hash }}
|
||||
<span
|
||||
v-text="transactionDetailsDialog.data.payment_hash"
|
||||
></span>
|
||||
<q-icon
|
||||
name="content_copy"
|
||||
@click="copyText(transactionDetailsDialog.data.payment_hash)"
|
||||
@@ -33,7 +34,7 @@
|
||||
>
|
||||
<div class="col-3"><b v-text="$t('payment_proof')"></b>:</div>
|
||||
<div class="col-9 text-wrap mono">
|
||||
{{ transactionDetailsDialog.data.preimage }}
|
||||
<span v-text="transactionDetailsDialog.data.preimage"></span>
|
||||
<q-icon
|
||||
name="content_copy"
|
||||
@click="copyText(transactionDetailsDialog.data.preimage)"
|
||||
@@ -66,7 +67,6 @@
|
||||
></q-btn>
|
||||
</div>
|
||||
</div>
|
||||
{% endraw %}
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
@@ -102,7 +102,6 @@
|
||||
:filter="paymentsTable.filter"
|
||||
@request="getPayments"
|
||||
>
|
||||
{% raw %}
|
||||
<template v-slot:body-cell-pending="props">
|
||||
<q-td auto-width class="text-center">
|
||||
<q-icon
|
||||
@@ -211,9 +210,8 @@
|
||||
<q-badge
|
||||
:style="`background-color: #${props.row.destination?.color}`"
|
||||
class="text-bold"
|
||||
>
|
||||
{{ props.row.destination?.alias }}
|
||||
</q-badge>
|
||||
v-text="props.row.destination?.alias"
|
||||
></q-badge>
|
||||
<div>
|
||||
<q-btn
|
||||
size="xs"
|
||||
@@ -233,7 +231,6 @@
|
||||
</div>
|
||||
</q-td>
|
||||
</template>
|
||||
{% endraw %}
|
||||
</q-table>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
@@ -266,7 +263,6 @@
|
||||
:filter="invoiceTable.filter"
|
||||
@request="getInvoices"
|
||||
>
|
||||
{% raw %}
|
||||
<template v-slot:body-cell-pending="props">
|
||||
<q-td auto-width class="text-center">
|
||||
<q-icon
|
||||
@@ -305,8 +301,6 @@
|
||||
></lnbits-date>
|
||||
</q-td>
|
||||
</template>
|
||||
|
||||
{% endraw %}
|
||||
</q-table>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// update cache version every time there is a new deployment
|
||||
// so the service worker reinitializes the cache
|
||||
const CACHE_VERSION = 117
|
||||
const CURRENT_CACHE = `lnbits-${CACHE_VERSION}-`
|
||||
const CURRENT_CACHE = 'lnbits-{{ cache_version }}-'
|
||||
|
||||
const getApiKey = request => {
|
||||
let api_key = request.headers.get('X-Api-Key')
|
||||
@@ -17,8 +16,7 @@ self.addEventListener('activate', evt =>
|
||||
caches.keys().then(cacheNames => {
|
||||
return Promise.all(
|
||||
cacheNames.map(cacheName => {
|
||||
const currentCacheVersion = cacheName.split('-').slice(-2, 2)
|
||||
if (currentCacheVersion !== CACHE_VERSION) {
|
||||
if (!cacheName.startsWith(CURRENT_CACHE)) {
|
||||
return caches.delete(cacheName)
|
||||
}
|
||||
})
|
||||
@@ -26,11 +26,11 @@ from lnbits.tasks import invoice_listeners
|
||||
from .. import core_app_extra
|
||||
from ..crud import delete_admin_settings, get_admin_settings, update_admin_settings
|
||||
|
||||
admin_router = APIRouter()
|
||||
admin_router = APIRouter(tags=["Admin UI"], prefix="/admin")
|
||||
|
||||
|
||||
@admin_router.get(
|
||||
"/admin/api/v1/audit",
|
||||
"/api/v1/audit",
|
||||
name="Audit",
|
||||
description="show the current balance of the node and the LNbits database",
|
||||
dependencies=[Depends(check_admin)],
|
||||
@@ -51,7 +51,7 @@ async def api_auditor():
|
||||
|
||||
|
||||
@admin_router.get(
|
||||
"/admin/api/v1/monitor",
|
||||
"/api/v1/monitor",
|
||||
name="Monitor",
|
||||
description="show the current listeners and other monitoring data",
|
||||
dependencies=[Depends(check_admin)],
|
||||
@@ -63,7 +63,7 @@ async def api_monitor():
|
||||
}
|
||||
|
||||
|
||||
@admin_router.get("/admin/api/v1/settings/", response_model=Optional[AdminSettings])
|
||||
@admin_router.get("/api/v1/settings", response_model=Optional[AdminSettings])
|
||||
async def api_get_settings(
|
||||
user: User = Depends(check_admin),
|
||||
) -> Optional[AdminSettings]:
|
||||
@@ -72,7 +72,7 @@ async def api_get_settings(
|
||||
|
||||
|
||||
@admin_router.put(
|
||||
"/admin/api/v1/settings/",
|
||||
"/api/v1/settings",
|
||||
status_code=HTTPStatus.OK,
|
||||
)
|
||||
async def api_update_settings(data: UpdateSettings, user: User = Depends(check_admin)):
|
||||
@@ -85,7 +85,7 @@ async def api_update_settings(data: UpdateSettings, user: User = Depends(check_a
|
||||
|
||||
|
||||
@admin_router.delete(
|
||||
"/admin/api/v1/settings/",
|
||||
"/api/v1/settings",
|
||||
status_code=HTTPStatus.OK,
|
||||
dependencies=[Depends(check_super_user)],
|
||||
)
|
||||
@@ -95,7 +95,7 @@ async def api_delete_settings() -> None:
|
||||
|
||||
|
||||
@admin_router.get(
|
||||
"/admin/api/v1/restart/",
|
||||
"/api/v1/restart",
|
||||
status_code=HTTPStatus.OK,
|
||||
dependencies=[Depends(check_super_user)],
|
||||
)
|
||||
@@ -105,7 +105,7 @@ async def api_restart_server() -> dict[str, str]:
|
||||
|
||||
|
||||
@admin_router.put(
|
||||
"/admin/api/v1/topup/",
|
||||
"/api/v1/topup",
|
||||
name="Topup",
|
||||
status_code=HTTPStatus.OK,
|
||||
dependencies=[Depends(check_super_user)],
|
||||
@@ -129,7 +129,7 @@ async def api_topup_balance(data: CreateTopup) -> dict[str, str]:
|
||||
|
||||
|
||||
@admin_router.get(
|
||||
"/admin/api/v1/backup/",
|
||||
"/api/v1/backup",
|
||||
status_code=HTTPStatus.OK,
|
||||
dependencies=[Depends(check_super_user)],
|
||||
response_class=FileResponse,
|
||||
|
||||
+19
-735
@@ -1,111 +1,52 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import uuid
|
||||
from http import HTTPStatus
|
||||
from io import BytesIO
|
||||
from math import ceil
|
||||
from typing import Dict, List, Optional, Union
|
||||
from typing import Dict, List
|
||||
from urllib.parse import ParseResult, parse_qs, urlencode, urlparse, urlunparse
|
||||
|
||||
import httpx
|
||||
import pyqrcode
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
Body,
|
||||
Depends,
|
||||
Header,
|
||||
Request,
|
||||
WebSocket,
|
||||
WebSocketDisconnect,
|
||||
)
|
||||
from fastapi.exceptions import HTTPException
|
||||
from fastapi.responses import JSONResponse
|
||||
from loguru import logger
|
||||
from sse_starlette.sse import EventSourceResponse
|
||||
from starlette.responses import StreamingResponse
|
||||
|
||||
from lnbits import bolt11
|
||||
from lnbits.core.db import core_app_extra, db
|
||||
from lnbits.core.helpers import (
|
||||
migrate_extension_database,
|
||||
stop_extension_background_work,
|
||||
)
|
||||
from lnbits.core.models import (
|
||||
BaseWallet,
|
||||
ConversionData,
|
||||
CreateInvoice,
|
||||
CreateLnurl,
|
||||
CreateLnurlAuth,
|
||||
CreateWallet,
|
||||
DecodePayment,
|
||||
Payment,
|
||||
PaymentFilters,
|
||||
PaymentHistoryPoint,
|
||||
Query,
|
||||
User,
|
||||
Wallet,
|
||||
WalletType,
|
||||
)
|
||||
from lnbits.db import Filters, Page
|
||||
from lnbits.decorators import (
|
||||
WalletTypeInfo,
|
||||
check_access_token,
|
||||
check_admin,
|
||||
check_user_exists,
|
||||
get_key_type,
|
||||
parse_filters,
|
||||
require_admin_key,
|
||||
require_invoice_key,
|
||||
)
|
||||
from lnbits.extension_manager import (
|
||||
CreateExtension,
|
||||
Extension,
|
||||
ExtensionRelease,
|
||||
InstallableExtension,
|
||||
fetch_github_release_config,
|
||||
get_valid_extensions,
|
||||
)
|
||||
from lnbits.helpers import generate_filter_params_openapi, url_for
|
||||
from lnbits.lnurl import decode as lnurl_decode
|
||||
from lnbits.settings import settings
|
||||
from lnbits.utils.exchange_rates import (
|
||||
currencies,
|
||||
allowed_currencies,
|
||||
fiat_amount_as_satoshis,
|
||||
satoshis_amount_as_fiat,
|
||||
)
|
||||
|
||||
from ..crud import (
|
||||
DateTrunc,
|
||||
add_installed_extension,
|
||||
create_account,
|
||||
create_wallet,
|
||||
delete_dbversion,
|
||||
delete_installed_extension,
|
||||
delete_wallet,
|
||||
drop_extension_db,
|
||||
get_dbversions,
|
||||
get_payments,
|
||||
get_payments_history,
|
||||
get_payments_paginated,
|
||||
get_standalone_payment,
|
||||
get_wallet_for_key,
|
||||
save_balance_check,
|
||||
update_pending_payments,
|
||||
update_wallet,
|
||||
)
|
||||
from ..services import (
|
||||
InvoiceFailure,
|
||||
PaymentFailure,
|
||||
check_transaction_status,
|
||||
create_invoice,
|
||||
fee_reserve_total,
|
||||
pay_invoice,
|
||||
perform_lnurlauth,
|
||||
websocketManager,
|
||||
websocketUpdater,
|
||||
)
|
||||
from ..tasks import api_invoice_listeners
|
||||
from ..services import perform_lnurlauth
|
||||
|
||||
api_router = APIRouter()
|
||||
# backwards compatibility for extension
|
||||
# TODO: remove api_payment and pay_invoice imports from extensions
|
||||
from .payment_api import api_payment, pay_invoice # noqa: F401
|
||||
|
||||
api_router = APIRouter(tags=["Core"])
|
||||
|
||||
|
||||
@api_router.get("/api/v1/health", status_code=HTTPStatus.OK)
|
||||
@@ -113,55 +54,13 @@ async def health():
|
||||
return
|
||||
|
||||
|
||||
@api_router.get("/api/v1/wallet")
|
||||
async def api_wallet(wallet: WalletTypeInfo = Depends(get_key_type)):
|
||||
if wallet.wallet_type == WalletType.admin:
|
||||
return {
|
||||
"id": wallet.wallet.id,
|
||||
"name": wallet.wallet.name,
|
||||
"balance": wallet.wallet.balance_msat,
|
||||
}
|
||||
else:
|
||||
return {"name": wallet.wallet.name, "balance": wallet.wallet.balance_msat}
|
||||
|
||||
|
||||
@api_router.put("/api/v1/wallet/{new_name}")
|
||||
async def api_update_wallet_name(
|
||||
new_name: str, wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||
):
|
||||
await update_wallet(wallet.wallet.id, new_name)
|
||||
return {
|
||||
"id": wallet.wallet.id,
|
||||
"name": wallet.wallet.name,
|
||||
"balance": wallet.wallet.balance_msat,
|
||||
}
|
||||
|
||||
|
||||
@api_router.patch("/api/v1/wallet", response_model=Wallet)
|
||||
async def api_update_wallet(
|
||||
name: Optional[str] = Body(None),
|
||||
currency: Optional[str] = Body(None),
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||
):
|
||||
return await update_wallet(wallet.wallet.id, name, currency)
|
||||
|
||||
|
||||
@api_router.delete("/api/v1/wallet")
|
||||
async def api_delete_wallet(
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||
) -> None:
|
||||
await delete_wallet(
|
||||
user_id=wallet.wallet.user,
|
||||
wallet_id=wallet.wallet.id,
|
||||
)
|
||||
|
||||
|
||||
@api_router.post("/api/v1/wallet", response_model=Wallet)
|
||||
async def api_create_wallet(
|
||||
data: CreateWallet,
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||
) -> Wallet:
|
||||
return await create_wallet(user_id=wallet.wallet.user, wallet_name=data.name)
|
||||
@api_router.get(
|
||||
"/api/v1/wallets",
|
||||
name="Wallets",
|
||||
description="Get basic info for all of user's wallets.",
|
||||
)
|
||||
async def api_wallets(user: User = Depends(check_user_exists)) -> List[BaseWallet]:
|
||||
return [BaseWallet(**w.dict()) for w in user.wallets]
|
||||
|
||||
|
||||
@api_router.post("/api/v1/account", response_model=Wallet)
|
||||
@@ -175,397 +74,6 @@ async def api_create_account(data: CreateWallet) -> Wallet:
|
||||
return await create_wallet(user_id=account.id, wallet_name=data.name)
|
||||
|
||||
|
||||
@api_router.get(
|
||||
"/api/v1/payments",
|
||||
name="Payment List",
|
||||
summary="get list of payments",
|
||||
response_description="list of payments",
|
||||
response_model=List[Payment],
|
||||
openapi_extra=generate_filter_params_openapi(PaymentFilters),
|
||||
)
|
||||
async def api_payments(
|
||||
wallet: WalletTypeInfo = Depends(get_key_type),
|
||||
filters: Filters = Depends(parse_filters(PaymentFilters)),
|
||||
):
|
||||
await update_pending_payments(wallet.wallet.id)
|
||||
return await get_payments(
|
||||
wallet_id=wallet.wallet.id,
|
||||
pending=True,
|
||||
complete=True,
|
||||
filters=filters,
|
||||
)
|
||||
|
||||
|
||||
@api_router.get(
|
||||
"/api/v1/payments/history",
|
||||
name="Get payments history",
|
||||
response_model=List[PaymentHistoryPoint],
|
||||
openapi_extra=generate_filter_params_openapi(PaymentFilters),
|
||||
)
|
||||
async def api_payments_history(
|
||||
wallet: WalletTypeInfo = Depends(get_key_type),
|
||||
group: DateTrunc = Query("day"),
|
||||
filters: Filters[PaymentFilters] = Depends(parse_filters(PaymentFilters)),
|
||||
):
|
||||
await update_pending_payments(wallet.wallet.id)
|
||||
return await get_payments_history(wallet.wallet.id, group, filters)
|
||||
|
||||
|
||||
@api_router.get(
|
||||
"/api/v1/payments/paginated",
|
||||
name="Payment List",
|
||||
summary="get paginated list of payments",
|
||||
response_description="list of payments",
|
||||
response_model=Page[Payment],
|
||||
openapi_extra=generate_filter_params_openapi(PaymentFilters),
|
||||
)
|
||||
async def api_payments_paginated(
|
||||
wallet: WalletTypeInfo = Depends(get_key_type),
|
||||
filters: Filters = Depends(parse_filters(PaymentFilters)),
|
||||
):
|
||||
await update_pending_payments(wallet.wallet.id)
|
||||
page = await get_payments_paginated(
|
||||
wallet_id=wallet.wallet.id,
|
||||
pending=True,
|
||||
complete=True,
|
||||
filters=filters,
|
||||
)
|
||||
return page
|
||||
|
||||
|
||||
async def api_payments_create_invoice(data: CreateInvoice, wallet: Wallet):
|
||||
description_hash = b""
|
||||
unhashed_description = b""
|
||||
memo = data.memo or settings.lnbits_site_title
|
||||
if data.description_hash or data.unhashed_description:
|
||||
if data.description_hash:
|
||||
try:
|
||||
description_hash = bytes.fromhex(data.description_hash)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail="'description_hash' must be a valid hex string",
|
||||
)
|
||||
if data.unhashed_description:
|
||||
try:
|
||||
unhashed_description = bytes.fromhex(data.unhashed_description)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail="'unhashed_description' must be a valid hex string",
|
||||
)
|
||||
# do not save memo if description_hash or unhashed_description is set
|
||||
memo = ""
|
||||
|
||||
async with db.connect() as conn:
|
||||
try:
|
||||
payment_hash, payment_request = await create_invoice(
|
||||
wallet_id=wallet.id,
|
||||
amount=data.amount,
|
||||
memo=memo,
|
||||
currency=data.unit,
|
||||
description_hash=description_hash,
|
||||
unhashed_description=unhashed_description,
|
||||
expiry=data.expiry,
|
||||
extra=data.extra,
|
||||
webhook=data.webhook,
|
||||
internal=data.internal,
|
||||
conn=conn,
|
||||
)
|
||||
# NOTE: we get the checking_id with a seperate query because create_invoice
|
||||
# does not return it and it would be a big hustle to change its return type
|
||||
# (used across extensions)
|
||||
payment_db = await get_standalone_payment(payment_hash, conn=conn)
|
||||
assert payment_db is not None, "payment not found"
|
||||
checking_id = payment_db.checking_id
|
||||
except InvoiceFailure as e:
|
||||
raise HTTPException(status_code=520, detail=str(e))
|
||||
except Exception as exc:
|
||||
raise exc
|
||||
|
||||
invoice = bolt11.decode(payment_request)
|
||||
|
||||
lnurl_response: Union[None, bool, str] = None
|
||||
if data.lnurl_callback:
|
||||
if data.lnurl_balance_check is not None:
|
||||
await save_balance_check(wallet.id, data.lnurl_balance_check)
|
||||
|
||||
headers = {"User-Agent": settings.user_agent}
|
||||
async with httpx.AsyncClient(headers=headers) as client:
|
||||
try:
|
||||
r = await client.get(
|
||||
data.lnurl_callback,
|
||||
params={
|
||||
"pr": payment_request,
|
||||
"balanceNotify": url_for(
|
||||
f"/withdraw/notify/{urlparse(data.lnurl_callback).netloc}",
|
||||
external=True,
|
||||
wal=wallet.id,
|
||||
),
|
||||
},
|
||||
timeout=10,
|
||||
)
|
||||
if r.is_error:
|
||||
lnurl_response = r.text
|
||||
else:
|
||||
resp = json.loads(r.text)
|
||||
if resp["status"] != "OK":
|
||||
lnurl_response = resp["reason"]
|
||||
else:
|
||||
lnurl_response = True
|
||||
except (httpx.ConnectError, httpx.RequestError) as ex:
|
||||
logger.error(ex)
|
||||
lnurl_response = False
|
||||
|
||||
return {
|
||||
"payment_hash": invoice.payment_hash,
|
||||
"payment_request": payment_request,
|
||||
# maintain backwards compatibility with API clients:
|
||||
"checking_id": checking_id,
|
||||
"lnurl_response": lnurl_response,
|
||||
}
|
||||
|
||||
|
||||
async def api_payments_pay_invoice(
|
||||
bolt11: str, wallet: Wallet, extra: Optional[dict] = None
|
||||
):
|
||||
try:
|
||||
payment_hash = await pay_invoice(
|
||||
wallet_id=wallet.id, payment_request=bolt11, extra=extra
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e))
|
||||
except PermissionError as e:
|
||||
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail=str(e))
|
||||
except PaymentFailure as e:
|
||||
raise HTTPException(status_code=520, detail=str(e))
|
||||
except Exception as exc:
|
||||
raise exc
|
||||
|
||||
return {
|
||||
"payment_hash": payment_hash,
|
||||
# maintain backwards compatibility with API clients:
|
||||
"checking_id": payment_hash,
|
||||
}
|
||||
|
||||
|
||||
@api_router.post(
|
||||
"/api/v1/payments",
|
||||
summary="Create or pay an invoice",
|
||||
description="""
|
||||
This endpoint can be used both to generate and pay a BOLT11 invoice.
|
||||
To generate a new invoice for receiving funds into the authorized account,
|
||||
specify at least the first four fields in the POST body: `out: false`,
|
||||
`amount`, `unit`, and `memo`. To pay an arbitrary invoice from the funds
|
||||
already in the authorized account, specify `out: true` and use the `bolt11`
|
||||
field to supply the BOLT11 invoice to be paid.
|
||||
""",
|
||||
status_code=HTTPStatus.CREATED,
|
||||
)
|
||||
async def api_payments_create(
|
||||
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
||||
invoiceData: CreateInvoice = Body(...),
|
||||
):
|
||||
if invoiceData.out is True and wallet.wallet_type == WalletType.admin:
|
||||
if not invoiceData.bolt11:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail="BOLT11 string is invalid or not given",
|
||||
)
|
||||
return await api_payments_pay_invoice(
|
||||
invoiceData.bolt11, wallet.wallet, invoiceData.extra
|
||||
) # admin key
|
||||
elif not invoiceData.out:
|
||||
# invoice key
|
||||
return await api_payments_create_invoice(invoiceData, wallet.wallet)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.UNAUTHORIZED,
|
||||
detail="Invoice (or Admin) key required.",
|
||||
)
|
||||
|
||||
|
||||
@api_router.get("/api/v1/payments/fee-reserve")
|
||||
async def api_payments_fee_reserve(invoice: str = Query("invoice")) -> JSONResponse:
|
||||
invoice_obj = bolt11.decode(invoice)
|
||||
if invoice_obj.amount_msat:
|
||||
response = {
|
||||
"fee_reserve": fee_reserve_total(invoice_obj.amount_msat),
|
||||
}
|
||||
return JSONResponse(response)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail="Invoice has no amount.",
|
||||
)
|
||||
|
||||
|
||||
@api_router.post("/api/v1/payments/lnurl")
|
||||
async def api_payments_pay_lnurl(
|
||||
data: CreateLnurl, wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||
):
|
||||
domain = urlparse(data.callback).netloc
|
||||
|
||||
headers = {"User-Agent": settings.user_agent}
|
||||
async with httpx.AsyncClient(headers=headers, follow_redirects=True) as client:
|
||||
try:
|
||||
if data.unit and data.unit != "sat":
|
||||
amount_msat = await fiat_amount_as_satoshis(data.amount, data.unit)
|
||||
# no msat precision
|
||||
amount_msat = ceil(amount_msat // 1000) * 1000
|
||||
else:
|
||||
amount_msat = data.amount
|
||||
r = await client.get(
|
||||
data.callback,
|
||||
params={"amount": amount_msat, "comment": data.comment},
|
||||
timeout=40,
|
||||
)
|
||||
if r.is_error:
|
||||
raise httpx.ConnectError("LNURL callback connection error")
|
||||
r.raise_for_status()
|
||||
except (httpx.ConnectError, httpx.RequestError):
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail=f"Failed to connect to {domain}.",
|
||||
)
|
||||
|
||||
params = json.loads(r.text)
|
||||
if params.get("status") == "ERROR":
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail=f"{domain} said: '{params.get('reason', '')}'",
|
||||
)
|
||||
|
||||
if not params.get("pr"):
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail=f"{domain} did not return a payment request.",
|
||||
)
|
||||
|
||||
invoice = bolt11.decode(params["pr"])
|
||||
if invoice.amount_msat != amount_msat:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail=(
|
||||
(
|
||||
f"{domain} returned an invalid invoice. Expected"
|
||||
f" {amount_msat} msat, got {invoice.amount_msat}."
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
extra = {}
|
||||
|
||||
if params.get("successAction"):
|
||||
extra["success_action"] = params["successAction"]
|
||||
if data.comment:
|
||||
extra["comment"] = data.comment
|
||||
if data.unit and data.unit != "sat":
|
||||
extra["fiat_currency"] = data.unit
|
||||
extra["fiat_amount"] = data.amount / 1000
|
||||
assert data.description is not None, "description is required"
|
||||
payment_hash = await pay_invoice(
|
||||
wallet_id=wallet.wallet.id,
|
||||
payment_request=params["pr"],
|
||||
description=data.description,
|
||||
extra=extra,
|
||||
)
|
||||
|
||||
return {
|
||||
"success_action": params.get("successAction"),
|
||||
"payment_hash": payment_hash,
|
||||
# maintain backwards compatibility with API clients:
|
||||
"checking_id": payment_hash,
|
||||
}
|
||||
|
||||
|
||||
async def subscribe_wallet_invoices(request: Request, wallet: Wallet):
|
||||
"""
|
||||
Subscribe to new invoices for a wallet. Can be wrapped in EventSourceResponse.
|
||||
Listenes invoming payments for a wallet and yields jsons with payment details.
|
||||
"""
|
||||
this_wallet_id = wallet.id
|
||||
|
||||
payment_queue: asyncio.Queue[Payment] = asyncio.Queue(0)
|
||||
|
||||
uid = f"{this_wallet_id}_{str(uuid.uuid4())[:8]}"
|
||||
logger.debug(f"adding sse listener for wallet: {uid}")
|
||||
api_invoice_listeners[uid] = payment_queue
|
||||
|
||||
try:
|
||||
while True:
|
||||
if await request.is_disconnected():
|
||||
await request.close()
|
||||
break
|
||||
payment: Payment = await payment_queue.get()
|
||||
if payment.wallet_id == this_wallet_id:
|
||||
logger.debug("sse listener: payment received", payment)
|
||||
yield dict(data=payment.json(), event="payment-received")
|
||||
except asyncio.CancelledError:
|
||||
logger.debug(f"removing listener for wallet {uid}")
|
||||
except Exception as exc:
|
||||
logger.error(f"Error in sse: {exc}")
|
||||
finally:
|
||||
api_invoice_listeners.pop(uid)
|
||||
|
||||
|
||||
@api_router.get("/api/v1/payments/sse")
|
||||
async def api_payments_sse(
|
||||
request: Request, wallet: WalletTypeInfo = Depends(get_key_type)
|
||||
):
|
||||
return EventSourceResponse(
|
||||
subscribe_wallet_invoices(request, wallet.wallet),
|
||||
ping=20,
|
||||
media_type="text/event-stream",
|
||||
)
|
||||
|
||||
|
||||
# TODO: refactor this route into a public and admin one
|
||||
@api_router.get("/api/v1/payments/{payment_hash}")
|
||||
async def api_payment(payment_hash, X_Api_Key: Optional[str] = Header(None)):
|
||||
# We use X_Api_Key here because we want this call to work with and without keys
|
||||
# If a valid key is given, we also return the field "details", otherwise not
|
||||
wallet = await get_wallet_for_key(X_Api_Key) if isinstance(X_Api_Key, str) else None
|
||||
wallet = wallet if wallet and not wallet.deleted else None
|
||||
# we have to specify the wallet id here, because postgres and sqlite return
|
||||
# internal payments in different order and get_standalone_payment otherwise
|
||||
# just fetches the first one, causing unpredictable results
|
||||
payment = await get_standalone_payment(
|
||||
payment_hash, wallet_id=wallet.id if wallet else None
|
||||
)
|
||||
if payment is None:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Payment does not exist."
|
||||
)
|
||||
await check_transaction_status(payment.wallet_id, payment_hash)
|
||||
payment = await get_standalone_payment(
|
||||
payment_hash, wallet_id=wallet.id if wallet else None
|
||||
)
|
||||
if not payment:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Payment does not exist."
|
||||
)
|
||||
elif not payment.pending:
|
||||
if wallet and wallet.id == payment.wallet_id:
|
||||
return {"paid": True, "preimage": payment.preimage, "details": payment}
|
||||
return {"paid": True, "preimage": payment.preimage}
|
||||
|
||||
try:
|
||||
await payment.check_status()
|
||||
except Exception:
|
||||
if wallet and wallet.id == payment.wallet_id:
|
||||
return {"paid": False, "details": payment}
|
||||
return {"paid": False}
|
||||
|
||||
if wallet and wallet.id == payment.wallet_id:
|
||||
return {
|
||||
"paid": not payment.pending,
|
||||
"preimage": payment.preimage,
|
||||
"details": payment,
|
||||
}
|
||||
return {"paid": not payment.pending, "preimage": payment.preimage}
|
||||
|
||||
|
||||
@api_router.get("/api/v1/lnurlscan/{code}")
|
||||
async def api_lnurlscan(code: str, wallet: WalletTypeInfo = Depends(get_key_type)):
|
||||
try:
|
||||
@@ -682,23 +190,6 @@ async def api_lnurlscan(code: str, wallet: WalletTypeInfo = Depends(get_key_type
|
||||
return params
|
||||
|
||||
|
||||
@api_router.post("/api/v1/payments/decode", status_code=HTTPStatus.OK)
|
||||
async def api_payments_decode(data: DecodePayment) -> JSONResponse:
|
||||
payment_str = data.data
|
||||
try:
|
||||
if payment_str[:5] == "LNURL":
|
||||
url = str(lnurl_decode(payment_str))
|
||||
return JSONResponse({"domain": url})
|
||||
else:
|
||||
invoice = bolt11.decode(payment_str)
|
||||
return JSONResponse(invoice.data)
|
||||
except Exception as exc:
|
||||
return JSONResponse(
|
||||
{"message": f"Failed to decode: {str(exc)}"},
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
@api_router.post("/api/v1/lnurlauth")
|
||||
async def api_perform_lnurlauth(
|
||||
data: CreateLnurlAuth, wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||
@@ -712,14 +203,8 @@ async def api_perform_lnurlauth(
|
||||
|
||||
|
||||
@api_router.get("/api/v1/currencies")
|
||||
async def api_list_currencies_available():
|
||||
if len(settings.lnbits_allowed_currencies) > 0:
|
||||
return [
|
||||
item
|
||||
for item in currencies.keys()
|
||||
if item.upper() in settings.lnbits_allowed_currencies
|
||||
]
|
||||
return list(currencies.keys())
|
||||
async def api_list_currencies_available() -> List[str]:
|
||||
return allowed_currencies()
|
||||
|
||||
|
||||
@api_router.post("/api/v1/conversion")
|
||||
@@ -759,204 +244,3 @@ async def img(data):
|
||||
"Expires": "0",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@api_router.websocket("/api/v1/ws/{item_id}")
|
||||
async def websocket_connect(websocket: WebSocket, item_id: str):
|
||||
await websocketManager.connect(websocket, item_id)
|
||||
try:
|
||||
while True:
|
||||
await websocket.receive_text()
|
||||
except WebSocketDisconnect:
|
||||
websocketManager.disconnect(websocket)
|
||||
|
||||
|
||||
@api_router.post("/api/v1/ws/{item_id}")
|
||||
async def websocket_update_post(item_id: str, data: str):
|
||||
try:
|
||||
await websocketUpdater(item_id, data)
|
||||
return {"sent": True, "data": data}
|
||||
except Exception:
|
||||
return {"sent": False, "data": data}
|
||||
|
||||
|
||||
@api_router.get("/api/v1/ws/{item_id}/{data}")
|
||||
async def websocket_update_get(item_id: str, data: str):
|
||||
try:
|
||||
await websocketUpdater(item_id, data)
|
||||
return {"sent": True, "data": data}
|
||||
except Exception:
|
||||
return {"sent": False, "data": data}
|
||||
|
||||
|
||||
@api_router.post("/api/v1/extension")
|
||||
async def api_install_extension(
|
||||
data: CreateExtension,
|
||||
user: User = Depends(check_admin),
|
||||
access_token: Optional[str] = Depends(check_access_token),
|
||||
):
|
||||
release = await InstallableExtension.get_extension_release(
|
||||
data.ext_id, data.source_repo, data.archive
|
||||
)
|
||||
if not release:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Release not found"
|
||||
)
|
||||
|
||||
if not release.is_version_compatible:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST, detail="Incompatible extension version"
|
||||
)
|
||||
|
||||
ext_info = InstallableExtension(
|
||||
id=data.ext_id, name=data.ext_id, installed_release=release, icon=release.icon
|
||||
)
|
||||
|
||||
ext_info.download_archive()
|
||||
|
||||
try:
|
||||
ext_info.extract_archive()
|
||||
|
||||
extension = Extension.from_installable_ext(ext_info)
|
||||
|
||||
db_version = (await get_dbversions()).get(data.ext_id, 0)
|
||||
await migrate_extension_database(extension, db_version)
|
||||
|
||||
await add_installed_extension(ext_info)
|
||||
|
||||
if extension.is_upgrade_extension:
|
||||
# call stop while the old routes are still active
|
||||
await stop_extension_background_work(data.ext_id, user.id, access_token)
|
||||
|
||||
if data.ext_id not in settings.lnbits_deactivated_extensions:
|
||||
settings.lnbits_deactivated_extensions += [data.ext_id]
|
||||
|
||||
# mount routes for the new version
|
||||
core_app_extra.register_new_ext_routes(extension)
|
||||
|
||||
if extension.upgrade_hash:
|
||||
ext_info.nofiy_upgrade()
|
||||
|
||||
return extension
|
||||
|
||||
except Exception as ex:
|
||||
logger.warning(ex)
|
||||
ext_info.clean_extension_files()
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
detail=(
|
||||
f"Failed to install extension {ext_info.id} "
|
||||
f"({ext_info.installed_version})."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@api_router.delete("/api/v1/extension/{ext_id}")
|
||||
async def api_uninstall_extension(
|
||||
ext_id: str,
|
||||
user: User = Depends(check_admin),
|
||||
access_token: Optional[str] = Depends(check_access_token),
|
||||
):
|
||||
installable_extensions = await InstallableExtension.get_installable_extensions()
|
||||
|
||||
extensions = [e for e in installable_extensions if e.id == ext_id]
|
||||
if len(extensions) == 0:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail=f"Unknown extension id: {ext_id}",
|
||||
)
|
||||
|
||||
# check that other extensions do not depend on this one
|
||||
for valid_ext_id in list(map(lambda e: e.code, get_valid_extensions())):
|
||||
installed_ext = next(
|
||||
(ext for ext in installable_extensions if ext.id == valid_ext_id), None
|
||||
)
|
||||
if installed_ext and ext_id in installed_ext.dependencies:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail=(
|
||||
f"Cannot uninstall. Extension '{installed_ext.name}' "
|
||||
"depends on this one."
|
||||
),
|
||||
)
|
||||
|
||||
try:
|
||||
# call stop while the old routes are still active
|
||||
await stop_extension_background_work(ext_id, user.id, access_token)
|
||||
|
||||
if ext_id not in settings.lnbits_deactivated_extensions:
|
||||
settings.lnbits_deactivated_extensions += [ext_id]
|
||||
|
||||
for ext_info in extensions:
|
||||
ext_info.clean_extension_files()
|
||||
await delete_installed_extension(ext_id=ext_info.id)
|
||||
|
||||
logger.success(f"Extension '{ext_id}' uninstalled.")
|
||||
except Exception as ex:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(ex)
|
||||
)
|
||||
|
||||
|
||||
@api_router.get(
|
||||
"/api/v1/extension/{ext_id}/releases", dependencies=[Depends(check_admin)]
|
||||
)
|
||||
async def get_extension_releases(ext_id: str):
|
||||
try:
|
||||
extension_releases: List[
|
||||
ExtensionRelease
|
||||
] = await InstallableExtension.get_extension_releases(ext_id)
|
||||
|
||||
return extension_releases
|
||||
|
||||
except Exception as ex:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(ex)
|
||||
)
|
||||
|
||||
|
||||
@api_router.get(
|
||||
"/api/v1/extension/release/{org}/{repo}/{tag_name}",
|
||||
dependencies=[Depends(check_admin)],
|
||||
)
|
||||
async def get_extension_release(org: str, repo: str, tag_name: str):
|
||||
try:
|
||||
config = await fetch_github_release_config(org, repo, tag_name)
|
||||
if not config:
|
||||
return {}
|
||||
|
||||
return {
|
||||
"min_lnbits_version": config.min_lnbits_version,
|
||||
"is_version_compatible": config.is_version_compatible(),
|
||||
"warning": config.warning,
|
||||
}
|
||||
except Exception as ex:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(ex)
|
||||
)
|
||||
|
||||
|
||||
@api_router.delete(
|
||||
"/api/v1/extension/{ext_id}/db",
|
||||
dependencies=[Depends(check_admin)],
|
||||
)
|
||||
async def delete_extension_db(ext_id: str):
|
||||
try:
|
||||
db_version = (await get_dbversions()).get(ext_id, None)
|
||||
if not db_version:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail=f"Unknown extension id: {ext_id}",
|
||||
)
|
||||
await drop_extension_db(ext_id=ext_id)
|
||||
await delete_dbversion(ext_id=ext_id)
|
||||
logger.success(f"Database removed for extension '{ext_id}'")
|
||||
except HTTPException as ex:
|
||||
logger.error(ex)
|
||||
raise ex
|
||||
except Exception as ex:
|
||||
logger.error(ex)
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
detail=f"Cannot delete data for extension '{ext_id}'",
|
||||
)
|
||||
|
||||
@@ -44,15 +44,15 @@ from ..models import (
|
||||
UserConfig,
|
||||
)
|
||||
|
||||
auth_router = APIRouter()
|
||||
auth_router = APIRouter(prefix="/api/v1/auth", tags=["Auth"])
|
||||
|
||||
|
||||
@auth_router.get("/api/v1/auth", description="Get the authenticated user")
|
||||
@auth_router.get("", description="Get the authenticated user")
|
||||
async def get_auth_user(user: User = Depends(check_user_exists)) -> User:
|
||||
return user
|
||||
|
||||
|
||||
@auth_router.post("/api/v1/auth", description="Login via the username and password")
|
||||
@auth_router.post("", description="Login via the username and password")
|
||||
async def login(data: LoginUsernamePassword) -> JSONResponse:
|
||||
if not settings.is_auth_method_allowed(AuthMethods.username_and_password):
|
||||
raise HTTPException(
|
||||
@@ -75,7 +75,7 @@ async def login(data: LoginUsernamePassword) -> JSONResponse:
|
||||
raise HTTPException(HTTP_500_INTERNAL_SERVER_ERROR, "Cannot login.")
|
||||
|
||||
|
||||
@auth_router.post("/api/v1/auth/usr", description="Login via the User ID")
|
||||
@auth_router.post("/usr", description="Login via the User ID")
|
||||
async def login_usr(data: LoginUsr) -> JSONResponse:
|
||||
if not settings.is_auth_method_allowed(AuthMethods.user_id_only):
|
||||
raise HTTPException(HTTP_401_UNAUTHORIZED, "Login by 'User ID' not allowed.")
|
||||
@@ -93,7 +93,7 @@ async def login_usr(data: LoginUsr) -> JSONResponse:
|
||||
raise HTTPException(HTTP_500_INTERNAL_SERVER_ERROR, "Cannot login.")
|
||||
|
||||
|
||||
@auth_router.get("/api/v1/auth/{provider}", description="SSO Provider")
|
||||
@auth_router.get("/{provider}", description="SSO Provider")
|
||||
async def login_with_sso_provider(
|
||||
request: Request, provider: str, user_id: Optional[str] = None
|
||||
):
|
||||
@@ -109,7 +109,7 @@ async def login_with_sso_provider(
|
||||
return await provider_sso.get_login_redirect(state=state)
|
||||
|
||||
|
||||
@auth_router.get("/api/v1/auth/{provider}/token", description="Handle OAuth callback")
|
||||
@auth_router.get("/{provider}/token", description="Handle OAuth callback")
|
||||
async def handle_oauth_token(request: Request, provider: str) -> RedirectResponse:
|
||||
provider_sso = _new_sso(provider)
|
||||
if not provider_sso:
|
||||
@@ -136,16 +136,18 @@ async def handle_oauth_token(request: Request, provider: str) -> RedirectRespons
|
||||
)
|
||||
|
||||
|
||||
@auth_router.post("/api/v1/auth/logout")
|
||||
@auth_router.post("/logout")
|
||||
async def logout() -> JSONResponse:
|
||||
response = JSONResponse({"status": "success"}, status_code=status.HTTP_200_OK)
|
||||
response.delete_cookie("cookie_access_token")
|
||||
response.delete_cookie("is_lnbits_user_authorized")
|
||||
response.delete_cookie("is_access_token_expired")
|
||||
response.delete_cookie("lnbits_last_active_wallet")
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@auth_router.post("/api/v1/auth/register")
|
||||
@auth_router.post("/register")
|
||||
async def register(data: CreateUser) -> JSONResponse:
|
||||
if not settings.is_auth_method_allowed(AuthMethods.username_and_password):
|
||||
raise HTTPException(
|
||||
@@ -174,7 +176,7 @@ async def register(data: CreateUser) -> JSONResponse:
|
||||
raise HTTPException(HTTP_500_INTERNAL_SERVER_ERROR, "Cannot create user.")
|
||||
|
||||
|
||||
@auth_router.put("/api/v1/auth/password")
|
||||
@auth_router.put("/password")
|
||||
async def update_password(
|
||||
data: UpdateUserPassword, user: User = Depends(check_user_exists)
|
||||
) -> Optional[User]:
|
||||
@@ -196,7 +198,7 @@ async def update_password(
|
||||
)
|
||||
|
||||
|
||||
@auth_router.put("/api/v1/auth/update")
|
||||
@auth_router.put("/update")
|
||||
async def update(
|
||||
data: UpdateUser, user: User = Depends(check_user_exists)
|
||||
) -> Optional[User]:
|
||||
@@ -216,7 +218,7 @@ async def update(
|
||||
raise HTTPException(HTTP_500_INTERNAL_SERVER_ERROR, "Cannot update user.")
|
||||
|
||||
|
||||
@auth_router.put("/api/v1/auth/first_install")
|
||||
@auth_router.put("/first_install")
|
||||
async def first_install(data: UpdateSuperuserPassword) -> JSONResponse:
|
||||
if not settings.first_install:
|
||||
raise HTTPException(HTTP_401_UNAUTHORIZED, "This is not your first install")
|
||||
@@ -286,9 +288,7 @@ def _auth_success_response(
|
||||
)
|
||||
response = JSONResponse({"access_token": access_token, "token_type": "bearer"})
|
||||
response.set_cookie("cookie_access_token", access_token, httponly=True)
|
||||
response.set_cookie(
|
||||
"is_lnbits_user_authorized", "true", samesite="none", secure=True
|
||||
)
|
||||
response.set_cookie("is_lnbits_user_authorized", "true")
|
||||
response.delete_cookie("is_access_token_expired")
|
||||
|
||||
return response
|
||||
@@ -298,9 +298,7 @@ def _auth_redirect_response(path: str, email: str) -> RedirectResponse:
|
||||
access_token = create_access_token(data={"sub": "" or "", "email": email})
|
||||
response = RedirectResponse(path)
|
||||
response.set_cookie("cookie_access_token", access_token, httponly=True)
|
||||
response.set_cookie(
|
||||
"is_lnbits_user_authorized", "true", samesite="none", secure=True
|
||||
)
|
||||
response.set_cookie("is_lnbits_user_authorized", "true")
|
||||
response.delete_cookie("is_access_token_expired")
|
||||
return response
|
||||
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
from typing import (
|
||||
List,
|
||||
Optional,
|
||||
)
|
||||
|
||||
from bolt11 import decode as bolt11_decode
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
Depends,
|
||||
HTTPException,
|
||||
)
|
||||
from fastapi import (
|
||||
status as HTTPStatus,
|
||||
)
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.core.db import core_app_extra
|
||||
from lnbits.core.helpers import (
|
||||
migrate_extension_database,
|
||||
stop_extension_background_work,
|
||||
)
|
||||
from lnbits.core.models import (
|
||||
User,
|
||||
)
|
||||
from lnbits.decorators import (
|
||||
check_access_token,
|
||||
check_admin,
|
||||
)
|
||||
from lnbits.extension_manager import (
|
||||
CreateExtension,
|
||||
Extension,
|
||||
ExtensionRelease,
|
||||
InstallableExtension,
|
||||
fetch_github_release_config,
|
||||
fetch_release_payment_info,
|
||||
get_valid_extensions,
|
||||
)
|
||||
from lnbits.settings import settings
|
||||
|
||||
from ..crud import (
|
||||
add_installed_extension,
|
||||
delete_dbversion,
|
||||
delete_installed_extension,
|
||||
drop_extension_db,
|
||||
get_dbversions,
|
||||
get_installed_extension,
|
||||
)
|
||||
|
||||
extension_router = APIRouter(
|
||||
tags=["Extension Managment"],
|
||||
prefix="/api/v1/extension",
|
||||
)
|
||||
|
||||
|
||||
@extension_router.post("")
|
||||
async def api_install_extension(
|
||||
data: CreateExtension,
|
||||
user: User = Depends(check_admin),
|
||||
access_token: Optional[str] = Depends(check_access_token),
|
||||
):
|
||||
release = await InstallableExtension.get_extension_release(
|
||||
data.ext_id, data.source_repo, data.archive, data.version
|
||||
)
|
||||
if not release:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Release not found"
|
||||
)
|
||||
|
||||
if not release.is_version_compatible:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST, detail="Incompatible extension version"
|
||||
)
|
||||
|
||||
release.payment_hash = data.payment_hash
|
||||
ext_info = InstallableExtension(
|
||||
id=data.ext_id, name=data.ext_id, installed_release=release, icon=release.icon
|
||||
)
|
||||
|
||||
try:
|
||||
installed_ext = await get_installed_extension(data.ext_id)
|
||||
ext_info.payments = installed_ext.payments if installed_ext else []
|
||||
|
||||
await ext_info.download_archive()
|
||||
|
||||
ext_info.extract_archive()
|
||||
|
||||
extension = Extension.from_installable_ext(ext_info)
|
||||
|
||||
db_version = (await get_dbversions()).get(data.ext_id, 0)
|
||||
await migrate_extension_database(extension, db_version)
|
||||
|
||||
await add_installed_extension(ext_info)
|
||||
|
||||
if extension.is_upgrade_extension:
|
||||
# call stop while the old routes are still active
|
||||
await stop_extension_background_work(data.ext_id, user.id, access_token)
|
||||
|
||||
if data.ext_id not in settings.lnbits_deactivated_extensions:
|
||||
settings.lnbits_deactivated_extensions += [data.ext_id]
|
||||
|
||||
# mount routes for the new version
|
||||
core_app_extra.register_new_ext_routes(extension)
|
||||
|
||||
if extension.upgrade_hash:
|
||||
ext_info.notify_upgrade()
|
||||
|
||||
return extension
|
||||
except AssertionError as e:
|
||||
raise HTTPException(HTTPStatus.BAD_REQUEST, str(e))
|
||||
except Exception as ex:
|
||||
logger.warning(ex)
|
||||
ext_info.clean_extension_files()
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
detail=(
|
||||
f"Failed to install extension {ext_info.id} "
|
||||
f"({ext_info.installed_version})."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@extension_router.delete("/{ext_id}")
|
||||
async def api_uninstall_extension(
|
||||
ext_id: str,
|
||||
user: User = Depends(check_admin),
|
||||
access_token: Optional[str] = Depends(check_access_token),
|
||||
):
|
||||
installable_extensions = await InstallableExtension.get_installable_extensions()
|
||||
|
||||
extensions = [e for e in installable_extensions if e.id == ext_id]
|
||||
if len(extensions) == 0:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail=f"Unknown extension id: {ext_id}",
|
||||
)
|
||||
|
||||
# check that other extensions do not depend on this one
|
||||
for valid_ext_id in [ext.code for ext in get_valid_extensions()]:
|
||||
installed_ext = next(
|
||||
(ext for ext in installable_extensions if ext.id == valid_ext_id), None
|
||||
)
|
||||
if installed_ext and ext_id in installed_ext.dependencies:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail=(
|
||||
f"Cannot uninstall. Extension '{installed_ext.name}' "
|
||||
"depends on this one."
|
||||
),
|
||||
)
|
||||
|
||||
try:
|
||||
# call stop while the old routes are still active
|
||||
await stop_extension_background_work(ext_id, user.id, access_token)
|
||||
|
||||
if ext_id not in settings.lnbits_deactivated_extensions:
|
||||
settings.lnbits_deactivated_extensions += [ext_id]
|
||||
|
||||
for ext_info in extensions:
|
||||
ext_info.clean_extension_files()
|
||||
await delete_installed_extension(ext_id=ext_info.id)
|
||||
|
||||
logger.success(f"Extension '{ext_id}' uninstalled.")
|
||||
except Exception as ex:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(ex)
|
||||
)
|
||||
|
||||
|
||||
@extension_router.get("/{ext_id}/releases", dependencies=[Depends(check_admin)])
|
||||
async def get_extension_releases(ext_id: str):
|
||||
try:
|
||||
extension_releases: List[ExtensionRelease] = (
|
||||
await InstallableExtension.get_extension_releases(ext_id)
|
||||
)
|
||||
|
||||
installed_ext = await get_installed_extension(ext_id)
|
||||
if not installed_ext:
|
||||
return extension_releases
|
||||
|
||||
for release in extension_releases:
|
||||
payment_info = installed_ext.find_existing_payment(release.pay_link)
|
||||
if payment_info:
|
||||
release.paid_sats = payment_info.amount
|
||||
|
||||
return extension_releases
|
||||
|
||||
except Exception as ex:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(ex)
|
||||
)
|
||||
|
||||
|
||||
@extension_router.put("/invoice", dependencies=[Depends(check_admin)])
|
||||
async def get_extension_invoice(data: CreateExtension):
|
||||
try:
|
||||
assert data.cost_sats, "A non-zero amount must be specified"
|
||||
release = await InstallableExtension.get_extension_release(
|
||||
data.ext_id, data.source_repo, data.archive, data.version
|
||||
)
|
||||
assert release, "Release not found"
|
||||
assert release.pay_link, "Pay link not found for release"
|
||||
|
||||
payment_info = await fetch_release_payment_info(
|
||||
release.pay_link, data.cost_sats
|
||||
)
|
||||
assert payment_info and payment_info.payment_request, "Cannot request invoice"
|
||||
invoice = bolt11_decode(payment_info.payment_request)
|
||||
|
||||
assert invoice.amount_msat is not None, "Invoic amount is missing"
|
||||
invoice_amount = int(invoice.amount_msat / 1000)
|
||||
assert (
|
||||
invoice_amount == data.cost_sats
|
||||
), f"Wrong invoice amount: {invoice_amount}."
|
||||
assert (
|
||||
payment_info.payment_hash == invoice.payment_hash
|
||||
), "Wroong invoice payment hash"
|
||||
|
||||
return payment_info
|
||||
|
||||
except AssertionError as e:
|
||||
raise HTTPException(HTTPStatus.BAD_REQUEST, str(e))
|
||||
except Exception as ex:
|
||||
logger.warning(ex)
|
||||
raise HTTPException(HTTPStatus.INTERNAL_SERVER_ERROR, "Cannot request invoice")
|
||||
|
||||
|
||||
@extension_router.get(
|
||||
"/release/{org}/{repo}/{tag_name}",
|
||||
dependencies=[Depends(check_admin)],
|
||||
)
|
||||
async def get_extension_release(org: str, repo: str, tag_name: str):
|
||||
try:
|
||||
config = await fetch_github_release_config(org, repo, tag_name)
|
||||
if not config:
|
||||
return {}
|
||||
|
||||
return {
|
||||
"min_lnbits_version": config.min_lnbits_version,
|
||||
"is_version_compatible": config.is_version_compatible(),
|
||||
"warning": config.warning,
|
||||
}
|
||||
except Exception as ex:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(ex)
|
||||
)
|
||||
|
||||
|
||||
@extension_router.delete(
|
||||
"/{ext_id}/db",
|
||||
dependencies=[Depends(check_admin)],
|
||||
)
|
||||
async def delete_extension_db(ext_id: str):
|
||||
try:
|
||||
db_version = (await get_dbversions()).get(ext_id, None)
|
||||
if not db_version:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail=f"Unknown extension id: {ext_id}",
|
||||
)
|
||||
await drop_extension_db(ext_id=ext_id)
|
||||
await delete_dbversion(ext_id=ext_id)
|
||||
logger.success(f"Database removed for extension '{ext_id}'")
|
||||
except HTTPException as ex:
|
||||
logger.error(ex)
|
||||
raise ex
|
||||
except Exception as ex:
|
||||
logger.error(ex)
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
detail=f"Cannot delete data for extension '{ext_id}'",
|
||||
)
|
||||
@@ -21,7 +21,7 @@ from lnbits.settings import settings
|
||||
from lnbits.wallets import get_wallet_class
|
||||
|
||||
from ...extension_manager import InstallableExtension, get_valid_extensions
|
||||
from ...utils.exchange_rates import currencies
|
||||
from ...utils.exchange_rates import allowed_currencies, currencies
|
||||
from ..crud import (
|
||||
create_account,
|
||||
create_wallet,
|
||||
@@ -49,7 +49,7 @@ async def favicon():
|
||||
@generic_router.get("/", response_class=HTMLResponse)
|
||||
async def home(request: Request, lightning: str = ""):
|
||||
return template_renderer().TemplateResponse(
|
||||
"core/index.html", {"request": request, "lnurl": lightning}
|
||||
request, "core/index.html", {"lnurl": lightning}
|
||||
)
|
||||
|
||||
|
||||
@@ -57,15 +57,15 @@ async def home(request: Request, lightning: str = ""):
|
||||
async def first_install(request: Request):
|
||||
if not settings.first_install:
|
||||
return template_renderer().TemplateResponse(
|
||||
request,
|
||||
"error.html",
|
||||
{
|
||||
"request": request,
|
||||
"err": "Super user account has already been configured.",
|
||||
},
|
||||
)
|
||||
return template_renderer().TemplateResponse(
|
||||
request,
|
||||
"core/first_install.html",
|
||||
{"request": request},
|
||||
)
|
||||
|
||||
|
||||
@@ -113,6 +113,7 @@ async def extensions_install(
|
||||
except Exception as ex:
|
||||
logger.warning(ex)
|
||||
installable_exts = []
|
||||
installed_exts_ids = []
|
||||
|
||||
try:
|
||||
ext_id = activate or deactivate
|
||||
@@ -137,42 +138,40 @@ async def extensions_install(
|
||||
ext_id=ext_id, active=activate is not None
|
||||
)
|
||||
|
||||
all_ext_ids = list(map(lambda e: e.code, all_extensions))
|
||||
all_ext_ids = [ext.code for ext in all_extensions]
|
||||
inactive_extensions = await get_inactive_extensions()
|
||||
db_version = await get_dbversions()
|
||||
extensions = list(
|
||||
map(
|
||||
lambda ext: {
|
||||
"id": ext.id,
|
||||
"name": ext.name,
|
||||
"icon": ext.icon,
|
||||
"shortDescription": ext.short_description,
|
||||
"stars": ext.stars,
|
||||
"isFeatured": ext.featured,
|
||||
"dependencies": ext.dependencies,
|
||||
"isInstalled": ext.id in installed_exts_ids,
|
||||
"hasDatabaseTables": ext.id in db_version,
|
||||
"isAvailable": ext.id in all_ext_ids,
|
||||
"isAdminOnly": ext.id in settings.lnbits_admin_extensions,
|
||||
"isActive": ext.id not in inactive_extensions,
|
||||
"latestRelease": (
|
||||
dict(ext.latest_release) if ext.latest_release else None
|
||||
),
|
||||
"installedRelease": (
|
||||
dict(ext.installed_release) if ext.installed_release else None
|
||||
),
|
||||
},
|
||||
installable_exts,
|
||||
)
|
||||
)
|
||||
extensions = [
|
||||
{
|
||||
"id": ext.id,
|
||||
"name": ext.name,
|
||||
"icon": ext.icon,
|
||||
"shortDescription": ext.short_description,
|
||||
"stars": ext.stars,
|
||||
"isFeatured": ext.featured,
|
||||
"dependencies": ext.dependencies,
|
||||
"isInstalled": ext.id in installed_exts_ids,
|
||||
"hasDatabaseTables": ext.id in db_version,
|
||||
"isAvailable": ext.id in all_ext_ids,
|
||||
"isAdminOnly": ext.id in settings.lnbits_admin_extensions,
|
||||
"isActive": ext.id not in inactive_extensions,
|
||||
"latestRelease": (
|
||||
dict(ext.latest_release) if ext.latest_release else None
|
||||
),
|
||||
"installedRelease": (
|
||||
dict(ext.installed_release) if ext.installed_release else None
|
||||
),
|
||||
}
|
||||
for ext in installable_exts
|
||||
]
|
||||
|
||||
# refresh user state. Eg: enabled extensions.
|
||||
user = await get_user(user.id) or user
|
||||
|
||||
return template_renderer().TemplateResponse(
|
||||
request,
|
||||
"core/extensions.html",
|
||||
{
|
||||
"request": request,
|
||||
"user": user.dict(),
|
||||
"extensions": extensions,
|
||||
},
|
||||
@@ -207,23 +206,22 @@ async def wallet(
|
||||
user_wallet = user.get_wallet(wallet_id)
|
||||
if not user_wallet or user_wallet.deleted:
|
||||
return template_renderer().TemplateResponse(
|
||||
"error.html", {"request": request, "err": "Wallet not found"}
|
||||
request, "error.html", {"err": "Wallet not found"}
|
||||
)
|
||||
|
||||
resp = template_renderer().TemplateResponse(
|
||||
request,
|
||||
"core/wallet.html",
|
||||
{
|
||||
"request": request,
|
||||
"user": user.dict(),
|
||||
"wallet": user_wallet.dict(),
|
||||
"currencies": allowed_currencies(),
|
||||
"service_fee": settings.lnbits_service_fee,
|
||||
"service_fee_max": settings.lnbits_service_fee_max,
|
||||
"web_manifest": f"/manifest/{user.id}.webmanifest",
|
||||
},
|
||||
)
|
||||
resp.set_cookie(
|
||||
"lnbits_last_active_wallet", wallet_id, samesite="none", secure=True
|
||||
)
|
||||
resp.set_cookie("lnbits_last_active_wallet", wallet_id)
|
||||
return resp
|
||||
|
||||
|
||||
@@ -237,9 +235,9 @@ async def account(
|
||||
user: User = Depends(check_user_exists),
|
||||
):
|
||||
return template_renderer().TemplateResponse(
|
||||
request,
|
||||
"core/account.html",
|
||||
{
|
||||
"request": request,
|
||||
"user": user.dict(),
|
||||
},
|
||||
)
|
||||
@@ -354,9 +352,16 @@ async def lnurlwallet(request: Request):
|
||||
)
|
||||
|
||||
|
||||
@generic_router.get("/service-worker.js", response_class=FileResponse)
|
||||
async def service_worker():
|
||||
return FileResponse(Path("lnbits", "static", "js", "service-worker.js"))
|
||||
@generic_router.get("/service-worker.js")
|
||||
async def service_worker(request: Request):
|
||||
return template_renderer().TemplateResponse(
|
||||
request,
|
||||
"service-worker.js",
|
||||
{
|
||||
"cache_version": settings.server_startup_time,
|
||||
},
|
||||
media_type="text/javascript",
|
||||
)
|
||||
|
||||
|
||||
@generic_router.get("/manifest/{usr}.webmanifest")
|
||||
@@ -451,9 +456,9 @@ async def node(request: Request, user: User = Depends(check_admin)):
|
||||
_, balance = await WALLET.status()
|
||||
|
||||
return template_renderer().TemplateResponse(
|
||||
request,
|
||||
"node/index.html",
|
||||
{
|
||||
"request": request,
|
||||
"user": user.dict(),
|
||||
"settings": settings.dict(),
|
||||
"balance": balance,
|
||||
@@ -471,9 +476,9 @@ async def node_public(request: Request):
|
||||
_, balance = await WALLET.status()
|
||||
|
||||
return template_renderer().TemplateResponse(
|
||||
request,
|
||||
"node/public.html",
|
||||
{
|
||||
"request": request,
|
||||
"settings": settings.dict(),
|
||||
"balance": balance,
|
||||
},
|
||||
@@ -489,9 +494,9 @@ async def index(request: Request, user: User = Depends(check_admin)):
|
||||
_, balance = await WALLET.status()
|
||||
|
||||
return template_renderer().TemplateResponse(
|
||||
request,
|
||||
"admin/index.html",
|
||||
{
|
||||
"request": request,
|
||||
"user": user.dict(),
|
||||
"settings": settings.dict(),
|
||||
"balance": balance,
|
||||
|
||||
@@ -49,12 +49,20 @@ def check_public():
|
||||
)
|
||||
|
||||
|
||||
node_router = APIRouter(prefix="/node/api/v1", dependencies=[Depends(check_admin)])
|
||||
node_router = APIRouter(
|
||||
tags=["Node Managment"],
|
||||
prefix="/node/api/v1",
|
||||
dependencies=[Depends(check_admin)],
|
||||
)
|
||||
super_node_router = APIRouter(
|
||||
prefix="/node/api/v1", dependencies=[Depends(check_super_user)]
|
||||
tags=["Node Managment"],
|
||||
prefix="/node/api/v1",
|
||||
dependencies=[Depends(check_super_user)],
|
||||
)
|
||||
public_node_router = APIRouter(
|
||||
prefix="/node/public/api/v1", dependencies=[Depends(check_public)]
|
||||
tags=["Node Managment"],
|
||||
prefix="/node/public/api/v1",
|
||||
dependencies=[Depends(check_public)],
|
||||
)
|
||||
|
||||
|
||||
@@ -108,9 +116,11 @@ async def api_delete_channel(
|
||||
) -> Optional[List[NodeChannel]]:
|
||||
return await node.close_channel(
|
||||
short_id,
|
||||
ChannelPoint(funding_txid=funding_txid, output_index=output_index)
|
||||
if funding_txid is not None and output_index is not None
|
||||
else None,
|
||||
(
|
||||
ChannelPoint(funding_txid=funding_txid, output_index=output_index)
|
||||
if funding_txid is not None and output_index is not None
|
||||
else None
|
||||
),
|
||||
force,
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,473 @@
|
||||
import asyncio
|
||||
import json
|
||||
import uuid
|
||||
from http import HTTPStatus
|
||||
from math import ceil
|
||||
from typing import List, Optional, Union
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
Body,
|
||||
Depends,
|
||||
Header,
|
||||
HTTPException,
|
||||
Request,
|
||||
)
|
||||
from fastapi.responses import JSONResponse
|
||||
from loguru import logger
|
||||
from sse_starlette.sse import EventSourceResponse
|
||||
|
||||
from lnbits import bolt11
|
||||
from lnbits.core.db import db
|
||||
from lnbits.core.models import (
|
||||
CreateInvoice,
|
||||
CreateLnurl,
|
||||
DecodePayment,
|
||||
Payment,
|
||||
PaymentFilters,
|
||||
PaymentHistoryPoint,
|
||||
Query,
|
||||
Wallet,
|
||||
WalletType,
|
||||
)
|
||||
from lnbits.db import Filters, Page
|
||||
from lnbits.decorators import (
|
||||
WalletTypeInfo,
|
||||
get_key_type,
|
||||
parse_filters,
|
||||
require_admin_key,
|
||||
require_invoice_key,
|
||||
)
|
||||
from lnbits.helpers import generate_filter_params_openapi, url_for
|
||||
from lnbits.lnurl import decode as lnurl_decode
|
||||
from lnbits.settings import settings
|
||||
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis
|
||||
|
||||
from ..crud import (
|
||||
DateTrunc,
|
||||
get_payments,
|
||||
get_payments_history,
|
||||
get_payments_paginated,
|
||||
get_standalone_payment,
|
||||
get_wallet_for_key,
|
||||
save_balance_check,
|
||||
update_pending_payments,
|
||||
)
|
||||
from ..services import (
|
||||
InvoiceFailure,
|
||||
PaymentFailure,
|
||||
check_transaction_status,
|
||||
create_invoice,
|
||||
fee_reserve_total,
|
||||
pay_invoice,
|
||||
)
|
||||
from ..tasks import api_invoice_listeners
|
||||
|
||||
payment_router = APIRouter(prefix="/api/v1/payments", tags=["Payments"])
|
||||
|
||||
|
||||
@payment_router.get(
|
||||
"",
|
||||
name="Payment List",
|
||||
summary="get list of payments",
|
||||
response_description="list of payments",
|
||||
response_model=List[Payment],
|
||||
openapi_extra=generate_filter_params_openapi(PaymentFilters),
|
||||
)
|
||||
async def api_payments(
|
||||
wallet: WalletTypeInfo = Depends(get_key_type),
|
||||
filters: Filters = Depends(parse_filters(PaymentFilters)),
|
||||
):
|
||||
await update_pending_payments(wallet.wallet.id)
|
||||
return await get_payments(
|
||||
wallet_id=wallet.wallet.id,
|
||||
pending=True,
|
||||
complete=True,
|
||||
filters=filters,
|
||||
)
|
||||
|
||||
|
||||
@payment_router.get(
|
||||
"/history",
|
||||
name="Get payments history",
|
||||
response_model=List[PaymentHistoryPoint],
|
||||
openapi_extra=generate_filter_params_openapi(PaymentFilters),
|
||||
)
|
||||
async def api_payments_history(
|
||||
wallet: WalletTypeInfo = Depends(get_key_type),
|
||||
group: DateTrunc = Query("day"),
|
||||
filters: Filters[PaymentFilters] = Depends(parse_filters(PaymentFilters)),
|
||||
):
|
||||
await update_pending_payments(wallet.wallet.id)
|
||||
return await get_payments_history(wallet.wallet.id, group, filters)
|
||||
|
||||
|
||||
@payment_router.get(
|
||||
"/paginated",
|
||||
name="Payment List",
|
||||
summary="get paginated list of payments",
|
||||
response_description="list of payments",
|
||||
response_model=Page[Payment],
|
||||
openapi_extra=generate_filter_params_openapi(PaymentFilters),
|
||||
)
|
||||
async def api_payments_paginated(
|
||||
wallet: WalletTypeInfo = Depends(get_key_type),
|
||||
filters: Filters = Depends(parse_filters(PaymentFilters)),
|
||||
):
|
||||
await update_pending_payments(wallet.wallet.id)
|
||||
page = await get_payments_paginated(
|
||||
wallet_id=wallet.wallet.id,
|
||||
pending=True,
|
||||
complete=True,
|
||||
filters=filters,
|
||||
)
|
||||
return page
|
||||
|
||||
|
||||
async def api_payments_create_invoice(data: CreateInvoice, wallet: Wallet):
|
||||
description_hash = b""
|
||||
unhashed_description = b""
|
||||
memo = data.memo or settings.lnbits_site_title
|
||||
if data.description_hash or data.unhashed_description:
|
||||
if data.description_hash:
|
||||
try:
|
||||
description_hash = bytes.fromhex(data.description_hash)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail="'description_hash' must be a valid hex string",
|
||||
)
|
||||
if data.unhashed_description:
|
||||
try:
|
||||
unhashed_description = bytes.fromhex(data.unhashed_description)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail="'unhashed_description' must be a valid hex string",
|
||||
)
|
||||
# do not save memo if description_hash or unhashed_description is set
|
||||
memo = ""
|
||||
|
||||
async with db.connect() as conn:
|
||||
try:
|
||||
payment_hash, payment_request = await create_invoice(
|
||||
wallet_id=wallet.id,
|
||||
amount=data.amount,
|
||||
memo=memo,
|
||||
currency=data.unit,
|
||||
description_hash=description_hash,
|
||||
unhashed_description=unhashed_description,
|
||||
expiry=data.expiry,
|
||||
extra=data.extra,
|
||||
webhook=data.webhook,
|
||||
internal=data.internal,
|
||||
conn=conn,
|
||||
)
|
||||
# NOTE: we get the checking_id with a seperate query because create_invoice
|
||||
# does not return it and it would be a big hustle to change its return type
|
||||
# (used across extensions)
|
||||
payment_db = await get_standalone_payment(payment_hash, conn=conn)
|
||||
assert payment_db is not None, "payment not found"
|
||||
checking_id = payment_db.checking_id
|
||||
except InvoiceFailure as e:
|
||||
raise HTTPException(status_code=520, detail=str(e))
|
||||
except Exception as exc:
|
||||
raise exc
|
||||
|
||||
invoice = bolt11.decode(payment_request)
|
||||
|
||||
lnurl_response: Union[None, bool, str] = None
|
||||
if data.lnurl_callback:
|
||||
if data.lnurl_balance_check is not None:
|
||||
await save_balance_check(wallet.id, data.lnurl_balance_check)
|
||||
|
||||
headers = {"User-Agent": settings.user_agent}
|
||||
async with httpx.AsyncClient(headers=headers) as client:
|
||||
try:
|
||||
r = await client.get(
|
||||
data.lnurl_callback,
|
||||
params={
|
||||
"pr": payment_request,
|
||||
"balanceNotify": url_for(
|
||||
f"/withdraw/notify/{urlparse(data.lnurl_callback).netloc}",
|
||||
external=True,
|
||||
wal=wallet.id,
|
||||
),
|
||||
},
|
||||
timeout=10,
|
||||
)
|
||||
if r.is_error:
|
||||
lnurl_response = r.text
|
||||
else:
|
||||
resp = json.loads(r.text)
|
||||
if resp["status"] != "OK":
|
||||
lnurl_response = resp["reason"]
|
||||
else:
|
||||
lnurl_response = True
|
||||
except (httpx.ConnectError, httpx.RequestError) as ex:
|
||||
logger.error(ex)
|
||||
lnurl_response = False
|
||||
|
||||
return {
|
||||
"payment_hash": invoice.payment_hash,
|
||||
"payment_request": payment_request,
|
||||
# maintain backwards compatibility with API clients:
|
||||
"checking_id": checking_id,
|
||||
"lnurl_response": lnurl_response,
|
||||
}
|
||||
|
||||
|
||||
async def api_payments_pay_invoice(
|
||||
bolt11: str, wallet: Wallet, extra: Optional[dict] = None
|
||||
):
|
||||
try:
|
||||
payment_hash = await pay_invoice(
|
||||
wallet_id=wallet.id, payment_request=bolt11, extra=extra
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e))
|
||||
except PermissionError as e:
|
||||
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail=str(e))
|
||||
except PaymentFailure as e:
|
||||
raise HTTPException(status_code=520, detail=str(e))
|
||||
except Exception as exc:
|
||||
raise exc
|
||||
|
||||
return {
|
||||
"payment_hash": payment_hash,
|
||||
# maintain backwards compatibility with API clients:
|
||||
"checking_id": payment_hash,
|
||||
}
|
||||
|
||||
|
||||
@payment_router.post(
|
||||
"",
|
||||
summary="Create or pay an invoice",
|
||||
description="""
|
||||
This endpoint can be used both to generate and pay a BOLT11 invoice.
|
||||
To generate a new invoice for receiving funds into the authorized account,
|
||||
specify at least the first four fields in the POST body: `out: false`,
|
||||
`amount`, `unit`, and `memo`. To pay an arbitrary invoice from the funds
|
||||
already in the authorized account, specify `out: true` and use the `bolt11`
|
||||
field to supply the BOLT11 invoice to be paid.
|
||||
""",
|
||||
status_code=HTTPStatus.CREATED,
|
||||
)
|
||||
async def api_payments_create(
|
||||
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
||||
invoiceData: CreateInvoice = Body(...),
|
||||
):
|
||||
if invoiceData.out is True and wallet.wallet_type == WalletType.admin:
|
||||
if not invoiceData.bolt11:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail="BOLT11 string is invalid or not given",
|
||||
)
|
||||
return await api_payments_pay_invoice(
|
||||
invoiceData.bolt11, wallet.wallet, invoiceData.extra
|
||||
) # admin key
|
||||
elif not invoiceData.out:
|
||||
# invoice key
|
||||
return await api_payments_create_invoice(invoiceData, wallet.wallet)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.UNAUTHORIZED,
|
||||
detail="Invoice (or Admin) key required.",
|
||||
)
|
||||
|
||||
|
||||
@payment_router.get("/fee-reserve")
|
||||
async def api_payments_fee_reserve(invoice: str = Query("invoice")) -> JSONResponse:
|
||||
invoice_obj = bolt11.decode(invoice)
|
||||
if invoice_obj.amount_msat:
|
||||
response = {
|
||||
"fee_reserve": fee_reserve_total(invoice_obj.amount_msat),
|
||||
}
|
||||
return JSONResponse(response)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail="Invoice has no amount.",
|
||||
)
|
||||
|
||||
|
||||
@payment_router.post("/lnurl")
|
||||
async def api_payments_pay_lnurl(
|
||||
data: CreateLnurl, wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||
):
|
||||
domain = urlparse(data.callback).netloc
|
||||
|
||||
headers = {"User-Agent": settings.user_agent}
|
||||
async with httpx.AsyncClient(headers=headers, follow_redirects=True) as client:
|
||||
try:
|
||||
if data.unit and data.unit != "sat":
|
||||
amount_msat = await fiat_amount_as_satoshis(data.amount, data.unit)
|
||||
# no msat precision
|
||||
amount_msat = ceil(amount_msat // 1000) * 1000
|
||||
else:
|
||||
amount_msat = data.amount
|
||||
r = await client.get(
|
||||
data.callback,
|
||||
params={"amount": amount_msat, "comment": data.comment},
|
||||
timeout=40,
|
||||
)
|
||||
if r.is_error:
|
||||
raise httpx.ConnectError("LNURL callback connection error")
|
||||
r.raise_for_status()
|
||||
except (httpx.ConnectError, httpx.RequestError):
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail=f"Failed to connect to {domain}.",
|
||||
)
|
||||
|
||||
params = json.loads(r.text)
|
||||
if params.get("status") == "ERROR":
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail=f"{domain} said: '{params.get('reason', '')}'",
|
||||
)
|
||||
|
||||
if not params.get("pr"):
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail=f"{domain} did not return a payment request.",
|
||||
)
|
||||
|
||||
invoice = bolt11.decode(params["pr"])
|
||||
if invoice.amount_msat != amount_msat:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail=(
|
||||
(
|
||||
f"{domain} returned an invalid invoice. Expected"
|
||||
f" {amount_msat} msat, got {invoice.amount_msat}."
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
extra = {}
|
||||
|
||||
if params.get("successAction"):
|
||||
extra["success_action"] = params["successAction"]
|
||||
if data.comment:
|
||||
extra["comment"] = data.comment
|
||||
if data.unit and data.unit != "sat":
|
||||
extra["fiat_currency"] = data.unit
|
||||
extra["fiat_amount"] = data.amount / 1000
|
||||
assert data.description is not None, "description is required"
|
||||
payment_hash = await pay_invoice(
|
||||
wallet_id=wallet.wallet.id,
|
||||
payment_request=params["pr"],
|
||||
description=data.description,
|
||||
extra=extra,
|
||||
)
|
||||
|
||||
return {
|
||||
"success_action": params.get("successAction"),
|
||||
"payment_hash": payment_hash,
|
||||
# maintain backwards compatibility with API clients:
|
||||
"checking_id": payment_hash,
|
||||
}
|
||||
|
||||
|
||||
async def subscribe_wallet_invoices(request: Request, wallet: Wallet):
|
||||
"""
|
||||
Subscribe to new invoices for a wallet. Can be wrapped in EventSourceResponse.
|
||||
Listenes invoming payments for a wallet and yields jsons with payment details.
|
||||
"""
|
||||
this_wallet_id = wallet.id
|
||||
|
||||
payment_queue: asyncio.Queue[Payment] = asyncio.Queue(0)
|
||||
|
||||
uid = f"{this_wallet_id}_{str(uuid.uuid4())[:8]}"
|
||||
logger.debug(f"adding sse listener for wallet: {uid}")
|
||||
api_invoice_listeners[uid] = payment_queue
|
||||
|
||||
try:
|
||||
while True:
|
||||
if await request.is_disconnected():
|
||||
await request.close()
|
||||
break
|
||||
payment: Payment = await payment_queue.get()
|
||||
if payment.wallet_id == this_wallet_id:
|
||||
logger.debug("sse listener: payment received", payment)
|
||||
yield {"data": payment.json(), "event": "payment-received"}
|
||||
except asyncio.CancelledError:
|
||||
logger.debug(f"removing listener for wallet {uid}")
|
||||
except Exception as exc:
|
||||
logger.error(f"Error in sse: {exc}")
|
||||
finally:
|
||||
api_invoice_listeners.pop(uid)
|
||||
|
||||
|
||||
@payment_router.get("/sse")
|
||||
async def api_payments_sse(
|
||||
request: Request, wallet: WalletTypeInfo = Depends(get_key_type)
|
||||
):
|
||||
return EventSourceResponse(
|
||||
subscribe_wallet_invoices(request, wallet.wallet),
|
||||
ping=20,
|
||||
media_type="text/event-stream",
|
||||
)
|
||||
|
||||
|
||||
# TODO: refactor this route into a public and admin one
|
||||
@payment_router.get("/{payment_hash}")
|
||||
async def api_payment(payment_hash, X_Api_Key: Optional[str] = Header(None)):
|
||||
# We use X_Api_Key here because we want this call to work with and without keys
|
||||
# If a valid key is given, we also return the field "details", otherwise not
|
||||
wallet = await get_wallet_for_key(X_Api_Key) if isinstance(X_Api_Key, str) else None
|
||||
|
||||
payment = await get_standalone_payment(
|
||||
payment_hash, wallet_id=wallet.id if wallet else None
|
||||
)
|
||||
if payment is None:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Payment does not exist."
|
||||
)
|
||||
await check_transaction_status(payment.wallet_id, payment_hash)
|
||||
payment = await get_standalone_payment(
|
||||
payment_hash, wallet_id=wallet.id if wallet else None
|
||||
)
|
||||
if not payment:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Payment does not exist."
|
||||
)
|
||||
elif not payment.pending:
|
||||
if wallet and wallet.id == payment.wallet_id:
|
||||
return {"paid": True, "preimage": payment.preimage, "details": payment}
|
||||
return {"paid": True, "preimage": payment.preimage}
|
||||
|
||||
try:
|
||||
await payment.check_status()
|
||||
except Exception:
|
||||
if wallet and wallet.id == payment.wallet_id:
|
||||
return {"paid": False, "details": payment}
|
||||
return {"paid": False}
|
||||
|
||||
if wallet and wallet.id == payment.wallet_id:
|
||||
return {
|
||||
"paid": not payment.pending,
|
||||
"preimage": payment.preimage,
|
||||
"details": payment,
|
||||
}
|
||||
return {"paid": not payment.pending, "preimage": payment.preimage}
|
||||
|
||||
|
||||
@payment_router.post("/decode", status_code=HTTPStatus.OK)
|
||||
async def api_payments_decode(data: DecodePayment) -> JSONResponse:
|
||||
payment_str = data.data
|
||||
try:
|
||||
if payment_str[:5] == "LNURL":
|
||||
url = str(lnurl_decode(payment_str))
|
||||
return JSONResponse({"domain": url})
|
||||
else:
|
||||
invoice = bolt11.decode(payment_str)
|
||||
return JSONResponse(invoice.data)
|
||||
except Exception as exc:
|
||||
return JSONResponse(
|
||||
{"message": f"Failed to decode: {str(exc)}"},
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
)
|
||||
@@ -9,7 +9,7 @@ from lnbits import bolt11
|
||||
from ..crud import get_standalone_payment
|
||||
from ..tasks import api_invoice_listeners
|
||||
|
||||
public_router = APIRouter()
|
||||
public_router = APIRouter(tags=["Core"])
|
||||
|
||||
|
||||
@public_router.get("/public/v1/payment/{payment_hash}")
|
||||
|
||||
@@ -9,7 +9,8 @@ from starlette.responses import RedirectResponse
|
||||
|
||||
from lnbits.decorators import (
|
||||
WalletTypeInfo,
|
||||
get_key_type,
|
||||
require_admin_key,
|
||||
require_invoice_key,
|
||||
)
|
||||
|
||||
from ..crud import (
|
||||
@@ -19,7 +20,7 @@ from ..crud import (
|
||||
get_tinyurl_by_url,
|
||||
)
|
||||
|
||||
tinyurl_router = APIRouter()
|
||||
tinyurl_router = APIRouter(tags=["Tinyurl"])
|
||||
|
||||
|
||||
@tinyurl_router.post(
|
||||
@@ -28,15 +29,15 @@ tinyurl_router = APIRouter()
|
||||
description="creates a tinyurl",
|
||||
)
|
||||
async def api_create_tinyurl(
|
||||
url: str, endless: bool = False, wallet: WalletTypeInfo = Depends(get_key_type)
|
||||
url: str, endless: bool = False, wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||
):
|
||||
tinyurls = await get_tinyurl_by_url(url)
|
||||
try:
|
||||
for tinyurl in tinyurls:
|
||||
if tinyurl:
|
||||
if tinyurl.wallet == wallet.wallet.inkey:
|
||||
if tinyurl.wallet == wallet.wallet.id:
|
||||
return tinyurl
|
||||
return await create_tinyurl(url, endless, wallet.wallet.inkey)
|
||||
return await create_tinyurl(url, endless, wallet.wallet.id)
|
||||
except Exception:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST, detail="Unable to create tinyurl"
|
||||
@@ -49,12 +50,12 @@ async def api_create_tinyurl(
|
||||
description="get a tinyurl by id",
|
||||
)
|
||||
async def api_get_tinyurl(
|
||||
tinyurl_id: str, wallet: WalletTypeInfo = Depends(get_key_type)
|
||||
tinyurl_id: str, wallet: WalletTypeInfo = Depends(require_invoice_key)
|
||||
):
|
||||
try:
|
||||
tinyurl = await get_tinyurl(tinyurl_id)
|
||||
if tinyurl:
|
||||
if tinyurl.wallet == wallet.wallet.inkey:
|
||||
if tinyurl.wallet == wallet.wallet.id:
|
||||
return tinyurl
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.FORBIDDEN, detail="Wrong key provided."
|
||||
@@ -71,12 +72,12 @@ async def api_get_tinyurl(
|
||||
description="delete a tinyurl by id",
|
||||
)
|
||||
async def api_delete_tinyurl(
|
||||
tinyurl_id: str, wallet: WalletTypeInfo = Depends(get_key_type)
|
||||
tinyurl_id: str, wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||
):
|
||||
try:
|
||||
tinyurl = await get_tinyurl(tinyurl_id)
|
||||
if tinyurl:
|
||||
if tinyurl.wallet == wallet.wallet.inkey:
|
||||
if tinyurl.wallet == wallet.wallet.id:
|
||||
await delete_tinyurl(tinyurl_id)
|
||||
return {"deleted": True}
|
||||
raise HTTPException(
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
Body,
|
||||
Depends,
|
||||
)
|
||||
|
||||
from lnbits.core.models import (
|
||||
CreateWallet,
|
||||
Wallet,
|
||||
WalletType,
|
||||
)
|
||||
from lnbits.decorators import (
|
||||
WalletTypeInfo,
|
||||
get_key_type,
|
||||
require_admin_key,
|
||||
)
|
||||
|
||||
from ..crud import (
|
||||
create_wallet,
|
||||
delete_wallet,
|
||||
update_wallet,
|
||||
)
|
||||
|
||||
wallet_router = APIRouter(prefix="/api/v1/wallet", tags=["Wallet"])
|
||||
|
||||
|
||||
@wallet_router.get("")
|
||||
async def api_wallet(wallet: WalletTypeInfo = Depends(get_key_type)):
|
||||
if wallet.wallet_type == WalletType.admin:
|
||||
return {
|
||||
"id": wallet.wallet.id,
|
||||
"name": wallet.wallet.name,
|
||||
"balance": wallet.wallet.balance_msat,
|
||||
}
|
||||
else:
|
||||
return {"name": wallet.wallet.name, "balance": wallet.wallet.balance_msat}
|
||||
|
||||
|
||||
@wallet_router.put("/{new_name}")
|
||||
async def api_update_wallet_name(
|
||||
new_name: str, wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||
):
|
||||
await update_wallet(wallet.wallet.id, new_name)
|
||||
return {
|
||||
"id": wallet.wallet.id,
|
||||
"name": wallet.wallet.name,
|
||||
"balance": wallet.wallet.balance_msat,
|
||||
}
|
||||
|
||||
|
||||
@wallet_router.patch("", response_model=Wallet)
|
||||
async def api_update_wallet(
|
||||
name: Optional[str] = Body(None),
|
||||
currency: Optional[str] = Body(None),
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||
):
|
||||
return await update_wallet(wallet.wallet.id, name, currency)
|
||||
|
||||
|
||||
@wallet_router.delete("")
|
||||
async def api_delete_wallet(
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||
) -> None:
|
||||
await delete_wallet(
|
||||
user_id=wallet.wallet.user,
|
||||
wallet_id=wallet.wallet.id,
|
||||
)
|
||||
|
||||
|
||||
@wallet_router.post("", response_model=Wallet)
|
||||
async def api_create_wallet(
|
||||
data: CreateWallet,
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||
) -> Wallet:
|
||||
return await create_wallet(user_id=wallet.wallet.user, wallet_name=data.name)
|
||||
@@ -24,10 +24,10 @@ from ..crud import (
|
||||
get_webpush_subscription,
|
||||
)
|
||||
|
||||
webpush_router = APIRouter(prefix="/api/v1/webpush", tags=["webpush"])
|
||||
webpush_router = APIRouter(prefix="/api/v1/webpush", tags=["Webpush"])
|
||||
|
||||
|
||||
@webpush_router.post("/", status_code=HTTPStatus.CREATED)
|
||||
@webpush_router.post("", status_code=HTTPStatus.CREATED)
|
||||
async def api_create_webpush_subscription(
|
||||
request: Request,
|
||||
data: CreateWebPushSubscription,
|
||||
@@ -49,7 +49,7 @@ async def api_create_webpush_subscription(
|
||||
)
|
||||
|
||||
|
||||
@webpush_router.delete("/", status_code=HTTPStatus.OK)
|
||||
@webpush_router.delete("", status_code=HTTPStatus.OK)
|
||||
async def api_delete_webpush_subscription(
|
||||
request: Request,
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
WebSocket,
|
||||
WebSocketDisconnect,
|
||||
)
|
||||
|
||||
from ..services import (
|
||||
websocketManager,
|
||||
websocketUpdater,
|
||||
)
|
||||
|
||||
websocket_router = APIRouter(prefix="/api/v1/ws", tags=["Websocket"])
|
||||
|
||||
|
||||
@websocket_router.websocket("/{item_id}")
|
||||
async def websocket_connect(websocket: WebSocket, item_id: str):
|
||||
await websocketManager.connect(websocket, item_id)
|
||||
try:
|
||||
while True:
|
||||
await websocket.receive_text()
|
||||
except WebSocketDisconnect:
|
||||
websocketManager.disconnect(websocket)
|
||||
|
||||
|
||||
@websocket_router.post("/{item_id}")
|
||||
async def websocket_update_post(item_id: str, data: str):
|
||||
try:
|
||||
await websocketUpdater(item_id, data)
|
||||
return {"sent": True, "data": data}
|
||||
except Exception:
|
||||
return {"sent": False, "data": data}
|
||||
|
||||
|
||||
@websocket_router.get("/{item_id}/{data}")
|
||||
async def websocket_update_get(item_id: str, data: str):
|
||||
try:
|
||||
await websocketUpdater(item_id, data)
|
||||
return {"sent": True, "data": data}
|
||||
except Exception:
|
||||
return {"sent": False, "data": data}
|
||||
+21
-6
@@ -179,16 +179,27 @@ class Connection(Compat):
|
||||
values: Optional[List[str]] = None,
|
||||
filters: Optional[Filters] = None,
|
||||
model: Optional[Type[TRowModel]] = None,
|
||||
group_by: Optional[List[str]] = None,
|
||||
) -> Page[TRowModel]:
|
||||
if not filters:
|
||||
filters = Filters()
|
||||
clause = filters.where(where)
|
||||
parsed_values = filters.values(values)
|
||||
|
||||
group_by_string = ""
|
||||
if group_by:
|
||||
for field in group_by:
|
||||
if not re.fullmatch(
|
||||
r"[a-zA-Z_][a-zA-Z0-9_]*(\.[a-zA-Z_][a-zA-Z0-9_]*)?", field
|
||||
):
|
||||
raise ValueError("Value for GROUP BY is invalid")
|
||||
group_by_string = f"GROUP BY {', '.join(group_by)}"
|
||||
|
||||
rows = await self.fetchall(
|
||||
f"""
|
||||
{query}
|
||||
{clause}
|
||||
{group_by_string}
|
||||
{filters.order_by()}
|
||||
{filters.pagination()}
|
||||
""",
|
||||
@@ -202,6 +213,7 @@ class Connection(Compat):
|
||||
SELECT COUNT(*) FROM (
|
||||
{query}
|
||||
{clause}
|
||||
{group_by_string}
|
||||
) as count
|
||||
""",
|
||||
parsed_values,
|
||||
@@ -242,7 +254,9 @@ class Database(Compat):
|
||||
else:
|
||||
self.schema = None
|
||||
|
||||
self.engine = create_engine(database_uri, strategy=ASYNCIO_STRATEGY)
|
||||
self.engine = create_engine(
|
||||
database_uri, strategy=ASYNCIO_STRATEGY, echo=settings.debug_database
|
||||
)
|
||||
self.lock = asyncio.Lock()
|
||||
|
||||
logger.trace(f"database {self.type} added for {self.name}")
|
||||
@@ -288,9 +302,10 @@ class Database(Compat):
|
||||
values: Optional[List[str]] = None,
|
||||
filters: Optional[Filters] = None,
|
||||
model: Optional[Type[TRowModel]] = None,
|
||||
group_by: Optional[List[str]] = None,
|
||||
) -> Page[TRowModel]:
|
||||
async with self.connect() as conn:
|
||||
return await conn.fetch_page(query, where, values, filters, model)
|
||||
return await conn.fetch_page(query, where, values, filters, model, group_by)
|
||||
|
||||
async def execute(self, query: str, values: tuple = ()):
|
||||
async with self.connect() as conn:
|
||||
@@ -463,8 +478,8 @@ class Filters(BaseModel, Generic[TFilterModel]):
|
||||
if not where_stmts:
|
||||
where_stmts = []
|
||||
if self.filters:
|
||||
for filter in self.filters:
|
||||
where_stmts.append(filter.statement)
|
||||
for page_filter in self.filters:
|
||||
where_stmts.append(page_filter.statement)
|
||||
if self.search and self.model:
|
||||
if DB_TYPE == POSTGRES:
|
||||
where_stmts.append(
|
||||
@@ -487,8 +502,8 @@ class Filters(BaseModel, Generic[TFilterModel]):
|
||||
if not values:
|
||||
values = []
|
||||
if self.filters:
|
||||
for filter in self.filters:
|
||||
values.extend(filter.values)
|
||||
for page_filter in self.filters:
|
||||
values.extend(page_filter.values)
|
||||
if self.search and self.model:
|
||||
values.append(f"%{self.search}%")
|
||||
return tuple(values)
|
||||
|
||||
+14
-13
@@ -3,7 +3,7 @@ from typing import Annotated, Literal, Optional, Type, Union
|
||||
|
||||
from fastapi import Cookie, Depends, Query, Request, Security
|
||||
from fastapi.exceptions import HTTPException
|
||||
from fastapi.openapi.models import APIKey, APIKeyIn
|
||||
from fastapi.openapi.models import APIKey, APIKeyIn, SecuritySchemeType
|
||||
from fastapi.security import APIKeyHeader, APIKeyQuery, OAuth2PasswordBearer
|
||||
from fastapi.security.base import SecurityBase
|
||||
from jose import ExpiredSignatureError, JWTError, jwt
|
||||
@@ -17,14 +17,13 @@ from lnbits.core.crud import (
|
||||
get_user,
|
||||
get_wallet_for_key,
|
||||
)
|
||||
from lnbits.core.models import User, WalletType, WalletTypeInfo
|
||||
from lnbits.core.models import User, Wallet, WalletType, WalletTypeInfo
|
||||
from lnbits.db import Filter, Filters, TFilterModel
|
||||
from lnbits.settings import AuthMethods, settings
|
||||
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="api/v1/auth", auto_error=False)
|
||||
|
||||
|
||||
# TODO: fix type ignores
|
||||
class KeyChecker(SecurityBase):
|
||||
def __init__(
|
||||
self,
|
||||
@@ -33,23 +32,25 @@ class KeyChecker(SecurityBase):
|
||||
api_key: Optional[str] = None,
|
||||
):
|
||||
self.scheme_name = scheme_name or self.__class__.__name__
|
||||
self.auto_error = auto_error
|
||||
self._key_type = WalletType.invoice
|
||||
self.auto_error: bool = auto_error
|
||||
self._key_type: WalletType = WalletType.invoice
|
||||
self._api_key = api_key
|
||||
if api_key:
|
||||
key = APIKey(
|
||||
**{"in": APIKeyIn.query}, # type: ignore
|
||||
openapi_model = APIKey(
|
||||
**{"in": APIKeyIn.query},
|
||||
type=SecuritySchemeType.apiKey,
|
||||
name="X-API-KEY",
|
||||
description="Wallet API Key - QUERY",
|
||||
)
|
||||
else:
|
||||
key = APIKey(
|
||||
**{"in": APIKeyIn.header}, # type: ignore
|
||||
openapi_model = APIKey(
|
||||
**{"in": APIKeyIn.header},
|
||||
type=SecuritySchemeType.apiKey,
|
||||
name="X-API-KEY",
|
||||
description="Wallet API Key - HEADER",
|
||||
)
|
||||
self.wallet = None
|
||||
self.model: APIKey = key
|
||||
self.wallet: Optional[Wallet] = None
|
||||
self.model: APIKey = openapi_model
|
||||
|
||||
async def __call__(self, request: Request):
|
||||
try:
|
||||
@@ -62,12 +63,12 @@ class KeyChecker(SecurityBase):
|
||||
# avoided here. Also, we should not return the wallet here - thats
|
||||
# silly. Possibly store it in a Redis DB
|
||||
wallet = await get_wallet_for_key(key_value, self._key_type)
|
||||
if not wallet or wallet.deleted:
|
||||
if not wallet:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.UNAUTHORIZED,
|
||||
detail="Invalid key or wallet.",
|
||||
)
|
||||
self.wallet = wallet # type: ignore
|
||||
self.wallet = wallet
|
||||
except KeyError:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST, detail="`X-API-KEY` header missing."
|
||||
|
||||
+122
-29
@@ -1,16 +1,15 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import zipfile
|
||||
from http import HTTPStatus
|
||||
from pathlib import Path
|
||||
from typing import Any, List, NamedTuple, Optional, Tuple
|
||||
from urllib import request
|
||||
|
||||
import httpx
|
||||
from fastapi import HTTPException
|
||||
from loguru import logger
|
||||
from packaging import version
|
||||
from pydantic import BaseModel
|
||||
@@ -33,6 +32,7 @@ class ExplicitRelease(BaseModel):
|
||||
warning: Optional[str]
|
||||
info_notification: Optional[str]
|
||||
critical_notification: Optional[str]
|
||||
pay_link: Optional[str]
|
||||
|
||||
def is_version_compatible(self):
|
||||
if not self.min_lnbits_version:
|
||||
@@ -78,8 +78,15 @@ class ExtensionConfig(BaseModel):
|
||||
return version_parse(self.min_lnbits_version) <= version_parse(settings.version)
|
||||
|
||||
|
||||
class ReleasePaymentInfo(BaseModel):
|
||||
amount: Optional[int] = None
|
||||
pay_link: Optional[str] = None
|
||||
payment_hash: Optional[str] = None
|
||||
payment_request: Optional[str] = None
|
||||
|
||||
|
||||
def download_url(url, save_path):
|
||||
with request.urlopen(url) as dl_file:
|
||||
with request.urlopen(url, timeout=60) as dl_file:
|
||||
with open(save_path, "wb") as out_file:
|
||||
out_file.write(dl_file.read())
|
||||
|
||||
@@ -155,6 +162,21 @@ async def github_api_get(url: str, error_msg: Optional[str]) -> Any:
|
||||
return resp.json()
|
||||
|
||||
|
||||
async def fetch_release_payment_info(
|
||||
url: str, amount: Optional[int] = None
|
||||
) -> Optional[ReleasePaymentInfo]:
|
||||
if amount:
|
||||
url = f"{url}?amount={amount}"
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(url)
|
||||
resp.raise_for_status()
|
||||
return ReleasePaymentInfo(**resp.json())
|
||||
except Exception as e:
|
||||
logger.warning(e)
|
||||
return None
|
||||
|
||||
|
||||
def icon_to_github_url(source_repo: str, path: Optional[str]) -> str:
|
||||
if not path:
|
||||
return ""
|
||||
@@ -260,6 +282,26 @@ class ExtensionRelease(BaseModel):
|
||||
repo: Optional[str] = None
|
||||
icon: Optional[str] = None
|
||||
|
||||
pay_link: Optional[str] = None
|
||||
cost_sats: Optional[int] = None
|
||||
paid_sats: Optional[int] = 0
|
||||
payment_hash: Optional[str] = None
|
||||
|
||||
@property
|
||||
def archive_url(self) -> str:
|
||||
if not self.pay_link:
|
||||
return self.archive
|
||||
return (
|
||||
f"{self.archive}?version=v{self.version}&payment_hash={self.payment_hash}"
|
||||
)
|
||||
|
||||
async def check_payment_requirements(self):
|
||||
if not self.pay_link:
|
||||
return
|
||||
|
||||
payment_info = await fetch_release_payment_info(self.pay_link)
|
||||
self.cost_sats = payment_info.amount if payment_info else None
|
||||
|
||||
@classmethod
|
||||
def from_github_release(
|
||||
cls, source_repo: str, r: "GitHubRepoRelease"
|
||||
@@ -290,12 +332,13 @@ class ExtensionRelease(BaseModel):
|
||||
is_version_compatible=e.is_version_compatible(),
|
||||
warning=e.warning,
|
||||
html_url=e.html_url,
|
||||
pay_link=e.pay_link,
|
||||
repo=e.repo,
|
||||
icon=e.icon,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def all_releases(cls, org: str, repo: str) -> List["ExtensionRelease"]:
|
||||
async def get_github_releases(cls, org: str, repo: str) -> List["ExtensionRelease"]:
|
||||
try:
|
||||
github_releases = await fetch_github_releases(org, repo)
|
||||
return [
|
||||
@@ -318,6 +361,7 @@ class InstallableExtension(BaseModel):
|
||||
featured = False
|
||||
latest_release: Optional[ExtensionRelease] = None
|
||||
installed_release: Optional[ExtensionRelease] = None
|
||||
payments: List[ReleasePaymentInfo] = []
|
||||
archive: Optional[str] = None
|
||||
|
||||
@property
|
||||
@@ -368,30 +412,32 @@ class InstallableExtension(BaseModel):
|
||||
return self.installed_release.version
|
||||
return ""
|
||||
|
||||
def download_archive(self):
|
||||
async def download_archive(self):
|
||||
logger.info(f"Downloading extension {self.name} ({self.installed_version}).")
|
||||
ext_zip_file = self.zip_path
|
||||
if ext_zip_file.is_file():
|
||||
os.remove(ext_zip_file)
|
||||
try:
|
||||
assert self.installed_release, "installed_release is none."
|
||||
download_url(self.installed_release.archive, ext_zip_file)
|
||||
|
||||
self._restore_payment_info()
|
||||
|
||||
await asyncio.to_thread(
|
||||
download_url, self.installed_release.archive_url, ext_zip_file
|
||||
)
|
||||
|
||||
self._remember_payment_info()
|
||||
|
||||
except Exception as ex:
|
||||
logger.warning(ex)
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND,
|
||||
detail="Cannot fetch extension archive file",
|
||||
)
|
||||
raise AssertionError("Cannot fetch extension archive file")
|
||||
|
||||
archive_hash = file_hash(ext_zip_file)
|
||||
if self.installed_release.hash and self.installed_release.hash != archive_hash:
|
||||
# remove downloaded archive
|
||||
if ext_zip_file.is_file():
|
||||
os.remove(ext_zip_file)
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND,
|
||||
detail="File hash missmatch. Will not install.",
|
||||
)
|
||||
raise AssertionError("File hash missmatch. Will not install.")
|
||||
|
||||
def extract_archive(self):
|
||||
logger.info(f"Extracting extension {self.name} ({self.installed_version}).")
|
||||
@@ -433,7 +479,7 @@ class InstallableExtension(BaseModel):
|
||||
shutil.copytree(Path(self.ext_upgrade_dir), Path(self.ext_dir))
|
||||
logger.success(f"Extension {self.name} ({self.installed_version}) installed.")
|
||||
|
||||
def nofiy_upgrade(self) -> None:
|
||||
def notify_upgrade(self) -> None:
|
||||
"""
|
||||
Update the list of upgraded extensions. The middleware will perform
|
||||
redirects based on this
|
||||
@@ -468,14 +514,54 @@ class InstallableExtension(BaseModel):
|
||||
if version_parse(self.latest_release.version) < version_parse(release.version):
|
||||
self.latest_release = release
|
||||
|
||||
def find_existing_payment(
|
||||
self, pay_link: Optional[str]
|
||||
) -> Optional[ReleasePaymentInfo]:
|
||||
if not pay_link:
|
||||
return None
|
||||
return next(
|
||||
(p for p in self.payments if p.pay_link == pay_link),
|
||||
None,
|
||||
)
|
||||
|
||||
def _restore_payment_info(self):
|
||||
if not self.installed_release:
|
||||
return
|
||||
if not self.installed_release.pay_link:
|
||||
return
|
||||
if self.installed_release.payment_hash:
|
||||
return
|
||||
payment_info = self.find_existing_payment(self.installed_release.pay_link)
|
||||
if payment_info:
|
||||
self.installed_release.payment_hash = payment_info.payment_hash
|
||||
|
||||
def _remember_payment_info(self):
|
||||
if not self.installed_release or not self.installed_release.pay_link:
|
||||
return
|
||||
payment_info = ReleasePaymentInfo(
|
||||
amount=self.installed_release.cost_sats,
|
||||
pay_link=self.installed_release.pay_link,
|
||||
payment_hash=self.installed_release.payment_hash,
|
||||
)
|
||||
self.payments = [
|
||||
p for p in self.payments if p.pay_link != payment_info.pay_link
|
||||
]
|
||||
self.payments.append(payment_info)
|
||||
|
||||
@classmethod
|
||||
def from_row(cls, data: dict) -> "InstallableExtension":
|
||||
meta = json.loads(data["meta"])
|
||||
ext = InstallableExtension(**data)
|
||||
if "installed_release" in meta:
|
||||
ext.installed_release = ExtensionRelease(**meta["installed_release"])
|
||||
if meta.get("payments"):
|
||||
ext.payments = [ReleasePaymentInfo(**p) for p in meta["payments"]]
|
||||
return ext
|
||||
|
||||
@classmethod
|
||||
def from_rows(cls, rows: List[Any] = []) -> List["InstallableExtension"]:
|
||||
return [InstallableExtension.from_row(row) for row in rows]
|
||||
|
||||
@classmethod
|
||||
async def from_github_release(
|
||||
cls, github_release: GitHubRelease
|
||||
@@ -565,17 +651,19 @@ class InstallableExtension(BaseModel):
|
||||
try:
|
||||
manifest = await fetch_manifest(url)
|
||||
for r in manifest.repos:
|
||||
if r.id == ext_id:
|
||||
repo_releases = await ExtensionRelease.all_releases(
|
||||
r.organisation, r.repository
|
||||
)
|
||||
extension_releases += repo_releases
|
||||
if r.id != ext_id:
|
||||
continue
|
||||
repo_releases = await ExtensionRelease.get_github_releases(
|
||||
r.organisation, r.repository
|
||||
)
|
||||
extension_releases += repo_releases
|
||||
|
||||
for e in manifest.extensions:
|
||||
if e.id == ext_id:
|
||||
extension_releases += [
|
||||
ExtensionRelease.from_explicit_release(url, e)
|
||||
]
|
||||
if e.id != ext_id:
|
||||
continue
|
||||
explicit_release = ExtensionRelease.from_explicit_release(url, e)
|
||||
await explicit_release.check_payment_requirements()
|
||||
extension_releases.append(explicit_release)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Manifest {url} failed with '{str(e)}'")
|
||||
@@ -584,15 +672,17 @@ class InstallableExtension(BaseModel):
|
||||
|
||||
@classmethod
|
||||
async def get_extension_release(
|
||||
cls, ext_id: str, source_repo: str, archive: str
|
||||
cls, ext_id: str, source_repo: str, archive: str, version: str
|
||||
) -> Optional["ExtensionRelease"]:
|
||||
all_releases: List[
|
||||
ExtensionRelease
|
||||
] = await InstallableExtension.get_extension_releases(ext_id)
|
||||
all_releases: List[ExtensionRelease] = (
|
||||
await InstallableExtension.get_extension_releases(ext_id)
|
||||
)
|
||||
selected_release = [
|
||||
r
|
||||
for r in all_releases
|
||||
if r.archive == archive and r.source_repo == source_repo
|
||||
if r.archive == archive
|
||||
and r.source_repo == source_repo
|
||||
and r.version == version
|
||||
]
|
||||
|
||||
return selected_release[0] if len(selected_release) != 0 else None
|
||||
@@ -602,6 +692,9 @@ class CreateExtension(BaseModel):
|
||||
ext_id: str
|
||||
archive: str
|
||||
source_repo: str
|
||||
version: str
|
||||
cost_sats: Optional[int] = 0
|
||||
payment_hash: Optional[str] = None
|
||||
|
||||
|
||||
def get_valid_extensions(include_deactivated: Optional[bool] = True) -> List[Extension]:
|
||||
|
||||
@@ -64,6 +64,9 @@ def template_renderer(additional_folders: Optional[List] = None) -> Jinja2Templa
|
||||
t.env.globals["LNBITS_NEW_ACCOUNTS_ALLOWED"] = settings.new_accounts_allowed
|
||||
t.env.globals["LNBITS_AUTH_METHODS"] = settings.auth_allowed_methods
|
||||
t.env.globals["LNBITS_ADMIN_UI"] = settings.lnbits_admin_ui
|
||||
t.env.globals["LNBITS_EXTENSIONS_DEACTIVATE_ALL"] = (
|
||||
settings.lnbits_extensions_deactivate_all
|
||||
)
|
||||
t.env.globals["LNBITS_SERVICE_FEE"] = settings.lnbits_service_fee
|
||||
t.env.globals["LNBITS_SERVICE_FEE_MAX"] = settings.lnbits_service_fee_max
|
||||
t.env.globals["LNBITS_SERVICE_FEE_WALLET"] = settings.lnbits_service_fee_wallet
|
||||
|
||||
@@ -35,7 +35,7 @@ class InstalledExtensionMiddleware:
|
||||
# block path for all users if the extension is disabled
|
||||
if top_path in settings.lnbits_deactivated_extensions:
|
||||
response = self._response_by_accepted_type(
|
||||
headers, f"Extension '{top_path}' disabled", HTTPStatus.NOT_FOUND
|
||||
scope, headers, f"Extension '{top_path}' disabled", HTTPStatus.NOT_FOUND
|
||||
)
|
||||
await response(scope, receive, send)
|
||||
return
|
||||
@@ -61,7 +61,7 @@ class InstalledExtensionMiddleware:
|
||||
await self.app(scope, receive, send)
|
||||
|
||||
def _response_by_accepted_type(
|
||||
self, headers: List[Any], msg: str, status_code: HTTPStatus
|
||||
self, scope: Scope, headers: List[Any], msg: str, status_code: HTTPStatus
|
||||
) -> Union[HTMLResponse, JSONResponse]:
|
||||
"""
|
||||
Build an HTTP response containing the `msg` as HTTP body and the `status_code`
|
||||
@@ -78,11 +78,11 @@ class InstalledExtensionMiddleware:
|
||||
"",
|
||||
)
|
||||
|
||||
if "text/html" in [a for a in accept_header.split(",")]:
|
||||
if "text/html" in accept_header.split(","):
|
||||
return HTMLResponse(
|
||||
status_code=status_code,
|
||||
content=template_renderer()
|
||||
.TemplateResponse("error.html", {"request": {}, "err": msg})
|
||||
.TemplateResponse(Request(scope), "error.html", {"err": msg})
|
||||
.body,
|
||||
)
|
||||
|
||||
|
||||
+14
-10
@@ -217,17 +217,21 @@ class CoreLightningNode(Node):
|
||||
state=(
|
||||
ChannelState.ACTIVE
|
||||
if ch["state"] == "CHANNELD_NORMAL"
|
||||
else ChannelState.PENDING
|
||||
if ch["state"] in ("CHANNELD_AWAITING_LOCKIN", "OPENINGD")
|
||||
else ChannelState.CLOSED
|
||||
if ch["state"]
|
||||
in (
|
||||
"CHANNELD_CLOSING",
|
||||
"CLOSINGD_COMPLETE",
|
||||
"CLOSINGD_SIGEXCHANGE",
|
||||
"ONCHAIN",
|
||||
else (
|
||||
ChannelState.PENDING
|
||||
if ch["state"] in ("CHANNELD_AWAITING_LOCKIN", "OPENINGD")
|
||||
else (
|
||||
ChannelState.CLOSED
|
||||
if ch["state"]
|
||||
in (
|
||||
"CHANNELD_CLOSING",
|
||||
"CLOSINGD_COMPLETE",
|
||||
"CLOSINGD_SIGEXCHANGE",
|
||||
"ONCHAIN",
|
||||
)
|
||||
else ChannelState.INACTIVE
|
||||
)
|
||||
)
|
||||
else ChannelState.INACTIVE
|
||||
),
|
||||
)
|
||||
for ch in funds["channels"]
|
||||
|
||||
+14
-8
@@ -167,6 +167,8 @@ class LndRestNode(Node):
|
||||
point: Optional[ChannelPoint] = None,
|
||||
force: bool = False,
|
||||
):
|
||||
if short_id:
|
||||
logger.debug(f"Closing channel with short_id: {short_id}")
|
||||
if not point:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST, detail="Channel point required"
|
||||
@@ -237,9 +239,11 @@ class LndRestNode(Node):
|
||||
remote_msat=msat(channel["remote_balance"]),
|
||||
total_msat=msat(channel["capacity"]),
|
||||
),
|
||||
state=ChannelState.ACTIVE
|
||||
if channel["active"]
|
||||
else ChannelState.INACTIVE,
|
||||
state=(
|
||||
ChannelState.ACTIVE
|
||||
if channel["active"]
|
||||
else ChannelState.INACTIVE
|
||||
),
|
||||
# name=channel['peer_alias'],
|
||||
name=info.alias,
|
||||
color=info.color,
|
||||
@@ -318,11 +322,13 @@ class LndRestNode(Node):
|
||||
amount=payment["value_msat"],
|
||||
fee=payment["fee_msat"],
|
||||
time=payment["creation_date"],
|
||||
destination=await self.get_peer_info(
|
||||
payment["htlcs"][0]["route"]["hops"][-1]["pub_key"]
|
||||
)
|
||||
if payment["htlcs"]
|
||||
else None,
|
||||
destination=(
|
||||
await self.get_peer_info(
|
||||
payment["htlcs"][0]["route"]["hops"][-1]["pub_key"]
|
||||
)
|
||||
if payment["htlcs"]
|
||||
else None
|
||||
),
|
||||
bolt11=payment["payment_request"],
|
||||
preimage=payment["payment_preimage"],
|
||||
)
|
||||
|
||||
+5
-5
@@ -10,10 +10,10 @@ from lnbits.settings import set_cli_settings, settings
|
||||
|
||||
|
||||
@click.command(
|
||||
context_settings=dict(
|
||||
ignore_unknown_options=True,
|
||||
allow_extra_args=True,
|
||||
)
|
||||
context_settings={
|
||||
"ignore_unknown_options": True,
|
||||
"allow_extra_args": True,
|
||||
}
|
||||
)
|
||||
@click.option("--port", default=settings.port, help="Port to listen on")
|
||||
@click.option("--host", default=settings.host, help="Host to run LNBits on")
|
||||
@@ -48,7 +48,7 @@ def main(
|
||||
|
||||
# this beautiful beast parses all command line arguments and
|
||||
# passes them to the uvicorn server
|
||||
d = dict()
|
||||
d = {}
|
||||
for a in ctx.args:
|
||||
item = a.split("=")
|
||||
if len(item) > 1: # argument like --key=value
|
||||
|
||||
+10
-1
@@ -47,6 +47,7 @@ class UsersSettings(LNbitsSettings):
|
||||
|
||||
class ExtensionsSettings(LNbitsSettings):
|
||||
lnbits_admin_extensions: List[str] = Field(default=[])
|
||||
lnbits_extensions_deactivate_all: bool = Field(default=False)
|
||||
lnbits_extensions_manifests: List[str] = Field(
|
||||
default=[
|
||||
"https://raw.githubusercontent.com/lnbits/lnbits-extensions/main/extensions.json"
|
||||
@@ -182,6 +183,7 @@ class LndRestFundingSource(LNbitsSettings):
|
||||
lnd_rest_cert: Optional[str] = Field(default=None)
|
||||
lnd_rest_macaroon: Optional[str] = Field(default=None)
|
||||
lnd_rest_macaroon_encrypted: Optional[str] = Field(default=None)
|
||||
lnd_rest_route_hints: bool = Field(default=True)
|
||||
lnd_cert: Optional[str] = Field(default=None)
|
||||
lnd_admin_macaroon: Optional[str] = Field(default=None)
|
||||
lnd_invoice_macaroon: Optional[str] = Field(default=None)
|
||||
@@ -206,6 +208,11 @@ class LnPayFundingSource(LNbitsSettings):
|
||||
lnpay_admin_key: Optional[str] = Field(default=None)
|
||||
|
||||
|
||||
class ZBDFundingSource(LNbitsSettings):
|
||||
zbd_api_endpoint: Optional[str] = Field(default="https://api.zebedee.io/v0/")
|
||||
zbd_api_key: Optional[str] = Field(default=None)
|
||||
|
||||
|
||||
class AlbyFundingSource(LNbitsSettings):
|
||||
alby_api_endpoint: Optional[str] = Field(default="https://api.getalby.com/")
|
||||
alby_access_token: Optional[str] = Field(default=None)
|
||||
@@ -245,6 +252,7 @@ class FundingSourcesSettings(
|
||||
LndGrpcFundingSource,
|
||||
LnPayFundingSource,
|
||||
AlbyFundingSource,
|
||||
ZBDFundingSource,
|
||||
OpenNodeFundingSource,
|
||||
SparkFundingSource,
|
||||
LnTipsFundingSource,
|
||||
@@ -352,6 +360,7 @@ class UpdateSettings(EditableSettings):
|
||||
|
||||
class EnvSettings(LNbitsSettings):
|
||||
debug: bool = Field(default=False)
|
||||
debug_database: bool = Field(default=False)
|
||||
bundle_assets: bool = Field(default=True)
|
||||
host: str = Field(default="127.0.0.1")
|
||||
port: int = Field(default=5000)
|
||||
@@ -367,7 +376,6 @@ class EnvSettings(LNbitsSettings):
|
||||
log_rotation: str = Field(default="100 MB")
|
||||
log_retention: str = Field(default="3 months")
|
||||
server_startup_time: int = Field(default=time())
|
||||
lnbits_extensions_deactivate_all: bool = Field(default=False)
|
||||
cleanup_wallets_days: int = Field(default=90)
|
||||
|
||||
@property
|
||||
@@ -399,6 +407,7 @@ class SuperUserSettings(LNbitsSettings):
|
||||
"LnTipsWallet",
|
||||
"LNPayWallet",
|
||||
"AlbyWallet",
|
||||
"ZBDWallet",
|
||||
"LNbitsWallet",
|
||||
"OpenNodeWallet",
|
||||
]
|
||||
|
||||
Vendored
+10
-10
File diff suppressed because one or more lines are too long
@@ -36,7 +36,7 @@ window.localisation.br = {
|
||||
name_your_wallet: 'Nomeie sua carteira %{name}',
|
||||
paste_invoice_label: 'Cole uma fatura, pedido de pagamento ou código lnurl *',
|
||||
lnbits_description:
|
||||
'Fácil de configurar e leve, o LNbits pode ser executado em qualquer fonte de financiamento da lightning-network, atualmente suportando LND, c-lightning, OpenNode, Alby, LNPay e até mesmo o LNbits em si! Você pode executar o LNbits para si mesmo ou oferecer facilmente uma solução de custódia para outros. Cada carteira tem suas próprias chaves de API e não há limite para o número de carteiras que você pode criar. Ser capaz de particionar fundos torna o LNbits uma ferramenta útil para gerenciamento de dinheiro e como uma ferramenta de desenvolvimento. As extensões adicionam funcionalidades extras ao LNbits para que você possa experimentar uma série de tecnologias de ponta na rede lightning. Nós tornamos o desenvolvimento de extensões o mais fácil possível e, como um projeto gratuito e de código aberto, incentivamos as pessoas a desenvolver e enviar as suas próprias.',
|
||||
'Fácil de configurar e leve, o LNbits pode ser executado em qualquer fonte de financiamento da Lightning Network e até mesmo o LNbits em si! Você pode executar o LNbits para si mesmo ou oferecer facilmente uma solução de custódia para outros. Cada carteira tem suas próprias chaves de API e não há limite para o número de carteiras que você pode criar. Ser capaz de particionar fundos torna o LNbits uma ferramenta útil para gerenciamento de dinheiro e como uma ferramenta de desenvolvimento. As extensões adicionam funcionalidades extras ao LNbits para que você possa experimentar uma série de tecnologias de ponta na rede lightning. Nós tornamos o desenvolvimento de extensões o mais fácil possível e, como um projeto gratuito e de código aberto, incentivamos as pessoas a desenvolver e enviar as suas próprias.',
|
||||
export_to_phone: 'Exportar para o telefone com código QR',
|
||||
export_to_phone_desc:
|
||||
'Este código QR contém a URL da sua carteira com acesso total. Você pode escaneá-lo do seu telefone para abrir sua carteira a partir dele.',
|
||||
|
||||
@@ -35,7 +35,7 @@ window.localisation.cn = {
|
||||
name_your_wallet: '给你的 %{name}钱包起个名字',
|
||||
paste_invoice_label: '粘贴发票,付款请求或lnurl*',
|
||||
lnbits_description:
|
||||
'LNbits 设置简单、轻量级,可以运行在任何闪电网络的版本上,目前支持 LND、Core Lightning、OpenNode、Alby, LNPay,甚至 LNbits 本身!您可以为自己运行 LNbits,或者为他人轻松提供资金托管。每个钱包都有自己的 API 密钥,你可以创建的钱包数量没有限制。能够把资金分开管理使 LNbits 成为一款有用的资金管理和开发工具。扩展程序增加了 LNbits 的额外功能,所以你可以在闪电网络上尝试各种尖端技术。我们已经尽可能简化了开发扩展程序的过程,作为一个免费和开源的项目,我们鼓励人们开发并提交自己的扩展程序。',
|
||||
'LNbits 设置简单、轻量级,可以在任何闪电网络的资金来源上运行,甚至可以在LNbits自身上运行!您可以为自己运行LNbits,或者轻松为他人提供托管解决方案。每个钱包都有自己的 API 密钥,你可以创建的钱包数量没有限制。能够把资金分开管理使 LNbits 成为一款有用的资金管理和开发工具。扩展程序增加了 LNbits 的额外功能,所以你可以在闪电网络上尝试各种尖端技术。我们已经尽可能简化了开发扩展程序的过程,作为一个免费和开源的项目,我们鼓励人们开发并提交自己的扩展程序。',
|
||||
export_to_phone: '通过二维码导出到手机',
|
||||
export_to_phone_desc:
|
||||
'这个二维码包含您钱包的URL。您可以使用手机扫描的方式打开您的钱包。',
|
||||
|
||||
@@ -35,7 +35,7 @@ window.localisation.cs = {
|
||||
name_your_wallet: 'Pojmenujte svou %{name} peněženku',
|
||||
paste_invoice_label: 'Vložte fakturu, platební požadavek nebo lnurl kód *',
|
||||
lnbits_description:
|
||||
'Snadno nastavitelný a lehkotonážní, LNbits může běžet na jakémkoliv zdroji financování lightning-network, v současné době podporuje LND, Core Lightning, OpenNode, Alby, LNPay a dokonce LNbits samotné! LNbits můžete provozovat pro sebe, nebo snadno nabízet správu peněženek pro ostatní. Každá peněženka má své vlastní API klíče a není omezen počet peněženek, které můžete vytvořit. Možnost rozdělení prostředků dělá z LNbits užitečný nástroj pro správu peněz a jako vývojový nástroj. Rozšíření přidávají extra funkčnost k LNbits, takže můžete experimentovat s řadou špičkových technologií na lightning network. Vývoj rozšíření jsme učinili co nejjednodušší a jako svobodný a open-source projekt podporujeme lidi ve vývoji a zasílání vlastních rozšíření.',
|
||||
'Snadno nastavitelný a lehkotonážní, LNbits může běžet na jakémkoliv zdroji financování Lightning Network a dokonce LNbits samotné! LNbits můžete provozovat pro sebe, nebo snadno nabízet správu peněženek pro ostatní. Každá peněženka má své vlastní API klíče a není omezen počet peněženek, které můžete vytvořit. Možnost rozdělení prostředků dělá z LNbits užitečný nástroj pro správu peněz a jako vývojový nástroj. Rozšíření přidávají extra funkčnost k LNbits, takže můžete experimentovat s řadou špičkových technologií na lightning network. Vývoj rozšíření jsme učinili co nejjednodušší a jako svobodný a open-source projekt podporujeme lidi ve vývoji a zasílání vlastních rozšíření.',
|
||||
export_to_phone: 'Exportovat do telefonu pomocí QR kódu',
|
||||
export_to_phone_desc:
|
||||
'Tento QR kód obsahuje URL vaší peněženky s plným přístupem. Můžete jej naskenovat z telefonu a otevřít peněženku odtamtud.',
|
||||
|
||||
@@ -37,7 +37,7 @@ window.localisation.de = {
|
||||
paste_invoice_label:
|
||||
'Füge eine Rechnung, Zahlungsanforderung oder LNURL ein *',
|
||||
lnbits_description:
|
||||
'Einfach zu installieren und kompakt, LNbits kann auf jeder Funding-Quelle im Lightning Netzwerk aufsetzen. Derzeit unterstützt: LND, Core Lightning, OpenNode, Alby, LNPay und sogar LNbits selbst! Du kannst LNbits für dich selbst betreiben oder anderen die Verwaltung durch dich anbieten. Jede Wallet hat ihre eigenen API-Schlüssel und die Anzahl der Wallets ist unbegrenzt. Die Möglichkeit, Gelder auf verschiedene Accounts mit unterschiedlicher Logik aufteilen zu können macht LNbits zu einem nützlichen Werkzeug für deine Buchhaltung - aber auch als Entwicklungswerkzeug. Erweiterungen bereichern LNbits Accounts um zusätzliche Funktionalität, so dass du mit einer Reihe von neuartigen Technologien auf dem Lightning-Netzwerk experimentieren kannst. Wir haben es so einfach wie möglich gemacht, Erweiterungen zu entwickeln, und als freies und Open-Source-Projekt möchten wir Menschen ermutigen, sich selbst hieran zu versuchen und gemeinsam mit uns neue Funktionalitäten zu entwickeln.',
|
||||
'Einfach zu installieren und kompakt, LNbits kann auf jeder Funding-Quelle im Lightning Netzwerk aufsetzen und sogar LNbits selbst! Du kannst LNbits für dich selbst betreiben oder anderen die Verwaltung durch dich anbieten. Jede Wallet hat ihre eigenen API-Schlüssel und die Anzahl der Wallets ist unbegrenzt. Die Möglichkeit, Gelder auf verschiedene Accounts mit unterschiedlicher Logik aufteilen zu können macht LNbits zu einem nützlichen Werkzeug für deine Buchhaltung - aber auch als Entwicklungswerkzeug. Erweiterungen bereichern LNbits Accounts um zusätzliche Funktionalität, so dass du mit einer Reihe von neuartigen Technologien auf dem Lightning-Netzwerk experimentieren kannst. Wir haben es so einfach wie möglich gemacht, Erweiterungen zu entwickeln, und als freies und Open-Source-Projekt möchten wir Menschen ermutigen, sich selbst hieran zu versuchen und gemeinsam mit uns neue Funktionalitäten zu entwickeln.',
|
||||
export_to_phone: 'Auf dem Telefon öffnen',
|
||||
export_to_phone_desc:
|
||||
'Dieser QR-Code beinhaltet vollständige Rechte auf deine Wallet. Du kannst den QR-Code mit Deinem Telefon scannen, um deine Wallet dort zu öffnen.',
|
||||
|
||||
@@ -35,7 +35,7 @@ window.localisation.en = {
|
||||
name_your_wallet: 'Name your %{name} wallet',
|
||||
paste_invoice_label: 'Paste an invoice, payment request or lnurl code *',
|
||||
lnbits_description:
|
||||
'Easy to set up and lightweight, LNbits can run on any lightning-network funding source, currently supporting LND, Core Lightning, OpenNode, Alby, LNPay and even LNbits itself! You can run LNbits for yourself, or easily offer a custodian solution for others. Each wallet has its own API keys and there is no limit to the number of wallets you can make. Being able to partition funds makes LNbits a useful tool for money management and as a development tool. Extensions add extra functionality to LNbits so you can experiment with a range of cutting-edge technologies on the lightning network. We have made developing extensions as easy as possible, and as a free and open-source project, we encourage people to develop and submit their own.',
|
||||
'Easy to set up and lightweight, LNbits can run on any Lightning Network funding source and even LNbits itself! You can run LNbits for yourself, or easily offer a custodian solution for others. Each wallet has its own API keys and there is no limit to the number of wallets you can make. Being able to partition funds makes LNbits a useful tool for money management and as a development tool. Extensions add extra functionality to LNbits so you can experiment with a range of cutting-edge technologies on the lightning network. We have made developing extensions as easy as possible, and as a free and open-source project, we encourage people to develop and submit their own.',
|
||||
export_to_phone: 'Export to Phone with QR Code',
|
||||
export_to_phone_desc:
|
||||
'This QR code contains your wallet URL with full access. You can scan it from your phone to open your wallet from there.',
|
||||
@@ -239,5 +239,12 @@ This service is in BETA. LNbits holds no responsibility for loss of access to fu
|
||||
logout: 'Logout',
|
||||
look_and_feel: 'Look and Feel',
|
||||
language: 'Language',
|
||||
color_scheme: 'Color Scheme'
|
||||
color_scheme: 'Color Scheme',
|
||||
extension_cost: 'This release requires a payment of minimum %{cost} sats.',
|
||||
extension_paid_sats: 'You have already paid %{paid_sats} sats.',
|
||||
release_details_error: 'Cannot get the release details.',
|
||||
pay_from_wallet: 'Pay from Wallet',
|
||||
show_qr: 'Show QR',
|
||||
retry_install: 'Retry Install',
|
||||
new_payment: 'Make New Payment'
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ window.localisation.es = {
|
||||
name_your_wallet: 'Nombre de su billetera %{name}',
|
||||
paste_invoice_label: 'Pegue la factura aquí',
|
||||
lnbits_description:
|
||||
'Fácil de instalar y liviano, LNbits puede ejecutarse en cualquier fuente de financiación de la red Lightning, actualmente compatible con LND, Core Lightning, OpenNode, Alby, LNPay y hasta LNbits mismo! Puede ejecutar LNbits para usted mismo o ofrecer una solución competente a otros. Cada billetera tiene su propia clave API y no hay límite para la cantidad de billeteras que puede crear. La capacidad de particionar fondos hace de LNbits una herramienta útil para la administración de fondos y como herramienta de desarrollo. Las extensiones agregan funcionalidad adicional a LNbits, por lo que puede experimentar con una variedad de tecnologías de vanguardia en la red Lightning. Lo hemos hecho lo más simple posible para desarrollar extensiones y, como un proyecto gratuito y de código abierto, animamos a las personas a que se desarrollen a sí mismas y envíen sus propios contribuciones.',
|
||||
'Fácil de instalar y liviano, LNbits puede ejecutarse en cualquier fuente de financiación de la red Lightning y hasta LNbits mismo! Puede ejecutar LNbits para usted mismo o ofrecer una solución competente a otros. Cada billetera tiene su propia clave API y no hay límite para la cantidad de billeteras que puede crear. La capacidad de particionar fondos hace de LNbits una herramienta útil para la administración de fondos y como herramienta de desarrollo. Las extensiones agregan funcionalidad adicional a LNbits, por lo que puede experimentar con una variedad de tecnologías de vanguardia en la red Lightning. Lo hemos hecho lo más simple posible para desarrollar extensiones y, como un proyecto gratuito y de código abierto, animamos a las personas a que se desarrollen a sí mismas y envíen sus propios contribuciones.',
|
||||
export_to_phone: 'Exportar a teléfono con código QR',
|
||||
export_to_phone_desc:
|
||||
'Este código QR contiene su URL de billetera con acceso completo. Puede escanearlo desde su teléfono para abrir su billetera allí.',
|
||||
|
||||
@@ -37,7 +37,7 @@ window.localisation.fi = {
|
||||
paste_invoice_label:
|
||||
'Liitä lasku, maksupyyntö, lnurl-koodi tai Lightning Address *',
|
||||
lnbits_description:
|
||||
'Kevyt ja helppokäyttöinen LNbits voi käyttää rahoituslähteinään erilaisia palveluita, kuten LND, Core Lightning, OpenNode, Alby, LNPay ja jopa itseään! Voit käyttää sitä itsenäisesti ja helposti tarjota erilaisia Lightning-palveluita. Pystyt luomaan sillä salamaverkkolompakoita eikä niiden määrää ole rajoitettu. Jokaiselle lompakolle saat yksilölliset API-avaimet. Varojen osittaminen tekee siitä erittäin kätevän varojen hallinnassa sekä myös ohjelmistokehityksen työkalun. Laajennukset lisäävät LNbits:in toiminnallisuuksia. Näinpä voit helposti testailla useita erilaisia ja viimeisimpiä salamaverkon teknologioita. Laajennuksien kehittämisen olemme pyrkineet tekemään mahdollisimman helpoksi pitämällä LNbits:in ilmaisena OpenSource-projektina. Kannustamme kaikkia kehittämään ja jakelemaan omia laajennuksia!',
|
||||
'Kevyt ja helppokäyttöinen LNbits voi käyttää rahoituslähteinään erilaisia palveluita, ja jopa LNbits itseään! Voit käyttää sitä itsenäisesti ja helposti tarjota erilaisia Lightning-palveluita. Pystyt luomaan sillä salamaverkkolompakoita eikä niiden määrää ole rajoitettu. Jokaiselle lompakolle saat yksilölliset API-avaimet. Varojen osittaminen tekee siitä erittäin kätevän varojen hallinnassa sekä myös ohjelmistokehityksen työkalun. Laajennukset lisäävät LNbits:in toiminnallisuuksia. Näinpä voit helposti testailla useita erilaisia ja viimeisimpiä salamaverkon teknologioita. Laajennuksien kehittämisen olemme pyrkineet tekemään mahdollisimman helpoksi pitämällä LNbits:in ilmaisena OpenSource-projektina. Kannustamme kaikkia kehittämään ja jakelemaan omia laajennuksia!',
|
||||
export_to_phone: 'Käytä puhelimessa lukemalla QR-koodi',
|
||||
export_to_phone_desc:
|
||||
'Tämä QR-koodi sisältää URL-osoitteen, jolla saa lompakkoosi täydet valtuudet. Voi lukea sen puhelimellasi ja avata sillä lompakkosi. Voit myös lisätä lompakkosi selaimella käytettäväksi PWA-sovellukseksi puhelimen aloitusruudulle. ',
|
||||
|
||||
@@ -39,7 +39,7 @@ window.localisation.fr = {
|
||||
paste_invoice_label:
|
||||
'Coller une facture, une demande de paiement ou un code lnurl *',
|
||||
lnbits_description:
|
||||
"Facile à installer et léger, LNbits peut fonctionner sur n'importe quelle source de financement du réseau Lightning, prenant actuellement en charge LND, Core Lightning, OpenNode, Alby, LNPay et même LNbits lui-même! Vous pouvez exécuter LNbits pour vous-même ou offrir facilement une solution de gardien pour les autres. Chaque portefeuille a ses propres clés API et il n'y a pas de limite au nombre de portefeuilles que vous pouvez créer. La capacité de partitionner les fonds rend LNbits un outil utile pour la gestion de l'argent et comme outil de développement. Les extensions ajoutent une fonctionnalité supplémentaire à LNbits afin que vous puissiez expérimenter une gamme de technologies de pointe sur le réseau Lightning. Nous avons rendu le développement d'extensions aussi simple que possible et, en tant que projet gratuit et open source, nous encourageons les gens à développer et à soumettre les leurs.",
|
||||
"Facile à installer et léger, LNbits peut fonctionner sur n'importe quelle source de financement du réseau Lightning et même LNbits lui-même! Vous pouvez exécuter LNbits pour vous-même ou offrir facilement une solution de gardien pour les autres. Chaque portefeuille a ses propres clés API et il n'y a pas de limite au nombre de portefeuilles que vous pouvez créer. La capacité de partitionner les fonds rend LNbits un outil utile pour la gestion de l'argent et comme outil de développement. Les extensions ajoutent une fonctionnalité supplémentaire à LNbits afin que vous puissiez expérimenter une gamme de technologies de pointe sur le réseau Lightning. Nous avons rendu le développement d'extensions aussi simple que possible et, en tant que projet gratuit et open source, nous encourageons les gens à développer et à soumettre les leurs.",
|
||||
export_to_phone: 'Exporter vers le téléphone avec un code QR',
|
||||
export_to_phone_desc:
|
||||
"Ce code QR contient l'URL de votre portefeuille avec un accès complet. Vous pouvez le scanner depuis votre téléphone pour ouvrir votre portefeuille depuis là-bas.",
|
||||
|
||||
@@ -37,7 +37,7 @@ window.localisation.it = {
|
||||
paste_invoice_label:
|
||||
'Incolla una fattura, una richiesta di pagamento o un codice lnurl *',
|
||||
lnbits_description:
|
||||
"Leggero e facile da configurare, LNbits può funzionare su qualsiasi fonte di finanziamento lightning-network, attualmente supporta LND, Core Lightning, OpenNode, Alby, LNPay e persino LNbits stesso! Potete gestire LNbits per conto vostro o offrire facilmente una soluzione di custodia per altri. Ogni portafoglio ha le proprie chiavi API e non c'è limite al numero di portafogli che si possono creare. La possibilità di suddividere i fondi rende LNbits uno strumento utile per la gestione del denaro e come strumento di sviluppo. Le estensioni aggiungono ulteriori funzionalità a LNbits, consentendo di sperimentare una serie di tecnologie all'avanguardia sulla rete Lightning. Abbiamo reso lo sviluppo delle estensioni il più semplice possibile e, in quanto progetto libero e open-source, incoraggiamo le persone a sviluppare e inviare le proprie",
|
||||
"Leggero e facile da configurare, LNbits può funzionare su qualsiasi fonte di finanziamento Lightning Network e persino LNbits stesso! Potete gestire LNbits per conto vostro o offrire facilmente una soluzione di custodia per altri. Ogni portafoglio ha le proprie chiavi API e non c'è limite al numero di portafogli che si possono creare. La possibilità di suddividere i fondi rende LNbits uno strumento utile per la gestione del denaro e come strumento di sviluppo. Le estensioni aggiungono ulteriori funzionalità a LNbits, consentendo di sperimentare una serie di tecnologie all'avanguardia sulla rete Lightning. Abbiamo reso lo sviluppo delle estensioni il più semplice possibile e, in quanto progetto libero e open-source, incoraggiamo le persone a sviluppare e inviare le proprie",
|
||||
export_to_phone: 'Esportazione su telefono con codice QR',
|
||||
export_to_phone_desc:
|
||||
"Questo codice QR contiene l'URL del portafoglio con accesso da amministratore. È possibile scansionarlo dal telefono per aprire il portafoglio da lì.",
|
||||
|
||||
@@ -35,7 +35,7 @@ window.localisation.jp = {
|
||||
name_your_wallet: 'あなたのウォレットの名前 %{name}',
|
||||
paste_invoice_label: '請求書を貼り付けてください',
|
||||
lnbits_description:
|
||||
'簡単にインストールでき、軽量で、LNbitsは現在LND、Core Lightning、OpenNode、Alby, LNPay、さらにLNbits自身で動作する任意のLightningネットワークの資金源で実行できます! LNbitsを自分で実行することも、他の人に優れたソリューションを提供することもできます。各ウォレットには独自のAPIキーがあり、作成できるウォレットの数に制限はありません。資金を分割する機能は、LNbitsを資金管理ツールとして使用したり、開発ツールとして使用したりするための便利なツールです。拡張機能は、LNbitsに追加の機能を追加します。そのため、LNbitsは最先端の技術をネットワークLightningで試すことができます。拡張機能を開発するのは簡単で、無料でオープンソースのプロジェクトであるため、人々が自分で開発し、自分の貢献を送信することを奨励しています。',
|
||||
'簡単にインストールでき、軽量なLNbitsは、あらゆるライトニングネットワークの資金源と、LNbits自身でさえも実行できます!LNbitsを個人で実行することも、他人に対してカストディアンソリューションをで実行できます! LNbitsを自分で実行することも、他の人に優れたソリューションを提供することもできます。各ウォレットには独自のAPIキーがあり、作成できるウォレットの数に制限はありません。資金を分割する機能は、LNbitsを資金管理ツールとして使用したり、開発ツールとして使用したりするための便利なツールです。拡張機能は、LNbitsに追加の機能を追加します。そのため、LNbitsは最先端の技術をネットワークLightningで試すことができます。拡張機能を開発するのは簡単で、無料でオープンソースのプロジェクトであるため、人々が自分で開発し、自分の貢献を送信することを奨励しています。',
|
||||
export_to_phone: '電話にエクスポート',
|
||||
export_to_phone_desc:
|
||||
'ウォレットを電話にエクスポートすると、ウォレットを削除する前にウォレットを復元できます。ウォレットを削除すると、ウォレットの秘密鍵が削除され、ウォレットを復元することはできません。',
|
||||
|
||||
@@ -36,7 +36,7 @@ window.localisation.kr = {
|
||||
name_your_wallet: '사용할 %{name}지갑의 이름을 정하세요',
|
||||
paste_invoice_label: '인보이스, 결제 요청, 혹은 lnurl 코드를 붙여넣으세요 *',
|
||||
lnbits_description:
|
||||
'설정이 쉽고 가벼운 LNBits는 어떤 라이트닝 네트워크의 예산 자원 위에서든 돌아갈 수 있습니다. 현재 지원하는 예산 자원의 형태는 LND, Core Lightning, OpenNode, Alby, LNPay, 그리고 다른 LNBits 지갑들입니다. 스스로 사용하기 위해, 또는 다른 사람들에게 수탁형 솔루션을 제공하기 위해 LNBits를 운영할 수 있습니다. 각 지갑들은 자신만의 API key를 가지며, 생성 가능한 지갑의 수에는 제한이 없습니다. 자금을 분할할 수 있는 기능으로 인해, LNBits는 자금 운영 도구로써뿐만 아니라 개발 도구로써도 유용합니다. 확장 기능들은 LNBits에 여러분들이 라이트닝 네트워크의 다양한 최신 기술들을 수행해볼 수 있게 하는 추가 기능을 제공합니다. LNBits 개발진들은 확장 기능들의 개발 또한 가능한 쉽게 만들었으며, 무료 오픈 소스 프로젝트답게 사람들이 자신만의 확장 기능들을 개발하고 제출하기를 응원합니다.',
|
||||
'설정이 쉽고 가벼운 LNBits는 어떤 라이트닝 네트워크의 예산 자원 위에서든 돌아갈 수 있습니다, 그리고 다른 LNBits 지갑들입니다. 스스로 사용하기 위해, 또는 다른 사람들에게 수탁형 솔루션을 제공하기 위해 LNBits를 운영할 수 있습니다. 각 지갑들은 자신만의 API key를 가지며, 생성 가능한 지갑의 수에는 제한이 없습니다. 자금을 분할할 수 있는 기능으로 인해, LNBits는 자금 운영 도구로써뿐만 아니라 개발 도구로써도 유용합니다. 확장 기능들은 LNBits에 여러분들이 라이트닝 네트워크의 다양한 최신 기술들을 수행해볼 수 있게 하는 추가 기능을 제공합니다. LNBits 개발진들은 확장 기능들의 개발 또한 가능한 쉽게 만들었으며, 무료 오픈 소스 프로젝트답게 사람들이 자신만의 확장 기능들을 개발하고 제출하기를 응원합니다.',
|
||||
export_to_phone: 'QR 코드를 이용해 모바일 기기로 내보내기',
|
||||
export_to_phone_desc:
|
||||
'이 QR 코드는 선택된 지갑의 최대 접근 권한을 가진 전체 URL을 담고 있습니다. 스캔 후, 모바일 기기에서 지갑을 열 수 있습니다.',
|
||||
|
||||
@@ -37,7 +37,7 @@ window.localisation.nl = {
|
||||
name_your_wallet: 'Geef je %{name} portemonnee een naam',
|
||||
paste_invoice_label: 'Plak een factuur, betalingsverzoek of lnurl-code*',
|
||||
lnbits_description:
|
||||
'Gemakkelijk in te stellen en lichtgewicht, LNbits kan op elke lightning-netwerkfinancieringsbron draaien, ondersteunt op dit moment LND, Core Lightning, OpenNode, Alby, LNPay en zelfs LNbits zelf! U kunt LNbits voor uzelf laten draaien of gemakkelijk een bewaardersoplossing voor anderen bieden. Elke portemonnee heeft zijn eigen API-sleutels en er is geen limiet aan het aantal portemonnees dat u kunt maken. Het kunnen partitioneren van fondsen maakt LNbits een nuttige tool voor geldbeheer en als ontwikkelingstool. Extensies voegen extra functionaliteit toe aan LNbits, zodat u kunt experimenteren met een reeks toonaangevende technologieën op het bliksemschichtnetwerk. We hebben het ontwikkelen van extensies zo eenvoudig mogelijk gemaakt en als een gratis en opensource-project moedigen we mensen aan om hun eigen ontwikkelingen in te dienen.',
|
||||
'Gemakkelijk in te stellen en lichtgewicht, LNbits kan op elke lightning-netwerkfinancieringsbron draaien en zelfs LNbits zelf! U kunt LNbits voor uzelf laten draaien of gemakkelijk een bewaardersoplossing voor anderen bieden. Elke portemonnee heeft zijn eigen API-sleutels en er is geen limiet aan het aantal portemonnees dat u kunt maken. Het kunnen partitioneren van fondsen maakt LNbits een nuttige tool voor geldbeheer en als ontwikkelingstool. Extensies voegen extra functionaliteit toe aan LNbits, zodat u kunt experimenteren met een reeks toonaangevende technologieën op het bliksemschichtnetwerk. We hebben het ontwikkelen van extensies zo eenvoudig mogelijk gemaakt en als een gratis en opensource-project moedigen we mensen aan om hun eigen ontwikkelingen in te dienen.',
|
||||
export_to_phone: 'Exporteren naar telefoon met QR-code',
|
||||
export_to_phone_desc:
|
||||
'Deze QR-code bevat uw portemonnee-URL met volledige toegang. U kunt het vanaf uw telefoon scannen om uw portemonnee van daaruit te openen.',
|
||||
|
||||
@@ -36,7 +36,7 @@ window.localisation.pi = {
|
||||
name_your_wallet: 'Name yer %{name} treasure chest',
|
||||
paste_invoice_label: 'Paste a booty, payment request or lnurl code, matey!',
|
||||
lnbits_description:
|
||||
'Arr, easy to set up and lightweight, LNbits can run on any lightning-network funding source, currently supporting LND, Core Lightning, OpenNode, Alby, LNPay and even LNbits itself! Ye can run LNbits for yourself, or easily offer a custodian solution for others. Each chest has its own API keys and there be no limit to the number of chests ye can make. Being able to partition booty makes LNbits a useful tool for money management and as a development tool. Arr, extensions add extra functionality to LNbits so ye can experiment with a range of cutting-edge technologies on the lightning network. We have made developing extensions as easy as possible, and as a free and open-source project, we encourage scallywags to develop and submit their own.',
|
||||
'Arr, easy to set up and lightweight, LNbits can run on any Lightning Network funding source and even LNbits itself! Ye can run LNbits for yourself, or easily offer a custodian solution for others. Each chest has its own API keys and there be no limit to the number of chests ye can make. Being able to partition booty makes LNbits a useful tool for money management and as a development tool. Arr, extensions add extra functionality to LNbits so ye can experiment with a range of cutting-edge technologies on the lightning network. We have made developing extensions as easy as possible, and as a free and open-source project, we encourage scallywags to develop and submit their own.',
|
||||
export_to_phone: 'Export to Phone with QR Code, me hearties',
|
||||
export_to_phone_desc:
|
||||
'This QR code contains yer chest URL with full access. Ye can scan it from yer phone to open yer chest from there, arr!',
|
||||
|
||||
@@ -35,7 +35,7 @@ window.localisation.pl = {
|
||||
name_your_wallet: 'Nazwij swój portfel %{name}',
|
||||
paste_invoice_label: 'Wklej fakturę, żądanie zapłaty lub kod lnurl *',
|
||||
lnbits_description:
|
||||
'Łatwy i lekki w konfiguracji, LNbits może działać w oparciu o dowolne źródło finansowania w sieci lightning, obecnie wspiera LND, Core Lightning, OpenNode, Alby, LNPay czy nawet inną instancję LNbits! Możesz uruchomić instancję LNbits dla siebie lub dla innych. Każdy portfel ma swoje klucze API i nie ma ograniczeń jeśli chodzi o ilość portfeli. LNbits umożliwia dzielenie środków w celu zarządzania nimi, jest również dobrym narzędziem deweloperskim. Rozszerzenia zwiększają funkcjonalność LNbits co umożliwia eksperymentowanie z nowym technologiami w sieci lightning. Tworzenie rozszerzeń jest proste dlatego zachęcamy innych deweloperów do tworzenia dodatkowych funkcjonalności i wysyłanie do nas PR',
|
||||
'Łatwy i lekki w konfiguracji, LNbits może działać w oparciu o dowolne źródło finansowania w sieci lightning czy nawet inną instancję LNbits! Możesz uruchomić instancję LNbits dla siebie lub dla innych. Każdy portfel ma swoje klucze API i nie ma ograniczeń jeśli chodzi o ilość portfeli. LNbits umożliwia dzielenie środków w celu zarządzania nimi, jest również dobrym narzędziem deweloperskim. Rozszerzenia zwiększają funkcjonalność LNbits co umożliwia eksperymentowanie z nowym technologiami w sieci lightning. Tworzenie rozszerzeń jest proste dlatego zachęcamy innych deweloperów do tworzenia dodatkowych funkcjonalności i wysyłanie do nas PR',
|
||||
export_to_phone: 'Eksport kodu QR na telefon',
|
||||
export_to_phone_desc:
|
||||
'Ten kod QR zawiera adres URL Twojego portfela z pełnym dostępem do niego. Możesz go zeskanować na swoim telefonie aby otworzyć na nim ten portfel.',
|
||||
|
||||
@@ -36,7 +36,7 @@ window.localisation.pt = {
|
||||
name_your_wallet: 'Nomeie sua carteira %{name}',
|
||||
paste_invoice_label: 'Cole uma fatura, pedido de pagamento ou código lnurl *',
|
||||
lnbits_description:
|
||||
'Fácil de configurar e leve, o LNbits pode ser executado em qualquer fonte de financiamento da lightning-network, atualmente suportando LND, c-lightning, OpenNode, Alby, LNPay e até mesmo o LNbits em si! Você pode executar o LNbits para si mesmo ou oferecer facilmente uma solução de custódia para outros. Cada carteira tem suas próprias chaves de API e não há limite para o número de carteiras que você pode criar. Ser capaz de particionar fundos torna o LNbits uma ferramenta útil para gerenciamento de dinheiro e como uma ferramenta de desenvolvimento. As extensões adicionam funcionalidades extras ao LNbits para que você possa experimentar uma série de tecnologias de ponta na rede lightning. Nós tornamos o desenvolvimento de extensões o mais fácil possível e, como um projeto gratuito e de código aberto, incentivamos as pessoas a desenvolver e enviar as suas próprias.',
|
||||
'Fácil de configurar e leve, o LNbits pode ser executado em qualquer fonte de financiamento da Lightning Network e até mesmo o LNbits em si! Você pode executar o LNbits para si mesmo ou oferecer facilmente uma solução de custódia para outros. Cada carteira tem suas próprias chaves de API e não há limite para o número de carteiras que você pode criar. Ser capaz de particionar fundos torna o LNbits uma ferramenta útil para gerenciamento de dinheiro e como uma ferramenta de desenvolvimento. As extensões adicionam funcionalidades extras ao LNbits para que você possa experimentar uma série de tecnologias de ponta na rede lightning. Nós tornamos o desenvolvimento de extensões o mais fácil possível e, como um projeto gratuito e de código aberto, incentivamos as pessoas a desenvolver e enviar as suas próprias.',
|
||||
export_to_phone: 'Exportar para o telefone com código QR',
|
||||
export_to_phone_desc:
|
||||
'Este código QR contém a URL da sua carteira com acesso total. Você pode escaneá-lo do seu telefone para abrir sua carteira a partir dele.',
|
||||
|
||||
@@ -35,7 +35,7 @@ window.localisation.sk = {
|
||||
name_your_wallet: 'Pomenujte vašu %{name} peňaženku',
|
||||
paste_invoice_label: 'Vložte faktúru, platobnú požiadavku alebo lnurl kód *',
|
||||
lnbits_description:
|
||||
'Ľahko nastaviteľný a ľahkotonážny, LNbits môže bežať na akomkoľvek zdroji financovania lightning-network, momentálne podporuje LND, Core Lightning, OpenNode, Alby, LNPay a dokonca LNbits samotný! LNbits môžete používať pre seba, alebo ľahko ponúknuť správcovské riešenie pre iných. Každá peňaženka má svoje vlastné API kľúče a nie je limit na počet peňaženiek, ktoré môžete vytvoriť. Schopnosť rozdeľovať finančné prostriedky robí z LNbits užitočný nástroj pre správu peňazí a ako vývojový nástroj. Rozšírenia pridávajú extra funkčnosť do LNbits, takže môžete experimentovať s radou najnovších technológií na lightning sieti. Vývoj rozšírení sme urobili čo najjednoduchší a ako voľný a open-source projekt, podporujeme ľudí vývoj a odovzdávanie vlastných rozšírení.',
|
||||
'Ľahko nastaviteľný a ľahkotonážny, LNbits môže bežať na akomkoľvek zdroji financovania Lightning Network a dokonca LNbits samotný! LNbits môžete používať pre seba, alebo ľahko ponúknuť správcovské riešenie pre iných. Každá peňaženka má svoje vlastné API kľúče a nie je limit na počet peňaženiek, ktoré môžete vytvoriť. Schopnosť rozdeľovať finančné prostriedky robí z LNbits užitočný nástroj pre správu peňazí a ako vývojový nástroj. Rozšírenia pridávajú extra funkčnosť do LNbits, takže môžete experimentovať s radou najnovších technológií na lightning sieti. Vývoj rozšírení sme urobili čo najjednoduchší a ako voľný a open-source projekt, podporujeme ľudí vývoj a odovzdávanie vlastných rozšírení.',
|
||||
export_to_phone: 'Exportovať do telefónu s QR kódom',
|
||||
export_to_phone_desc:
|
||||
'Tento QR kód obsahuje URL vašej peňaženky s plným prístupom. Môžete ho naskenovať z vášho telefónu a otvoriť vašu peňaženku odtiaľ.',
|
||||
|
||||
@@ -35,7 +35,7 @@ window.localisation.we = {
|
||||
name_your_wallet: 'Enwch eich waled %{name}',
|
||||
paste_invoice_label: 'Gludwch anfoneb, cais am daliad neu god lnurl *',
|
||||
lnbits_description:
|
||||
'Yn hawdd iw sefydlu ac yn ysgafn, gall LNbits redeg ar unrhyw ffynhonnell ariannu rhwydwaith mellt, ar hyn o bryd yn cefnogi LND, Core Lightning, OpenNode, Alby, LNPay a hyd yn oed LNbits ei hun! Gallwch redeg LNbits i chi`ch hun, neu gynnig datrysiad ceidwad i eraill yn hawdd. Mae gan bob waled ei allweddi API ei hun ac nid oes cyfyngiad ar nifer y waledi y gallwch eu gwneud. Mae gallu rhannu cronfeydd yn gwneud LNbits yn arf defnyddiol ar gyfer rheoli arian ac fel offeryn datblygu. Mae estyniadau yn ychwanegu ymarferoldeb ychwanegol at LNbits fel y gallwch arbrofi gydag ystod o dechnolegau blaengar ar y rhwydwaith mellt. Rydym wedi gwneud datblygu estyniadau mor hawdd â phosibl, ac fel prosiect ffynhonnell agored am ddim, rydym yn annog pobl i ddatblygu a chyflwyno eu rhai eu hunain.',
|
||||
'Yn hawdd iw sefydlu ac yn ysgafn, gall LNbits redeg ar unrhyw ffynhonnell ariannu rhwydwaith mellt a hyd yn oed LNbits ei hun! Gallwch redeg LNbits i chi`ch hun, neu gynnig datrysiad ceidwad i eraill yn hawdd. Mae gan bob waled ei allweddi API ei hun ac nid oes cyfyngiad ar nifer y waledi y gallwch eu gwneud. Mae gallu rhannu cronfeydd yn gwneud LNbits yn arf defnyddiol ar gyfer rheoli arian ac fel offeryn datblygu. Mae estyniadau yn ychwanegu ymarferoldeb ychwanegol at LNbits fel y gallwch arbrofi gydag ystod o dechnolegau blaengar ar y rhwydwaith mellt. Rydym wedi gwneud datblygu estyniadau mor hawdd â phosibl, ac fel prosiect ffynhonnell agored am ddim, rydym yn annog pobl i ddatblygu a chyflwyno eu rhai eu hunain.',
|
||||
export_to_phone: 'Allforio i Ffôn gyda chod QR',
|
||||
export_to_phone_desc:
|
||||
'Mae`r cod QR hwn yn cynnwys URL eich waled gyda mynediad llawn. Gallwch ei sganio o`ch ffôn i agor eich waled oddi yno.',
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.0 KiB |
@@ -229,7 +229,7 @@ new Vue({
|
||||
},
|
||||
getAudit() {
|
||||
LNbits.api
|
||||
.request('GET', '/admin/api/v1/audit/', this.g.user.wallets[0].adminkey)
|
||||
.request('GET', '/admin/api/v1/audit', this.g.user.wallets[0].adminkey)
|
||||
.then(response => {
|
||||
this.auditData = response.data
|
||||
})
|
||||
@@ -241,7 +241,7 @@ new Vue({
|
||||
LNbits.api
|
||||
.request(
|
||||
'GET',
|
||||
'/admin/api/v1/settings/',
|
||||
'/admin/api/v1/settings',
|
||||
this.g.user.wallets[0].adminkey
|
||||
)
|
||||
.then(response => {
|
||||
@@ -262,7 +262,7 @@ new Vue({
|
||||
LNbits.api
|
||||
.request(
|
||||
'PUT',
|
||||
'/admin/api/v1/settings/',
|
||||
'/admin/api/v1/settings',
|
||||
this.g.user.wallets[0].adminkey,
|
||||
data
|
||||
)
|
||||
@@ -290,7 +290,7 @@ new Vue({
|
||||
.confirmDialog('Are you sure you want to restore settings to default?')
|
||||
.onOk(() => {
|
||||
LNbits.api
|
||||
.request('DELETE', '/admin/api/v1/settings/')
|
||||
.request('DELETE', '/admin/api/v1/settings')
|
||||
.then(response => {
|
||||
this.$q.notify({
|
||||
type: 'positive',
|
||||
@@ -306,7 +306,7 @@ new Vue({
|
||||
})
|
||||
},
|
||||
downloadBackup() {
|
||||
window.open('/admin/api/v1/backup/', '_blank')
|
||||
window.open('/admin/api/v1/backup', '_blank')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -170,7 +170,12 @@ Vue.component('lnbits-extension-list', {
|
||||
})
|
||||
|
||||
Vue.component('lnbits-manage', {
|
||||
props: ['showAdmin', 'showNode'],
|
||||
props: ['showAdmin', 'showNode', 'showExtensions'],
|
||||
methods: {
|
||||
isActive: function (path) {
|
||||
return window.location.pathname === path
|
||||
}
|
||||
},
|
||||
data: function () {
|
||||
return {
|
||||
extensions: [],
|
||||
@@ -181,29 +186,29 @@ Vue.component('lnbits-manage', {
|
||||
<q-list v-if="user" dense class="lnbits-drawer__q-list">
|
||||
<q-item-label header v-text="$t('manage')"></q-item-label>
|
||||
<div v-if="user.admin">
|
||||
<q-item v-if='showAdmin' clickable tag="a" href="/admin">
|
||||
<q-item v-if='showAdmin' clickable tag="a" href="/admin" :active="isActive('/admin')">
|
||||
<q-item-section side>
|
||||
<q-icon name="admin_panel_settings" color="grey-5" size="md"></q-icon>
|
||||
<q-icon name="admin_panel_settings" :color="isActive('/admin') ? 'primary' : 'grey-5'" size="md"></q-icon>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label lines="1" class="text-caption" v-text="$t('server')"></q-item-label>
|
||||
<q-item-label lines="1" v-text="$t('server')"></q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item v-if='showNode' clickable tag="a" href="/node">
|
||||
<q-item v-if='showNode' clickable tag="a" href="/node" :active="isActive('/node')">
|
||||
<q-item-section side>
|
||||
<q-icon name="developer_board" color="grey-5" size="md"></q-icon>
|
||||
<q-icon name="developer_board" :color="isActive('/node') ? 'primary' : 'grey-5'" size="md"></q-icon>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label lines="1" class="text-caption" v-text="$t('node')"></q-item-label>
|
||||
<q-item-label lines="1" v-text="$t('node')"></q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</div>
|
||||
<q-item clickable tag="a" href="/extensions">
|
||||
<q-item v-if="showExtensions" clickable tag="a" href="/extensions" :active="isActive('/extensions')">
|
||||
<q-item-section side>
|
||||
<q-icon name="extension" color="grey-5" size="md"></q-icon>
|
||||
<q-icon name="extension" :color="isActive('/extensions') ? 'primary' : 'grey-5'" size="md"></q-icon>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label lines="1" class="text-caption" v-text="$t('extensions')"></q-item-label>
|
||||
<q-item-label lines="1" v-text="$t('extensions')"></q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
@@ -238,7 +243,7 @@ Vue.component('lnbits-payment-details', {
|
||||
{{ payment.date }} ({{ payment.dateFrom }})
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="row" v-if="hasExpiry">
|
||||
<b v-text="$t('expiry')"></b>:
|
||||
{{ payment.expirydate }} ({{ payment.expirydateFrom }})
|
||||
</div>
|
||||
@@ -298,6 +303,9 @@ Vue.component('lnbits-payment-details', {
|
||||
'0000000000000000000000000000000000000000000000000000000000000000'
|
||||
)
|
||||
},
|
||||
hasExpiry() {
|
||||
return !!this.payment.expiry
|
||||
},
|
||||
hasSuccessAction() {
|
||||
return (
|
||||
this.hasPreimage &&
|
||||
|
||||
@@ -50,7 +50,8 @@ Vue.component('lnbits-funding-sources', {
|
||||
lnd_rest_endpoint: 'Endpoint',
|
||||
lnd_rest_cert: 'Certificate',
|
||||
lnd_rest_macaroon: 'Macaroon',
|
||||
lnd_rest_macaroon_encrypted: 'Encrypted Macaroon'
|
||||
lnd_rest_macaroon_encrypted: 'Encrypted Macaroon',
|
||||
lnd_rest_route_hints: 'Enable Route Hints'
|
||||
}
|
||||
],
|
||||
[
|
||||
@@ -105,6 +106,14 @@ Vue.component('lnbits-funding-sources', {
|
||||
alby_access_token: 'Key'
|
||||
}
|
||||
],
|
||||
[
|
||||
'ZBDWallet',
|
||||
'ZBD',
|
||||
{
|
||||
zbd_api_endpoint: 'Endpoint',
|
||||
zbd_api_key: 'Key'
|
||||
}
|
||||
],
|
||||
[
|
||||
'OpenNodeWallet',
|
||||
'OpenNode',
|
||||
|
||||
@@ -360,7 +360,6 @@ new Vue({
|
||||
},
|
||||
onPaymentReceived: function (paymentHash) {
|
||||
this.fetchPayments()
|
||||
this.fetchBalance()
|
||||
|
||||
if (this.receive.paymentHash === paymentHash) {
|
||||
this.receive.show = false
|
||||
@@ -589,7 +588,6 @@ new Vue({
|
||||
clearInterval(this.parse.paymentChecker)
|
||||
dismissPaymentMsg()
|
||||
this.fetchPayments()
|
||||
this.fetchBalance()
|
||||
}
|
||||
})
|
||||
}, 2000)
|
||||
@@ -630,8 +628,6 @@ new Vue({
|
||||
dismissPaymentMsg()
|
||||
clearInterval(this.parse.paymentChecker)
|
||||
this.fetchPayments()
|
||||
this.fetchBalance()
|
||||
|
||||
// show lnurlpay success action
|
||||
if (response.data.success_action) {
|
||||
switch (response.data.success_action.tag) {
|
||||
@@ -840,20 +836,11 @@ new Vue({
|
||||
if (this.$q.screen.lt.md) {
|
||||
this.mobileSimple = true
|
||||
}
|
||||
this.fetchBalance()
|
||||
this.fetchPayments()
|
||||
|
||||
this.update.name = this.g.wallet.name
|
||||
this.update.currency = this.g.wallet.currency
|
||||
|
||||
LNbits.api
|
||||
.request('GET', '/api/v1/currencies')
|
||||
.then(response => {
|
||||
this.receive.units = ['sat', ...response.data]
|
||||
})
|
||||
.catch(err => {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
})
|
||||
this.receive.units = ['sat', ...window.currencies]
|
||||
},
|
||||
mounted: function () {
|
||||
// show disclaimer
|
||||
|
||||
+25
-19
@@ -4,9 +4,8 @@ import time
|
||||
import traceback
|
||||
import uuid
|
||||
from http import HTTPStatus
|
||||
from typing import Dict, List, Optional
|
||||
from typing import Coroutine, Dict, List, Optional
|
||||
|
||||
from fastapi.exceptions import HTTPException
|
||||
from loguru import logger
|
||||
from py_vapid import Vapid
|
||||
from pywebpush import WebPushException, webpush
|
||||
@@ -21,6 +20,7 @@ from lnbits.settings import settings
|
||||
from lnbits.wallets import get_wallet_class
|
||||
|
||||
tasks: List[asyncio.Task] = []
|
||||
unique_tasks: Dict[str, asyncio.Task] = {}
|
||||
|
||||
|
||||
def create_task(coro):
|
||||
@@ -33,12 +33,33 @@ def create_permanent_task(func):
|
||||
return create_task(catch_everything_and_restart(func))
|
||||
|
||||
|
||||
def create_unique_task(name: str, coro: Coroutine):
|
||||
if unique_tasks.get(name):
|
||||
logger.warning(f"task `{name}` already exists, cancelling it")
|
||||
try:
|
||||
unique_tasks[name].cancel()
|
||||
except Exception as exc:
|
||||
logger.warning(f"error while cancelling task `{name}`: {str(exc)}")
|
||||
task = asyncio.create_task(coro)
|
||||
unique_tasks[name] = task
|
||||
return task
|
||||
|
||||
|
||||
def create_permanent_unique_task(name: str, coro: Coroutine):
|
||||
return create_unique_task(name, catch_everything_and_restart(coro))
|
||||
|
||||
|
||||
def cancel_all_tasks():
|
||||
for task in tasks:
|
||||
try:
|
||||
task.cancel()
|
||||
except Exception as exc:
|
||||
logger.warning(f"error while cancelling task: {str(exc)}")
|
||||
for name, task in unique_tasks.items():
|
||||
try:
|
||||
task.cancel()
|
||||
except Exception as exc:
|
||||
logger.warning(f"error while cancelling task `{name}`: {str(exc)}")
|
||||
|
||||
|
||||
async def catch_everything_and_restart(func):
|
||||
@@ -54,10 +75,6 @@ async def catch_everything_and_restart(func):
|
||||
await catch_everything_and_restart(func)
|
||||
|
||||
|
||||
async def send_push_promise(a, b) -> None:
|
||||
pass
|
||||
|
||||
|
||||
invoice_listeners: Dict[str, asyncio.Queue] = {}
|
||||
|
||||
|
||||
@@ -79,17 +96,6 @@ def register_invoice_listener(send_chan: asyncio.Queue, name: Optional[str] = No
|
||||
invoice_listeners[name] = send_chan
|
||||
|
||||
|
||||
async def webhook_handler():
|
||||
"""
|
||||
Returns the webhook_handler for the selected wallet if present. Used by API.
|
||||
"""
|
||||
WALLET = get_wallet_class()
|
||||
handler = getattr(WALLET, "webhook_listener", None)
|
||||
if handler:
|
||||
return await handler()
|
||||
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
|
||||
|
||||
|
||||
internal_invoice_queue: asyncio.Queue = asyncio.Queue(0)
|
||||
|
||||
|
||||
@@ -103,7 +109,7 @@ async def internal_invoice_listener():
|
||||
while True:
|
||||
checking_id = await internal_invoice_queue.get()
|
||||
logger.info("> got internal payment notification", checking_id)
|
||||
asyncio.create_task(invoice_callback_dispatcher(checking_id))
|
||||
create_task(invoice_callback_dispatcher(checking_id))
|
||||
|
||||
|
||||
async def invoice_listener():
|
||||
@@ -116,7 +122,7 @@ async def invoice_listener():
|
||||
WALLET = get_wallet_class()
|
||||
async for checking_id in WALLET.paid_invoices_stream():
|
||||
logger.info("> got a payment notification", checking_id)
|
||||
asyncio.create_task(invoice_callback_dispatcher(checking_id))
|
||||
create_task(invoice_callback_dispatcher(checking_id))
|
||||
|
||||
|
||||
async def check_pending_payments():
|
||||
|
||||
@@ -173,6 +173,7 @@
|
||||
<lnbits-manage
|
||||
:show-admin="'{{LNBITS_ADMIN_UI}}' == 'True'"
|
||||
:show-node="'{{LNBITS_NODE_UI}}' == 'True'"
|
||||
:show-extensions="'{{LNBITS_EXTENSIONS_DEACTIVATE_ALL}}' == 'False'"
|
||||
></lnbits-manage>
|
||||
<lnbits-extension-list class="q-pb-xl"></lnbits-extension-list>
|
||||
</q-drawer>
|
||||
|
||||
+24
-11
@@ -13,18 +13,28 @@
|
||||
></q-icon>
|
||||
|
||||
<h5 class="q-my-none">{{ err }}</h5>
|
||||
<h4>
|
||||
If you believe this shouldn't be an error please bring it up on
|
||||
https://t.me/lnbits
|
||||
</h4>
|
||||
|
||||
<br />
|
||||
<q-btn
|
||||
@click="goBack"
|
||||
color="grey"
|
||||
outline
|
||||
label="Back"
|
||||
class="full-width"
|
||||
></q-btn>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<q-btn
|
||||
@click="goBack"
|
||||
color="grey"
|
||||
outline
|
||||
label="Back"
|
||||
style="width: 100%"
|
||||
></q-btn>
|
||||
</div>
|
||||
<div class="col">
|
||||
<q-btn
|
||||
@click="goHome"
|
||||
color="grey"
|
||||
outline
|
||||
label="Home"
|
||||
style="width: 100%"
|
||||
></q-btn>
|
||||
</div>
|
||||
</div>
|
||||
</center>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
@@ -42,6 +52,9 @@
|
||||
methods: {
|
||||
goBack: function () {
|
||||
window.history.back()
|
||||
},
|
||||
goHome: function () {
|
||||
window.location.href = '/'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
{% macro window_vars(user, wallet) -%}
|
||||
<script>
|
||||
window.extensions = {{ EXTENSIONS | tojson | safe }};
|
||||
{% if currencies %}
|
||||
window.currencies = {{ currencies | tojson | safe }};
|
||||
{% endif %}
|
||||
{% if user %}
|
||||
window.user = {{ user | tojson | safe }};
|
||||
{% endif %}
|
||||
|
||||
@@ -176,6 +176,16 @@ currencies = {
|
||||
}
|
||||
|
||||
|
||||
def allowed_currencies():
|
||||
if len(settings.lnbits_allowed_currencies) > 0:
|
||||
return [
|
||||
item
|
||||
for item in currencies.keys()
|
||||
if item.upper() in settings.lnbits_allowed_currencies
|
||||
]
|
||||
return list(currencies.keys())
|
||||
|
||||
|
||||
class Provider(NamedTuple):
|
||||
name: str
|
||||
domain: str
|
||||
|
||||
@@ -25,6 +25,7 @@ from .lntips import LnTipsWallet
|
||||
from .opennode import OpenNodeWallet
|
||||
from .spark import SparkWallet
|
||||
from .void import VoidWallet
|
||||
from .zbd import ZBDWallet
|
||||
|
||||
|
||||
def set_wallet_class(class_name: Optional[str] = None):
|
||||
|
||||
+105
-48
@@ -1,5 +1,6 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
from typing import AsyncGenerator, Dict, Optional
|
||||
|
||||
import httpx
|
||||
@@ -9,6 +10,7 @@ from lnbits.settings import settings
|
||||
|
||||
from .base import (
|
||||
InvoiceResponse,
|
||||
PaymentPendingStatus,
|
||||
PaymentResponse,
|
||||
PaymentStatus,
|
||||
StatusResponse,
|
||||
@@ -41,17 +43,28 @@ class AlbyWallet(Wallet):
|
||||
async def status(self) -> StatusResponse:
|
||||
try:
|
||||
r = await self.client.get("/balance", timeout=10)
|
||||
except (httpx.ConnectError, httpx.RequestError):
|
||||
return StatusResponse(f"Unable to connect to '{self.endpoint}'", 0)
|
||||
r.raise_for_status()
|
||||
|
||||
if r.is_error:
|
||||
error_message = r.json()["message"]
|
||||
return StatusResponse(error_message, 0)
|
||||
data = r.json()
|
||||
|
||||
data = r.json()
|
||||
assert data["unit"] == "sat"
|
||||
# multiply balance by 1000 to get msats balance
|
||||
return StatusResponse(None, data["balance"] * 1000)
|
||||
if len(data) == 0:
|
||||
return StatusResponse("no data", 0)
|
||||
|
||||
if r.is_error or data["unit"] != "sat":
|
||||
error_message = data["message"] if "message" in data else r.text
|
||||
return StatusResponse(f"Server error: '{error_message}'", 0)
|
||||
|
||||
# multiply balance by 1000 to get msats balance
|
||||
return StatusResponse(None, data["balance"] * 1000)
|
||||
except KeyError as exc:
|
||||
logger.warning(exc)
|
||||
return StatusResponse("Server error: 'missing required fields'", 0)
|
||||
except json.JSONDecodeError as exc:
|
||||
logger.warning(exc)
|
||||
return StatusResponse("Server error: 'invalid json response'", 0)
|
||||
except Exception as exc:
|
||||
logger.warning(exc)
|
||||
return StatusResponse(f"Unable to connect to {self.endpoint}.", 0)
|
||||
|
||||
async def create_invoice(
|
||||
self,
|
||||
@@ -70,63 +83,107 @@ class AlbyWallet(Wallet):
|
||||
else:
|
||||
data["memo"] = memo or ""
|
||||
|
||||
r = await self.client.post(
|
||||
"/invoices",
|
||||
json=data,
|
||||
timeout=40,
|
||||
)
|
||||
try:
|
||||
r = await self.client.post(
|
||||
"/invoices",
|
||||
json=data,
|
||||
timeout=40,
|
||||
)
|
||||
r.raise_for_status()
|
||||
|
||||
if r.is_error:
|
||||
error_message = r.json()["message"]
|
||||
return InvoiceResponse(False, None, None, error_message)
|
||||
data = r.json()
|
||||
|
||||
data = r.json()
|
||||
checking_id = data["payment_hash"]
|
||||
payment_request = data["payment_request"]
|
||||
return InvoiceResponse(True, checking_id, payment_request, None)
|
||||
if r.is_error:
|
||||
error_message = data["message"] if "message" in data else r.text
|
||||
return InvoiceResponse(False, None, None, error_message)
|
||||
|
||||
checking_id = data["payment_hash"]
|
||||
payment_request = data["payment_request"]
|
||||
return InvoiceResponse(True, checking_id, payment_request, None)
|
||||
except KeyError as exc:
|
||||
logger.warning(exc)
|
||||
return InvoiceResponse(
|
||||
False, None, None, "Server error: 'missing required fields'"
|
||||
)
|
||||
except json.JSONDecodeError as exc:
|
||||
logger.warning(exc)
|
||||
return InvoiceResponse(
|
||||
False, None, None, "Server error: 'invalid json response'"
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(exc)
|
||||
return InvoiceResponse(
|
||||
False, None, None, f"Unable to connect to {self.endpoint}."
|
||||
)
|
||||
|
||||
async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
|
||||
# https://api.getalby.com/payments/bolt11
|
||||
r = await self.client.post(
|
||||
"/payments/bolt11",
|
||||
json={"invoice": bolt11}, # assume never need amount in body
|
||||
timeout=None,
|
||||
)
|
||||
try:
|
||||
# https://api.getalby.com/payments/bolt11
|
||||
r = await self.client.post(
|
||||
"/payments/bolt11",
|
||||
json={"invoice": bolt11}, # assume never need amount in body
|
||||
timeout=None,
|
||||
)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
|
||||
if r.is_error:
|
||||
error_message = r.json()["message"]
|
||||
return PaymentResponse(False, None, None, None, error_message)
|
||||
if r.is_error:
|
||||
error_message = data["message"] if "message" in data else r.text
|
||||
return PaymentResponse(False, None, None, None, error_message)
|
||||
|
||||
data = r.json()
|
||||
checking_id = data["payment_hash"]
|
||||
fee_msat = -data["fee"]
|
||||
preimage = data["payment_preimage"]
|
||||
checking_id = data["payment_hash"]
|
||||
# todo: confirm with bitkarrot that having the minus is fine
|
||||
# other funding sources return a positive fee value
|
||||
fee_msat = -data["fee"]
|
||||
preimage = data["payment_preimage"]
|
||||
|
||||
return PaymentResponse(True, checking_id, fee_msat, preimage, None)
|
||||
return PaymentResponse(True, checking_id, fee_msat, preimage, None)
|
||||
except KeyError as exc:
|
||||
logger.warning(exc)
|
||||
return PaymentResponse(
|
||||
False, None, None, None, "Server error: 'missing required fields'"
|
||||
)
|
||||
except json.JSONDecodeError as exc:
|
||||
logger.warning(exc)
|
||||
return PaymentResponse(
|
||||
False, None, None, None, "Server error: 'invalid json response'"
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.info(f"Failed to pay invoice {bolt11}")
|
||||
logger.warning(exc)
|
||||
return PaymentResponse(
|
||||
False, None, None, None, f"Unable to connect to {self.endpoint}."
|
||||
)
|
||||
|
||||
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
|
||||
return await self.get_payment_status(checking_id)
|
||||
|
||||
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
|
||||
r = await self.client.get(f"/invoices/{checking_id}")
|
||||
try:
|
||||
r = await self.client.get(f"/invoices/{checking_id}")
|
||||
|
||||
if r.is_error:
|
||||
return PaymentStatus(None)
|
||||
if r.is_error:
|
||||
return PaymentPendingStatus()
|
||||
|
||||
data = r.json()
|
||||
data = r.json()
|
||||
|
||||
statuses = {
|
||||
"CREATED": None,
|
||||
"SETTLED": True,
|
||||
}
|
||||
return PaymentStatus(statuses[data.get("state")], fee_msat=None, preimage=None)
|
||||
statuses = {
|
||||
"CREATED": None,
|
||||
"SETTLED": True,
|
||||
}
|
||||
# todo: extract fee and preimage
|
||||
# maybe use the more specific endpoints:
|
||||
# - https://api.getalby.com/invoices/incoming
|
||||
# - https://api.getalby.com/invoices/outgoing
|
||||
return PaymentStatus(
|
||||
statuses[data.get("state")], fee_msat=None, preimage=None
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting invoice status: {e}")
|
||||
return PaymentPendingStatus()
|
||||
|
||||
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
|
||||
self.queue: asyncio.Queue = asyncio.Queue(0)
|
||||
while True:
|
||||
value = await self.queue.get()
|
||||
yield value
|
||||
|
||||
async def webhook_listener(self):
|
||||
logger.error("Alby webhook listener disabled")
|
||||
return
|
||||
|
||||
@@ -18,6 +18,18 @@ class InvoiceResponse(NamedTuple):
|
||||
payment_request: Optional[str] = None
|
||||
error_message: Optional[str] = None
|
||||
|
||||
@property
|
||||
def success(self) -> bool:
|
||||
return self.ok is True
|
||||
|
||||
@property
|
||||
def pending(self) -> bool:
|
||||
return self.ok is None
|
||||
|
||||
@property
|
||||
def failed(self) -> bool:
|
||||
return self.ok is False
|
||||
|
||||
|
||||
class PaymentResponse(NamedTuple):
|
||||
# when ok is None it means we don't know if this succeeded
|
||||
@@ -27,12 +39,28 @@ class PaymentResponse(NamedTuple):
|
||||
preimage: Optional[str] = None
|
||||
error_message: Optional[str] = None
|
||||
|
||||
@property
|
||||
def success(self) -> bool:
|
||||
return self.ok is True
|
||||
|
||||
@property
|
||||
def pending(self) -> bool:
|
||||
return self.ok is None
|
||||
|
||||
@property
|
||||
def failed(self) -> bool:
|
||||
return self.ok is False
|
||||
|
||||
|
||||
class PaymentStatus(NamedTuple):
|
||||
paid: Optional[bool] = None
|
||||
fee_msat: Optional[int] = None
|
||||
preimage: Optional[str] = None
|
||||
|
||||
@property
|
||||
def success(self) -> bool:
|
||||
return self.paid is True
|
||||
|
||||
@property
|
||||
def pending(self) -> bool:
|
||||
return self.paid is not True
|
||||
@@ -52,6 +80,18 @@ class PaymentStatus(NamedTuple):
|
||||
return "unknown (should never happen)"
|
||||
|
||||
|
||||
class PaymentSuccessStatus(PaymentStatus):
|
||||
paid = True
|
||||
|
||||
|
||||
class PaymentFailedStatus(PaymentStatus):
|
||||
paid = False
|
||||
|
||||
|
||||
class PaymentPendingStatus(PaymentStatus):
|
||||
paid = None
|
||||
|
||||
|
||||
class Wallet(ABC):
|
||||
async def cleanup(self):
|
||||
pass
|
||||
|
||||
@@ -10,6 +10,7 @@ from lnbits.settings import settings
|
||||
|
||||
from .base import (
|
||||
InvoiceResponse,
|
||||
PaymentPendingStatus,
|
||||
PaymentResponse,
|
||||
PaymentStatus,
|
||||
StatusResponse,
|
||||
@@ -139,7 +140,7 @@ class ClicheWallet(Wallet):
|
||||
|
||||
if data.get("error") is not None and data["error"].get("message"):
|
||||
logger.error(data["error"]["message"])
|
||||
return PaymentStatus(None)
|
||||
return PaymentPendingStatus()
|
||||
|
||||
statuses = {"pending": None, "complete": True, "failed": False}
|
||||
return PaymentStatus(statuses[data["result"]["status"]])
|
||||
@@ -152,7 +153,7 @@ class ClicheWallet(Wallet):
|
||||
|
||||
if data.get("error") is not None and data["error"].get("message"):
|
||||
logger.error(data["error"]["message"])
|
||||
return PaymentStatus(None)
|
||||
return PaymentPendingStatus()
|
||||
payment = data["result"]
|
||||
statuses = {"pending": None, "complete": True, "failed": False}
|
||||
return PaymentStatus(
|
||||
|
||||
@@ -12,8 +12,11 @@ from lnbits.settings import settings
|
||||
|
||||
from .base import (
|
||||
InvoiceResponse,
|
||||
PaymentFailedStatus,
|
||||
PaymentPendingStatus,
|
||||
PaymentResponse,
|
||||
PaymentStatus,
|
||||
PaymentSuccessStatus,
|
||||
StatusResponse,
|
||||
Unsupported,
|
||||
Wallet,
|
||||
@@ -139,8 +142,6 @@ class CoreLightningWallet(Wallet):
|
||||
f" '{exc.error.get('message') or exc.error}'." # type: ignore
|
||||
)
|
||||
return PaymentResponse(False, None, None, None, error_message)
|
||||
except Exception as exc:
|
||||
return PaymentResponse(False, None, None, None, str(exc))
|
||||
|
||||
fee_msat = -int(r["amount_sent_msat"] - r["amount_msat"])
|
||||
return PaymentResponse(
|
||||
@@ -151,33 +152,33 @@ class CoreLightningWallet(Wallet):
|
||||
try:
|
||||
r: dict = self.ln.listinvoices(payment_hash=checking_id) # type: ignore
|
||||
except RpcError:
|
||||
return PaymentStatus(None)
|
||||
return PaymentPendingStatus()
|
||||
if not r["invoices"]:
|
||||
return PaymentStatus(None)
|
||||
return PaymentPendingStatus()
|
||||
|
||||
invoice_resp = r["invoices"][-1]
|
||||
|
||||
if invoice_resp["payment_hash"] == checking_id:
|
||||
if invoice_resp["status"] == "paid":
|
||||
return PaymentStatus(True)
|
||||
return PaymentSuccessStatus()
|
||||
elif invoice_resp["status"] == "unpaid":
|
||||
return PaymentStatus(None)
|
||||
return PaymentPendingStatus()
|
||||
elif invoice_resp["status"] == "expired":
|
||||
return PaymentStatus(False)
|
||||
return PaymentFailedStatus()
|
||||
else:
|
||||
logger.warning(f"supplied an invalid checking_id: {checking_id}")
|
||||
return PaymentStatus(None)
|
||||
return PaymentPendingStatus()
|
||||
|
||||
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
|
||||
try:
|
||||
r: dict = self.ln.listpays(payment_hash=checking_id) # type: ignore
|
||||
except Exception:
|
||||
return PaymentStatus(None)
|
||||
return PaymentPendingStatus()
|
||||
if "pays" not in r:
|
||||
return PaymentStatus(None)
|
||||
return PaymentPendingStatus()
|
||||
if not r["pays"]:
|
||||
# no payment with this payment_hash is found
|
||||
return PaymentStatus(False)
|
||||
return PaymentFailedStatus()
|
||||
|
||||
payment_resp = r["pays"][-1]
|
||||
|
||||
@@ -188,14 +189,16 @@ class CoreLightningWallet(Wallet):
|
||||
payment_resp["amount_sent_msat"] - payment_resp["amount_msat"]
|
||||
)
|
||||
|
||||
return PaymentStatus(True, fee_msat, payment_resp["preimage"])
|
||||
return PaymentSuccessStatus(
|
||||
fee_msat=fee_msat, preimage=payment_resp["preimage"]
|
||||
)
|
||||
elif status == "failed":
|
||||
return PaymentStatus(False)
|
||||
return PaymentFailedStatus()
|
||||
else:
|
||||
return PaymentStatus(None)
|
||||
return PaymentPendingStatus()
|
||||
else:
|
||||
logger.warning(f"supplied an invalid checking_id: {checking_id}")
|
||||
return PaymentStatus(None)
|
||||
return PaymentPendingStatus()
|
||||
|
||||
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
|
||||
while True:
|
||||
|
||||
@@ -12,6 +12,7 @@ from lnbits.settings import settings
|
||||
|
||||
from .base import (
|
||||
InvoiceResponse,
|
||||
PaymentPendingStatus,
|
||||
PaymentResponse,
|
||||
PaymentStatus,
|
||||
StatusResponse,
|
||||
@@ -65,23 +66,28 @@ class CoreLightningRestWallet(Wallet):
|
||||
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
|
||||
try:
|
||||
r = await self.client.get(
|
||||
f"{self.url}/v1/channel/localremotebal", timeout=5
|
||||
)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
|
||||
data = r.json()
|
||||
if len(data) == 0:
|
||||
return StatusResponse("no data", 0)
|
||||
if len(data) == 0:
|
||||
return StatusResponse("no data", 0)
|
||||
|
||||
return StatusResponse(None, int(data.get("localBalance") * 1000))
|
||||
if "error" in data:
|
||||
return StatusResponse(f"""Server error: '{data["error"]}'""", 0)
|
||||
|
||||
if r.is_error or "localBalance" not in data:
|
||||
return StatusResponse(f"Server error: '{r.text}'", 0)
|
||||
|
||||
return StatusResponse(None, int(data.get("localBalance") * 1000))
|
||||
except json.JSONDecodeError:
|
||||
return StatusResponse("Server error: 'invalid json response'", 0)
|
||||
except Exception as exc:
|
||||
logger.warning(exc)
|
||||
return StatusResponse(f"Unable to connect to {self.url}.", 0)
|
||||
|
||||
async def create_invoice(
|
||||
self,
|
||||
@@ -91,7 +97,7 @@ class CoreLightningRestWallet(Wallet):
|
||||
unhashed_description: Optional[bytes] = None,
|
||||
**kwargs,
|
||||
) -> InvoiceResponse:
|
||||
label = f"lbl{random.random()}"
|
||||
label = kwargs.get("label", f"lbl{random.random()}")
|
||||
data: Dict = {
|
||||
"amount": amount * 1000,
|
||||
"description": memo,
|
||||
@@ -112,24 +118,41 @@ class CoreLightningRestWallet(Wallet):
|
||||
if kwargs.get("preimage"):
|
||||
data["preimage"] = kwargs["preimage"]
|
||||
|
||||
r = await self.client.post(
|
||||
f"{self.url}/v1/invoice/genInvoice",
|
||||
data=data,
|
||||
)
|
||||
try:
|
||||
r = await self.client.post(
|
||||
f"{self.url}/v1/invoice/genInvoice",
|
||||
data=data,
|
||||
)
|
||||
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
|
||||
data = r.json()
|
||||
|
||||
return InvoiceResponse(False, None, None, error_message)
|
||||
if len(data) == 0:
|
||||
return InvoiceResponse(False, None, None, "no data")
|
||||
|
||||
data = r.json()
|
||||
assert "payment_hash" in data
|
||||
assert "bolt11" in data
|
||||
return InvoiceResponse(True, data["payment_hash"], data["bolt11"], None)
|
||||
if "error" in data:
|
||||
return InvoiceResponse(
|
||||
False, None, None, f"""Server error: '{data["error"]}'"""
|
||||
)
|
||||
|
||||
if r.is_error:
|
||||
return InvoiceResponse(False, None, None, f"Server error: '{r.text}'")
|
||||
|
||||
if "payment_hash" not in data or "bolt11" not in data:
|
||||
return InvoiceResponse(
|
||||
False, None, None, "Server error: 'missing required fields'"
|
||||
)
|
||||
|
||||
return InvoiceResponse(True, data["payment_hash"], data["bolt11"], None)
|
||||
except json.JSONDecodeError:
|
||||
return InvoiceResponse(
|
||||
False, None, None, "Server error: 'invalid json response'"
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(exc)
|
||||
return InvoiceResponse(
|
||||
False, None, None, f"Unable to connect to {self.url}."
|
||||
)
|
||||
|
||||
async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
|
||||
try:
|
||||
@@ -141,37 +164,53 @@ class CoreLightningRestWallet(Wallet):
|
||||
error_message = "0 amount invoices are not allowed"
|
||||
return PaymentResponse(False, None, None, None, error_message)
|
||||
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,
|
||||
)
|
||||
try:
|
||||
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)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
|
||||
data = r.json()
|
||||
if "error" in data:
|
||||
return PaymentResponse(False, None, None, None, data["error"])
|
||||
if r.is_error:
|
||||
return PaymentResponse(False, None, None, None, r.text)
|
||||
if (
|
||||
"payment_hash" not in data
|
||||
or "payment_preimage" not in data
|
||||
or "msatoshi_sent" not in data
|
||||
or "msatoshi" not in data
|
||||
or "status" not in data
|
||||
):
|
||||
return PaymentResponse(
|
||||
False, None, None, None, "Server error: 'missing required fields'"
|
||||
)
|
||||
|
||||
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"]
|
||||
|
||||
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
|
||||
)
|
||||
return PaymentResponse(
|
||||
self.statuses.get(data["status"]), checking_id, fee_msat, preimage, None
|
||||
)
|
||||
except json.JSONDecodeError:
|
||||
return PaymentResponse(
|
||||
False, None, None, None, "Server error: 'invalid json response'"
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.info(f"Failed to pay invoice {bolt11}")
|
||||
logger.warning(exc)
|
||||
return PaymentResponse(
|
||||
False, None, None, None, f"Unable to connect to {self.url}."
|
||||
)
|
||||
|
||||
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
|
||||
r = await self.client.get(
|
||||
@@ -187,7 +226,7 @@ class CoreLightningRestWallet(Wallet):
|
||||
return PaymentStatus(self.statuses.get(data["invoices"][0]["status"]))
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting invoice status: {e}")
|
||||
return PaymentStatus(None)
|
||||
return PaymentPendingStatus()
|
||||
|
||||
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
|
||||
r = await self.client.get(
|
||||
@@ -214,7 +253,7 @@ class CoreLightningRestWallet(Wallet):
|
||||
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)
|
||||
return PaymentPendingStatus()
|
||||
|
||||
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
|
||||
while True:
|
||||
|
||||
@@ -13,6 +13,7 @@ from lnbits.settings import settings
|
||||
|
||||
from .base import (
|
||||
InvoiceResponse,
|
||||
PaymentPendingStatus,
|
||||
PaymentResponse,
|
||||
PaymentStatus,
|
||||
StatusResponse,
|
||||
@@ -180,7 +181,7 @@ class EclairWallet(Wallet):
|
||||
}
|
||||
return PaymentStatus(statuses.get(data["status"]["type"]))
|
||||
except Exception:
|
||||
return PaymentStatus(None)
|
||||
return PaymentPendingStatus()
|
||||
|
||||
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
|
||||
try:
|
||||
@@ -211,7 +212,7 @@ class EclairWallet(Wallet):
|
||||
statuses.get(data["status"]["type"]), fee_msat, preimage
|
||||
)
|
||||
except Exception:
|
||||
return PaymentStatus(None)
|
||||
return PaymentPendingStatus()
|
||||
|
||||
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
|
||||
while True:
|
||||
|
||||
+10
-4
@@ -19,8 +19,11 @@ from lnbits.settings import settings
|
||||
|
||||
from .base import (
|
||||
InvoiceResponse,
|
||||
PaymentFailedStatus,
|
||||
PaymentPendingStatus,
|
||||
PaymentResponse,
|
||||
PaymentStatus,
|
||||
PaymentSuccessStatus,
|
||||
StatusResponse,
|
||||
Wallet,
|
||||
)
|
||||
@@ -28,7 +31,7 @@ from .base import (
|
||||
|
||||
class FakeWallet(Wallet):
|
||||
queue: asyncio.Queue = asyncio.Queue(0)
|
||||
payment_secrets: Dict[str, str] = dict()
|
||||
payment_secrets: Dict[str, str] = {}
|
||||
paid_invoices: Set[str] = set()
|
||||
secret: str = settings.fake_wallet_secret
|
||||
privkey: str = hashlib.pbkdf2_hmac(
|
||||
@@ -117,11 +120,14 @@ class FakeWallet(Wallet):
|
||||
)
|
||||
|
||||
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
|
||||
paid = checking_id in self.paid_invoices
|
||||
return PaymentStatus(paid)
|
||||
if checking_id in self.paid_invoices:
|
||||
return PaymentSuccessStatus()
|
||||
if checking_id in list(self.payment_secrets.keys()):
|
||||
return PaymentPendingStatus()
|
||||
return PaymentFailedStatus()
|
||||
|
||||
async def get_payment_status(self, _: str) -> PaymentStatus:
|
||||
return PaymentStatus(None)
|
||||
return PaymentPendingStatus()
|
||||
|
||||
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
|
||||
while True:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user