Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
+3 |
bc81dc5a28 |
@@ -0,0 +1,14 @@
|
|||||||
|
# Top-most EditorConfig file
|
||||||
|
root = true
|
||||||
|
|
||||||
|
# Unix-style newlines with a newline ending every file, utf-8 charset
|
||||||
|
[*]
|
||||||
|
end_of_line = lf
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
charset = utf-8
|
||||||
|
|
||||||
|
[/lnbits/static/vendor/*]
|
||||||
|
end_of_line = unset
|
||||||
|
insert_final_newline = unset
|
||||||
|
trim_trailing_whitespace = unset
|
||||||
+12
-5
@@ -15,6 +15,7 @@ LNBITS_ALLOWED_IPS=""
|
|||||||
LNBITS_BLOCKED_IPS=""
|
LNBITS_BLOCKED_IPS=""
|
||||||
|
|
||||||
# Allow users and admins by user IDs (comma separated list)
|
# Allow users and admins by user IDs (comma separated list)
|
||||||
|
# if set new users will not be able to create accounts
|
||||||
LNBITS_ALLOWED_USERS=""
|
LNBITS_ALLOWED_USERS=""
|
||||||
LNBITS_ADMIN_USERS=""
|
LNBITS_ADMIN_USERS=""
|
||||||
# Extensions only admin can access
|
# Extensions only admin can access
|
||||||
@@ -40,6 +41,11 @@ LNBITS_HIDE_API=false
|
|||||||
# GitHub has rate-limits for its APIs. The limit can be increased specifying a GITHUB_TOKEN
|
# GitHub has rate-limits for its APIs. The limit can be increased specifying a GITHUB_TOKEN
|
||||||
# LNBITS_EXT_GITHUB_TOKEN=github_pat_xxxxxxxxxxxxxxxxxx
|
# LNBITS_EXT_GITHUB_TOKEN=github_pat_xxxxxxxxxxxxxxxxxx
|
||||||
|
|
||||||
|
# Path where extensions will be installed (defaults to `./lnbits/`).
|
||||||
|
# Inside this directory the `extensions` and `upgrades` sub-directories will be created.
|
||||||
|
# LNBITS_EXTENSIONS_PATH="/path/to/some/dir"
|
||||||
|
|
||||||
|
|
||||||
# Extensions to be installed by default. If an extension from this list is uninstalled then it will be re-installed on the next restart.
|
# Extensions to be installed by default. If an extension from this list is uninstalled then it will be re-installed on the next restart.
|
||||||
# The extension must be removed from this list in order to not be re-installed.
|
# The extension must be removed from this list in order to not be re-installed.
|
||||||
LNBITS_EXTENSIONS_DEFAULT_INSTALL="tpos"
|
LNBITS_EXTENSIONS_DEFAULT_INSTALL="tpos"
|
||||||
@@ -91,6 +97,12 @@ SPARK_TOKEN=myaccesstoken
|
|||||||
# CoreLightningWallet
|
# CoreLightningWallet
|
||||||
CORELIGHTNING_RPC="/home/bob/.lightning/bitcoin/lightning-rpc"
|
CORELIGHTNING_RPC="/home/bob/.lightning/bitcoin/lightning-rpc"
|
||||||
|
|
||||||
|
# CoreLightningRestWallet
|
||||||
|
CORELIGHTNING_REST_URL=http://127.0.0.1:8185/
|
||||||
|
# Path or BASE64/HEX STRING
|
||||||
|
CORELIGHTNING_REST_MACAROON="/path/to/clnrest/access.macaroon"
|
||||||
|
CORELIGHTNING_REST_CERT="/path/to/clnrest/tls.cert"
|
||||||
|
|
||||||
# LnbitsWallet
|
# LnbitsWallet
|
||||||
LNBITS_ENDPOINT=https://legend.lnbits.com
|
LNBITS_ENDPOINT=https://legend.lnbits.com
|
||||||
LNBITS_KEY=LNBITS_ADMIN_KEY
|
LNBITS_KEY=LNBITS_ADMIN_KEY
|
||||||
@@ -133,8 +145,3 @@ ECLAIR_PASS=eclairpw
|
|||||||
# Enter /api in LightningTipBot to get your key
|
# Enter /api in LightningTipBot to get your key
|
||||||
LNTIPS_API_KEY=LNTIPS_ADMIN_KEY
|
LNTIPS_API_KEY=LNTIPS_ADMIN_KEY
|
||||||
LNTIPS_API_ENDPOINT=https://ln.tips
|
LNTIPS_API_ENDPOINT=https://ln.tips
|
||||||
|
|
||||||
# Cashu Mint
|
|
||||||
# Use a long-enough random (!) private key.
|
|
||||||
# Once set, you cannot change this key as for now.
|
|
||||||
CASHU_PRIVATE_KEY="SuperSecretPrivateKey"
|
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ name: codeql
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main, ]
|
branches: [main, dev]
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
branches: [main, dev]
|
||||||
schedule:
|
schedule:
|
||||||
- cron: '0 12 * * 5'
|
- cron: '0 12 * * 5'
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ checkprettier:
|
|||||||
checkblack:
|
checkblack:
|
||||||
poetry run black --check .
|
poetry run black --check .
|
||||||
|
|
||||||
|
checkeditorconfig:
|
||||||
|
editorconfig-checker
|
||||||
|
|
||||||
dev:
|
dev:
|
||||||
poetry run lnbits --reload
|
poetry run lnbits --reload
|
||||||
|
|
||||||
@@ -48,18 +51,17 @@ test-real-wallet:
|
|||||||
poetry run pytest
|
poetry run pytest
|
||||||
|
|
||||||
test-migration:
|
test-migration:
|
||||||
rm -rf ./migration-data
|
LNBITS_ADMIN_UI=True \
|
||||||
mkdir -p ./migration-data
|
make test
|
||||||
unzip tests/data/mock_data.zip -d ./migration-data
|
|
||||||
HOST=0.0.0.0 \
|
HOST=0.0.0.0 \
|
||||||
PORT=5002 \
|
PORT=5002 \
|
||||||
LNBITS_DATA_FOLDER="./migration-data" \
|
LNBITS_DATA_FOLDER="./tests/data" \
|
||||||
timeout 5s poetry run lnbits --host 0.0.0.0 --port 5002 || code=$?; if [[ $code -ne 124 && $code -ne 0 ]]; then exit $code; fi
|
timeout 5s poetry run lnbits --host 0.0.0.0 --port 5002 || code=$?; if [[ $code -ne 124 && $code -ne 0 ]]; then exit $code; fi
|
||||||
HOST=0.0.0.0 \
|
HOST=0.0.0.0 \
|
||||||
PORT=5002 \
|
PORT=5002 \
|
||||||
LNBITS_DATABASE_URL="postgres://lnbits:lnbits@localhost:5432/migration" \
|
LNBITS_DATABASE_URL="postgres://lnbits:lnbits@localhost:5432/migration" \
|
||||||
timeout 5s poetry run lnbits --host 0.0.0.0 --port 5002 || code=$?; if [[ $code -ne 124 && $code -ne 0 ]]; then exit $code; fi
|
timeout 5s poetry run lnbits --host 0.0.0.0 --port 5002 || code=$?; if [[ $code -ne 124 && $code -ne 0 ]]; then exit $code; fi
|
||||||
LNBITS_DATA_FOLDER="./migration-data" \
|
LNBITS_DATA_FOLDER="./tests/data" \
|
||||||
LNBITS_DATABASE_URL="postgres://lnbits:lnbits@localhost:5432/migration" \
|
LNBITS_DATABASE_URL="postgres://lnbits:lnbits@localhost:5432/migration" \
|
||||||
poetry run python tools/conv.py
|
poetry run python tools/conv.py
|
||||||
|
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ LNURL has a fallback scheme, so if scanned by a regular QR code reader it can de
|
|||||||

|

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

|

|
||||||
|
|
||||||
|
|||||||
+16
-5
@@ -6,13 +6,13 @@ nav_order: 2
|
|||||||
---
|
---
|
||||||
|
|
||||||
|
|
||||||
Making extensions
|
Extension set up
|
||||||
=================
|
=================
|
||||||
|
|
||||||
Start off by cloning the [example extension](https://github.com/lnbits/example) into your `lnbits/extensions` folder and renaming it to `mysuperplugin`:
|
Start off by creating a fork of the [example extension](https://github.com/lnbits/example) into own GitHub repository and rename the repository to `mysuperplugin`:
|
||||||
```sh
|
```sh
|
||||||
cd lnbits/extensions
|
cd [my-working-folder]
|
||||||
git clone https://github.com/lnbits/example.git --depth=1 mysuperplugin # Let's not use dashes or anything; it doesn't like those.
|
git clone https://github.com/[my-user-name]/mysuperplugin.git --depth=1 # Let's not use dashes or anything; it doesn't like those.
|
||||||
cd mysuperplugin
|
cd mysuperplugin
|
||||||
rm -rf .git/
|
rm -rf .git/
|
||||||
find . -type f -print0 | xargs -0 sed -i 's/example/mysuperplugin/g' # Change all occurrences of 'example' to your plugin name 'mysuperplugin'.
|
find . -type f -print0 | xargs -0 sed -i 's/example/mysuperplugin/g' # Change all occurrences of 'example' to your plugin name 'mysuperplugin'.
|
||||||
@@ -20,7 +20,18 @@ mv templates/example templates/mysuperplugin # Rename templates folder.
|
|||||||
```
|
```
|
||||||
- if you are on macOS and having difficulty with 'sed', consider `brew install gnu-sed` and use 'gsed', without -0 option after xargs.
|
- if you are on macOS and having difficulty with 'sed', consider `brew install gnu-sed` and use 'gsed', without -0 option after xargs.
|
||||||
|
|
||||||
Going over the example extension's structure:
|
1. Edit `manifest.json` and change the organisation name to your GitHub username.
|
||||||
|
1. Push your changes to GitHub.
|
||||||
|
1. In GitHub create a new release for your extension repo. Tag the release with `0.0.1`
|
||||||
|
1. Copy the URL of the extension's raw `manifest.json` URL `https://raw.githubusercontent.com/[my-user-name]/mysuperplugin/master/manifest.json`
|
||||||
|
1. If you are using the LMNbits Admin UI, go to the Admin UI > Server > Extension Sources, click "Add", paste the URL, then click "Save"
|
||||||
|
1. If you are configuring LNbits via environment variables, add the URL to the .env file's `LNBITS_EXTENSIONS_MANIFESTS` variable. Restart the LNbits python process
|
||||||
|
1. You will now see your extension in the LNbits > Extensions list. Click "Enable" to enable it.
|
||||||
|
1. ...
|
||||||
|
1. Profit!!!
|
||||||
|
|
||||||
|
Extension structure explained
|
||||||
|
-----------------------------
|
||||||
* views_api.py: This is where your public API would go. It will be exposed at "$DOMAIN/$PLUGIN/$ROUTE". For example: https://lnbits.com/mysuperplugin/api/v1/tools.
|
* views_api.py: This is where your public API would go. It will be exposed at "$DOMAIN/$PLUGIN/$ROUTE". For example: https://lnbits.com/mysuperplugin/api/v1/tools.
|
||||||
* views.py: The `/` path will show up as your plugin's home page in lnbits' UI. Other pages you can define yourself. The `templates` folder should explain itself in relation to this.
|
* views.py: The `/` path will show up as your plugin's home page in lnbits' UI. Other pages you can define yourself. The `templates` folder should explain itself in relation to this.
|
||||||
* migrations.py: Create database tables for your plugin. They'll be created automatically when you start lnbits.
|
* migrations.py: Create database tables for your plugin. They'll be created automatically when you start lnbits.
|
||||||
|
|||||||
@@ -18,6 +18,12 @@ A backend wallet can be configured using the following LNbits environment variab
|
|||||||
- `LNBITS_BACKEND_WALLET_CLASS`: **CoreLightningWallet**
|
- `LNBITS_BACKEND_WALLET_CLASS`: **CoreLightningWallet**
|
||||||
- `CORELIGHTNING_RPC`: /file/path/lightning-rpc
|
- `CORELIGHTNING_RPC`: /file/path/lightning-rpc
|
||||||
|
|
||||||
|
### CoreLightning REST
|
||||||
|
- `LNBITS_BACKEND_WALLET_CLASS`: **CoreLightningRestWallet**
|
||||||
|
- `CORELIGHTNING_REST_URL`: http://127.0.0.1:8185/
|
||||||
|
- `CORELIGHTNING_REST_MACAROON`: /file/path/admin.macaroon or Base64/Hex
|
||||||
|
- `CORELIGHTNING_REST_CERT`: /home/lightning/clnrest/tls.cert
|
||||||
|
|
||||||
### Spark (Core Lightning)
|
### Spark (Core Lightning)
|
||||||
|
|
||||||
- `LNBITS_BACKEND_WALLET_CLASS`: **SparkWallet**
|
- `LNBITS_BACKEND_WALLET_CLASS`: **SparkWallet**
|
||||||
@@ -29,7 +35,7 @@ A backend wallet can be configured using the following LNbits environment variab
|
|||||||
- `LNBITS_BACKEND_WALLET_CLASS`: **LndRestWallet**
|
- `LNBITS_BACKEND_WALLET_CLASS`: **LndRestWallet**
|
||||||
- `LND_REST_ENDPOINT`: http://10.147.17.230:8080/
|
- `LND_REST_ENDPOINT`: http://10.147.17.230:8080/
|
||||||
- `LND_REST_CERT`: /file/path/tls.cert
|
- `LND_REST_CERT`: /file/path/tls.cert
|
||||||
- `LND_REST_MACAROON`: /file/path/admin.macaroon or Bech64/Hex
|
- `LND_REST_MACAROON`: /file/path/admin.macaroon or Base64/Hex
|
||||||
|
|
||||||
or
|
or
|
||||||
|
|
||||||
@@ -41,7 +47,7 @@ or
|
|||||||
- `LND_GRPC_ENDPOINT`: ip_address
|
- `LND_GRPC_ENDPOINT`: ip_address
|
||||||
- `LND_GRPC_PORT`: port
|
- `LND_GRPC_PORT`: port
|
||||||
- `LND_GRPC_CERT`: /file/path/tls.cert
|
- `LND_GRPC_CERT`: /file/path/tls.cert
|
||||||
- `LND_GRPC_MACAROON`: /file/path/admin.macaroon or Bech64/Hex
|
- `LND_GRPC_MACAROON`: /file/path/admin.macaroon or Base64/Hex
|
||||||
|
|
||||||
You can also use an AES-encrypted macaroon (more info) instead by using
|
You can also use an AES-encrypted macaroon (more info) instead by using
|
||||||
|
|
||||||
|
|||||||
Generated
+46
-6
@@ -1,12 +1,15 @@
|
|||||||
{
|
{
|
||||||
"nodes": {
|
"nodes": {
|
||||||
"flake-utils": {
|
"flake-utils": {
|
||||||
|
"inputs": {
|
||||||
|
"systems": "systems"
|
||||||
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1676283394,
|
"lastModified": 1689068808,
|
||||||
"narHash": "sha256-XX2f9c3iySLCw54rJ/CZs+ZK6IQy7GXNY4nSOyu2QG4=",
|
"narHash": "sha256-6ixXo3wt24N/melDWjq70UuHQLxGV8jZvooRanIHXw0=",
|
||||||
"owner": "numtide",
|
"owner": "numtide",
|
||||||
"repo": "flake-utils",
|
"repo": "flake-utils",
|
||||||
"rev": "3db36a8b464d0c4532ba1c7dda728f4576d6d073",
|
"rev": "919d646de7be200f3bf08cb76ae1f09402b6f9b4",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -15,6 +18,27 @@
|
|||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"nix-github-actions": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": [
|
||||||
|
"poetry2nix",
|
||||||
|
"nixpkgs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1688870561,
|
||||||
|
"narHash": "sha256-4UYkifnPEw1nAzqqPOTL2MvWtm3sNGw1UTYTalkTcGY=",
|
||||||
|
"owner": "nix-community",
|
||||||
|
"repo": "nix-github-actions",
|
||||||
|
"rev": "165b1650b753316aa7f1787f3005a8d2da0f5301",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nix-community",
|
||||||
|
"repo": "nix-github-actions",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1678470307,
|
"lastModified": 1678470307,
|
||||||
@@ -34,16 +58,17 @@
|
|||||||
"poetry2nix": {
|
"poetry2nix": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"flake-utils": "flake-utils",
|
"flake-utils": "flake-utils",
|
||||||
|
"nix-github-actions": "nix-github-actions",
|
||||||
"nixpkgs": [
|
"nixpkgs": [
|
||||||
"nixpkgs"
|
"nixpkgs"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1678513806,
|
"lastModified": 1695386222,
|
||||||
"narHash": "sha256-bEto1lp9bIQ+DlJsXQyRxN5l6B/oy3Skb7DueYRJJBo=",
|
"narHash": "sha256-5lgnhCCGW0NH5+m5iTED8u6NSSM/dbH9LBPvX0x0XXg=",
|
||||||
"owner": "nix-community",
|
"owner": "nix-community",
|
||||||
"repo": "poetry2nix",
|
"repo": "poetry2nix",
|
||||||
"rev": "bf239d53fafb53cf439a72b3a50e86dd6a3984a5",
|
"rev": "093383b3d7fdd36846a7d84e128ca11865800538",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -57,6 +82,21 @@
|
|||||||
"nixpkgs": "nixpkgs",
|
"nixpkgs": "nixpkgs",
|
||||||
"poetry2nix": "poetry2nix"
|
"poetry2nix": "poetry2nix"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"systems": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1681028828,
|
||||||
|
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"root": "root",
|
"root": "root",
|
||||||
|
|||||||
@@ -28,13 +28,29 @@
|
|||||||
});
|
});
|
||||||
overlays = {
|
overlays = {
|
||||||
default = final: prev: {
|
default = final: prev: {
|
||||||
${projectName} = self.packages.${final.hostPlatform.system}.${projectName};
|
${projectName} = self.packages.${prev.stdenv.hostPlatform.system}.${projectName};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
packages = forAllSystems (system: pkgs: {
|
packages = forAllSystems (system: pkgs: {
|
||||||
default = self.packages.${system}.${projectName};
|
default = self.packages.${system}.${projectName};
|
||||||
${projectName} = pkgs.poetry2nix.mkPoetryApplication {
|
${projectName} = pkgs.poetry2nix.mkPoetryApplication {
|
||||||
projectDir = ./.;
|
projectDir = ./.;
|
||||||
|
meta.rev = self.dirtyRev or self.rev;
|
||||||
|
overrides = pkgs.poetry2nix.overrides.withDefaults (final: prev: {
|
||||||
|
ruff = prev.ruff.override { preferWheel = true; };
|
||||||
|
fastapi = prev.fastapi.overridePythonAttrs (old: {
|
||||||
|
postPatch = ''
|
||||||
|
substituteInPlace pyproject.toml \
|
||||||
|
--replace '"Framework :: Pydantic",' "" \
|
||||||
|
--replace '"Framework :: Pydantic :: 1",' ""
|
||||||
|
'';
|
||||||
|
});
|
||||||
|
bolt11 = prev.bolt11.overrideAttrs (old: {
|
||||||
|
nativeBuildInputs = (old.nativeBuildInputs or [ ]) ++ [
|
||||||
|
prev.poetry
|
||||||
|
];
|
||||||
|
});
|
||||||
|
});
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
nixosModules = {
|
nixosModules = {
|
||||||
|
|||||||
+41
-26
@@ -9,6 +9,7 @@ import sys
|
|||||||
import traceback
|
import traceback
|
||||||
from hashlib import sha256
|
from hashlib import sha256
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
|
from pathlib import Path
|
||||||
from typing import Callable, List
|
from typing import Callable, List
|
||||||
|
|
||||||
from fastapi import FastAPI, HTTPException, Request
|
from fastapi import FastAPI, HTTPException, Request
|
||||||
@@ -33,14 +34,11 @@ from lnbits.utils.cache import cache
|
|||||||
from lnbits.wallets import get_wallet_class, set_wallet_class
|
from lnbits.wallets import get_wallet_class, set_wallet_class
|
||||||
|
|
||||||
from .commands import db_versions, load_disabled_extension_list, migrate_databases
|
from .commands import db_versions, load_disabled_extension_list, migrate_databases
|
||||||
from .core import (
|
from .core import init_core_routers
|
||||||
add_installed_extension,
|
from .core.db import core_app_extra
|
||||||
core_app,
|
|
||||||
core_app_extra,
|
|
||||||
update_installed_extension_state,
|
|
||||||
)
|
|
||||||
from .core.services import check_admin_settings, check_webpush_settings
|
from .core.services import check_admin_settings, check_webpush_settings
|
||||||
from .core.views.generic import core_html_routes
|
from .core.views.api import add_installed_extension
|
||||||
|
from .core.views.generic import update_installed_extension_state
|
||||||
from .extension_manager import Extension, InstallableExtension, get_valid_extensions
|
from .extension_manager import Extension, InstallableExtension, get_valid_extensions
|
||||||
from .helpers import template_renderer
|
from .helpers import template_renderer
|
||||||
from .middleware import (
|
from .middleware import (
|
||||||
@@ -74,6 +72,10 @@ def create_app() -> FastAPI:
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Allow registering new extensions routes without direct access to the `app` object
|
||||||
|
setattr(core_app_extra, "register_new_ext_routes", register_new_ext_routes(app))
|
||||||
|
setattr(core_app_extra, "register_new_ratelimiter", register_new_ratelimiter(app))
|
||||||
|
|
||||||
app.mount("/static", StaticFiles(packages=[("lnbits", "static")]), name="static")
|
app.mount("/static", StaticFiles(packages=[("lnbits", "static")]), name="static")
|
||||||
app.mount(
|
app.mount(
|
||||||
"/core/static",
|
"/core/static",
|
||||||
@@ -95,16 +97,18 @@ def create_app() -> FastAPI:
|
|||||||
app.add_middleware(InstalledExtensionMiddleware)
|
app.add_middleware(InstalledExtensionMiddleware)
|
||||||
app.add_middleware(ExtensionsRedirectMiddleware)
|
app.add_middleware(ExtensionsRedirectMiddleware)
|
||||||
|
|
||||||
|
register_custom_extensions_path()
|
||||||
|
|
||||||
|
# adds security middleware
|
||||||
|
add_ip_block_middleware(app)
|
||||||
|
add_ratelimit_middleware(app)
|
||||||
|
|
||||||
register_startup(app)
|
register_startup(app)
|
||||||
register_routes(app)
|
register_routes(app)
|
||||||
register_async_tasks(app)
|
register_async_tasks(app)
|
||||||
register_exception_handlers(app)
|
register_exception_handlers(app)
|
||||||
register_shutdown(app)
|
register_shutdown(app)
|
||||||
|
|
||||||
# Allow registering new extensions routes without direct access to the `app` object
|
|
||||||
setattr(core_app_extra, "register_new_ext_routes", register_new_ext_routes(app))
|
|
||||||
setattr(core_app_extra, "register_new_ratelimiter", register_new_ratelimiter(app))
|
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
@@ -228,9 +232,7 @@ def check_installed_extension_files(ext: InstallableExtension) -> bool:
|
|||||||
if ext.has_installed_version:
|
if ext.has_installed_version:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
zip_files = glob.glob(
|
zip_files = glob.glob(os.path.join(settings.lnbits_data_folder, "zips", "*.zip"))
|
||||||
os.path.join(settings.lnbits_data_folder, "extensions", "*.zip")
|
|
||||||
)
|
|
||||||
|
|
||||||
if f"./{str(ext.zip_path)}" not in zip_files:
|
if f"./{str(ext.zip_path)}" not in zip_files:
|
||||||
ext.download_archive()
|
ext.download_archive()
|
||||||
@@ -257,17 +259,32 @@ async def restore_installed_extension(app: FastAPI, ext: InstallableExtension):
|
|||||||
|
|
||||||
def register_routes(app: FastAPI) -> None:
|
def register_routes(app: FastAPI) -> None:
|
||||||
"""Register FastAPI routes / LNbits extensions."""
|
"""Register FastAPI routes / LNbits extensions."""
|
||||||
app.include_router(core_app)
|
init_core_routers(app)
|
||||||
app.include_router(core_html_routes)
|
|
||||||
|
|
||||||
for ext in get_valid_extensions():
|
for ext in get_valid_extensions():
|
||||||
try:
|
try:
|
||||||
register_ext_routes(app, ext)
|
register_ext_routes(app, ext)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(str(e))
|
logger.error(f"Could not load extension `{ext.code}`: {str(e)}")
|
||||||
raise ImportError(
|
|
||||||
f"Please make sure that the extension `{ext.code}` follows conventions."
|
|
||||||
)
|
def register_custom_extensions_path():
|
||||||
|
if settings.has_default_extension_path:
|
||||||
|
return
|
||||||
|
default_ext_path = os.path.join("lnbits", "extensions")
|
||||||
|
if os.path.isdir(default_ext_path) and len(os.listdir(default_ext_path)) != 0:
|
||||||
|
logger.warning(
|
||||||
|
"You are using a custom extensions path, "
|
||||||
|
+ "but the default extensions directory is not empty. "
|
||||||
|
+ f"Please clean-up the '{default_ext_path}' directory."
|
||||||
|
)
|
||||||
|
logger.warning(
|
||||||
|
f"You can move the existing '{default_ext_path}' directory to: "
|
||||||
|
+ f" '{settings.lnbits_extensions_path}/extensions'"
|
||||||
|
)
|
||||||
|
|
||||||
|
sys.path.append(str(Path(settings.lnbits_extensions_path, "extensions")))
|
||||||
|
sys.path.append(str(Path(settings.lnbits_extensions_path, "upgrades")))
|
||||||
|
|
||||||
|
|
||||||
def register_new_ext_routes(app: FastAPI) -> Callable:
|
def register_new_ext_routes(app: FastAPI) -> Callable:
|
||||||
@@ -306,7 +323,10 @@ def register_ext_routes(app: FastAPI, ext: Extension) -> None:
|
|||||||
if hasattr(ext_module, f"{ext.code}_static_files"):
|
if hasattr(ext_module, f"{ext.code}_static_files"):
|
||||||
ext_statics = getattr(ext_module, f"{ext.code}_static_files")
|
ext_statics = getattr(ext_module, f"{ext.code}_static_files")
|
||||||
for s in ext_statics:
|
for s in ext_statics:
|
||||||
app.mount(s["path"], s["app"], s["name"])
|
static_dir = Path(
|
||||||
|
settings.lnbits_extensions_path, "extensions", *s["path"].split("/")
|
||||||
|
)
|
||||||
|
app.mount(s["path"], StaticFiles(directory=static_dir), s["name"])
|
||||||
|
|
||||||
if hasattr(ext_module, f"{ext.code}_redirect_paths"):
|
if hasattr(ext_module, f"{ext.code}_redirect_paths"):
|
||||||
ext_redirects = getattr(ext_module, f"{ext.code}_redirect_paths")
|
ext_redirects = getattr(ext_module, f"{ext.code}_redirect_paths")
|
||||||
@@ -336,10 +356,6 @@ def register_startup(app: FastAPI):
|
|||||||
|
|
||||||
log_server_info()
|
log_server_info()
|
||||||
|
|
||||||
# adds security middleware
|
|
||||||
add_ratelimit_middleware(app)
|
|
||||||
add_ip_block_middleware(app)
|
|
||||||
|
|
||||||
# initialize WALLET
|
# initialize WALLET
|
||||||
try:
|
try:
|
||||||
set_wallet_class()
|
set_wallet_class()
|
||||||
@@ -402,7 +418,6 @@ def log_server_info():
|
|||||||
logger.info(f"Site title: {settings.lnbits_site_title}")
|
logger.info(f"Site title: {settings.lnbits_site_title}")
|
||||||
logger.info(f"Funding source: {settings.lnbits_backend_wallet_class}")
|
logger.info(f"Funding source: {settings.lnbits_backend_wallet_class}")
|
||||||
logger.info(f"Data folder: {settings.lnbits_data_folder}")
|
logger.info(f"Data folder: {settings.lnbits_data_folder}")
|
||||||
logger.info(f"Git version: {settings.lnbits_commit}")
|
|
||||||
logger.info(f"Database: {get_db_vendor_name()}")
|
logger.info(f"Database: {get_db_vendor_name()}")
|
||||||
logger.info(f"Service fee: {settings.lnbits_service_fee}")
|
logger.info(f"Service fee: {settings.lnbits_service_fee}")
|
||||||
|
|
||||||
|
|||||||
+7
-365
@@ -1,365 +1,7 @@
|
|||||||
import hashlib
|
from bolt11 import (
|
||||||
import re
|
Bolt11 as Invoice, # noqa: F401
|
||||||
import time
|
)
|
||||||
from decimal import Decimal
|
from bolt11 import (
|
||||||
from typing import List, NamedTuple, Optional
|
decode, # noqa: F401
|
||||||
|
encode, # noqa: F401
|
||||||
import bitstring
|
)
|
||||||
import secp256k1
|
|
||||||
from bech32 import CHARSET, bech32_decode, bech32_encode
|
|
||||||
from ecdsa import SECP256k1, VerifyingKey
|
|
||||||
from ecdsa.util import sigdecode_string
|
|
||||||
|
|
||||||
|
|
||||||
class Route(NamedTuple):
|
|
||||||
pubkey: str
|
|
||||||
short_channel_id: str
|
|
||||||
base_fee_msat: int
|
|
||||||
ppm_fee: int
|
|
||||||
cltv: int
|
|
||||||
|
|
||||||
|
|
||||||
class Invoice:
|
|
||||||
payment_hash: str
|
|
||||||
amount_msat: int = 0
|
|
||||||
description: Optional[str] = None
|
|
||||||
description_hash: Optional[str] = None
|
|
||||||
payee: Optional[str] = None
|
|
||||||
date: int
|
|
||||||
expiry: int = 3600
|
|
||||||
secret: Optional[str] = None
|
|
||||||
route_hints: List[Route] = []
|
|
||||||
min_final_cltv_expiry: int = 18
|
|
||||||
|
|
||||||
|
|
||||||
def decode(pr: str) -> Invoice:
|
|
||||||
"""bolt11 decoder,
|
|
||||||
based on https://github.com/rustyrussell/lightning-payencode/blob/master/lnaddr.py
|
|
||||||
"""
|
|
||||||
|
|
||||||
hrp, decoded_data = bech32_decode(pr)
|
|
||||||
if hrp is None or decoded_data is None:
|
|
||||||
raise ValueError("Bad bech32 checksum")
|
|
||||||
if not hrp.startswith("ln"):
|
|
||||||
raise ValueError("Does not start with ln")
|
|
||||||
|
|
||||||
bitarray = _u5_to_bitarray(decoded_data)
|
|
||||||
|
|
||||||
# final signature 65 bytes, split it off.
|
|
||||||
if len(bitarray) < 65 * 8:
|
|
||||||
raise ValueError("Too short to contain signature")
|
|
||||||
|
|
||||||
# extract the signature
|
|
||||||
signature = bitarray[-65 * 8 :].tobytes()
|
|
||||||
|
|
||||||
# the tagged fields as a bitstream
|
|
||||||
data = bitstring.ConstBitStream(bitarray[: -65 * 8])
|
|
||||||
|
|
||||||
# build the invoice object
|
|
||||||
invoice = Invoice()
|
|
||||||
|
|
||||||
# decode the amount from the hrp
|
|
||||||
m = re.search(r"[^\d]+", hrp[2:])
|
|
||||||
if m:
|
|
||||||
amountstr = hrp[2 + m.end() :]
|
|
||||||
if amountstr != "":
|
|
||||||
invoice.amount_msat = _unshorten_amount(amountstr)
|
|
||||||
|
|
||||||
# pull out date
|
|
||||||
date_bin = data.read(35)
|
|
||||||
invoice.date = date_bin.uint # type: ignore
|
|
||||||
|
|
||||||
while data.pos != data.len:
|
|
||||||
tag, tagdata, data = _pull_tagged(data)
|
|
||||||
data_length = len(tagdata or []) / 5
|
|
||||||
|
|
||||||
if tag == "d":
|
|
||||||
invoice.description = _trim_to_bytes(tagdata).decode()
|
|
||||||
elif tag == "h" and data_length == 52:
|
|
||||||
invoice.description_hash = _trim_to_bytes(tagdata).hex()
|
|
||||||
elif tag == "p" and data_length == 52:
|
|
||||||
invoice.payment_hash = _trim_to_bytes(tagdata).hex()
|
|
||||||
elif tag == "x":
|
|
||||||
invoice.expiry = tagdata.uint # type: ignore
|
|
||||||
elif tag == "n":
|
|
||||||
invoice.payee = _trim_to_bytes(tagdata).hex()
|
|
||||||
# this won't work in most cases, we must extract the payee
|
|
||||||
# from the signature
|
|
||||||
elif tag == "s":
|
|
||||||
invoice.secret = _trim_to_bytes(tagdata).hex()
|
|
||||||
elif tag == "r":
|
|
||||||
s = bitstring.ConstBitStream(tagdata)
|
|
||||||
while s.pos + 264 + 64 + 32 + 32 + 16 < s.len:
|
|
||||||
route = Route(
|
|
||||||
pubkey=s.read(264).tobytes().hex(), # type: ignore
|
|
||||||
short_channel_id=_readable_scid(s.read(64).intbe), # type: ignore
|
|
||||||
base_fee_msat=s.read(32).intbe, # type: ignore
|
|
||||||
ppm_fee=s.read(32).intbe, # type: ignore
|
|
||||||
cltv=s.read(16).intbe, # type: ignore
|
|
||||||
)
|
|
||||||
invoice.route_hints.append(route)
|
|
||||||
|
|
||||||
# BOLT #11:
|
|
||||||
# A reader MUST check that the `signature` is valid (see the `n` tagged
|
|
||||||
# field specified below).
|
|
||||||
# A reader MUST use the `n` field to validate the signature instead of
|
|
||||||
# performing signature recovery if a valid `n` field is provided.
|
|
||||||
message = bytearray([ord(c) for c in hrp]) + data.tobytes()
|
|
||||||
sig = signature[0:64]
|
|
||||||
if invoice.payee:
|
|
||||||
key = VerifyingKey.from_string(bytes.fromhex(invoice.payee), curve=SECP256k1)
|
|
||||||
key.verify(sig, message, hashlib.sha256, sigdecode=sigdecode_string)
|
|
||||||
else:
|
|
||||||
keys = VerifyingKey.from_public_key_recovery(
|
|
||||||
sig, message, SECP256k1, hashlib.sha256
|
|
||||||
)
|
|
||||||
signaling_byte = signature[64]
|
|
||||||
key = keys[int(signaling_byte)]
|
|
||||||
invoice.payee = key.to_string("compressed").hex()
|
|
||||||
|
|
||||||
return invoice
|
|
||||||
|
|
||||||
|
|
||||||
def encode(options):
|
|
||||||
"""Convert options into LnAddr and pass it to the encoder"""
|
|
||||||
addr = LnAddr()
|
|
||||||
addr.currency = options["currency"]
|
|
||||||
addr.fallback = options["fallback"] if options["fallback"] else None
|
|
||||||
if options["amount"]:
|
|
||||||
addr.amount = options["amount"]
|
|
||||||
if options["timestamp"]:
|
|
||||||
addr.date = int(options["timestamp"])
|
|
||||||
|
|
||||||
addr.paymenthash = bytes.fromhex(options["paymenthash"])
|
|
||||||
|
|
||||||
if options["description"]:
|
|
||||||
addr.tags.append(("d", options["description"]))
|
|
||||||
if options["description_hash"]:
|
|
||||||
addr.tags.append(("h", options["description_hash"]))
|
|
||||||
if options["expires"]:
|
|
||||||
addr.tags.append(("x", options["expires"]))
|
|
||||||
|
|
||||||
if options["fallback"]:
|
|
||||||
addr.tags.append(("f", options["fallback"]))
|
|
||||||
if options["route"]:
|
|
||||||
for r in options["route"]:
|
|
||||||
splits = r.split("/")
|
|
||||||
route = []
|
|
||||||
while len(splits) >= 5:
|
|
||||||
route.append(
|
|
||||||
(
|
|
||||||
bytes.fromhex(splits[0]),
|
|
||||||
bytes.fromhex(splits[1]),
|
|
||||||
int(splits[2]),
|
|
||||||
int(splits[3]),
|
|
||||||
int(splits[4]),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
splits = splits[5:]
|
|
||||||
assert len(splits) == 0
|
|
||||||
addr.tags.append(("r", route))
|
|
||||||
return lnencode(addr, options["privkey"])
|
|
||||||
|
|
||||||
|
|
||||||
def lnencode(addr, privkey):
|
|
||||||
if addr.amount:
|
|
||||||
amount = Decimal(str(addr.amount))
|
|
||||||
# We can only send down to millisatoshi.
|
|
||||||
if amount * 10**12 % 10:
|
|
||||||
raise ValueError(f"Cannot encode {addr.amount}: too many decimal places")
|
|
||||||
|
|
||||||
amount = addr.currency + shorten_amount(amount)
|
|
||||||
else:
|
|
||||||
amount = addr.currency if addr.currency else ""
|
|
||||||
|
|
||||||
hrp = f"ln{amount}0n"
|
|
||||||
|
|
||||||
# Start with the timestamp
|
|
||||||
data = bitstring.pack("uint:35", addr.date)
|
|
||||||
|
|
||||||
# Payment hash
|
|
||||||
data += tagged_bytes("p", addr.paymenthash)
|
|
||||||
tags_set = set()
|
|
||||||
|
|
||||||
for k, v in addr.tags:
|
|
||||||
# BOLT #11:
|
|
||||||
#
|
|
||||||
# A writer MUST NOT include more than one `d`, `h`, `n` or `x` fields,
|
|
||||||
if k in ("d", "h", "n", "x"):
|
|
||||||
if k in tags_set:
|
|
||||||
raise ValueError(f"Duplicate '{k}' tag")
|
|
||||||
|
|
||||||
if k == "r":
|
|
||||||
route = bitstring.BitArray()
|
|
||||||
for step in v:
|
|
||||||
pubkey, channel, feebase, feerate, cltv = step
|
|
||||||
route.append(
|
|
||||||
bitstring.BitArray(pubkey)
|
|
||||||
+ bitstring.BitArray(channel)
|
|
||||||
+ bitstring.pack("intbe:32", feebase)
|
|
||||||
+ bitstring.pack("intbe:32", feerate)
|
|
||||||
+ bitstring.pack("intbe:16", cltv)
|
|
||||||
)
|
|
||||||
data += tagged("r", route)
|
|
||||||
elif k == "f":
|
|
||||||
# NOTE: there was an error fallback here that's now removed
|
|
||||||
continue
|
|
||||||
elif k == "d":
|
|
||||||
data += tagged_bytes("d", v.encode())
|
|
||||||
elif k == "x":
|
|
||||||
# Get minimal length by trimming leading 5 bits at a time.
|
|
||||||
expirybits = bitstring.pack("intbe:64", v)[4:64]
|
|
||||||
while expirybits.startswith("0b00000"):
|
|
||||||
expirybits = expirybits[5:]
|
|
||||||
data += tagged("x", expirybits)
|
|
||||||
elif k == "h":
|
|
||||||
data += tagged_bytes("h", v)
|
|
||||||
elif k == "n":
|
|
||||||
data += tagged_bytes("n", v)
|
|
||||||
else:
|
|
||||||
# FIXME: Support unknown tags?
|
|
||||||
raise ValueError(f"Unknown tag {k}")
|
|
||||||
|
|
||||||
tags_set.add(k)
|
|
||||||
|
|
||||||
# BOLT #11:
|
|
||||||
#
|
|
||||||
# A writer MUST include either a `d` or `h` field, and MUST NOT include
|
|
||||||
# both.
|
|
||||||
if "d" in tags_set and "h" in tags_set:
|
|
||||||
raise ValueError("Cannot include both 'd' and 'h'")
|
|
||||||
if "d" not in tags_set and "h" not in tags_set:
|
|
||||||
raise ValueError("Must include either 'd' or 'h'")
|
|
||||||
|
|
||||||
# We actually sign the hrp, then data (padded to 8 bits with zeroes).
|
|
||||||
privkey = secp256k1.PrivateKey(bytes.fromhex(privkey))
|
|
||||||
sig = privkey.ecdsa_sign_recoverable(
|
|
||||||
bytearray([ord(c) for c in hrp]) + data.tobytes()
|
|
||||||
)
|
|
||||||
# This doesn't actually serialize, but returns a pair of values :(
|
|
||||||
sig, recid = privkey.ecdsa_recoverable_serialize(sig)
|
|
||||||
data += bytes(sig) + bytes([recid])
|
|
||||||
|
|
||||||
return bech32_encode(hrp, bitarray_to_u5(data))
|
|
||||||
|
|
||||||
|
|
||||||
class LnAddr:
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
paymenthash=None,
|
|
||||||
amount=None,
|
|
||||||
currency="bc",
|
|
||||||
tags=None,
|
|
||||||
date=None,
|
|
||||||
fallback=None,
|
|
||||||
):
|
|
||||||
self.date = int(time.time()) if not date else int(date)
|
|
||||||
self.tags = [] if not tags else tags
|
|
||||||
self.unknown_tags = []
|
|
||||||
self.paymenthash = paymenthash
|
|
||||||
self.signature = None
|
|
||||||
self.pubkey = None
|
|
||||||
self.fallback = fallback
|
|
||||||
self.currency = currency
|
|
||||||
self.amount = amount
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
assert self.pubkey, "LnAddr, pubkey must be set"
|
|
||||||
pubkey = bytes.hex(self.pubkey.serialize())
|
|
||||||
tags = ", ".join([f"{k}={v}" for k, v in self.tags])
|
|
||||||
return f"LnAddr[{pubkey}, amount={self.amount}{self.currency} tags=[{tags}]]"
|
|
||||||
|
|
||||||
|
|
||||||
def shorten_amount(amount):
|
|
||||||
"""Given an amount in bitcoin, shorten it"""
|
|
||||||
# Convert to pico initially
|
|
||||||
amount = int(amount * 10**12)
|
|
||||||
units = ["p", "n", "u", "m", ""]
|
|
||||||
unit = ""
|
|
||||||
for unit in units:
|
|
||||||
if amount % 1000 == 0:
|
|
||||||
amount //= 1000
|
|
||||||
else:
|
|
||||||
break
|
|
||||||
return str(amount) + unit
|
|
||||||
|
|
||||||
|
|
||||||
def _unshorten_amount(amount: str) -> int:
|
|
||||||
"""Given a shortened amount, return millisatoshis"""
|
|
||||||
# BOLT #11:
|
|
||||||
# The following `multiplier` letters are defined:
|
|
||||||
#
|
|
||||||
# * `m` (milli): multiply by 0.001
|
|
||||||
# * `u` (micro): multiply by 0.000001
|
|
||||||
# * `n` (nano): multiply by 0.000000001
|
|
||||||
# * `p` (pico): multiply by 0.000000000001
|
|
||||||
units = {"p": 10**12, "n": 10**9, "u": 10**6, "m": 10**3}
|
|
||||||
unit = str(amount)[-1]
|
|
||||||
|
|
||||||
# BOLT #11:
|
|
||||||
# A reader SHOULD fail if `amount` contains a non-digit, or is followed by
|
|
||||||
# anything except a `multiplier` in the table above.
|
|
||||||
if not re.fullmatch(r"\d+[pnum]?", str(amount)):
|
|
||||||
raise ValueError(f"Invalid amount '{amount}'")
|
|
||||||
|
|
||||||
if unit in units:
|
|
||||||
return int(int(amount[:-1]) * 100_000_000_000 / units[unit])
|
|
||||||
else:
|
|
||||||
return int(amount) * 100_000_000_000
|
|
||||||
|
|
||||||
|
|
||||||
def _pull_tagged(stream):
|
|
||||||
tag = stream.read(5).uint
|
|
||||||
length = stream.read(5).uint * 32 + stream.read(5).uint
|
|
||||||
return (CHARSET[tag], stream.read(length * 5), stream)
|
|
||||||
|
|
||||||
|
|
||||||
# Tagged field containing BitArray
|
|
||||||
def tagged(char, bits):
|
|
||||||
# Tagged fields need to be zero-padded to 5 bits.
|
|
||||||
while bits.len % 5 != 0:
|
|
||||||
bits.append("0b0")
|
|
||||||
return (
|
|
||||||
bitstring.pack(
|
|
||||||
"uint:5, uint:5, uint:5",
|
|
||||||
CHARSET.find(char),
|
|
||||||
(bits.len / 5) / 32,
|
|
||||||
(bits.len / 5) % 32,
|
|
||||||
)
|
|
||||||
+ bits
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def tagged_bytes(char, bits):
|
|
||||||
return tagged(char, bitstring.BitArray(bits))
|
|
||||||
|
|
||||||
|
|
||||||
def _trim_to_bytes(barr):
|
|
||||||
# Adds a byte if necessary.
|
|
||||||
b = barr.tobytes()
|
|
||||||
if barr.len % 8 != 0:
|
|
||||||
return b[:-1]
|
|
||||||
return b
|
|
||||||
|
|
||||||
|
|
||||||
def _readable_scid(short_channel_id: int) -> str:
|
|
||||||
blockheight = (short_channel_id >> 40) & 0xFFFFFF
|
|
||||||
transactionindex = (short_channel_id >> 16) & 0xFFFFFF
|
|
||||||
outputindex = short_channel_id & 0xFFFF
|
|
||||||
return f"{blockheight}x{transactionindex}x{outputindex}"
|
|
||||||
|
|
||||||
|
|
||||||
def _u5_to_bitarray(arr: List[int]) -> bitstring.BitArray:
|
|
||||||
ret = bitstring.BitArray()
|
|
||||||
for a in arr:
|
|
||||||
ret += bitstring.pack("uint:5", a)
|
|
||||||
return ret
|
|
||||||
|
|
||||||
|
|
||||||
def bitarray_to_u5(barr):
|
|
||||||
assert barr.len % 5 == 0
|
|
||||||
ret = []
|
|
||||||
s = bitstring.ConstBitStream(barr)
|
|
||||||
while s.pos != s.len:
|
|
||||||
ret.append(s.read(5).uint) # type: ignore
|
|
||||||
return ret
|
|
||||||
|
|||||||
+18
-13
@@ -15,31 +15,38 @@ from .extension_manager import get_valid_extensions
|
|||||||
|
|
||||||
|
|
||||||
@click.group()
|
@click.group()
|
||||||
def command_group():
|
def lnbits_cli():
|
||||||
"""
|
"""
|
||||||
Python CLI for LNbits
|
Python CLI for LNbits
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@lnbits_cli.group()
|
||||||
|
def db():
|
||||||
|
"""
|
||||||
|
Database related commands
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
def get_super_user() -> str:
|
def get_super_user() -> str:
|
||||||
"""Get the superuser"""
|
"""Get the superuser"""
|
||||||
with open(Path(settings.lnbits_data_folder) / ".super_user", "r") as file:
|
with open(Path(settings.lnbits_data_folder) / ".super_user", "r") as file:
|
||||||
return file.readline()
|
return file.readline()
|
||||||
|
|
||||||
|
|
||||||
@click.command("superuser")
|
@lnbits_cli.command("superuser")
|
||||||
def superuser():
|
def superuser():
|
||||||
"""Prints the superuser"""
|
"""Prints the superuser"""
|
||||||
click.echo(get_super_user())
|
click.echo(get_super_user())
|
||||||
|
|
||||||
|
|
||||||
@click.command("superuser-url")
|
@lnbits_cli.command("superuser-url")
|
||||||
def superuser_url():
|
def superuser_url():
|
||||||
"""Prints the superuser"""
|
"""Prints the superuser"""
|
||||||
click.echo(f"http://{settings.host}:{settings.port}/wallet?usr={get_super_user()}")
|
click.echo(f"http://{settings.host}:{settings.port}/wallet?usr={get_super_user()}")
|
||||||
|
|
||||||
|
|
||||||
@click.command("delete-settings")
|
@lnbits_cli.command("delete-settings")
|
||||||
def delete_settings():
|
def delete_settings():
|
||||||
"""Deletes the settings"""
|
"""Deletes the settings"""
|
||||||
|
|
||||||
@@ -51,7 +58,7 @@ def delete_settings():
|
|||||||
loop.run_until_complete(wrap())
|
loop.run_until_complete(wrap())
|
||||||
|
|
||||||
|
|
||||||
@click.command("database-migrate")
|
@db.command("migrate")
|
||||||
def database_migrate():
|
def database_migrate():
|
||||||
"""Migrate databases"""
|
"""Migrate databases"""
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
@@ -86,12 +93,15 @@ async def migrate_databases():
|
|||||||
|
|
||||||
for ext in get_valid_extensions():
|
for ext in get_valid_extensions():
|
||||||
current_version = current_versions.get(ext.code, 0)
|
current_version = current_versions.get(ext.code, 0)
|
||||||
await migrate_extension_database(ext, current_version)
|
try:
|
||||||
|
await migrate_extension_database(ext, current_version)
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"Error migrating extension {ext.code}: {e}")
|
||||||
|
|
||||||
logger.info("✔️ All migrations done.")
|
logger.info("✔️ All migrations done.")
|
||||||
|
|
||||||
|
|
||||||
@click.command("database-versions")
|
@db.command("versions")
|
||||||
def database_versions():
|
def database_versions():
|
||||||
"""Show current database versions"""
|
"""Show current database versions"""
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
@@ -112,12 +122,7 @@ async def load_disabled_extension_list() -> None:
|
|||||||
|
|
||||||
def main():
|
def main():
|
||||||
"""main function"""
|
"""main function"""
|
||||||
command_group.add_command(superuser)
|
lnbits_cli()
|
||||||
command_group.add_command(superuser_url)
|
|
||||||
command_group.add_command(delete_settings)
|
|
||||||
command_group.add_command(database_migrate)
|
|
||||||
command_group.add_command(database_versions)
|
|
||||||
command_group()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
+19
-10
@@ -1,15 +1,24 @@
|
|||||||
from fastapi.routing import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
from lnbits.core.models import CoreAppExtra
|
from .db import core_app_extra, db
|
||||||
from lnbits.db import Database
|
from .views.admin_api import admin_router
|
||||||
|
from .views.api import api_router
|
||||||
|
|
||||||
db = Database("database")
|
# this compat is needed for usermanager extension
|
||||||
|
from .views.generic import generic_router, update_user_extension
|
||||||
|
from .views.node_api import node_router, public_node_router, super_node_router
|
||||||
|
from .views.public_api import public_router
|
||||||
|
|
||||||
core_app: APIRouter = APIRouter(tags=["Core"])
|
# backwards compatibility for extensions
|
||||||
|
core_app = APIRouter(tags=["Core"])
|
||||||
|
|
||||||
core_app_extra: CoreAppExtra = CoreAppExtra()
|
|
||||||
|
|
||||||
from .views.admin_api import *
|
def init_core_routers(app):
|
||||||
from .views.api import *
|
app.include_router(core_app)
|
||||||
from .views.generic import *
|
app.include_router(generic_router)
|
||||||
from .views.public_api import *
|
app.include_router(public_router)
|
||||||
|
app.include_router(api_router)
|
||||||
|
app.include_router(node_router)
|
||||||
|
app.include_router(super_node_router)
|
||||||
|
app.include_router(public_node_router)
|
||||||
|
app.include_router(admin_router)
|
||||||
|
|||||||
+93
-21
@@ -1,27 +1,29 @@
|
|||||||
import datetime
|
import datetime
|
||||||
import json
|
import json
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Literal, Optional
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
from uuid import UUID, uuid4
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
import shortuuid
|
import shortuuid
|
||||||
|
from bolt11.decode import decode
|
||||||
|
|
||||||
from lnbits import bolt11
|
from lnbits.core.db import db
|
||||||
from lnbits.core.models import WalletType
|
from lnbits.core.models import WalletType
|
||||||
from lnbits.db import Connection, Database, Filters, Page
|
from lnbits.db import DB_TYPE, SQLITE, Connection, Database, Filters, Page
|
||||||
from lnbits.extension_manager import InstallableExtension
|
from lnbits.extension_manager import InstallableExtension
|
||||||
from lnbits.settings import (
|
from lnbits.settings import (
|
||||||
AdminSettings,
|
AdminSettings,
|
||||||
|
EditableSettings,
|
||||||
SuperSettings,
|
SuperSettings,
|
||||||
WebPushSettings,
|
WebPushSettings,
|
||||||
settings,
|
settings,
|
||||||
)
|
)
|
||||||
|
|
||||||
from . import db
|
|
||||||
from .models import (
|
from .models import (
|
||||||
BalanceCheck,
|
BalanceCheck,
|
||||||
Payment,
|
Payment,
|
||||||
PaymentFilters,
|
PaymentFilters,
|
||||||
|
PaymentHistoryPoint,
|
||||||
TinyURL,
|
TinyURL,
|
||||||
User,
|
User,
|
||||||
Wallet,
|
Wallet,
|
||||||
@@ -75,7 +77,7 @@ async def get_user(user_id: str, conn: Optional[Connection] = None) -> Optional[
|
|||||||
SELECT balance FROM balances WHERE wallet = wallets.id
|
SELECT balance FROM balances WHERE wallet = wallets.id
|
||||||
), 0) AS balance_msat
|
), 0) AS balance_msat
|
||||||
FROM wallets
|
FROM wallets
|
||||||
WHERE "user" = ?
|
WHERE "user" = ? and wallets.deleted = false
|
||||||
""",
|
""",
|
||||||
(user_id,),
|
(user_id,),
|
||||||
)
|
)
|
||||||
@@ -538,14 +540,16 @@ async def create_payment(
|
|||||||
webhook: Optional[str] = None,
|
webhook: Optional[str] = None,
|
||||||
conn: Optional[Connection] = None,
|
conn: Optional[Connection] = None,
|
||||||
) -> Payment:
|
) -> Payment:
|
||||||
# todo: add this when tests are fixed
|
# we don't allow the creation of the same invoice twice
|
||||||
previous_payment = await get_wallet_payment(wallet_id, payment_hash, conn=conn)
|
# note: this can be removed if the db uniquess constarints are set appropriately
|
||||||
|
previous_payment = await get_standalone_payment(checking_id, conn=conn)
|
||||||
assert previous_payment is None, "Payment already exists"
|
assert previous_payment is None, "Payment already exists"
|
||||||
|
|
||||||
try:
|
invoice = decode(payment_request)
|
||||||
invoice = bolt11.decode(payment_request)
|
|
||||||
|
if invoice.expiry:
|
||||||
expiration_date = datetime.datetime.fromtimestamp(invoice.date + invoice.expiry)
|
expiration_date = datetime.datetime.fromtimestamp(invoice.date + invoice.expiry)
|
||||||
except Exception:
|
else:
|
||||||
# assume maximum bolt11 expiry of 31 days to be on the safe side
|
# assume maximum bolt11 expiry of 31 days to be on the safe side
|
||||||
expiration_date = datetime.datetime.now() + datetime.timedelta(days=31)
|
expiration_date = datetime.datetime.now() + datetime.timedelta(days=31)
|
||||||
|
|
||||||
@@ -568,7 +572,7 @@ async def create_payment(
|
|||||||
fee,
|
fee,
|
||||||
(
|
(
|
||||||
json.dumps(extra)
|
json.dumps(extra)
|
||||||
if extra and extra != {} and type(extra) is dict
|
if extra and extra != {} and isinstance(extra, dict)
|
||||||
else None
|
else None
|
||||||
),
|
),
|
||||||
webhook,
|
webhook,
|
||||||
@@ -653,10 +657,77 @@ async def update_payment_extra(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def delete_payment(checking_id: str, conn: Optional[Connection] = None) -> None:
|
async def update_pending_payments(wallet_id: str):
|
||||||
await (conn or db).execute(
|
pending_payments = await get_payments(
|
||||||
"DELETE FROM apipayments WHERE checking_id = ?", (checking_id,)
|
wallet_id=wallet_id,
|
||||||
|
pending=True,
|
||||||
|
exclude_uncheckable=True,
|
||||||
)
|
)
|
||||||
|
for payment in pending_payments:
|
||||||
|
await payment.check_status()
|
||||||
|
|
||||||
|
|
||||||
|
DateTrunc = Literal["hour", "day", "month"]
|
||||||
|
sqlite_formats = {
|
||||||
|
"hour": "%Y-%m-%d %H:00:00",
|
||||||
|
"day": "%Y-%m-%d 00:00:00",
|
||||||
|
"month": "%Y-%m-01 00:00:00",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def get_payments_history(
|
||||||
|
wallet_id: Optional[str] = None,
|
||||||
|
group: DateTrunc = "day",
|
||||||
|
filters: Optional[Filters] = None,
|
||||||
|
) -> List[PaymentHistoryPoint]:
|
||||||
|
if not filters:
|
||||||
|
filters = Filters()
|
||||||
|
where = ["(pending = False OR amount < 0)"]
|
||||||
|
values = []
|
||||||
|
if wallet_id:
|
||||||
|
where.append("wallet = ?")
|
||||||
|
values.append(wallet_id)
|
||||||
|
|
||||||
|
if DB_TYPE == SQLITE and group in sqlite_formats:
|
||||||
|
date_trunc = f"strftime('{sqlite_formats[group]}', time, 'unixepoch')"
|
||||||
|
elif group in ("day", "hour", "month"):
|
||||||
|
date_trunc = f"date_trunc('{group}', time)"
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Invalid group value: {group}")
|
||||||
|
|
||||||
|
transactions = await db.fetchall(
|
||||||
|
f"""
|
||||||
|
SELECT {date_trunc} date,
|
||||||
|
SUM(CASE WHEN amount > 0 THEN amount ELSE 0 END) income,
|
||||||
|
SUM(CASE WHEN amount < 0 THEN abs(amount) + abs(fee) ELSE 0 END) spending
|
||||||
|
FROM apipayments
|
||||||
|
{filters.where(where)}
|
||||||
|
GROUP BY date
|
||||||
|
ORDER BY date DESC
|
||||||
|
""",
|
||||||
|
filters.values(values),
|
||||||
|
)
|
||||||
|
if wallet_id:
|
||||||
|
wallet = await get_wallet(wallet_id)
|
||||||
|
if wallet:
|
||||||
|
balance = wallet.balance_msat
|
||||||
|
else:
|
||||||
|
raise ValueError("Unknown wallet")
|
||||||
|
else:
|
||||||
|
balance = await get_total_balance()
|
||||||
|
|
||||||
|
# since we dont know the balance at the starting point,
|
||||||
|
# we take the current balance and walk backwards
|
||||||
|
results: list[PaymentHistoryPoint] = []
|
||||||
|
for row in transactions:
|
||||||
|
results.insert(
|
||||||
|
0,
|
||||||
|
PaymentHistoryPoint(
|
||||||
|
balance=balance, date=row[0], income=row[1], spending=row[2]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
balance -= row.income - row.spending
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
async def delete_wallet_payment(
|
async def delete_wallet_payment(
|
||||||
@@ -671,6 +742,10 @@ async def delete_wallet_payment(
|
|||||||
async def check_internal(
|
async def check_internal(
|
||||||
payment_hash: str, conn: Optional[Connection] = None
|
payment_hash: str, conn: Optional[Connection] = None
|
||||||
) -> Optional[str]:
|
) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Returns the checking_id of the internal payment if it exists,
|
||||||
|
otherwise None
|
||||||
|
"""
|
||||||
row = await (conn or db).fetchone(
|
row = await (conn or db).fetchone(
|
||||||
"""
|
"""
|
||||||
SELECT checking_id FROM apipayments
|
SELECT checking_id FROM apipayments
|
||||||
@@ -797,17 +872,14 @@ async def get_admin_settings(is_super_user: bool = False) -> Optional[AdminSetti
|
|||||||
return admin_settings
|
return admin_settings
|
||||||
|
|
||||||
|
|
||||||
async def delete_admin_settings():
|
async def delete_admin_settings() -> None:
|
||||||
await db.execute("DELETE FROM settings")
|
await db.execute("DELETE FROM settings")
|
||||||
|
|
||||||
|
|
||||||
async def update_admin_settings(data: dict):
|
async def update_admin_settings(data: EditableSettings) -> None:
|
||||||
row = await db.fetchone("SELECT editable_settings FROM settings")
|
row = await db.fetchone("SELECT editable_settings FROM settings")
|
||||||
if not row:
|
editable_settings = json.loads(row["editable_settings"]) if row else {}
|
||||||
return None
|
editable_settings.update(data.dict(exclude_unset=True))
|
||||||
editable_settings = json.loads(row["editable_settings"])
|
|
||||||
for key, value in data.items():
|
|
||||||
editable_settings[key] = value
|
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"UPDATE settings SET editable_settings = ?", (json.dumps(editable_settings),)
|
"UPDATE settings SET editable_settings = ?", (json.dumps(editable_settings),)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
from lnbits.core.models import CoreAppExtra
|
||||||
|
from lnbits.db import Database
|
||||||
|
|
||||||
|
db = Database("database")
|
||||||
|
core_app_extra: CoreAppExtra = CoreAppExtra()
|
||||||
@@ -6,11 +6,11 @@ from uuid import UUID
|
|||||||
import httpx
|
import httpx
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
|
from lnbits.core.db import db as core_db
|
||||||
from lnbits.db import Connection
|
from lnbits.db import Connection
|
||||||
from lnbits.extension_manager import Extension
|
from lnbits.extension_manager import Extension
|
||||||
from lnbits.settings import settings
|
from lnbits.settings import settings
|
||||||
|
|
||||||
from . import db as core_db
|
|
||||||
from .crud import update_migration_version
|
from .crud import update_migration_version
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+13
-2
@@ -235,9 +235,9 @@ class Payment(FromRowModel):
|
|||||||
return status
|
return status
|
||||||
|
|
||||||
async def delete(self, conn: Optional[Connection] = None) -> None:
|
async def delete(self, conn: Optional[Connection] = None) -> None:
|
||||||
from .crud import delete_payment
|
from .crud import delete_wallet_payment
|
||||||
|
|
||||||
await delete_payment(self.checking_id, conn=conn)
|
await delete_wallet_payment(self.checking_id, self.wallet_id, conn=conn)
|
||||||
|
|
||||||
|
|
||||||
class PaymentFilters(FilterModel):
|
class PaymentFilters(FilterModel):
|
||||||
@@ -258,6 +258,13 @@ class PaymentFilters(FilterModel):
|
|||||||
webhook_status: Optional[int]
|
webhook_status: Optional[int]
|
||||||
|
|
||||||
|
|
||||||
|
class PaymentHistoryPoint(BaseModel):
|
||||||
|
date: datetime.datetime
|
||||||
|
income: int
|
||||||
|
spending: int
|
||||||
|
balance: int
|
||||||
|
|
||||||
|
|
||||||
class BalanceCheck(BaseModel):
|
class BalanceCheck(BaseModel):
|
||||||
wallet: str
|
wallet: str
|
||||||
service: str
|
service: str
|
||||||
@@ -332,6 +339,10 @@ class CreateLnurlAuth(BaseModel):
|
|||||||
callback: str
|
callback: str
|
||||||
|
|
||||||
|
|
||||||
|
class CreateWallet(BaseModel):
|
||||||
|
name: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class CreateWebPushSubscription(BaseModel):
|
class CreateWebPushSubscription(BaseModel):
|
||||||
subscription: str
|
subscription: str
|
||||||
|
|
||||||
|
|||||||
+12
-4
@@ -15,6 +15,7 @@ from py_vapid import Vapid
|
|||||||
from py_vapid.utils import b64urlencode
|
from py_vapid.utils import b64urlencode
|
||||||
|
|
||||||
from lnbits import bolt11
|
from lnbits import bolt11
|
||||||
|
from lnbits.core.db import db
|
||||||
from lnbits.db import Connection
|
from lnbits.db import Connection
|
||||||
from lnbits.decorators import WalletTypeInfo, require_admin_key
|
from lnbits.decorators import WalletTypeInfo, require_admin_key
|
||||||
from lnbits.helpers import url_for
|
from lnbits.helpers import url_for
|
||||||
@@ -29,7 +30,6 @@ from lnbits.utils.exchange_rates import fiat_amount_as_satoshis, satoshis_amount
|
|||||||
from lnbits.wallets import FAKE_WALLET, get_wallet_class, set_wallet_class
|
from lnbits.wallets import FAKE_WALLET, get_wallet_class, set_wallet_class
|
||||||
from lnbits.wallets.base import PaymentResponse, PaymentStatus
|
from lnbits.wallets.base import PaymentResponse, PaymentStatus
|
||||||
|
|
||||||
from . import db
|
|
||||||
from .crud import (
|
from .crud import (
|
||||||
check_internal,
|
check_internal,
|
||||||
check_internal_pending,
|
check_internal_pending,
|
||||||
@@ -92,7 +92,9 @@ async def calculate_fiat_amounts(
|
|||||||
extra["wallet_fiat_amount"] = round(fiat_amount, ndigits=3)
|
extra["wallet_fiat_amount"] = round(fiat_amount, ndigits=3)
|
||||||
extra["wallet_fiat_rate"] = amount_sat / fiat_amount
|
extra["wallet_fiat_rate"] = amount_sat / fiat_amount
|
||||||
|
|
||||||
logger.debug(f"Calculated fiat amounts for {wallet}: {extra=}")
|
logger.debug(
|
||||||
|
f"Calculated fiat amounts {wallet.id=} {amount=} {currency=}: {extra=}"
|
||||||
|
)
|
||||||
|
|
||||||
return amount_sat, extra
|
return amount_sat, extra
|
||||||
|
|
||||||
@@ -174,6 +176,12 @@ async def pay_invoice(
|
|||||||
will regularly check for the payment.
|
will regularly check for the payment.
|
||||||
"""
|
"""
|
||||||
invoice = bolt11.decode(payment_request)
|
invoice = bolt11.decode(payment_request)
|
||||||
|
|
||||||
|
if not invoice.amount_msat or not invoice.amount_msat > 0:
|
||||||
|
raise ValueError("Amountless invoices not supported.")
|
||||||
|
if max_sat and invoice.amount_msat > max_sat * 1000:
|
||||||
|
raise ValueError("Amount in invoice is too high.")
|
||||||
|
|
||||||
fee_reserve_msat = fee_reserve(invoice.amount_msat)
|
fee_reserve_msat = fee_reserve(invoice.amount_msat)
|
||||||
async with db.reuse_conn(conn) if conn else db.connect() as conn:
|
async with db.reuse_conn(conn) if conn else db.connect() as conn:
|
||||||
temp_id = invoice.payment_hash
|
temp_id = invoice.payment_hash
|
||||||
@@ -185,7 +193,7 @@ async def pay_invoice(
|
|||||||
raise ValueError("Amount in invoice is too high.")
|
raise ValueError("Amount in invoice is too high.")
|
||||||
|
|
||||||
_, extra = await calculate_fiat_amounts(
|
_, extra = await calculate_fiat_amounts(
|
||||||
invoice.amount_msat, wallet_id, extra=extra, conn=conn
|
invoice.amount_msat / 1000, wallet_id, extra=extra, conn=conn
|
||||||
)
|
)
|
||||||
|
|
||||||
# put all parameters that don't change here
|
# put all parameters that don't change here
|
||||||
@@ -571,7 +579,7 @@ async def check_webpush_settings():
|
|||||||
"lnbits_webpush_pubkey": pubkey,
|
"lnbits_webpush_pubkey": pubkey,
|
||||||
}
|
}
|
||||||
update_cached_settings(push_settings)
|
update_cached_settings(push_settings)
|
||||||
await update_admin_settings(push_settings)
|
await update_admin_settings(EditableSettings(**push_settings))
|
||||||
|
|
||||||
logger.info("Initialized webpush settings with generated VAPID key pair.")
|
logger.info("Initialized webpush settings with generated VAPID key pair.")
|
||||||
logger.info(f"Pubkey: {settings.lnbits_webpush_pubkey}")
|
logger.info(f"Pubkey: {settings.lnbits_webpush_pubkey}")
|
||||||
|
|||||||
@@ -5,14 +5,22 @@ new Vue({
|
|||||||
return {
|
return {
|
||||||
disclaimerDialog: {
|
disclaimerDialog: {
|
||||||
show: false,
|
show: false,
|
||||||
data: {}
|
data: {},
|
||||||
|
description: ''
|
||||||
},
|
},
|
||||||
walletName: ''
|
walletName: ''
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
computed: {
|
||||||
|
formatDescription() {
|
||||||
|
return LNbits.utils.convertMarkdown(this.description)
|
||||||
|
}
|
||||||
|
},
|
||||||
methods: {
|
methods: {
|
||||||
createWallet: function () {
|
createWallet: function () {
|
||||||
LNbits.href.createWallet(this.walletName)
|
LNbits.api.createAccount(this.walletName).then(res => {
|
||||||
|
window.location = '/wallet?usr=' + res.data.user + '&wal=' + res.data.id
|
||||||
|
})
|
||||||
},
|
},
|
||||||
processing: function () {
|
processing: function () {
|
||||||
this.$q.notify({
|
this.$q.notify({
|
||||||
@@ -21,5 +29,8 @@ new Vue({
|
|||||||
icon: null
|
icon: null
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
created() {
|
||||||
|
this.description = SITE_DESCRIPTION
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
function shortenNodeId(nodeId) {
|
||||||
|
return nodeId
|
||||||
|
? nodeId.substring(0, 5) + '...' + nodeId.substring(nodeId.length - 5)
|
||||||
|
: '...'
|
||||||
|
}
|
||||||
|
|
||||||
|
Vue.component('lnbits-node-ranks', {
|
||||||
|
props: ['ranks'],
|
||||||
|
data: function () {
|
||||||
|
return {
|
||||||
|
user: {},
|
||||||
|
stats: [
|
||||||
|
{label: 'Capacity', key: 'capacity'},
|
||||||
|
{label: 'Channels', key: 'channelcount'},
|
||||||
|
{label: 'Age', key: 'age'},
|
||||||
|
{label: 'Growth', key: 'growth'},
|
||||||
|
{label: 'Availability', key: 'availability'}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
template: `
|
||||||
|
<q-card class='q-my-none'>
|
||||||
|
<div class='column q-ma-md'>
|
||||||
|
<h5 class='text-subtitle1 text-bold q-my-none'>1ml Node Rank</h5>
|
||||||
|
<div v-for='stat in stats' class='q-gutter-sm'>
|
||||||
|
<div class='row items-center'>
|
||||||
|
<div class='col-9'>{{ stat.label }}</div>
|
||||||
|
<div class='col-3 text-subtitle1 text-bold'>
|
||||||
|
{{ (ranks && ranks[stat.key]) ?? '-' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-card>
|
||||||
|
`
|
||||||
|
})
|
||||||
|
|
||||||
|
Vue.component('lnbits-channel-stats', {
|
||||||
|
props: ['stats'],
|
||||||
|
data: function () {
|
||||||
|
return {
|
||||||
|
states: [
|
||||||
|
{label: 'Active', value: 'active', color: 'green'},
|
||||||
|
{label: 'Pending', value: 'pending', color: 'orange'},
|
||||||
|
{label: 'Inactive', value: 'inactive', color: 'grey'},
|
||||||
|
{label: 'Closed', value: 'closed', color: 'red'}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
template: `
|
||||||
|
<q-card>
|
||||||
|
<div class='column q-ma-md'>
|
||||||
|
<h5 class='text-subtitle1 text-bold q-my-none'>Channels</h5>
|
||||||
|
<div v-for='state in states' class='q-gutter-sm'>
|
||||||
|
<div class='row'>
|
||||||
|
<div class='col-9'>
|
||||||
|
<q-badge rounded size='md' :color='state.color'>{{ state.label }}</q-badge>
|
||||||
|
</div>
|
||||||
|
<div class='col-3 text-subtitle1 text-bold'>
|
||||||
|
{{ (stats?.counts && stats.counts[state.value]) ?? "-" }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-card>
|
||||||
|
`,
|
||||||
|
created: function () {
|
||||||
|
if (window.user) {
|
||||||
|
this.user = LNbits.map.user(window.user)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
Vue.component('lnbits-stat', {
|
||||||
|
props: ['title', 'amount', 'msat', 'btc'],
|
||||||
|
computed: {
|
||||||
|
value: function () {
|
||||||
|
return (
|
||||||
|
this.amount ??
|
||||||
|
(this.btc
|
||||||
|
? LNbits.utils.formatSat(this.btc)
|
||||||
|
: LNbits.utils.formatMsat(this.msat))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
template: `
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<div class='text-overline text-primary'>
|
||||||
|
{{ title }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class='text-h4 text-bold q-my-none'>{{ value }}</span>
|
||||||
|
<span class='text-h5' v-if='msat != undefined'>sats</span>
|
||||||
|
<span class='text-h5' v-if='btc != undefined'>BTC</span>
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
`
|
||||||
|
})
|
||||||
|
|
||||||
|
Vue.component('lnbits-node-qrcode', {
|
||||||
|
props: ['info'],
|
||||||
|
mixins: [windowMixin],
|
||||||
|
template: `
|
||||||
|
<q-card class="my-card">
|
||||||
|
<q-card-section>
|
||||||
|
<div class="text-h6">
|
||||||
|
<div style="text-align: center">
|
||||||
|
<qrcode
|
||||||
|
:value="info.addresses[0]"
|
||||||
|
:options="{width: 250}"
|
||||||
|
v-if='info.addresses[0]'
|
||||||
|
class="rounded-borders"
|
||||||
|
></qrcode>
|
||||||
|
<div v-else class='text-subtitle1'>
|
||||||
|
No addresses available
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-actions vertical>
|
||||||
|
<q-btn
|
||||||
|
dense
|
||||||
|
unelevated
|
||||||
|
size="md"
|
||||||
|
@click="copyText(info.id)"
|
||||||
|
>Public Key<q-tooltip> Click to copy </q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
</q-card-actions>
|
||||||
|
</q-card>
|
||||||
|
`
|
||||||
|
})
|
||||||
|
|
||||||
|
Vue.component('lnbits-node-info', {
|
||||||
|
props: ['info'],
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
showDialog: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mixins: [windowMixin],
|
||||||
|
methods: {
|
||||||
|
shortenNodeId
|
||||||
|
},
|
||||||
|
template: `
|
||||||
|
<div class='row items-baseline q-gutter-x-sm'>
|
||||||
|
<div class='text-h4 text-bold'>{{ this.info.alias }}</div>
|
||||||
|
<div class='row items-center q-gutter-sm'>
|
||||||
|
<div class='text-subtitle1 text-light'>{{ this.info.backend_name }}</div>
|
||||||
|
<q-badge
|
||||||
|
:style='\`background-color: #\${this.info.color}\`'
|
||||||
|
class='text-bold'
|
||||||
|
>
|
||||||
|
#{{ this.info.color }}
|
||||||
|
</q-badge>
|
||||||
|
<div class='text-bold'>{{ shortenNodeId(this.info.id) }}</div>
|
||||||
|
<q-btn
|
||||||
|
size='xs'
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
icon='content_paste'
|
||||||
|
@click='copyText(info.id)'
|
||||||
|
></q-btn>
|
||||||
|
<q-btn
|
||||||
|
size='xs'
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
icon='qr_code'
|
||||||
|
@click='showDialog = true'
|
||||||
|
></q-btn>
|
||||||
|
</div>
|
||||||
|
<q-dialog v-model="showDialog">
|
||||||
|
<lnbits-node-qrcode :info='info'></lnbits-node-qrcode>
|
||||||
|
</q-dialog>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
})
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
// update cache version every time there is a new deployment
|
// update cache version every time there is a new deployment
|
||||||
// so the service worker reinitializes the cache
|
// so the service worker reinitializes the cache
|
||||||
const CACHE_VERSION = 53
|
const CACHE_VERSION = 58
|
||||||
const CURRENT_CACHE = `lnbits-${CACHE_VERSION}-`
|
const CURRENT_CACHE = `lnbits-${CACHE_VERSION}-`
|
||||||
|
|
||||||
const getApiKey = request => {
|
const getApiKey = request => {
|
||||||
|
|||||||
@@ -3,45 +3,24 @@
|
|||||||
Vue.component(VueQrcode.name, VueQrcode)
|
Vue.component(VueQrcode.name, VueQrcode)
|
||||||
Vue.use(VueQrcodeReader)
|
Vue.use(VueQrcodeReader)
|
||||||
|
|
||||||
function generateChart(canvas, payments) {
|
function generateChart(canvas, rawData) {
|
||||||
var txs = []
|
const data = rawData.reduce(
|
||||||
var n = 0
|
(previous, current) => {
|
||||||
var data = {
|
previous.labels.push(current.date)
|
||||||
labels: [],
|
previous.income.push(current.income)
|
||||||
income: [],
|
previous.spending.push(current.spending)
|
||||||
outcome: [],
|
previous.cumulative.push(current.balance)
|
||||||
cumulative: []
|
return previous
|
||||||
}
|
},
|
||||||
|
{
|
||||||
_.each(
|
labels: [],
|
||||||
payments.filter(p => !p.pending).sort((a, b) => a.time - b.time),
|
income: [],
|
||||||
tx => {
|
spending: [],
|
||||||
txs.push({
|
cumulative: []
|
||||||
hour: Quasar.utils.date.formatDate(tx.date, 'YYYY-MM-DDTHH:00'),
|
|
||||||
sat: tx.sat
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
_.each(_.groupBy(txs, 'hour'), (value, day) => {
|
return new Chart(canvas.getContext('2d'), {
|
||||||
var income = _.reduce(
|
|
||||||
value,
|
|
||||||
(memo, tx) => (tx.sat >= 0 ? memo + tx.sat : memo),
|
|
||||||
0
|
|
||||||
)
|
|
||||||
var outcome = _.reduce(
|
|
||||||
value,
|
|
||||||
(memo, tx) => (tx.sat < 0 ? memo + Math.abs(tx.sat) : memo),
|
|
||||||
0
|
|
||||||
)
|
|
||||||
n = n + income - outcome
|
|
||||||
data.labels.push(day)
|
|
||||||
data.income.push(income)
|
|
||||||
data.outcome.push(outcome)
|
|
||||||
data.cumulative.push(n)
|
|
||||||
})
|
|
||||||
|
|
||||||
new Chart(canvas.getContext('2d'), {
|
|
||||||
type: 'bar',
|
type: 'bar',
|
||||||
data: {
|
data: {
|
||||||
labels: data.labels,
|
labels: data.labels,
|
||||||
@@ -64,7 +43,7 @@ function generateChart(canvas, payments) {
|
|||||||
backgroundColor: window.Color('rgb(76,175,80)').alpha(0.5).rgbString() // green
|
backgroundColor: window.Color('rgb(76,175,80)').alpha(0.5).rgbString() // green
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
data: data.outcome,
|
data: data.spending,
|
||||||
type: 'bar',
|
type: 'bar',
|
||||||
label: 'out',
|
label: 'out',
|
||||||
barPercentage: 0.75,
|
barPercentage: 0.75,
|
||||||
@@ -85,7 +64,7 @@ function generateChart(canvas, payments) {
|
|||||||
{
|
{
|
||||||
type: 'time',
|
type: 'time',
|
||||||
display: true,
|
display: true,
|
||||||
offset: true,
|
//offset: true,
|
||||||
time: {
|
time: {
|
||||||
minUnit: 'hour',
|
minUnit: 'hour',
|
||||||
stepSize: 3
|
stepSize: 3
|
||||||
@@ -248,7 +227,14 @@ new Vue({
|
|||||||
loading: false
|
loading: false
|
||||||
},
|
},
|
||||||
paymentsChart: {
|
paymentsChart: {
|
||||||
show: false
|
show: false,
|
||||||
|
group: {value: 'hour', label: 'Hour'},
|
||||||
|
groupOptions: [
|
||||||
|
{value: 'month', label: 'Month'},
|
||||||
|
{value: 'day', label: 'Day'},
|
||||||
|
{value: 'hour', label: 'Hour'}
|
||||||
|
],
|
||||||
|
instance: null
|
||||||
},
|
},
|
||||||
disclaimerDialog: {
|
disclaimerDialog: {
|
||||||
show: false,
|
show: false,
|
||||||
@@ -301,9 +287,27 @@ new Vue({
|
|||||||
},
|
},
|
||||||
showChart: function () {
|
showChart: function () {
|
||||||
this.paymentsChart.show = true
|
this.paymentsChart.show = true
|
||||||
this.$nextTick(() => {
|
LNbits.api
|
||||||
generateChart(this.$refs.canvas, this.payments)
|
.request(
|
||||||
})
|
'GET',
|
||||||
|
'/api/v1/payments/history?group=' + this.paymentsChart.group.value,
|
||||||
|
this.g.wallet.adminkey
|
||||||
|
)
|
||||||
|
.then(response => {
|
||||||
|
this.$nextTick(() => {
|
||||||
|
if (this.paymentsChart.instance) {
|
||||||
|
this.paymentsChart.instance.destroy()
|
||||||
|
}
|
||||||
|
this.paymentsChart.instance = generateChart(
|
||||||
|
this.$refs.canvas,
|
||||||
|
response.data
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
LNbits.utils.notifyApiError(err)
|
||||||
|
this.paymentsChart.show = false
|
||||||
|
})
|
||||||
},
|
},
|
||||||
focusInput(el) {
|
focusInput(el) {
|
||||||
this.$nextTick(() => this.$refs[el].focus())
|
this.$nextTick(() => this.$refs[el].focus())
|
||||||
@@ -331,6 +335,7 @@ new Vue({
|
|||||||
this.parse.data.comment = ''
|
this.parse.data.comment = ''
|
||||||
this.parse.data.paymentChecker = null
|
this.parse.data.paymentChecker = null
|
||||||
this.parse.camera.show = false
|
this.parse.camera.show = false
|
||||||
|
this.focusInput('textArea')
|
||||||
},
|
},
|
||||||
updateBalance: function (credit) {
|
updateBalance: function (credit) {
|
||||||
LNbits.api
|
LNbits.api
|
||||||
@@ -697,7 +702,7 @@ new Vue({
|
|||||||
|
|
||||||
LNbits.api
|
LNbits.api
|
||||||
.authLnurl(this.g.wallet, this.parse.lnurlauth.callback)
|
.authLnurl(this.g.wallet, this.parse.lnurlauth.callback)
|
||||||
.then(response => {
|
.then(_ => {
|
||||||
dismissAuthMsg()
|
dismissAuthMsg()
|
||||||
this.$q.notify({
|
this.$q.notify({
|
||||||
message: `Authentication successful.`,
|
message: `Authentication successful.`,
|
||||||
@@ -723,27 +728,35 @@ new Vue({
|
|||||||
updateWallet: function (data) {
|
updateWallet: function (data) {
|
||||||
LNbits.api
|
LNbits.api
|
||||||
.request('PATCH', '/api/v1/wallet', this.g.wallet.adminkey, data)
|
.request('PATCH', '/api/v1/wallet', this.g.wallet.adminkey, data)
|
||||||
.then(res => {
|
.then(_ => {
|
||||||
this.$q.notify({
|
this.$q.notify({
|
||||||
message: `Wallet updated.`,
|
message: `Wallet updated.`,
|
||||||
type: 'positive',
|
type: 'positive',
|
||||||
timeout: 3500
|
timeout: 3500
|
||||||
})
|
})
|
||||||
LNbits.href.updateWallet(
|
window.location.reload()
|
||||||
res.data.name,
|
|
||||||
this.user.id,
|
|
||||||
this.g.wallet.id
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
LNbits.utils.notifyApiError(err)
|
LNbits.utils.notifyApiError(err)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
deleteWallet: function (walletId, user) {
|
deleteWallet: function () {
|
||||||
LNbits.utils
|
LNbits.utils
|
||||||
.confirmDialog('Are you sure you want to delete this wallet?')
|
.confirmDialog('Are you sure you want to delete this wallet?')
|
||||||
.onOk(() => {
|
.onOk(() => {
|
||||||
LNbits.href.deleteWallet(walletId, user)
|
LNbits.api
|
||||||
|
.deleteWallet(this.g.wallet)
|
||||||
|
.then(_ => {
|
||||||
|
this.$q.notify({
|
||||||
|
timeout: 3000,
|
||||||
|
message: `Wallet deleted!`,
|
||||||
|
spinner: true
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
this.paymentsTable.loading = false
|
||||||
|
LNbits.utils.notifyApiError(err)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
fetchPayments: function (props) {
|
fetchPayments: function (props) {
|
||||||
@@ -795,6 +808,7 @@ new Vue({
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
pasteToTextArea: function () {
|
pasteToTextArea: function () {
|
||||||
|
this.$refs.textArea.focus() // Set cursor to textarea
|
||||||
navigator.clipboard.readText().then(text => {
|
navigator.clipboard.readText().then(text => {
|
||||||
this.$refs.textArea.value = text
|
this.$refs.textArea.value = text
|
||||||
})
|
})
|
||||||
@@ -803,6 +817,9 @@ new Vue({
|
|||||||
watch: {
|
watch: {
|
||||||
payments: function () {
|
payments: function () {
|
||||||
this.fetchBalance()
|
this.fetchBalance()
|
||||||
|
},
|
||||||
|
'paymentsChart.group': function () {
|
||||||
|
this.showChart()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
created: function () {
|
created: function () {
|
||||||
|
|||||||
+12
-9
@@ -4,6 +4,18 @@ from typing import Dict
|
|||||||
import httpx
|
import httpx
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
|
from lnbits.core.crud import (
|
||||||
|
get_balance_notify,
|
||||||
|
get_wallet,
|
||||||
|
get_webpush_subscriptions_for_user,
|
||||||
|
)
|
||||||
|
from lnbits.core.db import db
|
||||||
|
from lnbits.core.models import Payment
|
||||||
|
from lnbits.core.services import (
|
||||||
|
get_balance_delta,
|
||||||
|
send_payment_notification,
|
||||||
|
switch_to_voidwallet,
|
||||||
|
)
|
||||||
from lnbits.settings import get_wallet_class, settings
|
from lnbits.settings import get_wallet_class, settings
|
||||||
from lnbits.tasks import (
|
from lnbits.tasks import (
|
||||||
SseListenersDict,
|
SseListenersDict,
|
||||||
@@ -13,15 +25,6 @@ from lnbits.tasks import (
|
|||||||
send_push_notification,
|
send_push_notification,
|
||||||
)
|
)
|
||||||
|
|
||||||
from . import db
|
|
||||||
from .crud import (
|
|
||||||
get_balance_notify,
|
|
||||||
get_wallet,
|
|
||||||
get_webpush_subscriptions_for_user,
|
|
||||||
)
|
|
||||||
from .models import Payment
|
|
||||||
from .services import get_balance_delta, send_payment_notification, switch_to_voidwallet
|
|
||||||
|
|
||||||
api_invoice_listeners: Dict[str, asyncio.Queue] = SseListenersDict(
|
api_invoice_listeners: Dict[str, asyncio.Queue] = SseListenersDict(
|
||||||
"api_invoice_listeners"
|
"api_invoice_listeners"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -26,6 +26,29 @@
|
|||||||
<br />
|
<br />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col">
|
||||||
|
<div v-if="'{{LNBITS_NODE_UI_AVAILABLE}}' === 'True'">
|
||||||
|
<div>Node Management</div>
|
||||||
|
<q-toggle
|
||||||
|
label="Node UI"
|
||||||
|
v-model="formData.lnbits_node_ui"
|
||||||
|
></q-toggle>
|
||||||
|
<q-toggle
|
||||||
|
v-if="formData.lnbits_node_ui"
|
||||||
|
label="Public node UI"
|
||||||
|
v-model="formData.lnbits_public_node_ui"
|
||||||
|
></q-toggle>
|
||||||
|
<q-toggle
|
||||||
|
v-if="formData.lnbits_node_ui"
|
||||||
|
label="Transactions Tab (Disable on large CLN nodes)"
|
||||||
|
v-model="formData.lnbits_node_ui_transactions"
|
||||||
|
></q-toggle>
|
||||||
|
<br />
|
||||||
|
</div>
|
||||||
|
<p v-else>Node Management not supported by active funding source</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="row q-col-gutter-md">
|
<div class="row q-col-gutter-md">
|
||||||
<div class="col-12 col-md-4">
|
<div class="col-12 col-md-4">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
@@ -92,7 +115,7 @@
|
|||||||
<q-expansion-item
|
<q-expansion-item
|
||||||
expand-separator
|
expand-separator
|
||||||
icon="payments"
|
icon="payments"
|
||||||
:label="fund"
|
:label="prettyFunding.get(fund) || fund"
|
||||||
v-if="funding_sources.get(fund)"
|
v-if="funding_sources.get(fund)"
|
||||||
>
|
>
|
||||||
<q-card>
|
<q-card>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
</template>
|
</template>
|
||||||
</q-banner>
|
</q-banner>
|
||||||
<q-banner v-if="!updateAvailable" class="bg-green text-white">
|
<q-banner v-if="!updateAvailable" class="bg-green text-white">
|
||||||
<q-icon size="28px" name="checknark"></q-icon>
|
<q-icon size="28px" name="checkmark"></q-icon>
|
||||||
<span v-text="$t('latest_update', {version: statusData.version})"></span>
|
<span v-text="$t('latest_update', {version: statusData.version})"></span>
|
||||||
</q-banner>
|
</q-banner>
|
||||||
<q-card>
|
<q-card>
|
||||||
|
|||||||
@@ -75,7 +75,7 @@
|
|||||||
clearable
|
clearable
|
||||||
hint="Default currency for accounting"
|
hint="Default currency for accounting"
|
||||||
label="Currency"
|
label="Currency"
|
||||||
:options="formData.lnbits_allowed_currencies.length ? formData.lnbits_allowed_currencies : {{ currencies }}"
|
:options="formData.lnbits_allowed_currencies?.length ? formData.lnbits_allowed_currencies : {{ currencies }}"
|
||||||
></q-select>
|
></q-select>
|
||||||
<br />
|
<br />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -31,7 +31,7 @@
|
|||||||
v-model="formData.lnbits_site_description"
|
v-model="formData.lnbits_site_description"
|
||||||
filled
|
filled
|
||||||
type="textarea"
|
type="textarea"
|
||||||
hint="Use plain text or raw HTML"
|
hint="Use plain text, Markdown, or raw HTML"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<br />
|
<br />
|
||||||
|
|||||||
@@ -216,6 +216,21 @@
|
|||||||
},
|
},
|
||||||
tab: 'funding',
|
tab: 'funding',
|
||||||
needsRestart: false,
|
needsRestart: false,
|
||||||
|
prettyFunding: new Map([
|
||||||
|
['VoidWallet', 'Void Wallet'],
|
||||||
|
['FakeWallet', 'Fake Wallet'],
|
||||||
|
['CoreLightningWallet', 'Core Lightning'],
|
||||||
|
['CoreLightningRestWallet', 'Core Lightning'],
|
||||||
|
['LndRestWallet', 'Lightning Network Daemon (LND Rest)'],
|
||||||
|
['LndWallet', 'Lightning Network Daemon (LND)'],
|
||||||
|
['LnTipsWallet', 'LN.Tips'],
|
||||||
|
['LNPayWallet', 'LN Pay'],
|
||||||
|
['EclairWallet', 'Eclair (ACINQ)'],
|
||||||
|
['LNbitsWallet', 'LNBits'],
|
||||||
|
['OpenNodeWallet', 'OpenNode'],
|
||||||
|
['ClicheWallet', 'Cliche (NBD)'],
|
||||||
|
['SparkWallet', 'Spark']
|
||||||
|
]),
|
||||||
funding_sources: new Map([
|
funding_sources: new Map([
|
||||||
['VoidWallet', null],
|
['VoidWallet', null],
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -165,7 +165,7 @@
|
|||||||
<q-toggle
|
<q-toggle
|
||||||
v-if="extension.isAvailable && extension.isInstalled && g.user.admin"
|
v-if="extension.isAvailable && extension.isInstalled && g.user.admin"
|
||||||
:label="extension.isActive ? $t('activated'): $t('deactivated') "
|
:label="extension.isActive ? $t('activated'): $t('deactivated') "
|
||||||
color="secodary"
|
color="secondary"
|
||||||
style="max-height: 21px"
|
style="max-height: 21px"
|
||||||
v-model="extension.isActive"
|
v-model="extension.isActive"
|
||||||
@input="toggleExtension(extension)"
|
@input="toggleExtension(extension)"
|
||||||
|
|||||||
@@ -61,7 +61,9 @@
|
|||||||
></q-btn>
|
></q-btn>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p v-else>{{SITE_DESCRIPTION | safe}}</p>
|
<div v-else>
|
||||||
|
<div v-html="formatDescription"></div>
|
||||||
|
</div>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-card>
|
</q-card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -335,7 +335,7 @@
|
|||||||
:options="{width:240}"
|
:options="{width:240}"
|
||||||
></qrcode>
|
></qrcode>
|
||||||
</a>
|
</a>
|
||||||
<p v-text="$('drain_funds_desc')"></p>
|
<p v-text="$t('drain_funds_desc')"></p>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-card>
|
</q-card>
|
||||||
</q-expansion-item>
|
</q-expansion-item>
|
||||||
@@ -428,7 +428,7 @@
|
|||||||
<q-btn
|
<q-btn
|
||||||
unelevated
|
unelevated
|
||||||
color="red-10"
|
color="red-10"
|
||||||
@click="deleteWallet('{{ wallet.id }}', '{{ user.id }}')"
|
@click="deleteWallet()"
|
||||||
:label="$t('delete_wallet')"
|
:label="$t('delete_wallet')"
|
||||||
></q-btn>
|
></q-btn>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
@@ -481,7 +481,7 @@
|
|||||||
filled
|
filled
|
||||||
dense
|
dense
|
||||||
v-model.number="receive.data.amount"
|
v-model.number="receive.data.amount"
|
||||||
:label="$('amount')+' ({{LNBITS_DENOMINATION}}) *'"
|
:label="$t('amount')+' ({{LNBITS_DENOMINATION}}) *'"
|
||||||
mask="#.##"
|
mask="#.##"
|
||||||
fill-mask="0"
|
fill-mask="0"
|
||||||
reverse-fill-mask
|
reverse-fill-mask
|
||||||
@@ -827,6 +827,19 @@
|
|||||||
<q-dialog v-model="paymentsChart.show">
|
<q-dialog v-model="paymentsChart.show">
|
||||||
<q-card class="q-pa-sm" style="width: 800px; max-width: unset">
|
<q-card class="q-pa-sm" style="width: 800px; max-width: unset">
|
||||||
<q-card-section>
|
<q-card-section>
|
||||||
|
<div class="row q-gutter-sm justify-between">
|
||||||
|
<div class="text-h6">Payments Chart</div>
|
||||||
|
<q-select
|
||||||
|
label="Group"
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model="paymentsChart.group"
|
||||||
|
style="min-width: 120px"
|
||||||
|
:options="paymentsChart.groupOptions"
|
||||||
|
>
|
||||||
|
</q-select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<canvas ref="canvas" width="600" height="400"></canvas>
|
<canvas ref="canvas" width="600" height="400"></canvas>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-card>
|
</q-card>
|
||||||
|
|||||||
@@ -0,0 +1,312 @@
|
|||||||
|
<q-tab-panel name="channels">
|
||||||
|
<q-dialog v-model="connectPeerDialog.show">
|
||||||
|
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||||
|
<q-form class="q-gutter-md">
|
||||||
|
<q-input
|
||||||
|
dense
|
||||||
|
type="text"
|
||||||
|
filled
|
||||||
|
v-model="connectPeerDialog.data.uri"
|
||||||
|
label="Node URI"
|
||||||
|
hint="pubkey@host:port"
|
||||||
|
></q-input>
|
||||||
|
|
||||||
|
<div class="row q-mt-lg">
|
||||||
|
<q-btn
|
||||||
|
:label="$t('connect')"
|
||||||
|
color="primary"
|
||||||
|
@click="connectPeer"
|
||||||
|
></q-btn>
|
||||||
|
<q-btn
|
||||||
|
v-close-popup
|
||||||
|
flat
|
||||||
|
color="grey"
|
||||||
|
class="q-ml-auto"
|
||||||
|
:label="$t('cancel')"
|
||||||
|
></q-btn>
|
||||||
|
</div>
|
||||||
|
</q-form>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
|
||||||
|
<q-dialog v-model="openChannelDialog.show">
|
||||||
|
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||||
|
<q-form class="q-gutter-md">
|
||||||
|
<q-input
|
||||||
|
dense
|
||||||
|
type="text"
|
||||||
|
filled
|
||||||
|
v-model="openChannelDialog.data.peer_id"
|
||||||
|
label="Peer ID"
|
||||||
|
></q-input>
|
||||||
|
<q-input
|
||||||
|
dense
|
||||||
|
type="number"
|
||||||
|
filled
|
||||||
|
v-model.number="openChannelDialog.data.funding_amount"
|
||||||
|
label="Funding Amount"
|
||||||
|
></q-input>
|
||||||
|
<q-expansion-item icon="warning" label="Advanced">
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<div class="column q-gutter-md">
|
||||||
|
<q-input
|
||||||
|
dense
|
||||||
|
type="number"
|
||||||
|
filled
|
||||||
|
v-model.number="openChannelDialog.data.push_amount"
|
||||||
|
label="Push Amount"
|
||||||
|
hint="This gifts sats to the other side!"
|
||||||
|
></q-input>
|
||||||
|
|
||||||
|
<q-input
|
||||||
|
dense
|
||||||
|
type="number"
|
||||||
|
filled
|
||||||
|
v-model.number="openChannelDialog.data.fee_rate"
|
||||||
|
label="Fee Rate"
|
||||||
|
></q-input>
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-expansion-item>
|
||||||
|
|
||||||
|
<div class="row q-mt-lg">
|
||||||
|
<q-btn
|
||||||
|
:label="$t('open')"
|
||||||
|
color="primary"
|
||||||
|
@click="openChannel"
|
||||||
|
></q-btn>
|
||||||
|
<q-btn
|
||||||
|
v-close-popup
|
||||||
|
flat
|
||||||
|
color="grey"
|
||||||
|
class="q-ml-auto"
|
||||||
|
:label="$t('cancel')"
|
||||||
|
></q-btn>
|
||||||
|
</div>
|
||||||
|
</q-form>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
|
||||||
|
<q-dialog v-model="closeChannelDialog.show">
|
||||||
|
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||||
|
<q-form class="q-gutter-md">
|
||||||
|
<div>
|
||||||
|
<q-checkbox v-model="closeChannelDialog.data.force" label="Force" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row q-mt-lg">
|
||||||
|
<q-btn
|
||||||
|
:label="$t('close')"
|
||||||
|
color="primary"
|
||||||
|
@click="closeChannel"
|
||||||
|
></q-btn>
|
||||||
|
<q-btn
|
||||||
|
v-close-popup
|
||||||
|
flat
|
||||||
|
color="grey"
|
||||||
|
class="q-ml-auto"
|
||||||
|
:label="$t('cancel')"
|
||||||
|
></q-btn>
|
||||||
|
</div>
|
||||||
|
</q-form>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
|
||||||
|
<q-card-section class="q-pa-none">
|
||||||
|
<div class="row q-col-gutter-lg">
|
||||||
|
<div class="col-12 col-xl-6">
|
||||||
|
<q-card class="full-height">
|
||||||
|
<q-card-section class="q-gutter-y-sm">
|
||||||
|
<div class="row items-center q-mt-none q-gutter-x-sm no-wrap">
|
||||||
|
<div class="col-grow text-h6 q-my-none col-grow">Channels</div>
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
clearable
|
||||||
|
v-model="channels.filter"
|
||||||
|
placeholder="Search..."
|
||||||
|
class="col-auto"
|
||||||
|
></q-input>
|
||||||
|
<q-select
|
||||||
|
dense
|
||||||
|
size="sm"
|
||||||
|
style="min-width: 200px"
|
||||||
|
filled
|
||||||
|
multiple
|
||||||
|
clearable
|
||||||
|
v-model="stateFilters"
|
||||||
|
:options="this.states"
|
||||||
|
class="col-auto"
|
||||||
|
></q-select>
|
||||||
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
color="primary"
|
||||||
|
size="md"
|
||||||
|
class="col-auto"
|
||||||
|
@click="showOpenChannelDialog()"
|
||||||
|
>
|
||||||
|
Open channel
|
||||||
|
</q-btn>
|
||||||
|
</div>
|
||||||
|
{% raw %}
|
||||||
|
<div>
|
||||||
|
<div class="text-subtitle1 col-grow">Total</div>
|
||||||
|
<lnbits-channel-balance
|
||||||
|
:balance="this.totalBalance"
|
||||||
|
></lnbits-channel-balance>
|
||||||
|
</div>
|
||||||
|
<q-separator></q-separator>
|
||||||
|
<q-table
|
||||||
|
dense
|
||||||
|
flat
|
||||||
|
:data="this.filteredChannels"
|
||||||
|
:filter="channels.filter"
|
||||||
|
no-data-label="No channels opened"
|
||||||
|
>
|
||||||
|
<template v-slot:header="props">
|
||||||
|
<q-tr :props="props" style="height: 0"> </q-tr>
|
||||||
|
</template>
|
||||||
|
<template v-slot:body="props">
|
||||||
|
<q-tr :props="props">
|
||||||
|
<div class="q-pb-sm">
|
||||||
|
<div class="row items-center q-gutter-sm">
|
||||||
|
<div class="text-subtitle1 col-grow">
|
||||||
|
{{props.row.name}}
|
||||||
|
</div>
|
||||||
|
<div class="text-caption" v-if="props.row.short_id">
|
||||||
|
{{ props.row.short_id }}
|
||||||
|
<q-btn
|
||||||
|
size="xs"
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
icon="content_paste"
|
||||||
|
@click="copyText(props.row.short_id)"
|
||||||
|
></q-btn>
|
||||||
|
</div>
|
||||||
|
<q-badge
|
||||||
|
rounded
|
||||||
|
:color="states.find(s => s.value == props.row.state)?.color"
|
||||||
|
>
|
||||||
|
{{ states.find(s => s.value == props.row.state)?.label
|
||||||
|
}}
|
||||||
|
</q-badge>
|
||||||
|
<q-btn
|
||||||
|
:disable='props.row.state !== "active"'
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
size="md"
|
||||||
|
@click="showCloseChannelDialog(props.row)"
|
||||||
|
icon="cancel"
|
||||||
|
color="pink"
|
||||||
|
></q-btn>
|
||||||
|
</div>
|
||||||
|
<lnbits-channel-balance
|
||||||
|
:balance="props.row.balance"
|
||||||
|
:color="props.row.color"
|
||||||
|
></lnbits-channel-balance>
|
||||||
|
</div>
|
||||||
|
</q-tr>
|
||||||
|
</template>
|
||||||
|
</q-table>
|
||||||
|
|
||||||
|
{% endraw %}
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-xl-6">
|
||||||
|
<q-card class="full-height">
|
||||||
|
<q-card-section class="column q-gutter-y-sm">
|
||||||
|
{% raw %}
|
||||||
|
<div
|
||||||
|
class="row items-center q-mt-none justify-between q-gutter-x-md no-wrap"
|
||||||
|
>
|
||||||
|
<div class="col-grow text-h6 q-my-none">Peers</div>
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
clearable
|
||||||
|
v-model="peers.filter"
|
||||||
|
placeholder="Search..."
|
||||||
|
class="col-auto"
|
||||||
|
></q-input>
|
||||||
|
<q-btn
|
||||||
|
class="col-auto"
|
||||||
|
color="primary"
|
||||||
|
@click="connectPeerDialog.show = true"
|
||||||
|
>
|
||||||
|
Connect Peer
|
||||||
|
</q-btn>
|
||||||
|
</div>
|
||||||
|
<q-separator></q-separator>
|
||||||
|
<q-table
|
||||||
|
dense
|
||||||
|
flat
|
||||||
|
:data="peers.data"
|
||||||
|
:filter="peers.filter"
|
||||||
|
no-data-label="No transactions made yet"
|
||||||
|
>
|
||||||
|
<template v-slot:header="props">
|
||||||
|
<q-tr :props="props" style="height: 0"> </q-tr>
|
||||||
|
</template>
|
||||||
|
<template v-slot:body="props">
|
||||||
|
<q-tr :props="props">
|
||||||
|
<div class="row no-wrap items-center q-gutter-sm">
|
||||||
|
<div class="q-my-sm col-grow">
|
||||||
|
<div class="text-subtitle1 text-bold">
|
||||||
|
{{ props.row.alias }}
|
||||||
|
</div>
|
||||||
|
<div class="row items-center q-gutter-sm">
|
||||||
|
<q-badge
|
||||||
|
:style="`background-color: #${props.row.color}`"
|
||||||
|
class="text-bold"
|
||||||
|
>
|
||||||
|
#{{ props.row.color }}
|
||||||
|
</q-badge>
|
||||||
|
<div class="text-bold">
|
||||||
|
{{ shortenNodeId(props.row.id) }}
|
||||||
|
</div>
|
||||||
|
<q-btn
|
||||||
|
size="xs"
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
icon="content_paste"
|
||||||
|
@click="copyText(props.row.id)"
|
||||||
|
></q-btn>
|
||||||
|
<q-btn
|
||||||
|
size="xs"
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
icon="qr_code"
|
||||||
|
@click="showNodeInfoDialog(props.row)"
|
||||||
|
></q-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
color="primary"
|
||||||
|
@click="showOpenChannelDialog(props.row.id)"
|
||||||
|
>
|
||||||
|
Open channel
|
||||||
|
</q-btn>
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
size="md"
|
||||||
|
@click="disconnectPeer(props.row.id)"
|
||||||
|
icon="cancel"
|
||||||
|
color="pink"
|
||||||
|
></q-btn>
|
||||||
|
</div>
|
||||||
|
</q-tr>
|
||||||
|
</template>
|
||||||
|
</q-table>
|
||||||
|
|
||||||
|
{% endraw %}
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
</q-tab-panel>
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<q-tab-panel name="dashboard">
|
||||||
|
<q-card-section class="q-pa-none">
|
||||||
|
{% raw %}
|
||||||
|
<lnbits-node-info :info="this.info"></lnbits-node-info>
|
||||||
|
<div class="row q-col-gutter-lg q-mt-sm">
|
||||||
|
<div class="col-12 col-md-8 q-gutter-y-md">
|
||||||
|
<div class="row q-col-gutter-md q-pb-lg">
|
||||||
|
<div class="col-12 col-md-6 col-xl-4 q-gutter-y-md">
|
||||||
|
<lnbits-stat
|
||||||
|
title="Total Capacity"
|
||||||
|
:msat="this.channel_stats.total_capacity"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-6 col-xl-4 q-gutter-y-md">
|
||||||
|
<lnbits-stat title="Balance" :msat="this.info.balance_msat" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 col-md-6 col-xl-4 q-gutter-y-md">
|
||||||
|
<lnbits-stat
|
||||||
|
title="Fees collected"
|
||||||
|
:msat="this.info.fees?.total_msat"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 col-md-6 col-xl-4 q-gutter-y-md">
|
||||||
|
<lnbits-stat
|
||||||
|
title="Onchain Balance"
|
||||||
|
:btc="this.info.onchain_balance_sat / 100000000"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 col-md-6 col-xl-4 q-gutter-y-md">
|
||||||
|
<lnbits-stat
|
||||||
|
title="Onchain Confirmed"
|
||||||
|
:btc="this.info.onchain_confirmed_sat / 100000000"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-6 col-xl-4 q-gutter-y-md">
|
||||||
|
<lnbits-stat title="Peers" :amount="this.info.num_peers" />
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-6 col-xl-4 q-gutter-y-md">
|
||||||
|
<lnbits-stat
|
||||||
|
:title="$t('avg_channel_size')"
|
||||||
|
:msat="this.channel_stats.avg_size"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-6 col-xl-4 q-gutter-y-md">
|
||||||
|
<lnbits-stat
|
||||||
|
:title="$t('biggest_channel_size')"
|
||||||
|
:msat="this.channel_stats.biggest_size"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-6 col-xl-4 q-gutter-y-md">
|
||||||
|
<lnbits-stat
|
||||||
|
:title="$t('smallest_channel_size')"
|
||||||
|
:msat="this.channel_stats.smallest_size"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="column col-12 col-md-4 q-gutter-y-md">
|
||||||
|
<lnbits-node-ranks :ranks="this.ranks"></lnbits-node-ranks>
|
||||||
|
<lnbits-channel-stats
|
||||||
|
:stats="this.channel_stats"
|
||||||
|
></lnbits-channel-stats>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endraw %}
|
||||||
|
</q-card-section>
|
||||||
|
</q-tab-panel>
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
<q-tab-panel name="transactions">
|
||||||
|
<q-card-section class="q-pa-none">
|
||||||
|
<q-dialog v-model="transactionDetailsDialog.show">
|
||||||
|
<q-card class="my-card">
|
||||||
|
<q-card-section>
|
||||||
|
{% raw %}
|
||||||
|
<div class="text-center q-mb-lg">
|
||||||
|
<div
|
||||||
|
v-if="transactionDetailsDialog.data.isIn && transactionDetailsDialog.data.pending"
|
||||||
|
>
|
||||||
|
<q-icon
|
||||||
|
size="18px"
|
||||||
|
:name="'call_received'"
|
||||||
|
:color="'green'"
|
||||||
|
></q-icon>
|
||||||
|
<span v-text="$t('payment_received')"></span>
|
||||||
|
</div>
|
||||||
|
<div class="row q-my-md">
|
||||||
|
<div class="col-3"><b v-text="$t('payment_hash')"></b>:</div>
|
||||||
|
<div class="col-9 text-wrap mono">
|
||||||
|
{{ transactionDetailsDialog.data.payment_hash }}
|
||||||
|
<q-icon
|
||||||
|
name="content_copy"
|
||||||
|
@click="copyText(transactionDetailsDialog.data.payment_hash)"
|
||||||
|
size="1em"
|
||||||
|
color="grey"
|
||||||
|
class="q-mb-xs cursor-pointer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="row"
|
||||||
|
v-if="transactionDetailsDialog.data.preimage && !transactionDetailsDialog.data.pending"
|
||||||
|
>
|
||||||
|
<div class="col-3"><b v-text="$t('payment_proof')"></b>:</div>
|
||||||
|
<div class="col-9 text-wrap mono">
|
||||||
|
{{ transactionDetailsDialog.data.preimage }}
|
||||||
|
<q-icon
|
||||||
|
name="content_copy"
|
||||||
|
@click="copyText(transactionDetailsDialog.data.preimage)"
|
||||||
|
size="1em"
|
||||||
|
color="grey"
|
||||||
|
class="q-mb-xs cursor-pointer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="transactionDetailsDialog.data.bolt11"
|
||||||
|
class="text-center q-mb-lg"
|
||||||
|
>
|
||||||
|
<a :href="'lightning:' + transactionDetailsDialog.data.bolt11">
|
||||||
|
<q-responsive :ratio="1" class="q-mx-xl">
|
||||||
|
<qrcode
|
||||||
|
:value="'lightning:' + transactionDetailsDialog.data.bolt11.toUpperCase()"
|
||||||
|
:options="{width: 340}"
|
||||||
|
class="rounded-borders"
|
||||||
|
></qrcode>
|
||||||
|
</q-responsive>
|
||||||
|
</a>
|
||||||
|
<q-btn
|
||||||
|
outline
|
||||||
|
color="grey"
|
||||||
|
@click="copyText(transactionDetailsDialog.data.bolt11)"
|
||||||
|
:label="$t('copy_invoice')"
|
||||||
|
class="q-mt-sm"
|
||||||
|
></q-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endraw %}
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
|
||||||
|
<div class="row q-col-gutter-md q-pb-lg"></div>
|
||||||
|
|
||||||
|
<div class="row q-col-gutter-lg">
|
||||||
|
<div class="col-12 col-lg-6 q-gutter-y-md">
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<div class="row items-center no-wrap q-mb-sm">
|
||||||
|
<div class="col text-h6 q-my-none">Payments</div>
|
||||||
|
<q-input
|
||||||
|
v-if="payments.length > 10"
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
clearable
|
||||||
|
v-model="paymentsTable.filter"
|
||||||
|
debounce="300"
|
||||||
|
placeholder="Search by tag, memo, amount"
|
||||||
|
class="q-mb-md"
|
||||||
|
>
|
||||||
|
</q-input>
|
||||||
|
</div>
|
||||||
|
<q-table
|
||||||
|
dense
|
||||||
|
flat
|
||||||
|
:data="paymentsTable.data"
|
||||||
|
:columns="paymentsTable.columns"
|
||||||
|
:pagination.sync="paymentsTable.pagination"
|
||||||
|
row-key="payment_hash"
|
||||||
|
no-data-label="No transactions made yet"
|
||||||
|
:filter="paymentsTable.filter"
|
||||||
|
@request="getPayments"
|
||||||
|
>
|
||||||
|
{% raw %}
|
||||||
|
<template v-slot:body-cell-pending="props">
|
||||||
|
<q-td auto-width class="text-center">
|
||||||
|
<q-icon
|
||||||
|
v-if="!props.row.pending"
|
||||||
|
size="xs"
|
||||||
|
name="call_made"
|
||||||
|
color="green"
|
||||||
|
@click="showTransactionDetailsDialog(props.row)"
|
||||||
|
></q-icon>
|
||||||
|
<q-icon
|
||||||
|
v-else
|
||||||
|
size="xs"
|
||||||
|
name="settings_ethernet"
|
||||||
|
color="grey"
|
||||||
|
@click="showTransactionDetailsDialog(props.row)"
|
||||||
|
>
|
||||||
|
<q-tooltip>Pending</q-tooltip>
|
||||||
|
</q-icon>
|
||||||
|
<q-dialog v-model="props.row.expand" :props="props">
|
||||||
|
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||||
|
<div class="text-center q-mb-lg">
|
||||||
|
<div v-if="props.row.isIn && props.row.pending">
|
||||||
|
<q-icon
|
||||||
|
name="settings_ethernet"
|
||||||
|
color="grey"
|
||||||
|
></q-icon>
|
||||||
|
<span v-text="$t('invoice_waiting')"></span>
|
||||||
|
<lnbits-payment-details
|
||||||
|
:payment="props.row"
|
||||||
|
></lnbits-payment-details>
|
||||||
|
<div
|
||||||
|
v-if="props.row.bolt11"
|
||||||
|
class="text-center q-mb-lg"
|
||||||
|
>
|
||||||
|
<a :href="'lightning:' + props.row.bolt11">
|
||||||
|
<q-responsive :ratio="1" class="q-mx-xl">
|
||||||
|
<qrcode
|
||||||
|
:value="'lightning:' + props.row.bolt11.toUpperCase()"
|
||||||
|
:options="{width: 340}"
|
||||||
|
class="rounded-borders"
|
||||||
|
></qrcode>
|
||||||
|
</q-responsive>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="row q-mt-lg">
|
||||||
|
<q-btn
|
||||||
|
outline
|
||||||
|
color="grey"
|
||||||
|
@click="copyText(props.row.bolt11)"
|
||||||
|
:label="$t('copy_invoice')"
|
||||||
|
></q-btn>
|
||||||
|
<q-btn
|
||||||
|
v-close-popup
|
||||||
|
flat
|
||||||
|
color="grey"
|
||||||
|
class="q-ml-auto"
|
||||||
|
:label="$t('close')"
|
||||||
|
></q-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="props.row.isPaid && props.row.isIn">
|
||||||
|
<q-icon
|
||||||
|
size="18px"
|
||||||
|
:name="'call_received'"
|
||||||
|
:color="'green'"
|
||||||
|
></q-icon>
|
||||||
|
<span v-text="$t('payment_received')"></span>
|
||||||
|
<lnbits-payment-details
|
||||||
|
:payment="props.row"
|
||||||
|
></lnbits-payment-details>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="props.row.isPaid && props.row.isOut">
|
||||||
|
<q-icon
|
||||||
|
size="18px"
|
||||||
|
:name="'call_made'"
|
||||||
|
:color="'pink'"
|
||||||
|
></q-icon>
|
||||||
|
<span v-text="$t('payment_sent')"></span>
|
||||||
|
<lnbits-payment-details
|
||||||
|
:payment="props.row"
|
||||||
|
></lnbits-payment-details>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="props.row.isOut && props.row.pending">
|
||||||
|
<q-icon
|
||||||
|
name="settings_ethernet"
|
||||||
|
color="grey"
|
||||||
|
></q-icon>
|
||||||
|
<span v-text="$t('outgoing_payment_pending')"></span>
|
||||||
|
<lnbits-payment-details
|
||||||
|
:payment="props.row"
|
||||||
|
></lnbits-payment-details>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
</q-td>
|
||||||
|
</template>
|
||||||
|
<template v-slot:body-cell-date="props">
|
||||||
|
<q-td auto-width key="date" :props="props">
|
||||||
|
<lnbits-date :ts="props.row.time"></lnbits-date>
|
||||||
|
</q-td>
|
||||||
|
</template>
|
||||||
|
<template v-slot:body-cell-destination="props">
|
||||||
|
<q-td auto-width key="destination">
|
||||||
|
<div class="row items-center justify-between no-wrap">
|
||||||
|
<q-badge
|
||||||
|
:style="`background-color: #${props.row.destination?.color}`"
|
||||||
|
class="text-bold"
|
||||||
|
>
|
||||||
|
{{ props.row.destination?.alias }}
|
||||||
|
</q-badge>
|
||||||
|
<div>
|
||||||
|
<q-btn
|
||||||
|
size="xs"
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
icon="content_paste"
|
||||||
|
@click="copyText(info.id)"
|
||||||
|
></q-btn>
|
||||||
|
<q-btn
|
||||||
|
size="xs"
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
icon="qr_code"
|
||||||
|
@click="showNodeInfoDialog(props.row.destination)"
|
||||||
|
></q-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-td>
|
||||||
|
</template>
|
||||||
|
{% endraw %}
|
||||||
|
</q-table>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-lg-6 q-gutter-y-md">
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<div class="row items-center no-wrap q-mb-sm">
|
||||||
|
<div class="col text-h6 q-my-none">Invoices</div>
|
||||||
|
<q-input
|
||||||
|
v-if="payments.length > 10"
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
clearable
|
||||||
|
v-model="paymentsTable.filter"
|
||||||
|
debounce="300"
|
||||||
|
placeholder="Search by tag, memo, amount"
|
||||||
|
class="q-mb-md"
|
||||||
|
>
|
||||||
|
</q-input>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<q-table
|
||||||
|
dense
|
||||||
|
flat
|
||||||
|
:data="invoiceTable.data"
|
||||||
|
:columns="invoiceTable.columns"
|
||||||
|
:pagination.sync="invoiceTable.pagination"
|
||||||
|
no-data-label="No transactions made yet"
|
||||||
|
:filter="invoiceTable.filter"
|
||||||
|
@request="getInvoices"
|
||||||
|
>
|
||||||
|
{% raw %}
|
||||||
|
<template v-slot:body-cell-pending="props">
|
||||||
|
<q-td auto-width class="text-center">
|
||||||
|
<q-icon
|
||||||
|
v-if="!props.row.pending"
|
||||||
|
size="xs"
|
||||||
|
name="call_received"
|
||||||
|
color="green"
|
||||||
|
@click="showTransactionDetailsDialog(props.row)"
|
||||||
|
></q-icon>
|
||||||
|
<q-icon
|
||||||
|
v-else
|
||||||
|
size="xs"
|
||||||
|
name="settings_ethernet"
|
||||||
|
color="grey"
|
||||||
|
@click="showTransactionDetailsDialog(props.row)"
|
||||||
|
>
|
||||||
|
<q-tooltip>Pending</q-tooltip>
|
||||||
|
</q-icon>
|
||||||
|
</q-td>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-slot:body-cell-paid_at="props">
|
||||||
|
<q-td auto-width :props="props">
|
||||||
|
<lnbits-date
|
||||||
|
v-if="props.row.paid_at"
|
||||||
|
:ts="props.row.paid_at"
|
||||||
|
></lnbits-date>
|
||||||
|
</q-td>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-slot:body-cell-expiry="props">
|
||||||
|
<q-td auto-width :props="props">
|
||||||
|
<lnbits-date
|
||||||
|
v-if="props.row.expiry"
|
||||||
|
:ts="props.row.expiry"
|
||||||
|
></lnbits-date>
|
||||||
|
</q-td>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
{% endraw %}
|
||||||
|
</q-table>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
</q-tab-panel>
|
||||||
@@ -0,0 +1,500 @@
|
|||||||
|
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
|
||||||
|
%} {% block page %}
|
||||||
|
|
||||||
|
<q-dialog v-model="nodeInfoDialog.show">
|
||||||
|
<lnbits-node-qrcode :info="nodeInfoDialog.data"></lnbits-node-qrcode>
|
||||||
|
</q-dialog>
|
||||||
|
|
||||||
|
<div class="row q-col-gutter-md justify-center">
|
||||||
|
<div class="col q-gutter-y-md">
|
||||||
|
<q-card>
|
||||||
|
<div class="q-pa-md">
|
||||||
|
<div class="q-gutter-y-md">
|
||||||
|
<q-tabs v-model="tab" active-color="primary" align="justify">
|
||||||
|
<q-tab
|
||||||
|
name="dashboard"
|
||||||
|
:label="$t('dashboard')"
|
||||||
|
@update="val => tab = val.name"
|
||||||
|
></q-tab>
|
||||||
|
<q-tab
|
||||||
|
name="channels"
|
||||||
|
:label="$t('channels')"
|
||||||
|
@update="val => tab = val.name"
|
||||||
|
></q-tab>
|
||||||
|
<q-tab
|
||||||
|
name="transactions"
|
||||||
|
:label="$t('transactions')"
|
||||||
|
@update="val => tab = val.name"
|
||||||
|
></q-tab>
|
||||||
|
</q-tabs>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<q-form name="settings_form" id="settings_form">
|
||||||
|
<q-tab-panels v-model="tab" animated>
|
||||||
|
{% include "node/_tab_dashboard.html" %} {% include
|
||||||
|
"node/_tab_channels.html" %} {% include "node/_tab_transactions.html"
|
||||||
|
%}
|
||||||
|
</q-tab-panels>
|
||||||
|
</q-form>
|
||||||
|
</q-card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %} {% block scripts %} {{ window_vars(user) }}
|
||||||
|
<script src="/core/static/js/node.js"></script>
|
||||||
|
<script>
|
||||||
|
Vue.component(VueQrcode.name, VueQrcode)
|
||||||
|
Vue.use(VueQrcodeReader)
|
||||||
|
|
||||||
|
{% raw %}
|
||||||
|
Vue.component('lnbits-stat',
|
||||||
|
{
|
||||||
|
props: ['title', 'amount', 'msat', 'btc'],
|
||||||
|
computed: {
|
||||||
|
value: function () {
|
||||||
|
return this.amount ?? (this.btc ? LNbits.utils.formatSat(this.btc) : LNbits.utils.formatMsat(this.msat))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
template: `
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<div class='text-overline text-primary'>
|
||||||
|
{{ title }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class='text-h4 text-bold q-my-none'>{{ value }}</span>
|
||||||
|
<span class='text-h5' v-if='msat != undefined'>sats</span>
|
||||||
|
<span class='text-h5' v-if='btc != undefined'>BTC</span>
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
Vue.component('lnbits-channel-balance',
|
||||||
|
{
|
||||||
|
props: ['balance', 'color'],
|
||||||
|
methods: {
|
||||||
|
formatMsat: function (msat) {
|
||||||
|
return LNbits.utils.formatMsat(msat)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
template: `
|
||||||
|
<div>
|
||||||
|
<div class="row items-center justify-between">
|
||||||
|
<span class="text-weight-thin">
|
||||||
|
Local: {{ formatMsat(balance.local_msat) }}
|
||||||
|
sats
|
||||||
|
</span>
|
||||||
|
<span class="text-weight-thin">
|
||||||
|
Remote: {{ formatMsat(balance.remote_msat) }}
|
||||||
|
sats
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<q-linear-progress
|
||||||
|
rounded
|
||||||
|
size="25px"
|
||||||
|
:value="balance.local_msat / balance.total_msat"
|
||||||
|
:color="color"
|
||||||
|
:style="\`color: #\${this.color}\`"
|
||||||
|
>
|
||||||
|
<div class="absolute-full flex flex-center">
|
||||||
|
<q-badge
|
||||||
|
color="white"
|
||||||
|
text-color="accent"
|
||||||
|
:label="formatMsat(balance.total_msat) + ' sats'"
|
||||||
|
>
|
||||||
|
{{ balance.alias }}
|
||||||
|
</q-badge>
|
||||||
|
</div>
|
||||||
|
</q-linear-progress>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
Vue.component('lnbits-date',
|
||||||
|
{
|
||||||
|
props: ['ts'],
|
||||||
|
computed: {
|
||||||
|
date: function () {
|
||||||
|
return Quasar.utils.date.formatDate(
|
||||||
|
new Date(this.ts * 1000),
|
||||||
|
'YYYY-MM-DD HH:mm'
|
||||||
|
)
|
||||||
|
},
|
||||||
|
dateFrom: function () {
|
||||||
|
return moment(this.date).fromNow()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
template: `
|
||||||
|
<div>
|
||||||
|
<q-tooltip>{{ this.date }}</q-tooltip>
|
||||||
|
{{ this.dateFrom }}
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
Vue.component('lnbits-date',
|
||||||
|
{
|
||||||
|
props: ['ts'],
|
||||||
|
computed: {
|
||||||
|
date: function () {
|
||||||
|
return Quasar.utils.date.formatDate(
|
||||||
|
new Date(this.ts * 1000),
|
||||||
|
'YYYY-MM-DD HH:mm'
|
||||||
|
)
|
||||||
|
},
|
||||||
|
dateFrom: function () {
|
||||||
|
return moment(this.date).fromNow()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
template: `
|
||||||
|
<div>
|
||||||
|
<q-tooltip>{{ this.date }}</q-tooltip>
|
||||||
|
{{ this.dateFrom }}
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
{% endraw %}
|
||||||
|
|
||||||
|
|
||||||
|
new Vue({
|
||||||
|
el: '#vue',
|
||||||
|
config: {
|
||||||
|
globalProperties: {
|
||||||
|
LNbits,
|
||||||
|
msg: 'hello'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mixins: [windowMixin],
|
||||||
|
data: function () {
|
||||||
|
return {
|
||||||
|
isSuperUser: false,
|
||||||
|
wallet: {},
|
||||||
|
tab: 'dashboard',
|
||||||
|
payments: 1000,
|
||||||
|
info: {},
|
||||||
|
channel_stats: {},
|
||||||
|
|
||||||
|
channels: {
|
||||||
|
data: [],
|
||||||
|
filter: ''
|
||||||
|
},
|
||||||
|
|
||||||
|
|
||||||
|
activeBalance: {},
|
||||||
|
ranks: {},
|
||||||
|
|
||||||
|
peers: {
|
||||||
|
data: [],
|
||||||
|
filter: '',
|
||||||
|
},
|
||||||
|
|
||||||
|
connectPeerDialog: {
|
||||||
|
show: false,
|
||||||
|
data: {}
|
||||||
|
},
|
||||||
|
|
||||||
|
openChannelDialog: {
|
||||||
|
show: false,
|
||||||
|
data: {}
|
||||||
|
},
|
||||||
|
|
||||||
|
closeChannelDialog: {
|
||||||
|
show: false,
|
||||||
|
data: {}
|
||||||
|
},
|
||||||
|
|
||||||
|
nodeInfoDialog: {
|
||||||
|
show: false,
|
||||||
|
data: {}
|
||||||
|
},
|
||||||
|
|
||||||
|
transactionDetailsDialog: {
|
||||||
|
show: false,
|
||||||
|
data: {}
|
||||||
|
},
|
||||||
|
|
||||||
|
states: [
|
||||||
|
{label: 'Active', value: 'active', color: 'green'},
|
||||||
|
{label: 'Pending', value: 'pending', color: 'orange'},
|
||||||
|
{label: 'Inactive', value: 'inactive', color: 'grey'},
|
||||||
|
{label: 'Closed', value: 'closed', color: 'red'}
|
||||||
|
],
|
||||||
|
|
||||||
|
stateFilters: [
|
||||||
|
{label: 'Active', value: 'active'},
|
||||||
|
{label: 'Pending', value: 'pending'}
|
||||||
|
],
|
||||||
|
|
||||||
|
paymentsTable: {
|
||||||
|
data: [],
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
name: 'pending',
|
||||||
|
label: ''
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'memo',
|
||||||
|
align: 'left',
|
||||||
|
label: this.$t('memo'),
|
||||||
|
field: 'memo'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'date',
|
||||||
|
align: 'left',
|
||||||
|
label: this.$t('date'),
|
||||||
|
field: 'date',
|
||||||
|
sortable: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'sat',
|
||||||
|
align: 'right',
|
||||||
|
label: this.$t('amount') + ' (' + LNBITS_DENOMINATION + ')',
|
||||||
|
field: row => this.formatMsat(row.amount),
|
||||||
|
sortable: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'fee',
|
||||||
|
align: 'right',
|
||||||
|
label: this.$t('fee') + ' (m' + LNBITS_DENOMINATION + ')',
|
||||||
|
field: 'fee'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'destination',
|
||||||
|
align: 'right',
|
||||||
|
label: 'Destination',
|
||||||
|
field: 'destination'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
pagination: {
|
||||||
|
rowsPerPage: 10,
|
||||||
|
page: 1,
|
||||||
|
rowsNumber: 10
|
||||||
|
},
|
||||||
|
filter: null
|
||||||
|
},
|
||||||
|
invoiceTable: {
|
||||||
|
data: [],
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
name: 'pending',
|
||||||
|
label: ''
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'memo',
|
||||||
|
align: 'left',
|
||||||
|
label: this.$t('memo'),
|
||||||
|
field: 'memo'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'paid_at',
|
||||||
|
field: 'paid_at',
|
||||||
|
align: 'right',
|
||||||
|
label: 'Paid at',
|
||||||
|
sortable: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'expiry',
|
||||||
|
label: this.$t('expiry'),
|
||||||
|
field: 'expiry',
|
||||||
|
align: 'right',
|
||||||
|
sortable: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'amount',
|
||||||
|
label: this.$t('amount') + ' (' + LNBITS_DENOMINATION + ')',
|
||||||
|
field: row => this.formatMsat(row.amount),
|
||||||
|
sortable: true
|
||||||
|
},
|
||||||
|
],
|
||||||
|
pagination: {
|
||||||
|
rowsPerPage: 10,
|
||||||
|
page: 1,
|
||||||
|
rowsNumber: 10
|
||||||
|
},
|
||||||
|
filter: null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created: function () {
|
||||||
|
this.getInfo()
|
||||||
|
this.get1MLStats()
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
tab: function (val) {
|
||||||
|
if (val === 'transactions' && !this.paymentsTable.data.length) {
|
||||||
|
this.getPayments()
|
||||||
|
this.getInvoices()
|
||||||
|
} else if (val === 'channels' && !this.channels.data.length) {
|
||||||
|
this.getChannels()
|
||||||
|
this.getPeers()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
checkChanges() {
|
||||||
|
return !_.isEqual(this.settings, this.formData)
|
||||||
|
},
|
||||||
|
filteredChannels: function () {
|
||||||
|
return this.stateFilters ? this.channels.data.filter(channel => {
|
||||||
|
return this.stateFilters.find(({value}) => value == channel.state)
|
||||||
|
}) : this.channels.data
|
||||||
|
},
|
||||||
|
totalBalance: function () {
|
||||||
|
return this.filteredChannels.reduce(
|
||||||
|
(balance, channel) => {
|
||||||
|
balance.local_msat += channel.balance.local_msat
|
||||||
|
balance.remote_msat += channel.balance.remote_msat
|
||||||
|
balance.total_msat += channel.balance.total_msat
|
||||||
|
return balance
|
||||||
|
},
|
||||||
|
{local_msat: 0, remote_msat: 0, total_msat: 0}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
formatMsat: function (msat) {
|
||||||
|
return LNbits.utils.formatMsat(msat)
|
||||||
|
},
|
||||||
|
api: function (method, url, options) {
|
||||||
|
const params = new URLSearchParams(options?.query)
|
||||||
|
params.set('usr', this.g.user.id)
|
||||||
|
return LNbits.api.request(method, `/node/api/v1${url}?${params}`, {}, options?.data)
|
||||||
|
.catch(error => {
|
||||||
|
LNbits.utils.notifyApiError(error)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
getChannels: function () {
|
||||||
|
return this.api('GET', '/channels')
|
||||||
|
.then(response => {
|
||||||
|
this.channels.data = response.data
|
||||||
|
})
|
||||||
|
},
|
||||||
|
getInfo: function () {
|
||||||
|
return this.api('GET', '/info')
|
||||||
|
.then(response => {
|
||||||
|
this.info = response.data
|
||||||
|
this.channel_stats = response.data.channel_stats
|
||||||
|
})
|
||||||
|
},
|
||||||
|
get1MLStats: function () {
|
||||||
|
return this.api('GET', '/rank')
|
||||||
|
.then(response => {
|
||||||
|
this.ranks = response.data
|
||||||
|
})
|
||||||
|
},
|
||||||
|
getPayments: function (props) {
|
||||||
|
if (props) {
|
||||||
|
this.paymentsTable.pagination = props.pagination
|
||||||
|
}
|
||||||
|
let pagination = this.paymentsTable.pagination
|
||||||
|
const query = {
|
||||||
|
limit: pagination.rowsPerPage,
|
||||||
|
offset: ((pagination.page - 1) * pagination.rowsPerPage) ?? 0,
|
||||||
|
}
|
||||||
|
return this.api('GET', '/payments', {query})
|
||||||
|
.then(response => {
|
||||||
|
this.paymentsTable.data = response.data.data
|
||||||
|
this.paymentsTable.pagination.rowsNumber = response.data.total
|
||||||
|
})
|
||||||
|
},
|
||||||
|
getInvoices: function (props) {
|
||||||
|
if (props) {
|
||||||
|
this.invoiceTable.pagination = props.pagination
|
||||||
|
}
|
||||||
|
let pagination = this.invoiceTable.pagination
|
||||||
|
const query = {
|
||||||
|
limit: pagination.rowsPerPage,
|
||||||
|
offset: ((pagination.page - 1) * pagination.rowsPerPage) ?? 0,
|
||||||
|
}
|
||||||
|
return this.api('GET', '/invoices', {query})
|
||||||
|
.then(response => {
|
||||||
|
this.invoiceTable.data = response.data.data
|
||||||
|
this.invoiceTable.pagination.rowsNumber = response.data.total
|
||||||
|
})
|
||||||
|
},
|
||||||
|
getPeers: function () {
|
||||||
|
return this.api('GET', '/peers')
|
||||||
|
.then(response => {
|
||||||
|
this.peers.data = response.data
|
||||||
|
console.log('peers', this.peers)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
connectPeer: function () {
|
||||||
|
console.log('peer', this.connectPeerDialog)
|
||||||
|
this.api('POST', '/peers', {data: this.connectPeerDialog.data})
|
||||||
|
.then(() => {
|
||||||
|
this.connectPeerDialog.show = false
|
||||||
|
this.getPeers()
|
||||||
|
})
|
||||||
|
},
|
||||||
|
disconnectPeer: function(id) {
|
||||||
|
LNbits.utils
|
||||||
|
.confirmDialog(
|
||||||
|
'Do you really wanna disconnect this peer?'
|
||||||
|
)
|
||||||
|
.onOk(() => {
|
||||||
|
this.api('DELETE', `/peers/${id}`)
|
||||||
|
.then(response => {
|
||||||
|
this.$q.notify({
|
||||||
|
message: 'Disconnected',
|
||||||
|
icon: null
|
||||||
|
})
|
||||||
|
this.needsRestart = true
|
||||||
|
this.getPeers()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
openChannel: function () {
|
||||||
|
this.api('POST', '/channels', {data: this.openChannelDialog.data})
|
||||||
|
.then(response => {
|
||||||
|
this.openChannelDialog.show = false
|
||||||
|
this.getChannels()
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.log(error)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
showCloseChannelDialog: function (channel) {
|
||||||
|
this.closeChannelDialog.show = true
|
||||||
|
this.closeChannelDialog.data = {force: false, short_id: channel.short_id, ...channel.point}
|
||||||
|
},
|
||||||
|
closeChannel: function () {
|
||||||
|
this.api('DELETE', '/channels', {query: this.closeChannelDialog.data})
|
||||||
|
.then(response => {
|
||||||
|
this.closeChannelDialog.show = false
|
||||||
|
this.getChannels()
|
||||||
|
})
|
||||||
|
},
|
||||||
|
showOpenChannelDialog: function (peer_id) {
|
||||||
|
this.openChannelDialog.show = true
|
||||||
|
this.openChannelDialog.data = {peer_id, funding_amount: 0}
|
||||||
|
},
|
||||||
|
showNodeInfoDialog: function (node) {
|
||||||
|
this.nodeInfoDialog.show = true
|
||||||
|
this.nodeInfoDialog.data = node
|
||||||
|
},
|
||||||
|
showTransactionDetailsDialog: function (details) {
|
||||||
|
this.transactionDetailsDialog.show = true
|
||||||
|
this.transactionDetailsDialog.data = details
|
||||||
|
console.log('details', details)
|
||||||
|
},
|
||||||
|
exportCSV: function () {
|
||||||
|
|
||||||
|
},
|
||||||
|
shortenNodeId
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
{% extends "public.html" %} {% from "macros.jinja" import window_vars with
|
||||||
|
context %} {% block page %}
|
||||||
|
|
||||||
|
<div class="q-ma-lg-xl q-mx-auto q-ma-xl" style="max-width: 1048px">
|
||||||
|
<lnbits-node-info :info="this.info"></lnbits-node-info>
|
||||||
|
|
||||||
|
<div class="row q-col-gutter-lg q-mt-sm">
|
||||||
|
<div class="col-12 col-md-8 q-gutter-y-md">
|
||||||
|
<div class="row q-col-gutter-md q-pb-lg">
|
||||||
|
<div class="col-12 col-md-6 q-gutter-y-md">
|
||||||
|
<lnbits-stat
|
||||||
|
title="Total Capacity"
|
||||||
|
:msat="this.channel_stats.total_capacity"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 col-md-6 q-gutter-y-md">
|
||||||
|
<lnbits-stat title="Peers" :amount="this.info.num_peers" />
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-6 q-gutter-y-md">
|
||||||
|
<lnbits-stat
|
||||||
|
:title="$t('avg_channel_size')"
|
||||||
|
:msat="this.channel_stats.avg_size"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-6 q-gutter-y-md">
|
||||||
|
<lnbits-stat
|
||||||
|
:title="$t('biggest_channel_size')"
|
||||||
|
:msat="this.channel_stats.biggest_size"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-6 q-gutter-y-md">
|
||||||
|
<lnbits-stat
|
||||||
|
:title="$t('smallest_channel_size')"
|
||||||
|
:msat="this.channel_stats.smallest_size"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-6 q-gutter-y-md">
|
||||||
|
<lnbits-stat
|
||||||
|
:title="$t('smallest_channel_size')"
|
||||||
|
:msat="this.channel_stats.smallest_size"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="column col-12 col-md-4 q-gutter-y-md">
|
||||||
|
<lnbits-node-ranks :ranks="this.ranks"></lnbits-node-ranks>
|
||||||
|
<lnbits-channel-stats :stats="this.channel_stats"></lnbits-channel-stats>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %} {% block scripts %} {{ window_vars(user) }}
|
||||||
|
<script src="/core/static/js/node.js"></script>
|
||||||
|
<script>
|
||||||
|
Vue.component(VueQrcode.name, VueQrcode)
|
||||||
|
Vue.use(VueQrcodeReader)
|
||||||
|
|
||||||
|
{% raw %}
|
||||||
|
|
||||||
|
{% endraw %}
|
||||||
|
|
||||||
|
|
||||||
|
new Vue({
|
||||||
|
el: '#vue',
|
||||||
|
config: {
|
||||||
|
globalProperties: {
|
||||||
|
LNbits,
|
||||||
|
msg: 'hello'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mixins: [windowMixin],
|
||||||
|
data: function () {
|
||||||
|
return {
|
||||||
|
isSuperUser: false,
|
||||||
|
wallet: {},
|
||||||
|
tab: 'dashboard',
|
||||||
|
payments: 1000,
|
||||||
|
info: {},
|
||||||
|
channel_stats: {},
|
||||||
|
channels: [],
|
||||||
|
activeBalance: {},
|
||||||
|
ranks: {},
|
||||||
|
|
||||||
|
peers: [],
|
||||||
|
|
||||||
|
connectPeerDialog: {
|
||||||
|
show: false,
|
||||||
|
data: {}
|
||||||
|
},
|
||||||
|
|
||||||
|
openChannelDialog: {
|
||||||
|
show: false,
|
||||||
|
data: {}
|
||||||
|
},
|
||||||
|
|
||||||
|
closeChannelDialog: {
|
||||||
|
show: false,
|
||||||
|
data: {}
|
||||||
|
},
|
||||||
|
|
||||||
|
nodeInfoDialog: {
|
||||||
|
show: false,
|
||||||
|
data: {}
|
||||||
|
},
|
||||||
|
|
||||||
|
states: [
|
||||||
|
{label: 'Active', value: 'active', color: 'green'},
|
||||||
|
{label: 'Pending', value: 'pending', color: 'orange'},
|
||||||
|
{label: 'Inactive', value: 'inactive', color: 'grey'},
|
||||||
|
{label: 'Closed', value: 'closed', color: 'red'}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created: function () {
|
||||||
|
this.getInfo()
|
||||||
|
this.get1MLStats()
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
formatMsat: function (msat) {
|
||||||
|
return LNbits.utils.formatMsat(msat)
|
||||||
|
},
|
||||||
|
api: function (method, url, data) {
|
||||||
|
return LNbits.api.request(method, '/node/public/api/v1' + url, {}, data)
|
||||||
|
},
|
||||||
|
getInfo: function () {
|
||||||
|
this.api('GET', '/info', {})
|
||||||
|
.then(response => {
|
||||||
|
this.info = response.data
|
||||||
|
this.channel_stats = response.data.channel_stats
|
||||||
|
})
|
||||||
|
},
|
||||||
|
get1MLStats: function () {
|
||||||
|
this.api('GET', '/rank', {})
|
||||||
|
.then(response => {
|
||||||
|
this.ranks = response.data
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -6,7 +6,7 @@ from subprocess import Popen
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from fastapi import Depends
|
from fastapi import APIRouter, Depends
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from starlette.exceptions import HTTPException
|
from starlette.exceptions import HTTPException
|
||||||
|
|
||||||
@@ -19,13 +19,15 @@ from lnbits.core.services import (
|
|||||||
)
|
)
|
||||||
from lnbits.decorators import check_admin, check_super_user
|
from lnbits.decorators import check_admin, check_super_user
|
||||||
from lnbits.server import server_restart
|
from lnbits.server import server_restart
|
||||||
from lnbits.settings import AdminSettings, settings
|
from lnbits.settings import AdminSettings, UpdateSettings, settings
|
||||||
|
|
||||||
from .. import core_app, core_app_extra
|
from .. import core_app_extra
|
||||||
from ..crud import delete_admin_settings, get_admin_settings, update_admin_settings
|
from ..crud import delete_admin_settings, get_admin_settings, update_admin_settings
|
||||||
|
|
||||||
|
admin_router = APIRouter()
|
||||||
|
|
||||||
@core_app.get(
|
|
||||||
|
@admin_router.get(
|
||||||
"/admin/api/v1/audit",
|
"/admin/api/v1/audit",
|
||||||
name="Audit",
|
name="Audit",
|
||||||
description="show the current balance of the node and the LNbits database",
|
description="show the current balance of the node and the LNbits database",
|
||||||
@@ -46,7 +48,7 @@ async def api_auditor():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@core_app.get("/admin/api/v1/settings/")
|
@admin_router.get("/admin/api/v1/settings/", response_model=Optional[AdminSettings])
|
||||||
async def api_get_settings(
|
async def api_get_settings(
|
||||||
user: User = Depends(check_admin),
|
user: User = Depends(check_admin),
|
||||||
) -> Optional[AdminSettings]:
|
) -> Optional[AdminSettings]:
|
||||||
@@ -54,11 +56,11 @@ async def api_get_settings(
|
|||||||
return admin_settings
|
return admin_settings
|
||||||
|
|
||||||
|
|
||||||
@core_app.put(
|
@admin_router.put(
|
||||||
"/admin/api/v1/settings/",
|
"/admin/api/v1/settings/",
|
||||||
status_code=HTTPStatus.OK,
|
status_code=HTTPStatus.OK,
|
||||||
)
|
)
|
||||||
async def api_update_settings(data: dict, user: User = Depends(check_admin)):
|
async def api_update_settings(data: UpdateSettings, user: User = Depends(check_admin)):
|
||||||
await update_admin_settings(data)
|
await update_admin_settings(data)
|
||||||
admin_settings = await get_admin_settings(user.super_user)
|
admin_settings = await get_admin_settings(user.super_user)
|
||||||
assert admin_settings, "Updated admin settings not found."
|
assert admin_settings, "Updated admin settings not found."
|
||||||
@@ -67,7 +69,7 @@ async def api_update_settings(data: dict, user: User = Depends(check_admin)):
|
|||||||
return {"status": "Success"}
|
return {"status": "Success"}
|
||||||
|
|
||||||
|
|
||||||
@core_app.delete(
|
@admin_router.delete(
|
||||||
"/admin/api/v1/settings/",
|
"/admin/api/v1/settings/",
|
||||||
status_code=HTTPStatus.OK,
|
status_code=HTTPStatus.OK,
|
||||||
dependencies=[Depends(check_super_user)],
|
dependencies=[Depends(check_super_user)],
|
||||||
@@ -77,7 +79,7 @@ async def api_delete_settings() -> None:
|
|||||||
server_restart.set()
|
server_restart.set()
|
||||||
|
|
||||||
|
|
||||||
@core_app.get(
|
@admin_router.get(
|
||||||
"/admin/api/v1/restart/",
|
"/admin/api/v1/restart/",
|
||||||
status_code=HTTPStatus.OK,
|
status_code=HTTPStatus.OK,
|
||||||
dependencies=[Depends(check_super_user)],
|
dependencies=[Depends(check_super_user)],
|
||||||
@@ -87,7 +89,7 @@ async def api_restart_server() -> dict[str, str]:
|
|||||||
return {"status": "Success"}
|
return {"status": "Success"}
|
||||||
|
|
||||||
|
|
||||||
@core_app.put(
|
@admin_router.put(
|
||||||
"/admin/api/v1/topup/",
|
"/admin/api/v1/topup/",
|
||||||
name="Topup",
|
name="Topup",
|
||||||
status_code=HTTPStatus.OK,
|
status_code=HTTPStatus.OK,
|
||||||
@@ -111,7 +113,7 @@ async def api_topup_balance(data: CreateTopup) -> dict[str, str]:
|
|||||||
return {"status": "Success"}
|
return {"status": "Success"}
|
||||||
|
|
||||||
|
|
||||||
@core_app.get(
|
@admin_router.get(
|
||||||
"/admin/api/v1/backup/",
|
"/admin/api/v1/backup/",
|
||||||
status_code=HTTPStatus.OK,
|
status_code=HTTPStatus.OK,
|
||||||
dependencies=[Depends(check_super_user)],
|
dependencies=[Depends(check_super_user)],
|
||||||
|
|||||||
+102
-88
@@ -11,20 +11,22 @@ from urllib.parse import ParseResult, parse_qs, unquote, urlencode, urlparse, ur
|
|||||||
import httpx
|
import httpx
|
||||||
import pyqrcode
|
import pyqrcode
|
||||||
from fastapi import (
|
from fastapi import (
|
||||||
|
APIRouter,
|
||||||
Body,
|
Body,
|
||||||
Depends,
|
Depends,
|
||||||
Header,
|
Header,
|
||||||
Request,
|
Request,
|
||||||
Response,
|
|
||||||
WebSocket,
|
WebSocket,
|
||||||
WebSocketDisconnect,
|
WebSocketDisconnect,
|
||||||
)
|
)
|
||||||
from fastapi.exceptions import HTTPException
|
from fastapi.exceptions import HTTPException
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from sse_starlette.sse import EventSourceResponse
|
from sse_starlette.sse import EventSourceResponse
|
||||||
from starlette.responses import RedirectResponse, StreamingResponse
|
from starlette.responses import RedirectResponse, StreamingResponse
|
||||||
|
|
||||||
from lnbits import bolt11, lnurl
|
from lnbits import bolt11, lnurl
|
||||||
|
from lnbits.core.db import core_app_extra, db
|
||||||
from lnbits.core.helpers import (
|
from lnbits.core.helpers import (
|
||||||
migrate_extension_database,
|
migrate_extension_database,
|
||||||
stop_extension_background_work,
|
stop_extension_background_work,
|
||||||
@@ -34,10 +36,13 @@ from lnbits.core.models import (
|
|||||||
CreateInvoice,
|
CreateInvoice,
|
||||||
CreateLnurl,
|
CreateLnurl,
|
||||||
CreateLnurlAuth,
|
CreateLnurlAuth,
|
||||||
|
CreateWallet,
|
||||||
CreateWebPushSubscription,
|
CreateWebPushSubscription,
|
||||||
DecodePayment,
|
DecodePayment,
|
||||||
Payment,
|
Payment,
|
||||||
PaymentFilters,
|
PaymentFilters,
|
||||||
|
PaymentHistoryPoint,
|
||||||
|
Query,
|
||||||
User,
|
User,
|
||||||
Wallet,
|
Wallet,
|
||||||
WalletType,
|
WalletType,
|
||||||
@@ -68,18 +73,22 @@ from lnbits.utils.exchange_rates import (
|
|||||||
satoshis_amount_as_fiat,
|
satoshis_amount_as_fiat,
|
||||||
)
|
)
|
||||||
|
|
||||||
from .. import core_app, core_app_extra, db
|
|
||||||
from ..crud import (
|
from ..crud import (
|
||||||
|
DateTrunc,
|
||||||
add_installed_extension,
|
add_installed_extension,
|
||||||
|
create_account,
|
||||||
create_tinyurl,
|
create_tinyurl,
|
||||||
|
create_wallet,
|
||||||
create_webpush_subscription,
|
create_webpush_subscription,
|
||||||
delete_dbversion,
|
delete_dbversion,
|
||||||
delete_installed_extension,
|
delete_installed_extension,
|
||||||
delete_tinyurl,
|
delete_tinyurl,
|
||||||
|
delete_wallet,
|
||||||
delete_webpush_subscription,
|
delete_webpush_subscription,
|
||||||
drop_extension_db,
|
drop_extension_db,
|
||||||
get_dbversions,
|
get_dbversions,
|
||||||
get_payments,
|
get_payments,
|
||||||
|
get_payments_history,
|
||||||
get_payments_paginated,
|
get_payments_paginated,
|
||||||
get_standalone_payment,
|
get_standalone_payment,
|
||||||
get_tinyurl,
|
get_tinyurl,
|
||||||
@@ -87,6 +96,7 @@ from ..crud import (
|
|||||||
get_wallet_for_key,
|
get_wallet_for_key,
|
||||||
get_webpush_subscription,
|
get_webpush_subscription,
|
||||||
save_balance_check,
|
save_balance_check,
|
||||||
|
update_pending_payments,
|
||||||
update_wallet,
|
update_wallet,
|
||||||
)
|
)
|
||||||
from ..services import (
|
from ..services import (
|
||||||
@@ -101,13 +111,15 @@ from ..services import (
|
|||||||
)
|
)
|
||||||
from ..tasks import api_invoice_listeners
|
from ..tasks import api_invoice_listeners
|
||||||
|
|
||||||
|
api_router = APIRouter()
|
||||||
|
|
||||||
@core_app.get("/api/v1/health", status_code=HTTPStatus.OK)
|
|
||||||
|
@api_router.get("/api/v1/health", status_code=HTTPStatus.OK)
|
||||||
async def health():
|
async def health():
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
||||||
@core_app.get("/api/v1/wallet")
|
@api_router.get("/api/v1/wallet")
|
||||||
async def api_wallet(wallet: WalletTypeInfo = Depends(get_key_type)):
|
async def api_wallet(wallet: WalletTypeInfo = Depends(get_key_type)):
|
||||||
if wallet.wallet_type == WalletType.admin:
|
if wallet.wallet_type == WalletType.admin:
|
||||||
return {
|
return {
|
||||||
@@ -119,7 +131,7 @@ async def api_wallet(wallet: WalletTypeInfo = Depends(get_key_type)):
|
|||||||
return {"name": wallet.wallet.name, "balance": wallet.wallet.balance_msat}
|
return {"name": wallet.wallet.name, "balance": wallet.wallet.balance_msat}
|
||||||
|
|
||||||
|
|
||||||
@core_app.put("/api/v1/wallet/{new_name}")
|
@api_router.put("/api/v1/wallet/{new_name}")
|
||||||
async def api_update_wallet_name(
|
async def api_update_wallet_name(
|
||||||
new_name: str, wallet: WalletTypeInfo = Depends(require_admin_key)
|
new_name: str, wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||||
):
|
):
|
||||||
@@ -131,7 +143,7 @@ async def api_update_wallet_name(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@core_app.patch("/api/v1/wallet", response_model=Wallet)
|
@api_router.patch("/api/v1/wallet", response_model=Wallet)
|
||||||
async def api_update_wallet(
|
async def api_update_wallet(
|
||||||
name: Optional[str] = Body(None),
|
name: Optional[str] = Body(None),
|
||||||
currency: Optional[str] = Body(None),
|
currency: Optional[str] = Body(None),
|
||||||
@@ -140,7 +152,36 @@ async def api_update_wallet(
|
|||||||
return await update_wallet(wallet.wallet.id, name, currency)
|
return await update_wallet(wallet.wallet.id, name, currency)
|
||||||
|
|
||||||
|
|
||||||
@core_app.get(
|
@api_router.delete("/api/v1/wallet")
|
||||||
|
async def api_delete_wallet(
|
||||||
|
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||||
|
) -> None:
|
||||||
|
await delete_wallet(
|
||||||
|
user_id=wallet.wallet.user,
|
||||||
|
wallet_id=wallet.wallet.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@api_router.post("/api/v1/wallet", response_model=Wallet)
|
||||||
|
async def api_create_wallet(
|
||||||
|
data: CreateWallet,
|
||||||
|
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||||
|
) -> Wallet:
|
||||||
|
return await create_wallet(user_id=wallet.wallet.user, wallet_name=data.name)
|
||||||
|
|
||||||
|
|
||||||
|
@api_router.post("/api/v1/account", response_model=Wallet)
|
||||||
|
async def api_create_account(data: CreateWallet) -> Wallet:
|
||||||
|
if len(settings.lnbits_allowed_users) > 0:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
|
detail="Account creation is disabled.",
|
||||||
|
)
|
||||||
|
account = await create_account()
|
||||||
|
return await create_wallet(user_id=account.id, wallet_name=data.name)
|
||||||
|
|
||||||
|
|
||||||
|
@api_router.get(
|
||||||
"/api/v1/payments",
|
"/api/v1/payments",
|
||||||
name="Payment List",
|
name="Payment List",
|
||||||
summary="get list of payments",
|
summary="get list of payments",
|
||||||
@@ -152,16 +193,7 @@ async def api_payments(
|
|||||||
wallet: WalletTypeInfo = Depends(get_key_type),
|
wallet: WalletTypeInfo = Depends(get_key_type),
|
||||||
filters: Filters = Depends(parse_filters(PaymentFilters)),
|
filters: Filters = Depends(parse_filters(PaymentFilters)),
|
||||||
):
|
):
|
||||||
pending_payments = await get_payments(
|
await update_pending_payments(wallet.wallet.id)
|
||||||
wallet_id=wallet.wallet.id,
|
|
||||||
pending=True,
|
|
||||||
exclude_uncheckable=True,
|
|
||||||
filters=filters,
|
|
||||||
)
|
|
||||||
for payment in pending_payments:
|
|
||||||
await check_transaction_status(
|
|
||||||
wallet_id=payment.wallet_id, payment_hash=payment.payment_hash
|
|
||||||
)
|
|
||||||
return await get_payments(
|
return await get_payments(
|
||||||
wallet_id=wallet.wallet.id,
|
wallet_id=wallet.wallet.id,
|
||||||
pending=True,
|
pending=True,
|
||||||
@@ -170,7 +202,22 @@ async def api_payments(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@core_app.get(
|
@api_router.get(
|
||||||
|
"/api/v1/payments/history",
|
||||||
|
name="Get payments history",
|
||||||
|
response_model=List[PaymentHistoryPoint],
|
||||||
|
openapi_extra=generate_filter_params_openapi(PaymentFilters),
|
||||||
|
)
|
||||||
|
async def api_payments_history(
|
||||||
|
wallet: WalletTypeInfo = Depends(get_key_type),
|
||||||
|
group: DateTrunc = Query("day"),
|
||||||
|
filters: Filters[PaymentFilters] = Depends(parse_filters(PaymentFilters)),
|
||||||
|
):
|
||||||
|
await update_pending_payments(wallet.wallet.id)
|
||||||
|
return await get_payments_history(wallet.wallet.id, group, filters)
|
||||||
|
|
||||||
|
|
||||||
|
@api_router.get(
|
||||||
"/api/v1/payments/paginated",
|
"/api/v1/payments/paginated",
|
||||||
name="Payment List",
|
name="Payment List",
|
||||||
summary="get paginated list of payments",
|
summary="get paginated list of payments",
|
||||||
@@ -182,16 +229,7 @@ async def api_payments_paginated(
|
|||||||
wallet: WalletTypeInfo = Depends(get_key_type),
|
wallet: WalletTypeInfo = Depends(get_key_type),
|
||||||
filters: Filters = Depends(parse_filters(PaymentFilters)),
|
filters: Filters = Depends(parse_filters(PaymentFilters)),
|
||||||
):
|
):
|
||||||
pending = await get_payments_paginated(
|
await update_pending_payments(wallet.wallet.id)
|
||||||
wallet_id=wallet.wallet.id,
|
|
||||||
pending=True,
|
|
||||||
exclude_uncheckable=True,
|
|
||||||
filters=filters,
|
|
||||||
)
|
|
||||||
for payment in pending.data:
|
|
||||||
await check_transaction_status(
|
|
||||||
wallet_id=payment.wallet_id, payment_hash=payment.payment_hash
|
|
||||||
)
|
|
||||||
page = await get_payments_paginated(
|
page = await get_payments_paginated(
|
||||||
wallet_id=wallet.wallet.id,
|
wallet_id=wallet.wallet.id,
|
||||||
pending=True,
|
pending=True,
|
||||||
@@ -312,7 +350,7 @@ async def api_payments_pay_invoice(bolt11: str, wallet: Wallet):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@core_app.post(
|
@api_router.post(
|
||||||
"/api/v1/payments",
|
"/api/v1/payments",
|
||||||
summary="Create or pay an invoice",
|
summary="Create or pay an invoice",
|
||||||
description="""
|
description="""
|
||||||
@@ -348,7 +386,7 @@ async def api_payments_create(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@core_app.post("/api/v1/payments/lnurl")
|
@api_router.post("/api/v1/payments/lnurl")
|
||||||
async def api_payments_pay_lnurl(
|
async def api_payments_pay_lnurl(
|
||||||
data: CreateLnurl, wallet: WalletTypeInfo = Depends(require_admin_key)
|
data: CreateLnurl, wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||||
):
|
):
|
||||||
@@ -394,17 +432,6 @@ async def api_payments_pay_lnurl(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
if invoice.description_hash != data.description_hash:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=HTTPStatus.BAD_REQUEST,
|
|
||||||
detail=(
|
|
||||||
(
|
|
||||||
f"{domain} returned an invalid invoice. Expected description_hash"
|
|
||||||
f" == {data.description_hash}, got {invoice.description_hash}."
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
extra = {}
|
extra = {}
|
||||||
|
|
||||||
if params.get("successAction"):
|
if params.get("successAction"):
|
||||||
@@ -451,11 +478,13 @@ async def subscribe_wallet_invoices(request: Request, wallet: Wallet):
|
|||||||
yield dict(data=payment.json(), event="payment-received")
|
yield dict(data=payment.json(), event="payment-received")
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
logger.debug(f"removing listener for wallet {uid}")
|
logger.debug(f"removing listener for wallet {uid}")
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(f"Error in sse: {exc}")
|
||||||
|
finally:
|
||||||
api_invoice_listeners.pop(uid)
|
api_invoice_listeners.pop(uid)
|
||||||
return
|
|
||||||
|
|
||||||
|
|
||||||
@core_app.get("/api/v1/payments/sse")
|
@api_router.get("/api/v1/payments/sse")
|
||||||
async def api_payments_sse(
|
async def api_payments_sse(
|
||||||
request: Request, wallet: WalletTypeInfo = Depends(get_key_type)
|
request: Request, wallet: WalletTypeInfo = Depends(get_key_type)
|
||||||
):
|
):
|
||||||
@@ -467,7 +496,7 @@ async def api_payments_sse(
|
|||||||
|
|
||||||
|
|
||||||
# TODO: refactor this route into a public and admin one
|
# TODO: refactor this route into a public and admin one
|
||||||
@core_app.get("/api/v1/payments/{payment_hash}")
|
@api_router.get("/api/v1/payments/{payment_hash}")
|
||||||
async def api_payment(payment_hash, X_Api_Key: Optional[str] = Header(None)):
|
async def api_payment(payment_hash, X_Api_Key: Optional[str] = Header(None)):
|
||||||
# We use X_Api_Key here because we want this call to work with and without keys
|
# We use X_Api_Key here because we want this call to work with and without keys
|
||||||
# If a valid key is given, we also return the field "details", otherwise not
|
# If a valid key is given, we also return the field "details", otherwise not
|
||||||
@@ -512,7 +541,7 @@ async def api_payment(payment_hash, X_Api_Key: Optional[str] = Header(None)):
|
|||||||
return {"paid": not payment.pending, "preimage": payment.preimage}
|
return {"paid": not payment.pending, "preimage": payment.preimage}
|
||||||
|
|
||||||
|
|
||||||
@core_app.get("/api/v1/lnurlscan/{code}")
|
@api_router.get("/api/v1/lnurlscan/{code}")
|
||||||
async def api_lnurlscan(code: str, wallet: WalletTypeInfo = Depends(get_key_type)):
|
async def api_lnurlscan(code: str, wallet: WalletTypeInfo = Depends(get_key_type)):
|
||||||
try:
|
try:
|
||||||
url = lnurl.decode(code)
|
url = lnurl.decode(code)
|
||||||
@@ -627,33 +656,24 @@ async def api_lnurlscan(code: str, wallet: WalletTypeInfo = Depends(get_key_type
|
|||||||
return params
|
return params
|
||||||
|
|
||||||
|
|
||||||
@core_app.post("/api/v1/payments/decode", status_code=HTTPStatus.OK)
|
@api_router.post("/api/v1/payments/decode", status_code=HTTPStatus.OK)
|
||||||
async def api_payments_decode(data: DecodePayment, response: Response):
|
async def api_payments_decode(data: DecodePayment) -> JSONResponse:
|
||||||
payment_str = data.data
|
payment_str = data.data
|
||||||
try:
|
try:
|
||||||
if payment_str[:5] == "LNURL":
|
if payment_str[:5] == "LNURL":
|
||||||
url = lnurl.decode(payment_str)
|
url = lnurl.decode(payment_str)
|
||||||
return {"domain": url}
|
return JSONResponse({"domain": url})
|
||||||
else:
|
else:
|
||||||
invoice = bolt11.decode(payment_str)
|
invoice = bolt11.decode(payment_str)
|
||||||
return {
|
return JSONResponse(invoice.data)
|
||||||
"payment_hash": invoice.payment_hash,
|
except Exception as exc:
|
||||||
"amount_msat": invoice.amount_msat,
|
return JSONResponse(
|
||||||
"description": invoice.description,
|
{"message": f"Failed to decode: {str(exc)}"},
|
||||||
"description_hash": invoice.description_hash,
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
"payee": invoice.payee,
|
)
|
||||||
"date": invoice.date,
|
|
||||||
"expiry": invoice.expiry,
|
|
||||||
"secret": invoice.secret,
|
|
||||||
"route_hints": invoice.route_hints,
|
|
||||||
"min_final_cltv_expiry": invoice.min_final_cltv_expiry,
|
|
||||||
}
|
|
||||||
except Exception:
|
|
||||||
response.status_code = HTTPStatus.BAD_REQUEST
|
|
||||||
return {"message": "Failed to decode"}
|
|
||||||
|
|
||||||
|
|
||||||
@core_app.post("/api/v1/lnurlauth")
|
@api_router.post("/api/v1/lnurlauth")
|
||||||
async def api_perform_lnurlauth(
|
async def api_perform_lnurlauth(
|
||||||
data: CreateLnurlAuth, wallet: WalletTypeInfo = Depends(require_admin_key)
|
data: CreateLnurlAuth, wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||||
):
|
):
|
||||||
@@ -665,7 +685,7 @@ async def api_perform_lnurlauth(
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
@core_app.get("/api/v1/currencies")
|
@api_router.get("/api/v1/currencies")
|
||||||
async def api_list_currencies_available():
|
async def api_list_currencies_available():
|
||||||
if len(settings.lnbits_allowed_currencies) > 0:
|
if len(settings.lnbits_allowed_currencies) > 0:
|
||||||
return [
|
return [
|
||||||
@@ -676,7 +696,7 @@ async def api_list_currencies_available():
|
|||||||
return list(currencies.keys())
|
return list(currencies.keys())
|
||||||
|
|
||||||
|
|
||||||
@core_app.post("/api/v1/conversion")
|
@api_router.post("/api/v1/conversion")
|
||||||
async def api_fiat_as_sats(data: ConversionData):
|
async def api_fiat_as_sats(data: ConversionData):
|
||||||
output = {}
|
output = {}
|
||||||
if data.from_ == "sat":
|
if data.from_ == "sat":
|
||||||
@@ -694,7 +714,7 @@ async def api_fiat_as_sats(data: ConversionData):
|
|||||||
return output
|
return output
|
||||||
|
|
||||||
|
|
||||||
@core_app.get("/api/v1/qrcode/{data}", response_class=StreamingResponse)
|
@api_router.get("/api/v1/qrcode/{data}", response_class=StreamingResponse)
|
||||||
async def img(data):
|
async def img(data):
|
||||||
qr = pyqrcode.create(data)
|
qr = pyqrcode.create(data)
|
||||||
stream = BytesIO()
|
stream = BytesIO()
|
||||||
@@ -715,7 +735,7 @@ async def img(data):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@core_app.websocket("/api/v1/ws/{item_id}")
|
@api_router.websocket("/api/v1/ws/{item_id}")
|
||||||
async def websocket_connect(websocket: WebSocket, item_id: str):
|
async def websocket_connect(websocket: WebSocket, item_id: str):
|
||||||
await websocketManager.connect(websocket, item_id)
|
await websocketManager.connect(websocket, item_id)
|
||||||
try:
|
try:
|
||||||
@@ -725,7 +745,7 @@ async def websocket_connect(websocket: WebSocket, item_id: str):
|
|||||||
websocketManager.disconnect(websocket)
|
websocketManager.disconnect(websocket)
|
||||||
|
|
||||||
|
|
||||||
@core_app.post("/api/v1/ws/{item_id}")
|
@api_router.post("/api/v1/ws/{item_id}")
|
||||||
async def websocket_update_post(item_id: str, data: str):
|
async def websocket_update_post(item_id: str, data: str):
|
||||||
try:
|
try:
|
||||||
await websocketUpdater(item_id, data)
|
await websocketUpdater(item_id, data)
|
||||||
@@ -734,7 +754,7 @@ async def websocket_update_post(item_id: str, data: str):
|
|||||||
return {"sent": False, "data": data}
|
return {"sent": False, "data": data}
|
||||||
|
|
||||||
|
|
||||||
@core_app.get("/api/v1/ws/{item_id}/{data}")
|
@api_router.get("/api/v1/ws/{item_id}/{data}")
|
||||||
async def websocket_update_get(item_id: str, data: str):
|
async def websocket_update_get(item_id: str, data: str):
|
||||||
try:
|
try:
|
||||||
await websocketUpdater(item_id, data)
|
await websocketUpdater(item_id, data)
|
||||||
@@ -743,7 +763,7 @@ async def websocket_update_get(item_id: str, data: str):
|
|||||||
return {"sent": False, "data": data}
|
return {"sent": False, "data": data}
|
||||||
|
|
||||||
|
|
||||||
@core_app.post("/api/v1/extension")
|
@api_router.post("/api/v1/extension")
|
||||||
async def api_install_extension(
|
async def api_install_extension(
|
||||||
data: CreateExtension, user: User = Depends(check_admin)
|
data: CreateExtension, user: User = Depends(check_admin)
|
||||||
):
|
):
|
||||||
@@ -802,7 +822,7 @@ async def api_install_extension(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@core_app.delete("/api/v1/extension/{ext_id}")
|
@api_router.delete("/api/v1/extension/{ext_id}")
|
||||||
async def api_uninstall_extension(ext_id: str, user: User = Depends(check_admin)):
|
async def api_uninstall_extension(ext_id: str, user: User = Depends(check_admin)):
|
||||||
installable_extensions = await InstallableExtension.get_installable_extensions()
|
installable_extensions = await InstallableExtension.get_installable_extensions()
|
||||||
|
|
||||||
@@ -845,7 +865,7 @@ async def api_uninstall_extension(ext_id: str, user: User = Depends(check_admin)
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@core_app.get(
|
@api_router.get(
|
||||||
"/api/v1/extension/{ext_id}/releases", dependencies=[Depends(check_admin)]
|
"/api/v1/extension/{ext_id}/releases", dependencies=[Depends(check_admin)]
|
||||||
)
|
)
|
||||||
async def get_extension_releases(ext_id: str):
|
async def get_extension_releases(ext_id: str):
|
||||||
@@ -862,7 +882,7 @@ async def get_extension_releases(ext_id: str):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@core_app.get(
|
@api_router.get(
|
||||||
"/api/v1/extension/release/{org}/{repo}/{tag_name}",
|
"/api/v1/extension/release/{org}/{repo}/{tag_name}",
|
||||||
dependencies=[Depends(check_admin)],
|
dependencies=[Depends(check_admin)],
|
||||||
)
|
)
|
||||||
@@ -883,7 +903,7 @@ async def get_extension_release(org: str, repo: str, tag_name: str):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@core_app.delete(
|
@api_router.delete(
|
||||||
"/api/v1/extension/{ext_id}/db",
|
"/api/v1/extension/{ext_id}/db",
|
||||||
dependencies=[Depends(check_admin)],
|
dependencies=[Depends(check_admin)],
|
||||||
)
|
)
|
||||||
@@ -909,10 +929,7 @@ async def delete_extension_db(ext_id: str):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# TINYURL
|
@api_router.post(
|
||||||
|
|
||||||
|
|
||||||
@core_app.post(
|
|
||||||
"/api/v1/tinyurl",
|
"/api/v1/tinyurl",
|
||||||
name="Tinyurl",
|
name="Tinyurl",
|
||||||
description="creates a tinyurl",
|
description="creates a tinyurl",
|
||||||
@@ -933,7 +950,7 @@ async def api_create_tinyurl(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@core_app.get(
|
@api_router.get(
|
||||||
"/api/v1/tinyurl/{tinyurl_id}",
|
"/api/v1/tinyurl/{tinyurl_id}",
|
||||||
name="Tinyurl",
|
name="Tinyurl",
|
||||||
description="get a tinyurl by id",
|
description="get a tinyurl by id",
|
||||||
@@ -955,7 +972,7 @@ async def api_get_tinyurl(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@core_app.delete(
|
@api_router.delete(
|
||||||
"/api/v1/tinyurl/{tinyurl_id}",
|
"/api/v1/tinyurl/{tinyurl_id}",
|
||||||
name="Tinyurl",
|
name="Tinyurl",
|
||||||
description="delete a tinyurl by id",
|
description="delete a tinyurl by id",
|
||||||
@@ -978,7 +995,7 @@ async def api_delete_tinyurl(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@core_app.get(
|
@api_router.get(
|
||||||
"/t/{tinyurl_id}",
|
"/t/{tinyurl_id}",
|
||||||
name="Tinyurl",
|
name="Tinyurl",
|
||||||
description="redirects a tinyurl by id",
|
description="redirects a tinyurl by id",
|
||||||
@@ -994,10 +1011,7 @@ async def api_tinyurl(tinyurl_id: str):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
############################WEBPUSH##################################
|
@api_router.post("/api/v1/webpush", status_code=HTTPStatus.CREATED)
|
||||||
|
|
||||||
|
|
||||||
@core_app.post("/api/v1/webpush", status_code=HTTPStatus.CREATED)
|
|
||||||
async def api_create_webpush_subscription(
|
async def api_create_webpush_subscription(
|
||||||
request: Request,
|
request: Request,
|
||||||
data: CreateWebPushSubscription,
|
data: CreateWebPushSubscription,
|
||||||
@@ -1019,12 +1033,12 @@ async def api_create_webpush_subscription(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@core_app.delete("/api/v1/webpush", status_code=HTTPStatus.OK)
|
@api_router.delete("/api/v1/webpush", status_code=HTTPStatus.OK)
|
||||||
async def api_delete_webpush_subscription(
|
async def api_delete_webpush_subscription(
|
||||||
request: Request,
|
request: Request,
|
||||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||||
):
|
):
|
||||||
endpoint = unquote(
|
endpoint = unquote(
|
||||||
base64.b64decode(request.query_params.get("endpoint")).decode("utf-8")
|
base64.b64decode(str(request.query_params.get("endpoint"))).decode("utf-8")
|
||||||
)
|
)
|
||||||
await delete_webpush_subscription(endpoint, wallet.wallet.user)
|
await delete_webpush_subscription(endpoint, wallet.wallet.user)
|
||||||
|
|||||||
+120
-109
@@ -5,13 +5,12 @@ from urllib.parse import urlparse
|
|||||||
|
|
||||||
from fastapi import Depends, Query, Request, status
|
from fastapi import Depends, Query, Request, status
|
||||||
from fastapi.exceptions import HTTPException
|
from fastapi.exceptions import HTTPException
|
||||||
from fastapi.responses import FileResponse, RedirectResponse
|
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse
|
||||||
from fastapi.routing import APIRouter
|
from fastapi.routing import APIRouter
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from pydantic.types import UUID4
|
from pydantic.types import UUID4
|
||||||
from starlette.responses import HTMLResponse, JSONResponse
|
|
||||||
|
|
||||||
from lnbits.core import db
|
from lnbits.core.db import db
|
||||||
from lnbits.core.helpers import to_valid_user_id
|
from lnbits.core.helpers import to_valid_user_id
|
||||||
from lnbits.core.models import User
|
from lnbits.core.models import User
|
||||||
from lnbits.decorators import check_admin, check_user_exists
|
from lnbits.decorators import check_admin, check_user_exists
|
||||||
@@ -24,7 +23,6 @@ from ...utils.exchange_rates import currencies
|
|||||||
from ..crud import (
|
from ..crud import (
|
||||||
create_account,
|
create_account,
|
||||||
create_wallet,
|
create_wallet,
|
||||||
delete_wallet,
|
|
||||||
get_balance_check,
|
get_balance_check,
|
||||||
get_dbversions,
|
get_dbversions,
|
||||||
get_inactive_extensions,
|
get_inactive_extensions,
|
||||||
@@ -36,24 +34,24 @@ from ..crud import (
|
|||||||
)
|
)
|
||||||
from ..services import pay_invoice, redeem_lnurl_withdraw
|
from ..services import pay_invoice, redeem_lnurl_withdraw
|
||||||
|
|
||||||
core_html_routes: APIRouter = APIRouter(
|
generic_router = APIRouter(
|
||||||
tags=["Core NON-API Website Routes"], include_in_schema=False
|
tags=["Core NON-API Website Routes"], include_in_schema=False
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@core_html_routes.get("/favicon.ico", response_class=FileResponse)
|
@generic_router.get("/favicon.ico", response_class=FileResponse)
|
||||||
async def favicon():
|
async def favicon():
|
||||||
return FileResponse("lnbits/core/static/favicon.ico")
|
return FileResponse("lnbits/core/static/favicon.ico")
|
||||||
|
|
||||||
|
|
||||||
@core_html_routes.get("/", response_class=HTMLResponse)
|
@generic_router.get("/", response_class=HTMLResponse)
|
||||||
async def home(request: Request, lightning: str = ""):
|
async def home(request: Request, lightning: str = ""):
|
||||||
return template_renderer().TemplateResponse(
|
return template_renderer().TemplateResponse(
|
||||||
"core/index.html", {"request": request, "lnurl": lightning}
|
"core/index.html", {"request": request, "lnurl": lightning}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@core_html_routes.get("/robots.txt", response_class=HTMLResponse)
|
@generic_router.get("/robots.txt", response_class=HTMLResponse)
|
||||||
async def robots():
|
async def robots():
|
||||||
data = """
|
data = """
|
||||||
User-agent: *
|
User-agent: *
|
||||||
@@ -62,7 +60,7 @@ async def robots():
|
|||||||
return HTMLResponse(content=data, media_type="text/plain")
|
return HTMLResponse(content=data, media_type="text/plain")
|
||||||
|
|
||||||
|
|
||||||
@core_html_routes.get(
|
@generic_router.get(
|
||||||
"/extensions", name="install.extensions", response_class=HTMLResponse
|
"/extensions", name="install.extensions", response_class=HTMLResponse
|
||||||
)
|
)
|
||||||
async def extensions_install(
|
async def extensions_install(
|
||||||
@@ -159,80 +157,55 @@ async def extensions_install(
|
|||||||
raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e))
|
raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@core_html_routes.get(
|
@generic_router.get(
|
||||||
"/wallet",
|
"/wallet",
|
||||||
response_class=HTMLResponse,
|
response_class=HTMLResponse,
|
||||||
description="""
|
description="show wallet page",
|
||||||
just **wallet_name**: create a new user, then create a new wallet
|
|
||||||
for user with wallet_name
|
|
||||||
just **user_id**: return the first user wallet or create one if none found
|
|
||||||
(with default wallet_name)
|
|
||||||
**user_id** and **wallet_name**: create a new wallet for user with wallet_name
|
|
||||||
**user_id** and **wallet_id**: return that wallet if user is the owner
|
|
||||||
nothing: create everything
|
|
||||||
""",
|
|
||||||
)
|
)
|
||||||
async def wallet(
|
async def wallet(
|
||||||
request: Request = Query(None),
|
request: Request,
|
||||||
nme: Optional[str] = Query(None),
|
usr: UUID4 = Query(...),
|
||||||
usr: Optional[UUID4] = Query(None),
|
|
||||||
wal: Optional[UUID4] = Query(None),
|
wal: Optional[UUID4] = Query(None),
|
||||||
):
|
):
|
||||||
user_id = usr.hex if usr else None
|
user_id = usr.hex
|
||||||
wallet_id = wal.hex if wal else None
|
user = await get_user(user_id)
|
||||||
wallet_name = nme
|
|
||||||
|
|
||||||
if not user_id:
|
if not user:
|
||||||
new_user = await create_account()
|
return template_renderer().TemplateResponse(
|
||||||
user = await get_user(new_user.id)
|
"error.html", {"request": request, "err": "User does not exist."}
|
||||||
assert user, "Newly created user has to exist."
|
|
||||||
logger.info(f"Create user {user.id}")
|
|
||||||
else:
|
|
||||||
user = await get_user(user_id)
|
|
||||||
if not user:
|
|
||||||
return template_renderer().TemplateResponse(
|
|
||||||
"error.html", {"request": request, "err": "User does not exist."}
|
|
||||||
)
|
|
||||||
if (
|
|
||||||
len(settings.lnbits_allowed_users) > 0
|
|
||||||
and user_id not in settings.lnbits_allowed_users
|
|
||||||
and user_id not in settings.lnbits_admin_users
|
|
||||||
and user_id != settings.super_user
|
|
||||||
):
|
|
||||||
return template_renderer().TemplateResponse(
|
|
||||||
"error.html", {"request": request, "err": "User not authorized."}
|
|
||||||
)
|
|
||||||
if user_id == settings.super_user or user_id in settings.lnbits_admin_users:
|
|
||||||
user.admin = True
|
|
||||||
if user_id == settings.super_user:
|
|
||||||
user.super_user = True
|
|
||||||
|
|
||||||
if not wallet_id:
|
|
||||||
if user.wallets and not wallet_name:
|
|
||||||
wallet = user.wallets[0]
|
|
||||||
else:
|
|
||||||
wallet = await create_wallet(user_id=user.id, wallet_name=wallet_name)
|
|
||||||
logger.info(
|
|
||||||
f"Created new wallet {wallet_name if wallet_name else '(no name)'} for"
|
|
||||||
f" user {user.id}"
|
|
||||||
)
|
|
||||||
|
|
||||||
return RedirectResponse(
|
|
||||||
f"/wallet?usr={user.id}&wal={wallet.id}",
|
|
||||||
status_code=status.HTTP_307_TEMPORARY_REDIRECT,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.debug(
|
if not wal:
|
||||||
"Access "
|
if len(user.wallets) == 0:
|
||||||
f"{'user '+ user.id + ' ' if user else ''} "
|
wallet = await create_wallet(user_id=user.id)
|
||||||
f"{'wallet ' + wallet_name if wallet_name else ''}"
|
return RedirectResponse(url=f"/wallet?usr={user_id}&wal={wallet.id}")
|
||||||
)
|
return RedirectResponse(url=f"/wallet?usr={user_id}&wal={user.wallets[0].id}")
|
||||||
|
else:
|
||||||
|
wallet_id = wal.hex
|
||||||
|
|
||||||
userwallet = user.get_wallet(wallet_id)
|
userwallet = user.get_wallet(wallet_id)
|
||||||
if not userwallet:
|
if not userwallet or userwallet.deleted:
|
||||||
return template_renderer().TemplateResponse(
|
return template_renderer().TemplateResponse(
|
||||||
"error.html", {"request": request, "err": "Wallet not found"}
|
"error.html", {"request": request, "err": "Wallet not found"}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (
|
||||||
|
len(settings.lnbits_allowed_users) > 0
|
||||||
|
and user_id not in settings.lnbits_allowed_users
|
||||||
|
and user_id not in settings.lnbits_admin_users
|
||||||
|
and user_id != settings.super_user
|
||||||
|
):
|
||||||
|
return template_renderer().TemplateResponse(
|
||||||
|
"error.html", {"request": request, "err": "User not authorized."}
|
||||||
|
)
|
||||||
|
|
||||||
|
if user_id == settings.super_user or user_id in settings.lnbits_admin_users:
|
||||||
|
user.admin = True
|
||||||
|
if user_id == settings.super_user:
|
||||||
|
user.super_user = True
|
||||||
|
|
||||||
|
logger.debug(f"Access user {user.id} wallet {userwallet.name}")
|
||||||
|
|
||||||
return template_renderer().TemplateResponse(
|
return template_renderer().TemplateResponse(
|
||||||
"core/wallet.html",
|
"core/wallet.html",
|
||||||
{
|
{
|
||||||
@@ -245,13 +218,21 @@ async def wallet(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@core_html_routes.get("/withdraw", response_class=JSONResponse)
|
@generic_router.get("/withdraw", response_class=JSONResponse)
|
||||||
async def lnurl_full_withdraw(request: Request):
|
async def lnurl_full_withdraw(request: Request):
|
||||||
user = await get_user(request.query_params.get("usr"))
|
usr_param = request.query_params.get("usr")
|
||||||
|
if not usr_param:
|
||||||
|
return {"status": "ERROR", "reason": "usr parameter not provided."}
|
||||||
|
|
||||||
|
user = await get_user(usr_param)
|
||||||
if not user:
|
if not user:
|
||||||
return {"status": "ERROR", "reason": "User does not exist."}
|
return {"status": "ERROR", "reason": "User does not exist."}
|
||||||
|
|
||||||
wallet = user.get_wallet(request.query_params.get("wal"))
|
wal_param = request.query_params.get("wal")
|
||||||
|
if not wal_param:
|
||||||
|
return {"status": "ERROR", "reason": "wal parameter not provided."}
|
||||||
|
|
||||||
|
wallet = user.get_wallet(wal_param)
|
||||||
if not wallet:
|
if not wallet:
|
||||||
return {"status": "ERROR", "reason": "Wallet does not exist."}
|
return {"status": "ERROR", "reason": "Wallet does not exist."}
|
||||||
|
|
||||||
@@ -268,17 +249,27 @@ async def lnurl_full_withdraw(request: Request):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@core_html_routes.get("/withdraw/cb", response_class=JSONResponse)
|
@generic_router.get("/withdraw/cb", response_class=JSONResponse)
|
||||||
async def lnurl_full_withdraw_callback(request: Request):
|
async def lnurl_full_withdraw_callback(request: Request):
|
||||||
user = await get_user(request.query_params.get("usr"))
|
usr_param = request.query_params.get("usr")
|
||||||
|
if not usr_param:
|
||||||
|
return {"status": "ERROR", "reason": "usr parameter not provided."}
|
||||||
|
|
||||||
|
user = await get_user(usr_param)
|
||||||
if not user:
|
if not user:
|
||||||
return {"status": "ERROR", "reason": "User does not exist."}
|
return {"status": "ERROR", "reason": "User does not exist."}
|
||||||
|
|
||||||
wallet = user.get_wallet(request.query_params.get("wal"))
|
wal_param = request.query_params.get("wal")
|
||||||
|
if not wal_param:
|
||||||
|
return {"status": "ERROR", "reason": "wal parameter not provided."}
|
||||||
|
|
||||||
|
wallet = user.get_wallet(wal_param)
|
||||||
if not wallet:
|
if not wallet:
|
||||||
return {"status": "ERROR", "reason": "Wallet does not exist."}
|
return {"status": "ERROR", "reason": "Wallet does not exist."}
|
||||||
|
|
||||||
pr = request.query_params.get("pr")
|
pr = request.query_params.get("pr")
|
||||||
|
if not pr:
|
||||||
|
return {"status": "ERROR", "reason": "payment_request not provided."}
|
||||||
|
|
||||||
async def pay():
|
async def pay():
|
||||||
try:
|
try:
|
||||||
@@ -295,40 +286,18 @@ async def lnurl_full_withdraw_callback(request: Request):
|
|||||||
return {"status": "OK"}
|
return {"status": "OK"}
|
||||||
|
|
||||||
|
|
||||||
@core_html_routes.get("/deletewallet", response_class=RedirectResponse)
|
@generic_router.get("/withdraw/notify/{service}")
|
||||||
async def deletewallet(wal: str = Query(...), usr: str = Query(...)):
|
|
||||||
user = await get_user(usr)
|
|
||||||
if not user:
|
|
||||||
raise HTTPException(HTTPStatus.FORBIDDEN, "User not found.")
|
|
||||||
|
|
||||||
user_wallet_ids = [u.id for u in user.wallets]
|
|
||||||
|
|
||||||
if wal not in user_wallet_ids:
|
|
||||||
raise HTTPException(HTTPStatus.FORBIDDEN, "Not your wallet.")
|
|
||||||
else:
|
|
||||||
await delete_wallet(user_id=user.id, wallet_id=wal)
|
|
||||||
user_wallet_ids.remove(wal)
|
|
||||||
logger.debug("Deleted wallet {wal} of user {user.id}")
|
|
||||||
|
|
||||||
if user_wallet_ids:
|
|
||||||
return RedirectResponse(
|
|
||||||
url_for("/wallet", usr=user.id, wal=user_wallet_ids[0]),
|
|
||||||
status_code=status.HTTP_307_TEMPORARY_REDIRECT,
|
|
||||||
)
|
|
||||||
|
|
||||||
return RedirectResponse(
|
|
||||||
url_for("/"), status_code=status.HTTP_307_TEMPORARY_REDIRECT
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@core_html_routes.get("/withdraw/notify/{service}")
|
|
||||||
async def lnurl_balance_notify(request: Request, service: str):
|
async def lnurl_balance_notify(request: Request, service: str):
|
||||||
bc = await get_balance_check(request.query_params.get("wal"), service)
|
wal_param = request.query_params.get("wal")
|
||||||
|
if not wal_param:
|
||||||
|
return {"status": "ERROR", "reason": "wal parameter not provided."}
|
||||||
|
|
||||||
|
bc = await get_balance_check(wal_param, service)
|
||||||
if bc:
|
if bc:
|
||||||
await redeem_lnurl_withdraw(bc.wallet, bc.url)
|
await redeem_lnurl_withdraw(bc.wallet, bc.url)
|
||||||
|
|
||||||
|
|
||||||
@core_html_routes.get(
|
@generic_router.get(
|
||||||
"/lnurlwallet", response_class=RedirectResponse, name="core.lnurlwallet"
|
"/lnurlwallet", response_class=RedirectResponse, name="core.lnurlwallet"
|
||||||
)
|
)
|
||||||
async def lnurlwallet(request: Request):
|
async def lnurlwallet(request: Request):
|
||||||
@@ -338,10 +307,14 @@ async def lnurlwallet(request: Request):
|
|||||||
assert user, "Newly created user not found."
|
assert user, "Newly created user not found."
|
||||||
wallet = await create_wallet(user_id=user.id, conn=conn)
|
wallet = await create_wallet(user_id=user.id, conn=conn)
|
||||||
|
|
||||||
|
lightning_param = request.query_params.get("lightning")
|
||||||
|
if not lightning_param:
|
||||||
|
return {"status": "ERROR", "reason": "lightning parameter not provided."}
|
||||||
|
|
||||||
asyncio.create_task(
|
asyncio.create_task(
|
||||||
redeem_lnurl_withdraw(
|
redeem_lnurl_withdraw(
|
||||||
wallet.id,
|
wallet.id,
|
||||||
request.query_params.get("lightning"),
|
lightning_param,
|
||||||
"LNbits initial funding: voucher redeem.",
|
"LNbits initial funding: voucher redeem.",
|
||||||
{"tag": "lnurlwallet"},
|
{"tag": "lnurlwallet"},
|
||||||
5, # wait 5 seconds before sending the invoice to the service
|
5, # wait 5 seconds before sending the invoice to the service
|
||||||
@@ -354,12 +327,12 @@ async def lnurlwallet(request: Request):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@core_html_routes.get("/service-worker.js", response_class=FileResponse)
|
@generic_router.get("/service-worker.js", response_class=FileResponse)
|
||||||
async def service_worker():
|
async def service_worker():
|
||||||
return FileResponse("lnbits/core/static/js/service-worker.js")
|
return FileResponse("lnbits/core/static/js/service-worker.js")
|
||||||
|
|
||||||
|
|
||||||
@core_html_routes.get("/manifest/{usr}.webmanifest")
|
@generic_router.get("/manifest/{usr}.webmanifest")
|
||||||
async def manifest(request: Request, usr: str):
|
async def manifest(request: Request, usr: str):
|
||||||
host = urlparse(str(request.url)).netloc
|
host = urlparse(str(request.url)).netloc
|
||||||
|
|
||||||
@@ -400,7 +373,45 @@ async def manifest(request: Request, usr: str):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@core_html_routes.get("/admin", response_class=HTMLResponse)
|
@generic_router.get("/node", response_class=HTMLResponse)
|
||||||
|
async def node(request: Request, user: User = Depends(check_admin)):
|
||||||
|
if not settings.lnbits_node_ui:
|
||||||
|
raise HTTPException(status_code=HTTPStatus.SERVICE_UNAVAILABLE)
|
||||||
|
|
||||||
|
WALLET = get_wallet_class()
|
||||||
|
_, balance = await WALLET.status()
|
||||||
|
|
||||||
|
return template_renderer().TemplateResponse(
|
||||||
|
"node/index.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"user": user.dict(),
|
||||||
|
"settings": settings.dict(),
|
||||||
|
"balance": balance,
|
||||||
|
"wallets": user.wallets[0].dict(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@generic_router.get("/node/public", response_class=HTMLResponse)
|
||||||
|
async def node_public(request: Request):
|
||||||
|
if not settings.lnbits_public_node_ui:
|
||||||
|
raise HTTPException(status_code=HTTPStatus.SERVICE_UNAVAILABLE)
|
||||||
|
|
||||||
|
WALLET = get_wallet_class()
|
||||||
|
_, balance = await WALLET.status()
|
||||||
|
|
||||||
|
return template_renderer().TemplateResponse(
|
||||||
|
"node/public.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"settings": settings.dict(),
|
||||||
|
"balance": balance,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@generic_router.get("/admin", response_class=HTMLResponse)
|
||||||
async def index(request: Request, user: User = Depends(check_admin)):
|
async def index(request: Request, user: User = Depends(check_admin)):
|
||||||
if not settings.lnbits_admin_ui:
|
if not settings.lnbits_admin_ui:
|
||||||
raise HTTPException(status_code=HTTPStatus.NOT_FOUND)
|
raise HTTPException(status_code=HTTPStatus.NOT_FOUND)
|
||||||
@@ -420,7 +431,7 @@ async def index(request: Request, user: User = Depends(check_admin)):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@core_html_routes.get("/uuidv4/{hex_value}")
|
@generic_router.get("/uuidv4/{hex_value}")
|
||||||
async def hex_to_uuid4(hex_value: str):
|
async def hex_to_uuid4(hex_value: str):
|
||||||
try:
|
try:
|
||||||
user_id = to_valid_user_id(hex_value).hex
|
user_id = to_valid_user_id(hex_value).hex
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
from http import HTTPStatus
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import APIRouter, Body, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from starlette.status import HTTP_503_SERVICE_UNAVAILABLE
|
||||||
|
|
||||||
|
from lnbits.decorators import check_admin, check_super_user, parse_filters
|
||||||
|
from lnbits.nodes import get_node_class
|
||||||
|
from lnbits.settings import settings
|
||||||
|
|
||||||
|
from ...db import Filters, Page
|
||||||
|
from ...nodes.base import (
|
||||||
|
ChannelPoint,
|
||||||
|
Node,
|
||||||
|
NodeChannel,
|
||||||
|
NodeInfoResponse,
|
||||||
|
NodeInvoice,
|
||||||
|
NodeInvoiceFilters,
|
||||||
|
NodePayment,
|
||||||
|
NodePaymentsFilters,
|
||||||
|
NodePeerInfo,
|
||||||
|
PublicNodeInfo,
|
||||||
|
)
|
||||||
|
from ...utils.cache import cache
|
||||||
|
|
||||||
|
|
||||||
|
def require_node():
|
||||||
|
NODE = get_node_class()
|
||||||
|
if not NODE:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.NOT_IMPLEMENTED,
|
||||||
|
detail="Active backend does not implement Node API",
|
||||||
|
)
|
||||||
|
if not settings.lnbits_node_ui:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.SERVICE_UNAVAILABLE,
|
||||||
|
detail="Not enabled",
|
||||||
|
)
|
||||||
|
return NODE
|
||||||
|
|
||||||
|
|
||||||
|
def check_public():
|
||||||
|
if not (settings.lnbits_node_ui and settings.lnbits_public_node_ui):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.SERVICE_UNAVAILABLE,
|
||||||
|
detail="Not enabled",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
node_router = APIRouter(prefix="/node/api/v1", dependencies=[Depends(check_admin)])
|
||||||
|
super_node_router = APIRouter(
|
||||||
|
prefix="/node/api/v1", dependencies=[Depends(check_super_user)]
|
||||||
|
)
|
||||||
|
public_node_router = APIRouter(
|
||||||
|
prefix="/node/public/api/v1", dependencies=[Depends(check_public)]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@node_router.get(
|
||||||
|
"/ok",
|
||||||
|
description="Check if node api can be enabled",
|
||||||
|
status_code=200,
|
||||||
|
dependencies=[Depends(require_node)],
|
||||||
|
)
|
||||||
|
async def api_get_ok():
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@public_node_router.get("/info", response_model=PublicNodeInfo)
|
||||||
|
async def api_get_public_info(node: Node = Depends(require_node)) -> PublicNodeInfo:
|
||||||
|
return await cache.save_result(node.get_public_info, key="node:public_info")
|
||||||
|
|
||||||
|
|
||||||
|
@node_router.get("/info")
|
||||||
|
async def api_get_info(
|
||||||
|
node: Node = Depends(require_node),
|
||||||
|
) -> Optional[NodeInfoResponse]:
|
||||||
|
return await node.get_info()
|
||||||
|
|
||||||
|
|
||||||
|
@node_router.get("/channels")
|
||||||
|
async def api_get_channels(
|
||||||
|
node: Node = Depends(require_node),
|
||||||
|
) -> Optional[List[NodeChannel]]:
|
||||||
|
return await node.get_channels()
|
||||||
|
|
||||||
|
|
||||||
|
@super_node_router.post("/channels", response_model=ChannelPoint)
|
||||||
|
async def api_create_channel(
|
||||||
|
node: Node = Depends(require_node),
|
||||||
|
peer_id: str = Body(),
|
||||||
|
funding_amount: int = Body(),
|
||||||
|
push_amount: Optional[int] = Body(None),
|
||||||
|
fee_rate: Optional[int] = Body(None),
|
||||||
|
):
|
||||||
|
return await node.open_channel(peer_id, funding_amount, push_amount, fee_rate)
|
||||||
|
|
||||||
|
|
||||||
|
@super_node_router.delete("/channels")
|
||||||
|
async def api_delete_channel(
|
||||||
|
short_id: Optional[str],
|
||||||
|
funding_txid: Optional[str],
|
||||||
|
output_index: Optional[int],
|
||||||
|
force: bool = False,
|
||||||
|
node: Node = Depends(require_node),
|
||||||
|
) -> Optional[List[NodeChannel]]:
|
||||||
|
return await node.close_channel(
|
||||||
|
short_id,
|
||||||
|
ChannelPoint(funding_txid=funding_txid, output_index=output_index)
|
||||||
|
if funding_txid is not None and output_index is not None
|
||||||
|
else None,
|
||||||
|
force,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@node_router.get("/payments", response_model=Page[NodePayment])
|
||||||
|
async def api_get_payments(
|
||||||
|
node: Node = Depends(require_node),
|
||||||
|
filters: Filters = Depends(parse_filters(NodePaymentsFilters)),
|
||||||
|
) -> Optional[Page[NodePayment]]:
|
||||||
|
if not settings.lnbits_node_ui_transactions:
|
||||||
|
raise HTTPException(
|
||||||
|
HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail="You can enable node transactions in the Admin UI",
|
||||||
|
)
|
||||||
|
return await node.get_payments(filters)
|
||||||
|
|
||||||
|
|
||||||
|
@node_router.get("/invoices", response_model=Page[NodeInvoice])
|
||||||
|
async def api_get_invoices(
|
||||||
|
node: Node = Depends(require_node),
|
||||||
|
filters: Filters = Depends(parse_filters(NodeInvoiceFilters)),
|
||||||
|
) -> Optional[Page[NodeInvoice]]:
|
||||||
|
if not settings.lnbits_node_ui_transactions:
|
||||||
|
raise HTTPException(
|
||||||
|
HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail="You can enable node transactions in the Admin UI",
|
||||||
|
)
|
||||||
|
return await node.get_invoices(filters)
|
||||||
|
|
||||||
|
|
||||||
|
@node_router.get("/peers", response_model=List[NodePeerInfo])
|
||||||
|
async def api_get_peers(node: Node = Depends(require_node)) -> List[NodePeerInfo]:
|
||||||
|
return await node.get_peers()
|
||||||
|
|
||||||
|
|
||||||
|
@super_node_router.post("/peers")
|
||||||
|
async def api_connect_peer(
|
||||||
|
uri: str = Body(embed=True), node: Node = Depends(require_node)
|
||||||
|
):
|
||||||
|
return await node.connect_peer(uri)
|
||||||
|
|
||||||
|
|
||||||
|
@super_node_router.delete("/peers/{peer_id}")
|
||||||
|
async def api_disconnect_peer(peer_id: str, node: Node = Depends(require_node)):
|
||||||
|
return await node.disconnect_peer(peer_id)
|
||||||
|
|
||||||
|
|
||||||
|
class NodeRank(BaseModel):
|
||||||
|
capacity: Optional[int]
|
||||||
|
channelcount: Optional[int]
|
||||||
|
age: Optional[int]
|
||||||
|
growth: Optional[int]
|
||||||
|
availability: Optional[int]
|
||||||
|
|
||||||
|
|
||||||
|
# Same for public and private api
|
||||||
|
@node_router.get(
|
||||||
|
"/rank",
|
||||||
|
description="Retrieve node ranks from https://1ml.com",
|
||||||
|
response_model=Optional[NodeRank],
|
||||||
|
)
|
||||||
|
@public_node_router.get(
|
||||||
|
"/rank",
|
||||||
|
description="Retrieve node ranks from https://1ml.com",
|
||||||
|
response_model=Optional[NodeRank],
|
||||||
|
)
|
||||||
|
async def api_get_1ml_stats(node: Node = Depends(require_node)) -> Optional[NodeRank]:
|
||||||
|
node_id = await node.get_id()
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
r = await client.get(url=f"https://1ml.com/node/{node_id}/json", timeout=15)
|
||||||
|
try:
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json()["noderank"]
|
||||||
|
except httpx.HTTPStatusError:
|
||||||
|
raise HTTPException(status_code=404, detail="Node not found on 1ml.com")
|
||||||
@@ -1,18 +1,18 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import datetime
|
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from lnbits import bolt11
|
from lnbits import bolt11
|
||||||
|
|
||||||
from .. import core_app
|
|
||||||
from ..crud import get_standalone_payment
|
from ..crud import get_standalone_payment
|
||||||
from ..tasks import api_invoice_listeners
|
from ..tasks import api_invoice_listeners
|
||||||
|
|
||||||
|
public_router = APIRouter()
|
||||||
|
|
||||||
@core_app.get("/public/v1/payment/{payment_hash}")
|
|
||||||
|
@public_router.get("/public/v1/payment/{payment_hash}")
|
||||||
async def api_public_payment_longpolling(payment_hash):
|
async def api_public_payment_longpolling(payment_hash):
|
||||||
payment = await get_standalone_payment(payment_hash)
|
payment = await get_standalone_payment(payment_hash)
|
||||||
|
|
||||||
@@ -25,8 +25,7 @@ async def api_public_payment_longpolling(payment_hash):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
invoice = bolt11.decode(payment.bolt11)
|
invoice = bolt11.decode(payment.bolt11)
|
||||||
expiration = datetime.datetime.fromtimestamp(invoice.date + invoice.expiry)
|
if invoice.has_expired():
|
||||||
if expiration < datetime.datetime.now():
|
|
||||||
return {"status": "expired"}
|
return {"status": "expired"}
|
||||||
except Exception:
|
except Exception:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|||||||
+4
-7
@@ -50,13 +50,10 @@ if settings.lnbits_database_url:
|
|||||||
|
|
||||||
register_type(new_type((1184, 1114), "TIMESTAMP2INT", _parse_timestamp))
|
register_type(new_type((1184, 1114), "TIMESTAMP2INT", _parse_timestamp))
|
||||||
else:
|
else:
|
||||||
if os.path.isdir(settings.lnbits_data_folder):
|
if not os.path.isdir(settings.lnbits_data_folder):
|
||||||
DB_TYPE = SQLITE
|
os.mkdir(settings.lnbits_data_folder)
|
||||||
else:
|
logger.info(f"Created {settings.lnbits_data_folder}")
|
||||||
raise NotADirectoryError(
|
DB_TYPE = SQLITE
|
||||||
f"LNBITS_DATA_FOLDER named {settings.lnbits_data_folder} was not created"
|
|
||||||
f" - please 'mkdir {settings.lnbits_data_folder}' and try again"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def compat_timestamp_placeholder():
|
def compat_timestamp_placeholder():
|
||||||
|
|||||||
+39
-26
@@ -182,12 +182,19 @@ class Extension(NamedTuple):
|
|||||||
upgrade_hash: Optional[str] = ""
|
upgrade_hash: Optional[str] = ""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def module_name(self):
|
def module_name(self) -> str:
|
||||||
return (
|
if self.is_upgrade_extension:
|
||||||
f"lnbits.extensions.{self.code}"
|
if settings.has_default_extension_path:
|
||||||
if self.upgrade_hash == ""
|
return f"lnbits.upgrades.{self.code}-{self.upgrade_hash}"
|
||||||
else f"lnbits.upgrades.{self.code}-{self.upgrade_hash}.{self.code}"
|
return f"{self.code}-{self.upgrade_hash}"
|
||||||
)
|
|
||||||
|
if settings.has_default_extension_path:
|
||||||
|
return f"lnbits.extensions.{self.code}"
|
||||||
|
return self.code
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_upgrade_extension(self) -> bool:
|
||||||
|
return self.upgrade_hash != ""
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_installable_ext(cls, ext_info: "InstallableExtension") -> "Extension":
|
def from_installable_ext(cls, ext_info: "InstallableExtension") -> "Extension":
|
||||||
@@ -205,7 +212,7 @@ class Extension(NamedTuple):
|
|||||||
|
|
||||||
class ExtensionManager:
|
class ExtensionManager:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
p = Path(settings.lnbits_path, "extensions")
|
p = Path(settings.lnbits_extensions_path, "extensions")
|
||||||
Path(p).mkdir(parents=True, exist_ok=True)
|
Path(p).mkdir(parents=True, exist_ok=True)
|
||||||
self._extension_folders: List[Path] = [f for f in p.iterdir() if f.is_dir()]
|
self._extension_folders: List[Path] = [f for f in p.iterdir() if f.is_dir()]
|
||||||
|
|
||||||
@@ -330,21 +337,25 @@ class InstallableExtension(BaseModel):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def zip_path(self) -> Path:
|
def zip_path(self) -> Path:
|
||||||
extensions_data_dir = Path(settings.lnbits_data_folder, "extensions")
|
extensions_data_dir = Path(settings.lnbits_data_folder, "zips")
|
||||||
Path(extensions_data_dir).mkdir(parents=True, exist_ok=True)
|
Path(extensions_data_dir).mkdir(parents=True, exist_ok=True)
|
||||||
return Path(extensions_data_dir, f"{self.id}.zip")
|
return Path(extensions_data_dir, f"{self.id}.zip")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def ext_dir(self) -> Path:
|
def ext_dir(self) -> Path:
|
||||||
return Path(settings.lnbits_path, "extensions", self.id)
|
return Path(settings.lnbits_extensions_path, "extensions", self.id)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def ext_upgrade_dir(self) -> Path:
|
def ext_upgrade_dir(self) -> Path:
|
||||||
return Path("lnbits", "upgrades", f"{self.id}-{self.hash}")
|
return Path(
|
||||||
|
settings.lnbits_extensions_path, "upgrades", f"{self.id}-{self.hash}"
|
||||||
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def module_name(self) -> str:
|
def module_name(self) -> str:
|
||||||
return f"lnbits.extensions.{self.id}"
|
if settings.has_default_extension_path:
|
||||||
|
return f"lnbits.extensions.{self.id}"
|
||||||
|
return self.id
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def module_installed(self) -> bool:
|
def module_installed(self) -> bool:
|
||||||
@@ -389,21 +400,26 @@ class InstallableExtension(BaseModel):
|
|||||||
|
|
||||||
def extract_archive(self):
|
def extract_archive(self):
|
||||||
logger.info(f"Extracting extension {self.name} ({self.installed_version}).")
|
logger.info(f"Extracting extension {self.name} ({self.installed_version}).")
|
||||||
Path("lnbits", "upgrades").mkdir(parents=True, exist_ok=True)
|
Path(settings.lnbits_extensions_path, "upgrades").mkdir(
|
||||||
shutil.rmtree(self.ext_upgrade_dir, True)
|
parents=True, exist_ok=True
|
||||||
with zipfile.ZipFile(self.zip_path, "r") as zip_ref:
|
|
||||||
zip_ref.extractall(self.ext_upgrade_dir)
|
|
||||||
generated_dir_name = os.listdir(self.ext_upgrade_dir)[0]
|
|
||||||
os.rename(
|
|
||||||
Path(self.ext_upgrade_dir, generated_dir_name),
|
|
||||||
Path(self.ext_upgrade_dir, self.id),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
tmp_dir = Path(settings.lnbits_data_folder, "unzip-temp", self.hash)
|
||||||
|
shutil.rmtree(tmp_dir, True)
|
||||||
|
|
||||||
|
with zipfile.ZipFile(self.zip_path, "r") as zip_ref:
|
||||||
|
zip_ref.extractall(tmp_dir)
|
||||||
|
generated_dir_name = os.listdir(tmp_dir)[0]
|
||||||
|
shutil.rmtree(self.ext_upgrade_dir, True)
|
||||||
|
shutil.copytree(
|
||||||
|
Path(tmp_dir, generated_dir_name),
|
||||||
|
Path(self.ext_upgrade_dir),
|
||||||
|
)
|
||||||
|
shutil.rmtree(tmp_dir, True)
|
||||||
|
|
||||||
# Pre-packed extensions can be upgraded
|
# Pre-packed extensions can be upgraded
|
||||||
# Mark the extension as installed so we know it is not the pre-packed version
|
# Mark the extension as installed so we know it is not the pre-packed version
|
||||||
with open(
|
with open(Path(self.ext_upgrade_dir, "config.json"), "r+") as json_file:
|
||||||
Path(self.ext_upgrade_dir, self.id, "config.json"), "r+"
|
|
||||||
) as json_file:
|
|
||||||
config_json = json.load(json_file)
|
config_json = json.load(json_file)
|
||||||
|
|
||||||
self.name = config_json.get("name")
|
self.name = config_json.get("name")
|
||||||
@@ -419,10 +435,7 @@ class InstallableExtension(BaseModel):
|
|||||||
)
|
)
|
||||||
|
|
||||||
shutil.rmtree(self.ext_dir, True)
|
shutil.rmtree(self.ext_dir, True)
|
||||||
shutil.copytree(
|
shutil.copytree(Path(self.ext_upgrade_dir), Path(self.ext_dir))
|
||||||
Path(self.ext_upgrade_dir, self.id),
|
|
||||||
Path(settings.lnbits_path, "extensions", self.id),
|
|
||||||
)
|
|
||||||
logger.success(f"Extension {self.name} ({self.installed_version}) installed.")
|
logger.success(f"Extension {self.name} ({self.installed_version}) installed.")
|
||||||
|
|
||||||
def nofiy_upgrade(self) -> None:
|
def nofiy_upgrade(self) -> None:
|
||||||
|
|||||||
+9
-1
@@ -7,6 +7,7 @@ import shortuuid
|
|||||||
from pydantic.schema import field_schema
|
from pydantic.schema import field_schema
|
||||||
|
|
||||||
from lnbits.jinja2_templating import Jinja2Templates
|
from lnbits.jinja2_templating import Jinja2Templates
|
||||||
|
from lnbits.nodes import get_node_class
|
||||||
from lnbits.requestvars import g
|
from lnbits.requestvars import g
|
||||||
from lnbits.settings import settings
|
from lnbits.settings import settings
|
||||||
|
|
||||||
@@ -30,6 +31,10 @@ def url_for(endpoint: str, external: Optional[bool] = False, **params: Any) -> s
|
|||||||
def template_renderer(additional_folders: Optional[List] = None) -> Jinja2Templates:
|
def template_renderer(additional_folders: Optional[List] = None) -> Jinja2Templates:
|
||||||
folders = ["lnbits/templates", "lnbits/core/templates"]
|
folders = ["lnbits/templates", "lnbits/core/templates"]
|
||||||
if additional_folders:
|
if additional_folders:
|
||||||
|
additional_folders += [
|
||||||
|
Path(settings.lnbits_extensions_path, "extensions", f)
|
||||||
|
for f in additional_folders
|
||||||
|
]
|
||||||
folders.extend(additional_folders)
|
folders.extend(additional_folders)
|
||||||
t = Jinja2Templates(loader=jinja2.FileSystemLoader(folders))
|
t = Jinja2Templates(loader=jinja2.FileSystemLoader(folders))
|
||||||
|
|
||||||
@@ -44,9 +49,12 @@ def template_renderer(additional_folders: Optional[List] = None) -> Jinja2Templa
|
|||||||
t.env.globals["SITE_TAGLINE"] = settings.lnbits_site_tagline
|
t.env.globals["SITE_TAGLINE"] = settings.lnbits_site_tagline
|
||||||
t.env.globals["SITE_DESCRIPTION"] = settings.lnbits_site_description
|
t.env.globals["SITE_DESCRIPTION"] = settings.lnbits_site_description
|
||||||
t.env.globals["LNBITS_THEME_OPTIONS"] = settings.lnbits_theme_options
|
t.env.globals["LNBITS_THEME_OPTIONS"] = settings.lnbits_theme_options
|
||||||
t.env.globals["COMMIT_VERSION"] = settings.lnbits_commit
|
|
||||||
t.env.globals["LNBITS_VERSION"] = settings.version
|
t.env.globals["LNBITS_VERSION"] = settings.version
|
||||||
t.env.globals["LNBITS_ADMIN_UI"] = settings.lnbits_admin_ui
|
t.env.globals["LNBITS_ADMIN_UI"] = settings.lnbits_admin_ui
|
||||||
|
t.env.globals["LNBITS_NODE_UI"] = (
|
||||||
|
settings.lnbits_node_ui and get_node_class() is not None
|
||||||
|
)
|
||||||
|
t.env.globals["LNBITS_NODE_UI_AVAILABLE"] = get_node_class() is not None
|
||||||
t.env.globals["EXTENSIONS"] = [
|
t.env.globals["EXTENSIONS"] = [
|
||||||
e
|
e
|
||||||
for e in get_valid_extensions()
|
for e in get_valid_extensions()
|
||||||
|
|||||||
+5
-2
@@ -1,4 +1,7 @@
|
|||||||
|
from typing import Union
|
||||||
|
|
||||||
from bech32 import bech32_decode, bech32_encode, convertbits
|
from bech32 import bech32_decode, bech32_encode, convertbits
|
||||||
|
from fastapi.datastructures import URL
|
||||||
|
|
||||||
|
|
||||||
def decode(lnurl: str) -> str:
|
def decode(lnurl: str) -> str:
|
||||||
@@ -10,8 +13,8 @@ def decode(lnurl: str) -> str:
|
|||||||
return bytes(bech32_data).decode()
|
return bytes(bech32_data).decode()
|
||||||
|
|
||||||
|
|
||||||
def encode(url: str) -> str:
|
def encode(url: Union[str, URL]) -> str:
|
||||||
bech32_data = convertbits(url.encode(), 8, 5, True)
|
bech32_data = convertbits(str(url).encode(), 8, 5, True)
|
||||||
assert bech32_data
|
assert bech32_data
|
||||||
lnurl = bech32_encode("lnurl", bech32_data)
|
lnurl = bech32_encode("lnurl", bech32_data)
|
||||||
return lnurl.upper()
|
return lnurl.upper()
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from slowapi.middleware import SlowAPIMiddleware
|
|||||||
from starlette.middleware.gzip import GZipMiddleware
|
from starlette.middleware.gzip import GZipMiddleware
|
||||||
from starlette.types import ASGIApp, Receive, Scope, Send
|
from starlette.types import ASGIApp, Receive, Scope, Send
|
||||||
|
|
||||||
from lnbits.core import core_app_extra
|
from lnbits.core.db import core_app_extra
|
||||||
from lnbits.helpers import template_renderer
|
from lnbits.helpers import template_renderer
|
||||||
from lnbits.settings import settings
|
from lnbits.settings import settings
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from .base import Node
|
||||||
|
|
||||||
|
|
||||||
|
def get_node_class() -> Optional[Node]:
|
||||||
|
return NODE
|
||||||
|
|
||||||
|
|
||||||
|
def set_node_class(node: Node):
|
||||||
|
global NODE
|
||||||
|
NODE = node
|
||||||
|
|
||||||
|
|
||||||
|
NODE: Optional[Node] = None
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from enum import Enum
|
||||||
|
from typing import TYPE_CHECKING, List, Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from lnbits.db import FilterModel, Filters, Page
|
||||||
|
from lnbits.utils.cache import cache
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from lnbits.wallets.base import Wallet
|
||||||
|
|
||||||
|
|
||||||
|
class NodePeerInfo(BaseModel):
|
||||||
|
id: str
|
||||||
|
alias: Optional[str] = None
|
||||||
|
color: Optional[str] = None
|
||||||
|
last_timestamp: Optional[int] = None
|
||||||
|
addresses: Optional[list[str]] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelState(Enum):
|
||||||
|
ACTIVE = "active"
|
||||||
|
PENDING = "pending"
|
||||||
|
CLOSED = "closed"
|
||||||
|
INACTIVE = "inactive"
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelBalance(BaseModel):
|
||||||
|
local_msat: int
|
||||||
|
remote_msat: int
|
||||||
|
total_msat: int
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelPoint(BaseModel):
|
||||||
|
funding_txid: str
|
||||||
|
output_index: int
|
||||||
|
|
||||||
|
|
||||||
|
class NodeChannel(BaseModel):
|
||||||
|
short_id: Optional[str] = None
|
||||||
|
point: Optional[ChannelPoint] = None
|
||||||
|
peer_id: str
|
||||||
|
balance: ChannelBalance
|
||||||
|
state: ChannelState
|
||||||
|
name: Optional[str]
|
||||||
|
color: Optional[str]
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelStats(BaseModel):
|
||||||
|
counts: dict[ChannelState, int]
|
||||||
|
avg_size: int
|
||||||
|
biggest_size: Optional[int]
|
||||||
|
smallest_size: Optional[int]
|
||||||
|
total_capacity: int
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_list(cls, channels: list[NodeChannel]):
|
||||||
|
counts: dict[ChannelState, int] = {}
|
||||||
|
for channel in channels:
|
||||||
|
counts[channel.state] = counts.get(channel.state, 0) + 1
|
||||||
|
|
||||||
|
return cls(
|
||||||
|
counts=counts,
|
||||||
|
avg_size=int(
|
||||||
|
sum(channel.balance.total_msat for channel in channels) / len(channels)
|
||||||
|
),
|
||||||
|
biggest_size=max(channel.balance.total_msat for channel in channels),
|
||||||
|
smallest_size=min(channel.balance.total_msat for channel in channels),
|
||||||
|
total_capacity=sum(channel.balance.total_msat for channel in channels),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class NodeFees(BaseModel):
|
||||||
|
total_msat: int
|
||||||
|
daily_msat: Optional[int] = None
|
||||||
|
weekly_msat: Optional[int] = None
|
||||||
|
monthly_msat: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
class PublicNodeInfo(BaseModel):
|
||||||
|
id: str
|
||||||
|
backend_name: str
|
||||||
|
alias: str
|
||||||
|
color: str
|
||||||
|
num_peers: int
|
||||||
|
blockheight: int
|
||||||
|
channel_stats: ChannelStats
|
||||||
|
addresses: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
class NodeInfoResponse(PublicNodeInfo):
|
||||||
|
onchain_balance_sat: int
|
||||||
|
onchain_confirmed_sat: int
|
||||||
|
fees: NodeFees
|
||||||
|
balance_msat: int
|
||||||
|
|
||||||
|
|
||||||
|
class NodePayment(BaseModel):
|
||||||
|
pending: bool
|
||||||
|
amount: int
|
||||||
|
fee: Optional[int] = None
|
||||||
|
memo: Optional[str] = None
|
||||||
|
time: int
|
||||||
|
bolt11: str
|
||||||
|
preimage: Optional[str]
|
||||||
|
payment_hash: str
|
||||||
|
expiry: Optional[float] = None
|
||||||
|
destination: Optional[NodePeerInfo] = None
|
||||||
|
|
||||||
|
|
||||||
|
class NodeInvoice(BaseModel):
|
||||||
|
pending: bool
|
||||||
|
amount: int
|
||||||
|
memo: Optional[str]
|
||||||
|
bolt11: str
|
||||||
|
preimage: Optional[str]
|
||||||
|
payment_hash: str
|
||||||
|
paid_at: Optional[int] = None
|
||||||
|
expiry: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
class NodeInvoiceFilters(FilterModel):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class NodePaymentsFilters(FilterModel):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Node(ABC):
|
||||||
|
wallet: Wallet
|
||||||
|
|
||||||
|
def __init__(self, wallet: Wallet):
|
||||||
|
self.wallet = wallet
|
||||||
|
self.id: Optional[str] = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self):
|
||||||
|
return self.__class__.__name__
|
||||||
|
|
||||||
|
async def get_id(self):
|
||||||
|
if not self.id:
|
||||||
|
self.id = await self._get_id()
|
||||||
|
return self.id
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def _get_id(self) -> str:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def get_peers(self) -> list[NodePeerInfo]:
|
||||||
|
peer_ids = await self.get_peer_ids()
|
||||||
|
return [await self.get_peer_info(peer_id) for peer_id in peer_ids]
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def get_peer_ids(self) -> list[str]:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def connect_peer(self, uri: str):
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def disconnect_peer(self, peer_id: str):
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def _get_peer_info(self, peer_id: str) -> NodePeerInfo:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def get_peer_info(self, peer_id: str) -> NodePeerInfo:
|
||||||
|
key = f"node:peers:{peer_id}"
|
||||||
|
info = cache.get(key)
|
||||||
|
if not info:
|
||||||
|
info = await self._get_peer_info(peer_id)
|
||||||
|
if info.last_timestamp:
|
||||||
|
cache.set(key, info)
|
||||||
|
return info
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def open_channel(
|
||||||
|
self,
|
||||||
|
peer_id: str,
|
||||||
|
local_amount: int,
|
||||||
|
push_amount: Optional[int] = None,
|
||||||
|
fee_rate: Optional[int] = None,
|
||||||
|
) -> ChannelPoint:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def close_channel(
|
||||||
|
self,
|
||||||
|
short_id: Optional[str] = None,
|
||||||
|
point: Optional[ChannelPoint] = None,
|
||||||
|
force: bool = False,
|
||||||
|
):
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def get_channels(self) -> List[NodeChannel]:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def get_info(self) -> NodeInfoResponse:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def get_public_info(self) -> PublicNodeInfo:
|
||||||
|
info = await self.get_info()
|
||||||
|
return PublicNodeInfo(**info.__dict__)
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def get_payments(
|
||||||
|
self, filters: Filters[NodePaymentsFilters]
|
||||||
|
) -> Page[NodePayment]:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def get_invoices(
|
||||||
|
self, filters: Filters[NodeInvoiceFilters]
|
||||||
|
) -> Page[NodeInvoice]:
|
||||||
|
pass
|
||||||
@@ -0,0 +1,323 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from http import HTTPStatus
|
||||||
|
from typing import TYPE_CHECKING, List, Optional
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from lnbits.db import Filters, Page
|
||||||
|
|
||||||
|
from ..utils.cache import cache
|
||||||
|
|
||||||
|
try:
|
||||||
|
from pyln.client import RpcError # type: ignore
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
# override the false type
|
||||||
|
class RpcError(RpcError): # type: ignore
|
||||||
|
error: dict
|
||||||
|
|
||||||
|
except ImportError: # pragma: nocover
|
||||||
|
LightningRpc = None
|
||||||
|
|
||||||
|
from lnbits.nodes.base import (
|
||||||
|
ChannelBalance,
|
||||||
|
ChannelPoint,
|
||||||
|
ChannelState,
|
||||||
|
ChannelStats,
|
||||||
|
Node,
|
||||||
|
NodeFees,
|
||||||
|
NodeInvoice,
|
||||||
|
NodeInvoiceFilters,
|
||||||
|
NodePaymentsFilters,
|
||||||
|
NodePeerInfo,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .base import NodeChannel, NodeInfoResponse, NodePayment
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from lnbits.wallets import CoreLightningWallet
|
||||||
|
|
||||||
|
|
||||||
|
def catch_rpc_errors(f):
|
||||||
|
async def wrapper(*args, **kwargs):
|
||||||
|
try:
|
||||||
|
return await f(*args, **kwargs)
|
||||||
|
except RpcError as e:
|
||||||
|
if e.error["code"] == -32602:
|
||||||
|
raise HTTPException(status_code=400, detail=e.error["message"])
|
||||||
|
else:
|
||||||
|
raise HTTPException(status_code=500, detail=e.error["message"])
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
class CoreLightningNode(Node):
|
||||||
|
wallet: CoreLightningWallet
|
||||||
|
|
||||||
|
async def ln_rpc(self, method, *args, **kwargs) -> dict:
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
fn = getattr(self.wallet.ln, method)
|
||||||
|
return await loop.run_in_executor(None, lambda: fn(*args, **kwargs))
|
||||||
|
|
||||||
|
@catch_rpc_errors
|
||||||
|
async def connect_peer(self, uri: str):
|
||||||
|
# https://docs.corelightning.org/reference/lightning-connect
|
||||||
|
try:
|
||||||
|
await self.ln_rpc("connect", uri)
|
||||||
|
except RpcError as e:
|
||||||
|
if e.error["code"] == 400:
|
||||||
|
raise HTTPException(HTTPStatus.BAD_REQUEST, detail=e.error["message"])
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
|
||||||
|
@catch_rpc_errors
|
||||||
|
async def disconnect_peer(self, peer_id: str):
|
||||||
|
try:
|
||||||
|
await self.ln_rpc("disconnect", peer_id)
|
||||||
|
except RpcError as e:
|
||||||
|
if e.error["code"] == -1:
|
||||||
|
raise HTTPException(
|
||||||
|
HTTPStatus.BAD_REQUEST,
|
||||||
|
detail=e.error["message"],
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
|
||||||
|
@catch_rpc_errors
|
||||||
|
async def open_channel(
|
||||||
|
self,
|
||||||
|
peer_id: str,
|
||||||
|
local_amount: int,
|
||||||
|
push_amount: Optional[int] = None,
|
||||||
|
fee_rate: Optional[int] = None,
|
||||||
|
) -> ChannelPoint:
|
||||||
|
try:
|
||||||
|
result = await self.ln_rpc(
|
||||||
|
"fundchannel",
|
||||||
|
peer_id,
|
||||||
|
amount=local_amount,
|
||||||
|
push_msat=int(push_amount * 1000) if push_amount else None,
|
||||||
|
feerate=fee_rate,
|
||||||
|
)
|
||||||
|
return ChannelPoint(
|
||||||
|
funding_txid=result["txid"],
|
||||||
|
output_index=result["outnum"],
|
||||||
|
)
|
||||||
|
except RpcError as e:
|
||||||
|
message = e.error["message"]
|
||||||
|
|
||||||
|
if "amount: should be a satoshi amount" in message:
|
||||||
|
raise HTTPException(
|
||||||
|
HTTPStatus.BAD_REQUEST,
|
||||||
|
detail="The amount is not a valid satoshi amount.",
|
||||||
|
)
|
||||||
|
|
||||||
|
if "Unknown peer" in message:
|
||||||
|
raise HTTPException(
|
||||||
|
HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||||
|
detail=(
|
||||||
|
"We where able to connect to the peer but CLN "
|
||||||
|
"can't find it when opening a channel."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
if "Owning subdaemon openingd died" in message:
|
||||||
|
# https://github.com/ElementsProject/lightning/issues/2798#issuecomment-511205719
|
||||||
|
raise HTTPException(
|
||||||
|
HTTPStatus.BAD_REQUEST,
|
||||||
|
detail=(
|
||||||
|
"Likely the peer didn't like our channel opening "
|
||||||
|
"proposal and disconnected from us."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (
|
||||||
|
"Number of pending channels exceed maximum" in message
|
||||||
|
or "exceeds maximum chan size of 10 BTC" in message
|
||||||
|
or "Could not afford" in message
|
||||||
|
):
|
||||||
|
raise HTTPException(HTTPStatus.BAD_REQUEST, detail=message)
|
||||||
|
raise
|
||||||
|
|
||||||
|
@catch_rpc_errors
|
||||||
|
async def close_channel(
|
||||||
|
self,
|
||||||
|
short_id: Optional[str] = None,
|
||||||
|
point: Optional[ChannelPoint] = None,
|
||||||
|
force: bool = False,
|
||||||
|
):
|
||||||
|
if not short_id:
|
||||||
|
raise HTTPException(status_code=400, detail="Short id required")
|
||||||
|
try:
|
||||||
|
await self.ln_rpc("close", short_id)
|
||||||
|
except RpcError as e:
|
||||||
|
message = e.error["message"]
|
||||||
|
if (
|
||||||
|
"Short channel ID not active:" in message
|
||||||
|
or "Short channel ID not found" in message
|
||||||
|
):
|
||||||
|
raise HTTPException(HTTPStatus.BAD_REQUEST, detail=message)
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
|
||||||
|
@catch_rpc_errors
|
||||||
|
async def _get_id(self) -> str:
|
||||||
|
info = await self.ln_rpc("getinfo")
|
||||||
|
return info["id"]
|
||||||
|
|
||||||
|
@catch_rpc_errors
|
||||||
|
async def get_peer_ids(self) -> List[str]:
|
||||||
|
peers = await self.ln_rpc("listpeers")
|
||||||
|
return [p["id"] for p in peers["peers"] if p["connected"]]
|
||||||
|
|
||||||
|
@catch_rpc_errors
|
||||||
|
async def _get_peer_info(self, peer_id: str) -> NodePeerInfo:
|
||||||
|
result = await self.ln_rpc("listnodes", peer_id)
|
||||||
|
nodes = result["nodes"]
|
||||||
|
if len(nodes) == 0:
|
||||||
|
return NodePeerInfo(id=peer_id)
|
||||||
|
node = nodes[0]
|
||||||
|
if "last_timestamp" in node:
|
||||||
|
return NodePeerInfo(
|
||||||
|
id=node["nodeid"],
|
||||||
|
alias=node["alias"],
|
||||||
|
color=node["color"],
|
||||||
|
last_timestamp=node["last_timestamp"],
|
||||||
|
addresses=[
|
||||||
|
address["address"] + ":" + str(address["port"])
|
||||||
|
for address in node["addresses"]
|
||||||
|
],
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return NodePeerInfo(id=node["nodeid"])
|
||||||
|
|
||||||
|
@catch_rpc_errors
|
||||||
|
async def get_channels(self) -> List[NodeChannel]:
|
||||||
|
funds = await self.ln_rpc("listfunds")
|
||||||
|
nodes = await self.ln_rpc("listnodes")
|
||||||
|
nodes_by_id = {n["nodeid"]: n for n in nodes["nodes"]}
|
||||||
|
|
||||||
|
return [
|
||||||
|
NodeChannel(
|
||||||
|
short_id=ch.get("short_channel_id"),
|
||||||
|
point=ChannelPoint(
|
||||||
|
funding_txid=ch["funding_txid"],
|
||||||
|
output_index=ch["funding_output"],
|
||||||
|
),
|
||||||
|
peer_id=ch["peer_id"],
|
||||||
|
balance=ChannelBalance(
|
||||||
|
local_msat=ch["our_amount_msat"],
|
||||||
|
remote_msat=ch["amount_msat"] - ch["our_amount_msat"],
|
||||||
|
total_msat=ch["amount_msat"],
|
||||||
|
),
|
||||||
|
name=nodes_by_id.get(ch["peer_id"], {}).get("alias"),
|
||||||
|
color=nodes_by_id.get(ch["peer_id"], {}).get("color"),
|
||||||
|
state=(
|
||||||
|
ChannelState.ACTIVE
|
||||||
|
if ch["state"] == "CHANNELD_NORMAL"
|
||||||
|
else ChannelState.PENDING
|
||||||
|
if ch["state"] in ("CHANNELD_AWAITING_LOCKIN", "OPENINGD")
|
||||||
|
else ChannelState.CLOSED
|
||||||
|
if ch["state"]
|
||||||
|
in (
|
||||||
|
"CHANNELD_CLOSING",
|
||||||
|
"CLOSINGD_COMPLETE",
|
||||||
|
"CLOSINGD_SIGEXCHANGE",
|
||||||
|
"ONCHAIN",
|
||||||
|
)
|
||||||
|
else ChannelState.INACTIVE
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for ch in funds["channels"]
|
||||||
|
]
|
||||||
|
|
||||||
|
@catch_rpc_errors
|
||||||
|
async def get_info(self) -> NodeInfoResponse:
|
||||||
|
info = await self.ln_rpc("getinfo")
|
||||||
|
funds = await self.ln_rpc("listfunds")
|
||||||
|
|
||||||
|
channels = await self.get_channels()
|
||||||
|
active_channels = [
|
||||||
|
channel for channel in channels if channel.state == ChannelState.ACTIVE
|
||||||
|
]
|
||||||
|
return NodeInfoResponse(
|
||||||
|
id=info["id"],
|
||||||
|
backend_name="CLN",
|
||||||
|
alias=info["alias"],
|
||||||
|
color=info["color"],
|
||||||
|
onchain_balance_sat=sum(output["value"] for output in funds["outputs"]),
|
||||||
|
onchain_confirmed_sat=sum(
|
||||||
|
output["amount_msat"] / 1000
|
||||||
|
for output in funds["outputs"]
|
||||||
|
if output["status"] == "confirmed"
|
||||||
|
),
|
||||||
|
channel_stats=ChannelStats.from_list(channels),
|
||||||
|
num_peers=info["num_peers"],
|
||||||
|
blockheight=info["blockheight"],
|
||||||
|
balance_msat=sum(channel.balance.local_msat for channel in active_channels),
|
||||||
|
fees=NodeFees(total_msat=info["fees_collected_msat"]),
|
||||||
|
addresses=[address["address"] for address in info["address"]],
|
||||||
|
)
|
||||||
|
|
||||||
|
@catch_rpc_errors
|
||||||
|
async def get_payments(
|
||||||
|
self, filters: Filters[NodePaymentsFilters]
|
||||||
|
) -> Page[NodePayment]:
|
||||||
|
async def get_payments():
|
||||||
|
result = await self.ln_rpc("listpays")
|
||||||
|
return [
|
||||||
|
NodePayment(
|
||||||
|
bolt11=pay["bolt11"],
|
||||||
|
amount=pay["amount_msat"],
|
||||||
|
fee=int(pay["amount_msat"]) - int(pay["amount_sent_msat"]),
|
||||||
|
memo=pay.get("description"),
|
||||||
|
time=pay["created_at"],
|
||||||
|
preimage=pay.get("preimage"),
|
||||||
|
payment_hash=pay["payment_hash"],
|
||||||
|
pending=pay["status"] != "complete",
|
||||||
|
destination=await self.get_peer_info(pay["destination"]),
|
||||||
|
)
|
||||||
|
for pay in reversed(result["pays"])
|
||||||
|
if pay["status"] != "failed"
|
||||||
|
]
|
||||||
|
|
||||||
|
results = await cache.save_result(get_payments, key="node:payments")
|
||||||
|
count = len(results)
|
||||||
|
if filters.offset:
|
||||||
|
results = results[filters.offset :]
|
||||||
|
if filters.limit:
|
||||||
|
results = results[: filters.limit]
|
||||||
|
return Page(data=results, total=count)
|
||||||
|
|
||||||
|
@catch_rpc_errors
|
||||||
|
async def get_invoices(
|
||||||
|
self, filters: Filters[NodeInvoiceFilters]
|
||||||
|
) -> Page[NodeInvoice]:
|
||||||
|
result = await cache.save_result(
|
||||||
|
lambda: self.ln_rpc("listinvoices"), key="node:invoices"
|
||||||
|
)
|
||||||
|
invoices = result["invoices"]
|
||||||
|
invoices.reverse()
|
||||||
|
count = len(invoices)
|
||||||
|
if filters.offset:
|
||||||
|
invoices = invoices[filters.offset :]
|
||||||
|
if filters.limit:
|
||||||
|
invoices = invoices[: filters.limit]
|
||||||
|
return Page(
|
||||||
|
data=[
|
||||||
|
NodeInvoice(
|
||||||
|
bolt11=invoice["bolt11"],
|
||||||
|
amount=invoice["amount_msat"],
|
||||||
|
preimage=invoice.get("payment_preimage"),
|
||||||
|
memo=invoice["description"],
|
||||||
|
paid_at=invoice.get("paid_at"),
|
||||||
|
expiry=invoice["expires_at"],
|
||||||
|
payment_hash=invoice["payment_hash"],
|
||||||
|
pending=invoice["status"] != "paid",
|
||||||
|
)
|
||||||
|
for invoice in invoices
|
||||||
|
],
|
||||||
|
total=count,
|
||||||
|
)
|
||||||
@@ -0,0 +1,382 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
from http import HTTPStatus
|
||||||
|
from typing import TYPE_CHECKING, List, Optional
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from httpx import HTTPStatusError
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from lnbits.db import Filters, Page
|
||||||
|
from lnbits.nodes import Node
|
||||||
|
from lnbits.nodes.base import (
|
||||||
|
ChannelBalance,
|
||||||
|
ChannelPoint,
|
||||||
|
ChannelState,
|
||||||
|
ChannelStats,
|
||||||
|
NodeChannel,
|
||||||
|
NodeFees,
|
||||||
|
NodeInfoResponse,
|
||||||
|
NodeInvoice,
|
||||||
|
NodeInvoiceFilters,
|
||||||
|
NodePayment,
|
||||||
|
NodePaymentsFilters,
|
||||||
|
NodePeerInfo,
|
||||||
|
PublicNodeInfo,
|
||||||
|
)
|
||||||
|
from lnbits.utils.cache import cache
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from lnbits.wallets import LndRestWallet
|
||||||
|
|
||||||
|
|
||||||
|
def msat(raw: str) -> int:
|
||||||
|
return int(raw) * 1000
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_bytes(data: str) -> str:
|
||||||
|
return base64.b64decode(data).hex()
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_channel_point(raw: str) -> ChannelPoint:
|
||||||
|
funding_tx, output_index = raw.split(":")
|
||||||
|
return ChannelPoint(
|
||||||
|
funding_txid=funding_tx,
|
||||||
|
output_index=int(output_index),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class LndRestNode(Node):
|
||||||
|
wallet: LndRestWallet
|
||||||
|
|
||||||
|
async def request(
|
||||||
|
self, method: str, path: str, json: Optional[dict] = None, **kwargs
|
||||||
|
):
|
||||||
|
response = await self.wallet.client.request(
|
||||||
|
method, f"{self.wallet.endpoint}{path}", json=json, **kwargs
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
response.raise_for_status()
|
||||||
|
except HTTPStatusError as e:
|
||||||
|
json = e.response.json()
|
||||||
|
if json:
|
||||||
|
error = json.get("error") or json
|
||||||
|
raise HTTPException(e.response.status_code, detail=error.get("message"))
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
def get(self, path: str, **kwargs):
|
||||||
|
return self.request("GET", path, **kwargs)
|
||||||
|
|
||||||
|
async def _get_id(self) -> str:
|
||||||
|
info = await self.get("/v1/getinfo")
|
||||||
|
return info["identity_pubkey"]
|
||||||
|
|
||||||
|
async def get_peer_ids(self) -> list[str]:
|
||||||
|
response = await self.get("/v1/peers")
|
||||||
|
return [p["pub_key"] for p in response["peers"]]
|
||||||
|
|
||||||
|
async def connect_peer(self, uri: str):
|
||||||
|
try:
|
||||||
|
pubkey, host = uri.split("@")
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(400, detail="Invalid peer URI")
|
||||||
|
await self.request(
|
||||||
|
"POST",
|
||||||
|
"/v1/peers",
|
||||||
|
json={
|
||||||
|
"addr": {"pubkey": pubkey, "host": host},
|
||||||
|
"perm": True,
|
||||||
|
"timeout": 30,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def disconnect_peer(self, peer_id: str):
|
||||||
|
try:
|
||||||
|
await self.request("DELETE", "/v1/peers/" + peer_id)
|
||||||
|
except HTTPException as e:
|
||||||
|
if "unable to disconnect" in e.detail:
|
||||||
|
raise HTTPException(
|
||||||
|
HTTPStatus.BAD_REQUEST, detail="Peer is not connected"
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def _get_peer_info(self, peer_id: str) -> NodePeerInfo:
|
||||||
|
try:
|
||||||
|
response = await self.get("/v1/graph/node/" + peer_id)
|
||||||
|
except HTTPException:
|
||||||
|
return NodePeerInfo(id=peer_id)
|
||||||
|
node = response["node"]
|
||||||
|
return NodePeerInfo(
|
||||||
|
id=peer_id,
|
||||||
|
alias=node["alias"],
|
||||||
|
color=node["color"].strip("#"),
|
||||||
|
last_timestamp=node["last_update"],
|
||||||
|
addresses=[a["addr"] for a in node["addresses"]],
|
||||||
|
)
|
||||||
|
|
||||||
|
async def open_channel(
|
||||||
|
self,
|
||||||
|
peer_id: str,
|
||||||
|
local_amount: int,
|
||||||
|
push_amount: Optional[int] = None,
|
||||||
|
fee_rate: Optional[int] = None,
|
||||||
|
) -> ChannelPoint:
|
||||||
|
response = await self.request(
|
||||||
|
"POST",
|
||||||
|
"/v1/channels",
|
||||||
|
data=json.dumps(
|
||||||
|
{
|
||||||
|
# 'node_pubkey': base64.b64encode(peer_id.encode()).decode(),
|
||||||
|
"node_pubkey_string": peer_id,
|
||||||
|
"sat_per_vbyte": fee_rate,
|
||||||
|
"local_funding_amount": local_amount,
|
||||||
|
"push_sat": push_amount,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return ChannelPoint(
|
||||||
|
# WHY IS THIS REVERSED?!
|
||||||
|
funding_txid=bytes(
|
||||||
|
reversed(base64.b64decode(response["funding_txid_bytes"]))
|
||||||
|
).hex(),
|
||||||
|
output_index=response["output_index"],
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _close_channel(
|
||||||
|
self,
|
||||||
|
point: ChannelPoint,
|
||||||
|
force: bool = False,
|
||||||
|
):
|
||||||
|
async with self.wallet.client.stream(
|
||||||
|
"DELETE",
|
||||||
|
f"{self.wallet.endpoint}/v1/channels/{point.funding_txid}/{point.output_index}",
|
||||||
|
params={"force": force},
|
||||||
|
timeout=None,
|
||||||
|
) as stream:
|
||||||
|
async for chunk in stream.aiter_text():
|
||||||
|
if chunk:
|
||||||
|
chunk = json.loads(chunk)
|
||||||
|
logger.info(f"LND Channel close update: {chunk['result']}")
|
||||||
|
|
||||||
|
async def close_channel(
|
||||||
|
self,
|
||||||
|
short_id: Optional[str] = None,
|
||||||
|
point: Optional[ChannelPoint] = None,
|
||||||
|
force: bool = False,
|
||||||
|
):
|
||||||
|
if not point:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.BAD_REQUEST, detail="Channel point required"
|
||||||
|
)
|
||||||
|
|
||||||
|
asyncio.create_task(self._close_channel(point, force))
|
||||||
|
|
||||||
|
async def get_channels(self) -> List[NodeChannel]:
|
||||||
|
normal, pending, closed = await asyncio.gather(
|
||||||
|
self.get("/v1/channels"),
|
||||||
|
self.get("/v1/channels/pending"),
|
||||||
|
self.get("/v1/channels/closed"),
|
||||||
|
)
|
||||||
|
|
||||||
|
channels = []
|
||||||
|
|
||||||
|
async def parse_pending(raw_channels, state):
|
||||||
|
for channel in raw_channels:
|
||||||
|
channel = channel["channel"]
|
||||||
|
info = await self.get_peer_info(channel["remote_node_pub"])
|
||||||
|
channels.append(
|
||||||
|
NodeChannel(
|
||||||
|
peer_id=info.id,
|
||||||
|
state=state,
|
||||||
|
name=info.alias,
|
||||||
|
color=info.color,
|
||||||
|
point=_parse_channel_point(channel["channel_point"]),
|
||||||
|
balance=ChannelBalance(
|
||||||
|
local_msat=msat(channel["local_balance"]),
|
||||||
|
remote_msat=msat(channel["remote_balance"]),
|
||||||
|
total_msat=msat(channel["capacity"]),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
await parse_pending(pending["pending_open_channels"], ChannelState.PENDING)
|
||||||
|
await parse_pending(
|
||||||
|
pending["pending_force_closing_channels"], ChannelState.CLOSED
|
||||||
|
)
|
||||||
|
await parse_pending(pending["waiting_close_channels"], ChannelState.CLOSED)
|
||||||
|
|
||||||
|
for channel in closed["channels"]:
|
||||||
|
info = await self.get_peer_info(channel["remote_pubkey"])
|
||||||
|
channels.append(
|
||||||
|
NodeChannel(
|
||||||
|
peer_id=info.id,
|
||||||
|
state=ChannelState.CLOSED,
|
||||||
|
name=info.alias,
|
||||||
|
color=info.color,
|
||||||
|
point=_parse_channel_point(channel["channel_point"]),
|
||||||
|
balance=ChannelBalance(
|
||||||
|
local_msat=0,
|
||||||
|
remote_msat=0,
|
||||||
|
total_msat=msat(channel["capacity"]),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
for channel in normal["channels"]:
|
||||||
|
info = await self.get_peer_info(channel["remote_pubkey"])
|
||||||
|
channels.append(
|
||||||
|
NodeChannel(
|
||||||
|
short_id=channel["chan_id"],
|
||||||
|
point=_parse_channel_point(channel["channel_point"]),
|
||||||
|
peer_id=channel["remote_pubkey"],
|
||||||
|
balance=ChannelBalance(
|
||||||
|
local_msat=msat(channel["local_balance"]),
|
||||||
|
remote_msat=msat(channel["remote_balance"]),
|
||||||
|
total_msat=msat(channel["capacity"]),
|
||||||
|
),
|
||||||
|
state=ChannelState.ACTIVE
|
||||||
|
if channel["active"]
|
||||||
|
else ChannelState.INACTIVE,
|
||||||
|
# name=channel['peer_alias'],
|
||||||
|
name=info.alias,
|
||||||
|
color=info.color,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return channels
|
||||||
|
|
||||||
|
async def get_public_info(self) -> PublicNodeInfo:
|
||||||
|
info = await self.get("/v1/getinfo")
|
||||||
|
channels = await self.get_channels()
|
||||||
|
return PublicNodeInfo(
|
||||||
|
backend_name="LND",
|
||||||
|
id=info["identity_pubkey"],
|
||||||
|
color=info["color"].lstrip("#"),
|
||||||
|
alias=info["alias"],
|
||||||
|
num_peers=info["num_peers"],
|
||||||
|
blockheight=info["block_height"],
|
||||||
|
addresses=info["uris"],
|
||||||
|
channel_stats=ChannelStats.from_list(channels),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_info(self) -> NodeInfoResponse:
|
||||||
|
public = await self.get_public_info()
|
||||||
|
onchain = await self.get("/v1/balance/blockchain")
|
||||||
|
fee_report = await self.get("/v1/fees")
|
||||||
|
balance = await self.get("/v1/balance/channels")
|
||||||
|
return NodeInfoResponse(
|
||||||
|
**public.dict(),
|
||||||
|
onchain_balance_sat=onchain["total_balance"],
|
||||||
|
onchain_confirmed_sat=onchain["confirmed_balance"],
|
||||||
|
balance_msat=balance["local_balance"]["msat"],
|
||||||
|
fees=NodeFees(
|
||||||
|
total_msat=0,
|
||||||
|
daily_msat=fee_report["day_fee_sum"],
|
||||||
|
weekly_msat=fee_report["week_fee_sum"],
|
||||||
|
monthly_msat=fee_report["month_fee_sum"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_payments(
|
||||||
|
self, filters: Filters[NodePaymentsFilters]
|
||||||
|
) -> Page[NodePayment]:
|
||||||
|
count_key = "node:payments_count"
|
||||||
|
payments_count = cache.get(count_key)
|
||||||
|
if not payments_count and filters.offset:
|
||||||
|
# this forces fetching the payments count
|
||||||
|
await self.get_payments(Filters(limit=1))
|
||||||
|
payments_count = cache.get(count_key)
|
||||||
|
|
||||||
|
if filters.offset and payments_count:
|
||||||
|
index_offset = max(payments_count + 1 - filters.offset, 0)
|
||||||
|
else:
|
||||||
|
index_offset = 0
|
||||||
|
|
||||||
|
response = await self.get(
|
||||||
|
"/v1/payments",
|
||||||
|
params={
|
||||||
|
"index_offset": index_offset,
|
||||||
|
"max_payments": filters.limit,
|
||||||
|
"include_incomplete": True,
|
||||||
|
"reversed": True,
|
||||||
|
"count_total_payments": not index_offset,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if not filters.offset:
|
||||||
|
payments_count = int(response["total_num_payments"])
|
||||||
|
|
||||||
|
cache.set(count_key, payments_count)
|
||||||
|
|
||||||
|
payments = [
|
||||||
|
NodePayment(
|
||||||
|
payment_hash=payment["payment_hash"],
|
||||||
|
pending=payment["status"] == "IN_FLIGHT",
|
||||||
|
amount=payment["value_msat"],
|
||||||
|
fee=payment["fee_msat"],
|
||||||
|
time=payment["creation_date"],
|
||||||
|
destination=await self.get_peer_info(
|
||||||
|
payment["htlcs"][0]["route"]["hops"][-1]["pub_key"]
|
||||||
|
)
|
||||||
|
if payment["htlcs"]
|
||||||
|
else None,
|
||||||
|
bolt11=payment["payment_request"],
|
||||||
|
preimage=payment["payment_preimage"],
|
||||||
|
)
|
||||||
|
for payment in response["payments"]
|
||||||
|
]
|
||||||
|
|
||||||
|
payments.sort(key=lambda p: p.time, reverse=True)
|
||||||
|
|
||||||
|
return Page(data=payments, total=payments_count or 0)
|
||||||
|
|
||||||
|
async def get_invoices(
|
||||||
|
self, filters: Filters[NodeInvoiceFilters]
|
||||||
|
) -> Page[NodeInvoice]:
|
||||||
|
last_invoice_key = "node:last_invoice_index"
|
||||||
|
last_invoice_index = cache.get(last_invoice_key)
|
||||||
|
if not last_invoice_index and filters.offset:
|
||||||
|
# this forces fetching the last invoice index so
|
||||||
|
await self.get_invoices(Filters(limit=1))
|
||||||
|
last_invoice_index = cache.get(last_invoice_key)
|
||||||
|
|
||||||
|
if filters.offset and last_invoice_index:
|
||||||
|
index_offset = max(last_invoice_index + 1 - filters.offset, 0)
|
||||||
|
else:
|
||||||
|
index_offset = 0
|
||||||
|
|
||||||
|
response = await self.get(
|
||||||
|
"/v1/invoices",
|
||||||
|
params={
|
||||||
|
"index_offset": index_offset,
|
||||||
|
"num_max_invoices": filters.limit,
|
||||||
|
"reversed": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if not filters.offset:
|
||||||
|
last_invoice_index = int(response["last_index_offset"])
|
||||||
|
|
||||||
|
cache.set(last_invoice_key, last_invoice_index)
|
||||||
|
|
||||||
|
invoices = [
|
||||||
|
NodeInvoice(
|
||||||
|
payment_hash=_decode_bytes(invoice["r_hash"]),
|
||||||
|
amount=invoice["value_msat"],
|
||||||
|
memo=invoice["memo"],
|
||||||
|
pending=invoice["state"] == "OPEN",
|
||||||
|
paid_at=invoice["settle_date"],
|
||||||
|
expiry=invoice["creation_date"] + invoice["expiry"],
|
||||||
|
preimage=_decode_bytes(invoice["r_preimage"]),
|
||||||
|
bolt11=invoice["payment_request"],
|
||||||
|
)
|
||||||
|
for invoice in reversed(response["invoices"])
|
||||||
|
]
|
||||||
|
|
||||||
|
return Page(
|
||||||
|
data=invoices,
|
||||||
|
total=last_invoice_index or 0,
|
||||||
|
)
|
||||||
+4
-2
@@ -38,8 +38,10 @@ def main(
|
|||||||
# create data dir if it does not exist
|
# create data dir if it does not exist
|
||||||
Path(settings.lnbits_data_folder).mkdir(parents=True, exist_ok=True)
|
Path(settings.lnbits_data_folder).mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
# create extension dir if it does not exist
|
# create `extensions`` dir if it does not exist
|
||||||
Path(settings.lnbits_path, "extensions").mkdir(parents=True, exist_ok=True)
|
Path(settings.lnbits_extensions_path, "extensions").mkdir(
|
||||||
|
parents=True, exist_ok=True
|
||||||
|
)
|
||||||
|
|
||||||
set_cli_settings(host=host, port=port, forwarded_allow_ips=forwarded_allow_ips)
|
set_cli_settings(host=host, port=port, forwarded_allow_ips=forwarded_allow_ips)
|
||||||
|
|
||||||
|
|||||||
+41
-27
@@ -1,15 +1,16 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import importlib
|
import importlib
|
||||||
import importlib.metadata
|
import importlib.metadata
|
||||||
import inspect
|
import inspect
|
||||||
import json
|
import json
|
||||||
import subprocess
|
|
||||||
from os import path
|
from os import path
|
||||||
from sqlite3 import Row
|
from sqlite3 import Row
|
||||||
from typing import Any, List, Optional
|
from typing import Any, List, Optional
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from pydantic import BaseSettings, Extra, Field, validator
|
from pydantic import BaseModel, BaseSettings, Extra, Field, validator
|
||||||
|
|
||||||
|
|
||||||
def list_parse_fallback(v: str):
|
def list_parse_fallback(v: str):
|
||||||
@@ -23,20 +24,13 @@ def list_parse_fallback(v: str):
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
class LNbitsSettings(BaseSettings):
|
class LNbitsSettings(BaseModel):
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate(cls, val):
|
def validate_list(cls, val):
|
||||||
if isinstance(val, str):
|
if isinstance(val, str):
|
||||||
val = val.split(",") if val else []
|
val = val.split(",") if val else []
|
||||||
return val
|
return val
|
||||||
|
|
||||||
class Config:
|
|
||||||
env_file = ".env"
|
|
||||||
env_file_encoding = "utf-8"
|
|
||||||
case_sensitive = False
|
|
||||||
json_loads = list_parse_fallback
|
|
||||||
extra = Extra.ignore
|
|
||||||
|
|
||||||
|
|
||||||
class UsersSettings(LNbitsSettings):
|
class UsersSettings(LNbitsSettings):
|
||||||
lnbits_admin_users: List[str] = Field(default=[])
|
lnbits_admin_users: List[str] = Field(default=[])
|
||||||
@@ -233,6 +227,16 @@ class WebPushSettings(LNbitsSettings):
|
|||||||
lnbits_webpush_privkey: str = Field(default=None)
|
lnbits_webpush_privkey: str = Field(default=None)
|
||||||
|
|
||||||
|
|
||||||
|
class NodeUISettings(LNbitsSettings):
|
||||||
|
# on-off switch for node ui
|
||||||
|
lnbits_node_ui: bool = Field(default=False)
|
||||||
|
# whether to display the public node ui (only if lnbits_node_ui is True)
|
||||||
|
lnbits_public_node_ui: bool = Field(default=False)
|
||||||
|
# can be used to disable the transactions tab in the node ui
|
||||||
|
# (recommended for large cln nodes)
|
||||||
|
lnbits_node_ui_transactions: bool = Field(default=False)
|
||||||
|
|
||||||
|
|
||||||
class EditableSettings(
|
class EditableSettings(
|
||||||
UsersSettings,
|
UsersSettings,
|
||||||
ExtensionsSettings,
|
ExtensionsSettings,
|
||||||
@@ -243,6 +247,7 @@ class EditableSettings(
|
|||||||
BoltzExtensionSettings,
|
BoltzExtensionSettings,
|
||||||
LightningSettings,
|
LightningSettings,
|
||||||
WebPushSettings,
|
WebPushSettings,
|
||||||
|
NodeUISettings,
|
||||||
):
|
):
|
||||||
@validator(
|
@validator(
|
||||||
"lnbits_admin_users",
|
"lnbits_admin_users",
|
||||||
@@ -253,7 +258,7 @@ class EditableSettings(
|
|||||||
)
|
)
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_editable_settings(cls, val):
|
def validate_editable_settings(cls, val):
|
||||||
return super().validate(val)
|
return super().validate_list(val)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, d: dict):
|
def from_dict(cls, d: dict):
|
||||||
@@ -269,6 +274,11 @@ class EditableSettings(
|
|||||||
prop.pop("env_names", None)
|
prop.pop("env_names", None)
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateSettings(EditableSettings):
|
||||||
|
class Config:
|
||||||
|
extra = Extra.forbid
|
||||||
|
|
||||||
|
|
||||||
class EnvSettings(LNbitsSettings):
|
class EnvSettings(LNbitsSettings):
|
||||||
debug: bool = Field(default=False)
|
debug: bool = Field(default=False)
|
||||||
bundle_assets: bool = Field(default=True)
|
bundle_assets: bool = Field(default=True)
|
||||||
@@ -277,10 +287,14 @@ class EnvSettings(LNbitsSettings):
|
|||||||
forwarded_allow_ips: str = Field(default="*")
|
forwarded_allow_ips: str = Field(default="*")
|
||||||
lnbits_title: str = Field(default="LNbits API")
|
lnbits_title: str = Field(default="LNbits API")
|
||||||
lnbits_path: str = Field(default=".")
|
lnbits_path: str = Field(default=".")
|
||||||
lnbits_commit: str = Field(default="unknown")
|
lnbits_extensions_path: str = Field(default="lnbits")
|
||||||
super_user: str = Field(default="")
|
super_user: str = Field(default="")
|
||||||
version: str = Field(default="0.0.0")
|
version: str = Field(default="0.0.0")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_default_extension_path(self) -> bool:
|
||||||
|
return self.lnbits_extensions_path == "lnbits"
|
||||||
|
|
||||||
|
|
||||||
class SaaSSettings(LNbitsSettings):
|
class SaaSSettings(LNbitsSettings):
|
||||||
lnbits_saas_callback: Optional[str] = Field(default=None)
|
lnbits_saas_callback: Optional[str] = Field(default=None)
|
||||||
@@ -338,19 +352,25 @@ class ReadOnlySettings(
|
|||||||
)
|
)
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_readonly_settings(cls, val):
|
def validate_readonly_settings(cls, val):
|
||||||
return super().validate(val)
|
return super().validate_list(val)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def readonly_fields(cls):
|
def readonly_fields(cls):
|
||||||
return [f for f in inspect.signature(cls).parameters if not f.startswith("_")]
|
return [f for f in inspect.signature(cls).parameters if not f.startswith("_")]
|
||||||
|
|
||||||
|
|
||||||
class Settings(EditableSettings, ReadOnlySettings, TransientSettings):
|
class Settings(EditableSettings, ReadOnlySettings, TransientSettings, BaseSettings):
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_row(cls, row: Row) -> "Settings":
|
def from_row(cls, row: Row) -> "Settings":
|
||||||
data = dict(row)
|
data = dict(row)
|
||||||
return cls(**data)
|
return cls(**data)
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
env_file = ".env"
|
||||||
|
env_file_encoding = "utf-8"
|
||||||
|
case_sensitive = False
|
||||||
|
json_loads = list_parse_fallback
|
||||||
|
|
||||||
|
|
||||||
class SuperSettings(EditableSettings):
|
class SuperSettings(EditableSettings):
|
||||||
super_user: str
|
super_user: str
|
||||||
@@ -398,20 +418,14 @@ settings = Settings()
|
|||||||
|
|
||||||
settings.lnbits_path = str(path.dirname(path.realpath(__file__)))
|
settings.lnbits_path = str(path.dirname(path.realpath(__file__)))
|
||||||
|
|
||||||
try:
|
|
||||||
settings.lnbits_commit = (
|
|
||||||
subprocess.check_output(
|
|
||||||
["git", "-C", settings.lnbits_path, "rev-parse", "HEAD"],
|
|
||||||
stderr=subprocess.DEVNULL,
|
|
||||||
)
|
|
||||||
.strip()
|
|
||||||
.decode("ascii")
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
settings.lnbits_commit = "docker"
|
|
||||||
|
|
||||||
settings.version = importlib.metadata.version("lnbits")
|
settings.version = importlib.metadata.version("lnbits")
|
||||||
|
|
||||||
|
# printing environment variable for debugging
|
||||||
|
if not settings.lnbits_admin_ui:
|
||||||
|
logger.debug("Environment Settings:")
|
||||||
|
for key, value in settings.dict(exclude_none=True).items():
|
||||||
|
logger.debug(f"{key}: {value}")
|
||||||
|
|
||||||
|
|
||||||
def get_wallet_class():
|
def get_wallet_class():
|
||||||
"""
|
"""
|
||||||
|
|||||||
Vendored
+10
-9
File diff suppressed because one or more lines are too long
@@ -37,7 +37,6 @@ window.localisation.br = {
|
|||||||
toggle_darkmode: 'Alternar modo escuro',
|
toggle_darkmode: 'Alternar modo escuro',
|
||||||
view_swagger_docs: 'Ver a documentação da API do LNbits Swagger',
|
view_swagger_docs: 'Ver a documentação da API do LNbits Swagger',
|
||||||
api_docs: 'Documentação da API',
|
api_docs: 'Documentação da API',
|
||||||
commit_version: 'Versão de commit',
|
|
||||||
lnbits_version: 'Versão do LNbits',
|
lnbits_version: 'Versão do LNbits',
|
||||||
runs_on: 'Executa em',
|
runs_on: 'Executa em',
|
||||||
credit_hint: 'Pressione Enter para creditar a conta',
|
credit_hint: 'Pressione Enter para creditar a conta',
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ window.localisation.cn = {
|
|||||||
toggle_darkmode: '切换暗黑模式',
|
toggle_darkmode: '切换暗黑模式',
|
||||||
view_swagger_docs: '查看 LNbits Swagger API 文档',
|
view_swagger_docs: '查看 LNbits Swagger API 文档',
|
||||||
api_docs: 'API文档',
|
api_docs: 'API文档',
|
||||||
commit_version: '提交版本',
|
|
||||||
lnbits_version: 'LNbits版本',
|
lnbits_version: 'LNbits版本',
|
||||||
runs_on: '可运行在',
|
runs_on: '可运行在',
|
||||||
credit_hint: '按 Enter 键充值账户',
|
credit_hint: '按 Enter 键充值账户',
|
||||||
|
|||||||
@@ -40,7 +40,6 @@ window.localisation.de = {
|
|||||||
toggle_darkmode: 'Auf Dark Mode umschalten',
|
toggle_darkmode: 'Auf Dark Mode umschalten',
|
||||||
view_swagger_docs: 'LNbits Swagger API-Dokumente',
|
view_swagger_docs: 'LNbits Swagger API-Dokumente',
|
||||||
api_docs: 'API docs',
|
api_docs: 'API docs',
|
||||||
commit_version: 'Commit Version',
|
|
||||||
runs_on: 'Läuft auf',
|
runs_on: 'Läuft auf',
|
||||||
credit_hint: 'Klicke Enter, um das Konto zu belasten',
|
credit_hint: 'Klicke Enter, um das Konto zu belasten',
|
||||||
credit_label: '%{denomination} zu belasten',
|
credit_label: '%{denomination} zu belasten',
|
||||||
|
|||||||
@@ -3,7 +3,25 @@ window.localisation.en = {
|
|||||||
server: 'Server',
|
server: 'Server',
|
||||||
theme: 'Theme',
|
theme: 'Theme',
|
||||||
funding: 'Funding',
|
funding: 'Funding',
|
||||||
|
|
||||||
users: 'Users',
|
users: 'Users',
|
||||||
|
apps: 'Apps',
|
||||||
|
channels: 'Channels',
|
||||||
|
transactions: 'Transactions',
|
||||||
|
dashboard: 'Dashboard',
|
||||||
|
manage_node: 'Manage Node',
|
||||||
|
total_capacity: 'Total Capacity',
|
||||||
|
avg_channel_size: 'Avg. Channel Size',
|
||||||
|
biggest_channel_size: 'Biggest Channel Size',
|
||||||
|
smallest_channel_size: 'Smallest Channel Size',
|
||||||
|
number_of_channels: 'Number of Channels',
|
||||||
|
active_channels: 'Active Channels',
|
||||||
|
connect_peer: 'Connect Peer',
|
||||||
|
connect: 'Connect',
|
||||||
|
open_channel: 'Open Channel',
|
||||||
|
open: 'Open',
|
||||||
|
close_channel: 'Close Channel',
|
||||||
|
close: 'Close',
|
||||||
restart: 'Restart server',
|
restart: 'Restart server',
|
||||||
save: 'Save',
|
save: 'Save',
|
||||||
save_tooltip: 'Save your changes',
|
save_tooltip: 'Save your changes',
|
||||||
@@ -40,7 +58,6 @@ window.localisation.en = {
|
|||||||
toggle_darkmode: 'Toggle Dark Mode',
|
toggle_darkmode: 'Toggle Dark Mode',
|
||||||
view_swagger_docs: 'View LNbits Swagger API docs',
|
view_swagger_docs: 'View LNbits Swagger API docs',
|
||||||
api_docs: 'Api docs',
|
api_docs: 'Api docs',
|
||||||
commit_version: 'Commit version',
|
|
||||||
lnbits_version: 'LNbits version',
|
lnbits_version: 'LNbits version',
|
||||||
runs_on: 'Runs on',
|
runs_on: 'Runs on',
|
||||||
credit_hint: 'Press Enter to credit account',
|
credit_hint: 'Press Enter to credit account',
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ window.localisation.es = {
|
|||||||
toggle_darkmode: 'Cambiar modo oscuro',
|
toggle_darkmode: 'Cambiar modo oscuro',
|
||||||
view_swagger_docs: 'Ver documentos de API de LNbits Swagger',
|
view_swagger_docs: 'Ver documentos de API de LNbits Swagger',
|
||||||
api_docs: 'Documentos de API',
|
api_docs: 'Documentos de API',
|
||||||
commit_version: 'Versión de compromiso',
|
|
||||||
runs_on: 'Corre en',
|
runs_on: 'Corre en',
|
||||||
credit_hint: 'Presione Enter para cargar la cuenta',
|
credit_hint: 'Presione Enter para cargar la cuenta',
|
||||||
credit_label: 'Cargar %{denomination}',
|
credit_label: 'Cargar %{denomination}',
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ window.localisation.fr = {
|
|||||||
toggle_darkmode: 'Basculer le mode sombre',
|
toggle_darkmode: 'Basculer le mode sombre',
|
||||||
view_swagger_docs: "Voir les documents de l'API Swagger de LNbits",
|
view_swagger_docs: "Voir les documents de l'API Swagger de LNbits",
|
||||||
api_docs: "Documents de l'API",
|
api_docs: "Documents de l'API",
|
||||||
commit_version: 'Version de commit',
|
|
||||||
lnbits_version: 'Version de LNbits',
|
lnbits_version: 'Version de LNbits',
|
||||||
runs_on: 'Fonctionne sur',
|
runs_on: 'Fonctionne sur',
|
||||||
credit_hint: 'Appuyez sur Entrée pour créditer le compte',
|
credit_hint: 'Appuyez sur Entrée pour créditer le compte',
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ window.localisation.it = {
|
|||||||
toggle_darkmode: 'Attiva la modalità notturna',
|
toggle_darkmode: 'Attiva la modalità notturna',
|
||||||
view_swagger_docs: "Visualizza i documenti dell'API Swagger di LNbits",
|
view_swagger_docs: "Visualizza i documenti dell'API Swagger di LNbits",
|
||||||
api_docs: 'Documenti API',
|
api_docs: 'Documenti API',
|
||||||
commit_version: 'Commit version',
|
|
||||||
lnbits_version: 'Versione di LNbits',
|
lnbits_version: 'Versione di LNbits',
|
||||||
runs_on: 'Esegue su',
|
runs_on: 'Esegue su',
|
||||||
credit_hint: 'Premere Invio per accreditare i fondi',
|
credit_hint: 'Premere Invio per accreditare i fondi',
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ window.localisation.jp = {
|
|||||||
toggle_dark_mode: 'ダークモードを切り替える',
|
toggle_dark_mode: 'ダークモードを切り替える',
|
||||||
view_swagger_docs: 'Swaggerドキュメントを表示',
|
view_swagger_docs: 'Swaggerドキュメントを表示',
|
||||||
api_docs: 'APIドキュメント',
|
api_docs: 'APIドキュメント',
|
||||||
commit_version: 'コミットバージョン',
|
|
||||||
runs_on: 'で実行',
|
runs_on: 'で実行',
|
||||||
credit_hint:
|
credit_hint:
|
||||||
'クレジットカードを使用して資金を追加するには、LNbitsを使用してください。',
|
'クレジットカードを使用して資金を追加するには、LNbitsを使用してください。',
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ window.localisation.nl = {
|
|||||||
toggle_darkmode: 'Donkere modus aan/uit',
|
toggle_darkmode: 'Donkere modus aan/uit',
|
||||||
view_swagger_docs: 'Bekijk LNbits Swagger API-documentatie',
|
view_swagger_docs: 'Bekijk LNbits Swagger API-documentatie',
|
||||||
api_docs: 'API-documentatie',
|
api_docs: 'API-documentatie',
|
||||||
commit_version: 'Commit-versie',
|
|
||||||
lnbits_version: 'LNbits-versie',
|
lnbits_version: 'LNbits-versie',
|
||||||
runs_on: 'Draait op',
|
runs_on: 'Draait op',
|
||||||
credit_hint: 'Druk op Enter om de rekening te crediteren',
|
credit_hint: 'Druk op Enter om de rekening te crediteren',
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ window.localisation.pi = {
|
|||||||
toggle_darkmode: 'Toggle Dark Mode, arr!',
|
toggle_darkmode: 'Toggle Dark Mode, arr!',
|
||||||
view_swagger_docs: 'View LNbits Swagger API docs and learn the secrets',
|
view_swagger_docs: 'View LNbits Swagger API docs and learn the secrets',
|
||||||
api_docs: 'API docs for the scallywags',
|
api_docs: 'API docs for the scallywags',
|
||||||
commit_version: 'Commit version like a true pirate',
|
|
||||||
lnbits_version: 'LNbits version, arr!',
|
lnbits_version: 'LNbits version, arr!',
|
||||||
runs_on: 'Runs on, matey',
|
runs_on: 'Runs on, matey',
|
||||||
credit_hint: 'Press Enter to credit account and make it richer',
|
credit_hint: 'Press Enter to credit account and make it richer',
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ window.localisation.pl = {
|
|||||||
toggle_darkmode: 'Tryb nocny',
|
toggle_darkmode: 'Tryb nocny',
|
||||||
view_swagger_docs: 'Dokumentacja Swagger API',
|
view_swagger_docs: 'Dokumentacja Swagger API',
|
||||||
api_docs: 'Dokumentacja API',
|
api_docs: 'Dokumentacja API',
|
||||||
commit_version: 'Commit',
|
|
||||||
lnbits_version: 'Wersja LNbits',
|
lnbits_version: 'Wersja LNbits',
|
||||||
runs_on: 'Działa na',
|
runs_on: 'Działa na',
|
||||||
credit_hint: 'Naciśnij Enter aby doładować konto',
|
credit_hint: 'Naciśnij Enter aby doładować konto',
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ window.localisation.pt = {
|
|||||||
toggle_darkmode: 'Alternar modo escuro',
|
toggle_darkmode: 'Alternar modo escuro',
|
||||||
view_swagger_docs: 'Ver a documentação da API do LNbits Swagger',
|
view_swagger_docs: 'Ver a documentação da API do LNbits Swagger',
|
||||||
api_docs: 'Documentação da API',
|
api_docs: 'Documentação da API',
|
||||||
commit_version: 'Versão de commit',
|
|
||||||
lnbits_version: 'Versão do LNbits',
|
lnbits_version: 'Versão do LNbits',
|
||||||
runs_on: 'Executa em',
|
runs_on: 'Executa em',
|
||||||
credit_hint: 'Pressione Enter para creditar a conta',
|
credit_hint: 'Pressione Enter para creditar a conta',
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ window.localisation.we = {
|
|||||||
toggle_darkmode: 'Toglo Modd Tywyll',
|
toggle_darkmode: 'Toglo Modd Tywyll',
|
||||||
view_swagger_docs: 'Gweld dogfennau API LNbits Swagger',
|
view_swagger_docs: 'Gweld dogfennau API LNbits Swagger',
|
||||||
api_docs: 'Api docs',
|
api_docs: 'Api docs',
|
||||||
commit_version: 'fersiwn ymrwymo',
|
|
||||||
lnbits_version: 'Fersiwn LNbits',
|
lnbits_version: 'Fersiwn LNbits',
|
||||||
Runs_on: 'Yn rhedeg ymlaen',
|
Runs_on: 'Yn rhedeg ymlaen',
|
||||||
credit_hint: 'Pwyswch Enter i gyfrif credyd',
|
credit_hint: 'Pwyswch Enter i gyfrif credyd',
|
||||||
|
|||||||
+38
-14
@@ -64,9 +64,35 @@ window.LNbits = {
|
|||||||
callback
|
callback
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
createAccount: function (name) {
|
||||||
|
return this.request('post', '/api/v1/account', null, {
|
||||||
|
name: name
|
||||||
|
})
|
||||||
|
},
|
||||||
getWallet: function (wallet) {
|
getWallet: function (wallet) {
|
||||||
return this.request('get', '/api/v1/wallet', wallet.inkey)
|
return this.request('get', '/api/v1/wallet', wallet.inkey)
|
||||||
},
|
},
|
||||||
|
createWallet: function (wallet, name) {
|
||||||
|
return this.request('post', '/api/v1/wallet', wallet.adminkey, {
|
||||||
|
name: name
|
||||||
|
}).then(res => {
|
||||||
|
window.location = '/wallet?usr=' + res.data.user + '&wal=' + res.data.id
|
||||||
|
})
|
||||||
|
},
|
||||||
|
updateWallet: function (name, wallet) {
|
||||||
|
return this.request('patch', '/api/v1/wallet', wallet.adminkey, {
|
||||||
|
name: name
|
||||||
|
})
|
||||||
|
},
|
||||||
|
deleteWallet: function (wallet) {
|
||||||
|
return this.request('delete', '/api/v1/wallet', wallet.adminkey).then(
|
||||||
|
_ => {
|
||||||
|
let url = new URL(window.location.href)
|
||||||
|
url.searchParams.delete('wal')
|
||||||
|
window.location = url
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
getPayments: function (wallet, query) {
|
getPayments: function (wallet, query) {
|
||||||
const params = new URLSearchParams(query)
|
const params = new URLSearchParams(query)
|
||||||
return this.request(
|
return this.request(
|
||||||
@@ -118,18 +144,6 @@ window.LNbits = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
href: {
|
|
||||||
createWallet: function (walletName, userId) {
|
|
||||||
window.location.href =
|
|
||||||
'/wallet?' + (userId ? 'usr=' + userId + '&' : '') + 'nme=' + walletName
|
|
||||||
},
|
|
||||||
updateWallet: function (walletName, userId, walletId) {
|
|
||||||
window.location.href = `/wallet?usr=${userId}&wal=${walletId}&nme=${walletName}`
|
|
||||||
},
|
|
||||||
deleteWallet: function (walletId, userId) {
|
|
||||||
window.location.href = '/deletewallet?usr=' + userId + '&wal=' + walletId
|
|
||||||
}
|
|
||||||
},
|
|
||||||
map: {
|
map: {
|
||||||
extension: function (data) {
|
extension: function (data) {
|
||||||
var obj = _.object(
|
var obj = _.object(
|
||||||
@@ -221,7 +235,7 @@ window.LNbits = {
|
|||||||
obj.expirydateFrom = moment(obj.expirydate).fromNow()
|
obj.expirydateFrom = moment(obj.expirydate).fromNow()
|
||||||
obj.msat = obj.amount
|
obj.msat = obj.amount
|
||||||
obj.sat = obj.msat / 1000
|
obj.sat = obj.msat / 1000
|
||||||
obj.tag = obj.extra.tag
|
obj.tag = obj.extra?.tag
|
||||||
obj.fsat = new Intl.NumberFormat(window.LOCALE).format(obj.sat)
|
obj.fsat = new Intl.NumberFormat(window.LOCALE).format(obj.sat)
|
||||||
obj.isIn = obj.amount > 0
|
obj.isIn = obj.amount > 0
|
||||||
obj.isOut = obj.amount < 0
|
obj.isOut = obj.amount < 0
|
||||||
@@ -262,6 +276,9 @@ window.LNbits = {
|
|||||||
formatSat: function (value) {
|
formatSat: function (value) {
|
||||||
return new Intl.NumberFormat(window.LOCALE).format(value)
|
return new Intl.NumberFormat(window.LOCALE).format(value)
|
||||||
},
|
},
|
||||||
|
formatMsat: function (value) {
|
||||||
|
return this.formatSat(value / 1000)
|
||||||
|
},
|
||||||
notifyApiError: function (error) {
|
notifyApiError: function (error) {
|
||||||
var types = {
|
var types = {
|
||||||
400: 'warning',
|
400: 'warning',
|
||||||
@@ -340,6 +357,12 @@ window.LNbits = {
|
|||||||
icon: null
|
icon: null
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
convertMarkdown(text) {
|
||||||
|
const converter = new showdown.Converter()
|
||||||
|
converter.setFlavor('github')
|
||||||
|
converter.setOption('simpleLineBreaks', true)
|
||||||
|
return converter.makeHtml(text)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -348,6 +371,7 @@ window.windowMixin = {
|
|||||||
i18n: window.i18n,
|
i18n: window.i18n,
|
||||||
data: function () {
|
data: function () {
|
||||||
return {
|
return {
|
||||||
|
toggleSubs: true,
|
||||||
g: {
|
g: {
|
||||||
offline: !navigator.onLine,
|
offline: !navigator.onLine,
|
||||||
visibleDrawer: false,
|
visibleDrawer: false,
|
||||||
@@ -451,7 +475,7 @@ window.windowMixin = {
|
|||||||
return !obj.hidden
|
return !obj.hidden
|
||||||
})
|
})
|
||||||
.filter(function (obj) {
|
.filter(function (obj) {
|
||||||
if (window.user.admin) return obj
|
if (window.user?.admin) return obj
|
||||||
return !obj.isAdminOnly
|
return !obj.isAdminOnly
|
||||||
})
|
})
|
||||||
.map(function (obj) {
|
.map(function (obj) {
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ Vue.component('lnbits-wallet-list', {
|
|||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
createWallet: function () {
|
createWallet: function () {
|
||||||
LNbits.href.createWallet(this.walletName, this.user.id)
|
LNbits.api.createWallet(this.user.wallets[0], this.walletName)
|
||||||
},
|
},
|
||||||
updateWalletBalance: function (payload) {
|
updateWalletBalance: function (payload) {
|
||||||
this.activeBalance = payload
|
this.activeBalance = payload
|
||||||
@@ -178,6 +178,7 @@ Vue.component('lnbits-extension-list', {
|
|||||||
})
|
})
|
||||||
|
|
||||||
Vue.component('lnbits-admin-ui', {
|
Vue.component('lnbits-admin-ui', {
|
||||||
|
props: ['showNode'],
|
||||||
data: function () {
|
data: function () {
|
||||||
return {
|
return {
|
||||||
extensions: [],
|
extensions: [],
|
||||||
@@ -195,6 +196,14 @@ Vue.component('lnbits-admin-ui', {
|
|||||||
<q-item-label lines="1" class="text-caption" v-text="$t('manage_server')"></q-item-label>
|
<q-item-label lines="1" class="text-caption" v-text="$t('manage_server')"></q-item-label>
|
||||||
</q-item-section>
|
</q-item-section>
|
||||||
</q-item>
|
</q-item>
|
||||||
|
<q-item v-if='showNode' clickable tag="a" :href="['/node?usr=', user.id].join('')">
|
||||||
|
<q-item-section side>
|
||||||
|
<q-icon name="developer_board" color="grey-5" size="md"></q-icon>
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label lines="1" class="text-caption" v-text="$t('manage_node')"></q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
</q-list>
|
</q-list>
|
||||||
`,
|
`,
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"/static/vendor/quasar.umd.js",
|
"/static/vendor/quasar.umd.js",
|
||||||
"/static/vendor/Chart.bundle.js",
|
"/static/vendor/Chart.bundle.js",
|
||||||
"/static/vendor/vue-i18n.js",
|
"/static/vendor/vue-i18n.js",
|
||||||
|
"/static/vendor/showdown.js",
|
||||||
"/static/i18n/i18n.js",
|
"/static/i18n/i18n.js",
|
||||||
"/static/i18n/de.js",
|
"/static/i18n/de.js",
|
||||||
"/static/i18n/en.js",
|
"/static/i18n/en.js",
|
||||||
|
|||||||
Vendored
+5157
File diff suppressed because it is too large
Load Diff
+27
-30
@@ -22,8 +22,6 @@ from lnbits.core.services import redeem_lnurl_withdraw
|
|||||||
from lnbits.settings import settings
|
from lnbits.settings import settings
|
||||||
from lnbits.wallets import get_wallet_class
|
from lnbits.wallets import get_wallet_class
|
||||||
|
|
||||||
from .core import db
|
|
||||||
|
|
||||||
tasks: List[asyncio.Task] = []
|
tasks: List[asyncio.Task] = []
|
||||||
|
|
||||||
|
|
||||||
@@ -150,37 +148,36 @@ async def check_pending_payments():
|
|||||||
incoming = True
|
incoming = True
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
async with db.connect() as conn:
|
logger.info(
|
||||||
logger.info(
|
f"Task: checking all pending payments (incoming={incoming},"
|
||||||
f"Task: checking all pending payments (incoming={incoming},"
|
f" outgoing={outgoing}) of last 15 days"
|
||||||
f" outgoing={outgoing}) of last 15 days"
|
)
|
||||||
)
|
start_time = time.time()
|
||||||
start_time = time.time()
|
pending_payments = await get_payments(
|
||||||
pending_payments = await get_payments(
|
since=(int(time.time()) - 60 * 60 * 24 * 15), # 15 days ago
|
||||||
since=(int(time.time()) - 60 * 60 * 24 * 15), # 15 days ago
|
complete=False,
|
||||||
complete=False,
|
pending=True,
|
||||||
pending=True,
|
outgoing=outgoing,
|
||||||
outgoing=outgoing,
|
incoming=incoming,
|
||||||
incoming=incoming,
|
exclude_uncheckable=True,
|
||||||
exclude_uncheckable=True,
|
)
|
||||||
conn=conn,
|
for payment in pending_payments:
|
||||||
)
|
await payment.check_status()
|
||||||
for payment in pending_payments:
|
await asyncio.sleep(0.01) # to avoid complete blocking
|
||||||
await payment.check_status(conn=conn)
|
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Task: pending check finished for {len(pending_payments)} payments"
|
||||||
|
f" (took {time.time() - start_time:0.3f} s)"
|
||||||
|
)
|
||||||
|
# we delete expired invoices once upon the first pending check
|
||||||
|
if incoming:
|
||||||
|
logger.debug("Task: deleting all expired invoices")
|
||||||
|
start_time = time.time()
|
||||||
|
await delete_expired_invoices()
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Task: pending check finished for {len(pending_payments)} payments"
|
"Task: expired invoice deletion finished (took"
|
||||||
f" (took {time.time() - start_time:0.3f} s)"
|
f" {time.time() - start_time:0.3f} s)"
|
||||||
)
|
)
|
||||||
# we delete expired invoices once upon the first pending check
|
|
||||||
if incoming:
|
|
||||||
logger.debug("Task: deleting all expired invoices")
|
|
||||||
start_time = time.time()
|
|
||||||
await delete_expired_invoices(conn=conn)
|
|
||||||
logger.info(
|
|
||||||
"Task: expired invoice deletion finished (took"
|
|
||||||
f" {time.time() - start_time:0.3f} s)"
|
|
||||||
)
|
|
||||||
|
|
||||||
# after the first check we will only check outgoing, not incoming
|
# after the first check we will only check outgoing, not incoming
|
||||||
# that will be handled by the global invoice listeners, hopefully
|
# that will be handled by the global invoice listeners, hopefully
|
||||||
|
|||||||
+15
-17
@@ -228,6 +228,7 @@
|
|||||||
|
|
||||||
<lnbits-admin-ui
|
<lnbits-admin-ui
|
||||||
v-if="'{{LNBITS_ADMIN_UI}}' == 'True'"
|
v-if="'{{LNBITS_ADMIN_UI}}' == 'True'"
|
||||||
|
:show-node="'{{LNBITS_NODE_UI}}' == 'True'"
|
||||||
></lnbits-admin-ui>
|
></lnbits-admin-ui>
|
||||||
<lnbits-extension-list class="q-pb-xl"></lnbits-extension-list>
|
<lnbits-extension-list class="q-pb-xl"></lnbits-extension-list>
|
||||||
</q-drawer>
|
</q-drawer>
|
||||||
@@ -252,10 +253,6 @@
|
|||||||
>{% raw %}{{ $t('lnbits_version') }}{% endraw %}:
|
>{% raw %}{{ $t('lnbits_version') }}{% endraw %}:
|
||||||
{{LNBITS_VERSION}},
|
{{LNBITS_VERSION}},
|
||||||
</small>
|
</small>
|
||||||
<small
|
|
||||||
>{% raw %}{{ $t('commit_version') }}{% endraw %}:
|
|
||||||
{{COMMIT_VERSION}}</small
|
|
||||||
>
|
|
||||||
</q-toolbar-title>
|
</q-toolbar-title>
|
||||||
<q-space></q-space>
|
<q-space></q-space>
|
||||||
<q-btn
|
<q-btn
|
||||||
@@ -297,6 +294,7 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
<!---->
|
<!---->
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
|
const SITE_DESCRIPTION = {{ SITE_DESCRIPTION | tojson}}
|
||||||
const themes = {{ LNBITS_THEME_OPTIONS | tojson }}
|
const themes = {{ LNBITS_THEME_OPTIONS | tojson }}
|
||||||
const LNBITS_DENOMINATION = {{ LNBITS_DENOMINATION | tojson }}
|
const LNBITS_DENOMINATION = {{ LNBITS_DENOMINATION | tojson }}
|
||||||
const LNBITS_VERSION = {{ LNBITS_VERSION | tojson }}
|
const LNBITS_VERSION = {{ LNBITS_VERSION | tojson }}
|
||||||
@@ -304,19 +302,19 @@
|
|||||||
window.allowedThemes = themes.map(str => str.trim())
|
window.allowedThemes = themes.map(str => str.trim())
|
||||||
}
|
}
|
||||||
window.langs = [
|
window.langs = [
|
||||||
{value: 'en', label: 'English', display: 'EN'},
|
{ value: 'en', label: 'English', display: 'EN' },
|
||||||
{value: 'de', label: 'Deutsch', display: 'DE'},
|
{ value: 'de', label: 'Deutsch', display: 'DE' },
|
||||||
{value: 'es', label: 'Español', display: 'ES'},
|
{ value: 'es', label: 'Español', display: 'ES' },
|
||||||
{value: 'jp', label: '日本語', display: 'JP'},
|
{ value: 'jp', label: '日本語', display: 'JP' },
|
||||||
{value: 'cn', label: '中文', display: 'CN'},
|
{ value: 'cn', label: '中文', display: 'CN' },
|
||||||
{value: 'fr', label: 'Français', display: 'FR'},
|
{ value: 'fr', label: 'Français', display: 'FR' },
|
||||||
{value: 'it', label: 'Italiano', display: 'IT'},
|
{ value: 'it', label: 'Italiano', display: 'IT' },
|
||||||
{value: 'pi', label: 'Pirate', display: 'PI'},
|
{ value: 'pi', label: 'Pirate', display: 'PI' },
|
||||||
{value: 'nl', label: 'Nederlands', display: 'NL'},
|
{ value: 'nl', label: 'Nederlands', display: 'NL' },
|
||||||
{value: 'we', label: 'Cymraeg', display: 'CY'},
|
{ value: 'we', label: 'Cymraeg', display: 'CY' },
|
||||||
{value: 'pl', label: 'Polski', display: 'PL'},
|
{ value: 'pl', label: 'Polski', display: 'PL' },
|
||||||
{value: 'pt', label: 'Português', display: 'PT'},
|
{ value: 'pt', label: 'Português', display: 'PT' },
|
||||||
{value: 'br', label: 'Português', display: 'BR'}
|
{ value: 'br', label: 'Português', display: 'BR' }
|
||||||
]
|
]
|
||||||
</script>
|
</script>
|
||||||
{% block scripts %}{% endblock %}
|
{% block scripts %}{% endblock %}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
import importlib
|
import importlib
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
from lnbits.nodes import set_node_class
|
||||||
from lnbits.settings import settings
|
from lnbits.settings import settings
|
||||||
from lnbits.wallets.base import Wallet
|
from lnbits.wallets.base import Wallet
|
||||||
|
|
||||||
@@ -27,6 +28,8 @@ def set_wallet_class(class_name: Optional[str] = None):
|
|||||||
wallet_class = getattr(wallets_module, backend_wallet_class)
|
wallet_class = getattr(wallets_module, backend_wallet_class)
|
||||||
global WALLET
|
global WALLET
|
||||||
WALLET = wallet_class()
|
WALLET = wallet_class()
|
||||||
|
if WALLET.__node_cls__:
|
||||||
|
set_node_class(WALLET.__node_cls__(WALLET))
|
||||||
|
|
||||||
|
|
||||||
def get_wallet_class() -> Wallet:
|
def get_wallet_class() -> Wallet:
|
||||||
@@ -34,7 +37,7 @@ def get_wallet_class() -> Wallet:
|
|||||||
|
|
||||||
|
|
||||||
wallets_module = importlib.import_module("lnbits.wallets")
|
wallets_module = importlib.import_module("lnbits.wallets")
|
||||||
FAKE_WALLET: Wallet = FakeWallet()
|
FAKE_WALLET = FakeWallet()
|
||||||
|
|
||||||
# initialize as fake wallet
|
# initialize as fake wallet
|
||||||
WALLET: Wallet = FAKE_WALLET
|
WALLET: Wallet = FAKE_WALLET
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from typing import AsyncGenerator, Coroutine, NamedTuple, Optional
|
from typing import TYPE_CHECKING, AsyncGenerator, Coroutine, NamedTuple, Optional, Type
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from lnbits.nodes.base import Node
|
||||||
|
|
||||||
|
|
||||||
class StatusResponse(NamedTuple):
|
class StatusResponse(NamedTuple):
|
||||||
@@ -51,6 +56,8 @@ class Wallet(ABC):
|
|||||||
async def cleanup(self):
|
async def cleanup(self):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
__node_cls__: Optional[Type[Node]] = None
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def status(self) -> Coroutine[None, None, StatusResponse]:
|
def status(self) -> Coroutine[None, None, StatusResponse]:
|
||||||
pass
|
pass
|
||||||
@@ -61,6 +68,7 @@ class Wallet(ABC):
|
|||||||
amount: int,
|
amount: int,
|
||||||
memo: Optional[str] = None,
|
memo: Optional[str] = None,
|
||||||
description_hash: Optional[bytes] = None,
|
description_hash: Optional[bytes] = None,
|
||||||
|
unhashed_description: Optional[bytes] = None,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
) -> Coroutine[None, None, InvoiceResponse]:
|
) -> Coroutine[None, None, InvoiceResponse]:
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ import asyncio
|
|||||||
import random
|
import random
|
||||||
from typing import Any, AsyncGenerator, Optional
|
from typing import Any, AsyncGenerator, Optional
|
||||||
|
|
||||||
|
from bolt11.decode import decode as bolt11_decode
|
||||||
|
from bolt11.exceptions import Bolt11Exception
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from pyln.client import LightningRpc, RpcError
|
from pyln.client import LightningRpc, RpcError
|
||||||
|
|
||||||
from lnbits import bolt11 as lnbits_bolt11
|
from lnbits.nodes.cln import CoreLightningNode
|
||||||
from lnbits.settings import settings
|
from lnbits.settings import settings
|
||||||
|
|
||||||
from .base import (
|
from .base import (
|
||||||
@@ -24,6 +26,8 @@ async def run_sync(func) -> Any:
|
|||||||
|
|
||||||
|
|
||||||
class CoreLightningWallet(Wallet):
|
class CoreLightningWallet(Wallet):
|
||||||
|
__node_cls__ = CoreLightningNode
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.rpc = settings.corelightning_rpc or settings.clightning_rpc
|
self.rpc = settings.corelightning_rpc or settings.clightning_rpc
|
||||||
self.ln = LightningRpc(self.rpc)
|
self.ln = LightningRpc(self.rpc)
|
||||||
@@ -95,12 +99,20 @@ class CoreLightningWallet(Wallet):
|
|||||||
return InvoiceResponse(False, None, None, str(e))
|
return InvoiceResponse(False, None, None, str(e))
|
||||||
|
|
||||||
async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
|
async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
|
||||||
invoice = lnbits_bolt11.decode(bolt11)
|
try:
|
||||||
|
invoice = bolt11_decode(bolt11)
|
||||||
|
except Bolt11Exception as exc:
|
||||||
|
return PaymentResponse(False, None, None, None, str(exc))
|
||||||
|
|
||||||
previous_payment = await self.get_payment_status(invoice.payment_hash)
|
previous_payment = await self.get_payment_status(invoice.payment_hash)
|
||||||
if previous_payment.paid:
|
if previous_payment.paid:
|
||||||
return PaymentResponse(False, None, None, None, "invoice already paid")
|
return PaymentResponse(False, None, None, None, "invoice already paid")
|
||||||
|
|
||||||
|
if not invoice.amount_msat or invoice.amount_msat <= 0:
|
||||||
|
return PaymentResponse(
|
||||||
|
False, None, None, None, "CLN 0 amount invoice not supported"
|
||||||
|
)
|
||||||
|
|
||||||
fee_limit_percent = fee_limit_msat / invoice.amount_msat * 100
|
fee_limit_percent = fee_limit_msat / invoice.amount_msat * 100
|
||||||
# so fee_limit_percent is applied even on payments with fee < 5000 millisatoshi
|
# so fee_limit_percent is applied even on payments with fee < 5000 millisatoshi
|
||||||
# (which is default value of exemptfee)
|
# (which is default value of exemptfee)
|
||||||
@@ -108,6 +120,9 @@ class CoreLightningWallet(Wallet):
|
|||||||
"bolt11": bolt11,
|
"bolt11": bolt11,
|
||||||
"maxfeepercent": f"{fee_limit_percent:.11}",
|
"maxfeepercent": f"{fee_limit_percent:.11}",
|
||||||
"exemptfee": 0,
|
"exemptfee": 0,
|
||||||
|
# so fee_limit_percent is applied even on payments with fee < 5000
|
||||||
|
# millisatoshi (which is default value of exemptfee)
|
||||||
|
"description": invoice.description,
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
r = await run_sync(lambda: self.ln.call("pay", payload))
|
r = await run_sync(lambda: self.ln.call("pay", payload))
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ import random
|
|||||||
from typing import AsyncGenerator, Dict, Optional
|
from typing import AsyncGenerator, Dict, Optional
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
from bolt11 import Bolt11Exception
|
||||||
|
from bolt11.decode import decode
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from lnbits import bolt11 as lnbits_bolt11
|
|
||||||
from lnbits.settings import settings
|
from lnbits.settings import settings
|
||||||
|
|
||||||
from .base import (
|
from .base import (
|
||||||
@@ -129,7 +130,14 @@ class CoreLightningRestWallet(Wallet):
|
|||||||
return InvoiceResponse(True, label, data["bolt11"], None)
|
return InvoiceResponse(True, label, data["bolt11"], None)
|
||||||
|
|
||||||
async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
|
async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
|
||||||
invoice = lnbits_bolt11.decode(bolt11)
|
try:
|
||||||
|
invoice = decode(bolt11)
|
||||||
|
except Bolt11Exception as exc:
|
||||||
|
return PaymentResponse(False, None, None, None, str(exc))
|
||||||
|
|
||||||
|
if not invoice.amount_msat or invoice.amount_msat <= 0:
|
||||||
|
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
|
fee_limit_percent = fee_limit_msat / invoice.amount_msat * 100
|
||||||
r = await self.client.post(
|
r = await self.client.post(
|
||||||
f"{self.url}/v1/pay",
|
f"{self.url}/v1/pay",
|
||||||
@@ -184,7 +192,7 @@ class CoreLightningRestWallet(Wallet):
|
|||||||
return PaymentStatus(None)
|
return PaymentStatus(None)
|
||||||
|
|
||||||
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
|
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
|
||||||
from lnbits.core import get_standalone_payment
|
from lnbits.core.services import get_standalone_payment
|
||||||
|
|
||||||
payment = await get_standalone_payment(checking_id)
|
payment = await get_standalone_payment(checking_id)
|
||||||
if not payment:
|
if not payment:
|
||||||
|
|||||||
+50
-30
@@ -2,13 +2,22 @@ import asyncio
|
|||||||
import hashlib
|
import hashlib
|
||||||
import random
|
import random
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import AsyncGenerator, Dict, Optional
|
from os import urandom
|
||||||
|
from typing import AsyncGenerator, Optional
|
||||||
|
|
||||||
|
from bolt11 import (
|
||||||
|
Bolt11,
|
||||||
|
Bolt11Exception,
|
||||||
|
MilliSatoshi,
|
||||||
|
TagChar,
|
||||||
|
Tags,
|
||||||
|
decode,
|
||||||
|
encode,
|
||||||
|
)
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from lnbits.settings import settings
|
from lnbits.settings import settings
|
||||||
|
|
||||||
from ..bolt11 import Invoice, decode, encode
|
|
||||||
from .base import (
|
from .base import (
|
||||||
InvoiceResponse,
|
InvoiceResponse,
|
||||||
PaymentResponse,
|
PaymentResponse,
|
||||||
@@ -42,44 +51,55 @@ class FakeWallet(Wallet):
|
|||||||
memo: Optional[str] = None,
|
memo: Optional[str] = None,
|
||||||
description_hash: Optional[bytes] = None,
|
description_hash: Optional[bytes] = None,
|
||||||
unhashed_description: Optional[bytes] = None,
|
unhashed_description: Optional[bytes] = None,
|
||||||
**kwargs,
|
expiry: Optional[int] = None,
|
||||||
|
payment_secret: Optional[bytes] = None,
|
||||||
|
**_,
|
||||||
) -> InvoiceResponse:
|
) -> InvoiceResponse:
|
||||||
data: Dict = {
|
tags = Tags()
|
||||||
"out": False,
|
|
||||||
"amount": amount * 1000,
|
|
||||||
"currency": "bc",
|
|
||||||
"privkey": self.privkey,
|
|
||||||
"memo": memo,
|
|
||||||
"description_hash": b"",
|
|
||||||
"description": "",
|
|
||||||
"fallback": None,
|
|
||||||
"expires": kwargs.get("expiry"),
|
|
||||||
"timestamp": datetime.now().timestamp(),
|
|
||||||
"route": None,
|
|
||||||
"tags_set": [],
|
|
||||||
}
|
|
||||||
if description_hash:
|
if description_hash:
|
||||||
data["tags_set"] = ["h"]
|
tags.add(TagChar.description_hash, description_hash.hex())
|
||||||
data["description_hash"] = description_hash
|
|
||||||
elif unhashed_description:
|
elif unhashed_description:
|
||||||
data["tags_set"] = ["h"]
|
tags.add(
|
||||||
data["description_hash"] = hashlib.sha256(unhashed_description).digest()
|
TagChar.description_hash,
|
||||||
|
hashlib.sha256(unhashed_description).hexdigest(),
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
data["tags_set"] = ["d"]
|
tags.add(TagChar.description, memo or "")
|
||||||
data["memo"] = memo
|
|
||||||
data["description"] = memo
|
if expiry:
|
||||||
randomHash = (
|
tags.add(TagChar.expire_time, expiry)
|
||||||
|
|
||||||
|
# random hash
|
||||||
|
checking_id = (
|
||||||
self.privkey[:6]
|
self.privkey[:6]
|
||||||
+ hashlib.sha256(str(random.getrandbits(256)).encode()).hexdigest()[6:]
|
+ hashlib.sha256(str(random.getrandbits(256)).encode()).hexdigest()[6:]
|
||||||
)
|
)
|
||||||
data["paymenthash"] = randomHash
|
|
||||||
payment_request = encode(data)
|
tags.add(TagChar.payment_hash, checking_id)
|
||||||
checking_id = randomHash
|
|
||||||
|
if payment_secret:
|
||||||
|
secret = payment_secret.hex()
|
||||||
|
else:
|
||||||
|
secret = urandom(32).hex()
|
||||||
|
tags.add(TagChar.payment_secret, secret)
|
||||||
|
|
||||||
|
bolt11 = Bolt11(
|
||||||
|
currency="bc",
|
||||||
|
amount_msat=MilliSatoshi(amount * 1000),
|
||||||
|
date=int(datetime.now().timestamp()),
|
||||||
|
tags=tags,
|
||||||
|
)
|
||||||
|
|
||||||
|
payment_request = encode(bolt11, self.privkey)
|
||||||
|
|
||||||
return InvoiceResponse(True, checking_id, payment_request)
|
return InvoiceResponse(True, checking_id, payment_request)
|
||||||
|
|
||||||
async def pay_invoice(self, bolt11: str, _: int) -> PaymentResponse:
|
async def pay_invoice(self, bolt11: str, _: int) -> PaymentResponse:
|
||||||
invoice = decode(bolt11)
|
try:
|
||||||
|
invoice = decode(bolt11)
|
||||||
|
except Bolt11Exception as exc:
|
||||||
|
return PaymentResponse(ok=False, error_message=str(exc))
|
||||||
|
|
||||||
if invoice.payment_hash[:6] == self.privkey[:6]:
|
if invoice.payment_hash[:6] == self.privkey[:6]:
|
||||||
await self.queue.put(invoice)
|
await self.queue.put(invoice)
|
||||||
@@ -97,5 +117,5 @@ class FakeWallet(Wallet):
|
|||||||
|
|
||||||
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
|
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
|
||||||
while True:
|
while True:
|
||||||
value: Invoice = await self.queue.get()
|
value: Bolt11 = await self.queue.get()
|
||||||
yield value.payment_hash
|
yield value.payment_hash
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from typing import AsyncGenerator, Dict, Optional
|
|||||||
import httpx
|
import httpx
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
|
from lnbits.nodes.lndrest import LndRestNode
|
||||||
from lnbits.settings import settings
|
from lnbits.settings import settings
|
||||||
|
|
||||||
from .base import (
|
from .base import (
|
||||||
@@ -22,6 +23,8 @@ from .macaroon import AESCipher, load_macaroon
|
|||||||
class LndRestWallet(Wallet):
|
class LndRestWallet(Wallet):
|
||||||
"""https://api.lightning.community/rest/index.html#lnd-rest-api-reference"""
|
"""https://api.lightning.community/rest/index.html#lnd-rest-api-reference"""
|
||||||
|
|
||||||
|
__node_cls__ = LndRestNode
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
endpoint = settings.lnd_rest_endpoint
|
endpoint = settings.lnd_rest_endpoint
|
||||||
cert = settings.lnd_rest_cert
|
cert = settings.lnd_rest_cert
|
||||||
|
|||||||
@@ -88,6 +88,8 @@ in
|
|||||||
after = [ "network-online.target" ];
|
after = [ "network-online.target" ];
|
||||||
environment = {
|
environment = {
|
||||||
LNBITS_DATA_FOLDER = "${cfg.stateDir}";
|
LNBITS_DATA_FOLDER = "${cfg.stateDir}";
|
||||||
|
LNBITS_EXTENSIONS_PATH = "${cfg.stateDir}/extensions";
|
||||||
|
LNBITS_PATH = "${cfg.package.src}";
|
||||||
};
|
};
|
||||||
serviceConfig = {
|
serviceConfig = {
|
||||||
User = cfg.user;
|
User = cfg.user;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{ pkgs, makeTest, inputs }:
|
{ pkgs, makeTest, inputs }:
|
||||||
makeTest {
|
makeTest {
|
||||||
|
name = "lnbits-nixos-module";
|
||||||
nodes = {
|
nodes = {
|
||||||
client = { config, pkgs, ... }: {
|
client = { config, pkgs, ... }: {
|
||||||
environment.systemPackages = [ pkgs.curl ];
|
environment.systemPackages = [ pkgs.curl ];
|
||||||
|
|||||||
Generated
+24
@@ -11,6 +11,7 @@
|
|||||||
"chart.js": "2.9",
|
"chart.js": "2.9",
|
||||||
"moment": "^2.29.4",
|
"moment": "^2.29.4",
|
||||||
"quasar": "1.13.2",
|
"quasar": "1.13.2",
|
||||||
|
"showdown": "^2.1.0",
|
||||||
"underscore": "^1.13.6",
|
"underscore": "^1.13.6",
|
||||||
"vue": "2.6.12",
|
"vue": "2.6.12",
|
||||||
"vue-i18n": "^8.28.2",
|
"vue-i18n": "^8.28.2",
|
||||||
@@ -1059,6 +1060,29 @@
|
|||||||
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/showdown": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/showdown/-/showdown-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-/6NVYu4U819R2pUIk79n67SYgJHWCce0a5xTP979WbNp0FL9MN1I1QK662IDU1b6JzKTvmhgI7T7JYIxBi3kMQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"commander": "^9.0.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"showdown": "bin/showdown.js"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://www.paypal.me/tiviesantos"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/showdown/node_modules/commander": {
|
||||||
|
"version": "9.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz",
|
||||||
|
"integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==",
|
||||||
|
"engines": {
|
||||||
|
"node": "^12.20.0 || >=14"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/simport": {
|
"node_modules/simport": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/simport/-/simport-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/simport/-/simport-1.2.0.tgz",
|
||||||
|
|||||||
+4
-1
@@ -22,6 +22,7 @@
|
|||||||
"chart.js": "2.9",
|
"chart.js": "2.9",
|
||||||
"moment": "^2.29.4",
|
"moment": "^2.29.4",
|
||||||
"quasar": "1.13.2",
|
"quasar": "1.13.2",
|
||||||
|
"showdown": "^2.1.0",
|
||||||
"underscore": "^1.13.6",
|
"underscore": "^1.13.6",
|
||||||
"vue": "2.6.12",
|
"vue": "2.6.12",
|
||||||
"vue-i18n": "^8.28.2",
|
"vue-i18n": "^8.28.2",
|
||||||
@@ -43,7 +44,8 @@
|
|||||||
"./node_modules/chart.js/dist/Chart.bundle.js",
|
"./node_modules/chart.js/dist/Chart.bundle.js",
|
||||||
"./node_modules/quasar/dist/quasar.css",
|
"./node_modules/quasar/dist/quasar.css",
|
||||||
"./node_modules/chart.js/dist/Chart.css",
|
"./node_modules/chart.js/dist/Chart.css",
|
||||||
"./node_modules/vue-i18n/dist/vue-i18n.js"
|
"./node_modules/vue-i18n/dist/vue-i18n.js",
|
||||||
|
"./node_modules/showdown/dist/showdown.js"
|
||||||
],
|
],
|
||||||
"bundle": {
|
"bundle": {
|
||||||
"js": [
|
"js": [
|
||||||
@@ -59,6 +61,7 @@
|
|||||||
"/static/vendor/quasar.umd.js",
|
"/static/vendor/quasar.umd.js",
|
||||||
"/static/vendor/Chart.bundle.js",
|
"/static/vendor/Chart.bundle.js",
|
||||||
"/static/vendor/vue-i18n.js",
|
"/static/vendor/vue-i18n.js",
|
||||||
|
"/static/vendor/showdown.js",
|
||||||
"/static/i18n/i18n.js",
|
"/static/i18n/i18n.js",
|
||||||
"/static/i18n/de.js",
|
"/static/i18n/de.js",
|
||||||
"/static/i18n/en.js",
|
"/static/i18n/en.js",
|
||||||
|
|||||||
Generated
+907
-785
File diff suppressed because it is too large
Load Diff
+31
-29
@@ -1,52 +1,55 @@
|
|||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "lnbits"
|
name = "lnbits"
|
||||||
version = "0.10.10"
|
version = "0.11.0"
|
||||||
description = "LNbits, free and open-source Lightning wallet and accounts system."
|
description = "LNbits, free and open-source Lightning wallet and accounts system."
|
||||||
authors = ["Alan Bits <alan@lnbits.com>"]
|
authors = ["Alan Bits <alan@lnbits.com>"]
|
||||||
|
|
||||||
[tool.poetry.dependencies]
|
[tool.poetry.dependencies]
|
||||||
python = "^3.10 | ^3.9"
|
python = "^3.10 | ^3.9"
|
||||||
bech32 = "1.2.0"
|
bech32 = "1.2.0"
|
||||||
bitstring = "3.1.9"
|
click = "8.1.7"
|
||||||
click = "8.0.4"
|
|
||||||
ecdsa = "0.18.0"
|
ecdsa = "0.18.0"
|
||||||
embit = "^0.7.0"
|
embit = "0.7.0"
|
||||||
fastapi = "0.83.0"
|
fastapi = "0.103.1"
|
||||||
httpx = "0.23.0"
|
httpx = "0.25.0"
|
||||||
jinja2 = "3.0.1"
|
jinja2 = "3.0.1"
|
||||||
lnurl = "0.3.6"
|
lnurl = "0.3.6"
|
||||||
psycopg2-binary = "2.9.1"
|
psycopg2-binary = "2.9.7"
|
||||||
pydantic = "1.10.4"
|
pydantic = "1.10.9"
|
||||||
pyqrcode = "1.2.1"
|
pyqrcode = "1.2.1"
|
||||||
shortuuid = "1.0.11"
|
shortuuid = "1.0.11"
|
||||||
sqlalchemy = "1.3.24"
|
sqlalchemy = "1.3.24"
|
||||||
sqlalchemy-aio = "0.17.0"
|
sqlalchemy-aio = "0.17.0"
|
||||||
sse-starlette = "0.6.2"
|
sse-starlette = "1.6.5"
|
||||||
typing-extensions = "4.4.0"
|
typing-extensions = "4.8.0"
|
||||||
uvicorn = "0.18.3"
|
uvicorn = "0.23.2"
|
||||||
uvloop = "0.16.0"
|
uvloop = "0.17.0"
|
||||||
websockets = "10.0"
|
websockets = "11.0.3"
|
||||||
loguru = "0.6.0"
|
loguru = "0.7.2"
|
||||||
grpcio = "1.51.1"
|
grpcio = "1.58.0"
|
||||||
protobuf = "4.21.12"
|
protobuf = "4.24.3"
|
||||||
Cerberus = "1.3.4"
|
Cerberus = "1.3.4"
|
||||||
async-timeout = "4.0.2"
|
async-timeout = "4.0.3"
|
||||||
pyln-client = "23.8"
|
pyln-client = "23.8"
|
||||||
cashu = "0.9.0"
|
pywebpush = "1.14.0"
|
||||||
slowapi = "^0.1.7"
|
slowapi = "0.1.8"
|
||||||
pywebpush = "^1.14.0"
|
python-dotenv = "1.0.0"
|
||||||
|
websocket-client = "1.6.3"
|
||||||
|
secp256k1 = "0.14.0"
|
||||||
|
pycryptodomex = "3.19.0"
|
||||||
|
packaging = "23.1"
|
||||||
|
bolt11 = "^2.0.5"
|
||||||
|
|
||||||
[tool.poetry.group.dev.dependencies]
|
[tool.poetry.group.dev.dependencies]
|
||||||
pytest = "^7.1.2"
|
|
||||||
black = "^23.7.0"
|
black = "^23.7.0"
|
||||||
pytest-asyncio = "^0.19.0"
|
pytest-asyncio = "^0.21.0"
|
||||||
|
pytest = "^7.3.2"
|
||||||
pytest-cov = "^4.1.0"
|
pytest-cov = "^4.1.0"
|
||||||
mypy = "^1.5.1"
|
mypy = "^1.5.1"
|
||||||
types-protobuf = "^3.19.22"
|
types-protobuf = "^4.24.0.2"
|
||||||
pre-commit = "^3.2.2"
|
pre-commit = "^3.2.2"
|
||||||
types-mock = "^5.0.0.6"
|
openapi-spec-validator = "^0.6.0"
|
||||||
openapi-spec-validator = "^0.5.5"
|
ruff = "^0.0.291"
|
||||||
ruff = "^0.0.284"
|
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["poetry-core>=1.0.0"]
|
requires = ["poetry-core>=1.0.0"]
|
||||||
@@ -87,6 +90,7 @@ module = [
|
|||||||
"shortuuid.*",
|
"shortuuid.*",
|
||||||
"grpc.*",
|
"grpc.*",
|
||||||
"lnurl.*",
|
"lnurl.*",
|
||||||
|
"bolt11.*",
|
||||||
"bitstring.*",
|
"bitstring.*",
|
||||||
"ecdsa.*",
|
"ecdsa.*",
|
||||||
"psycopg2.*",
|
"psycopg2.*",
|
||||||
@@ -122,9 +126,7 @@ line-length = 88
|
|||||||
# Enable pycodestyle (`E`) and Pyflakes (`F`) codes by default.
|
# Enable pycodestyle (`E`) and Pyflakes (`F`) codes by default.
|
||||||
# (`I`) is for `isort`.
|
# (`I`) is for `isort`.
|
||||||
select = ["E", "F", "I"]
|
select = ["E", "F", "I"]
|
||||||
ignore = [
|
ignore = []
|
||||||
"E402", # Module level import not at top of file
|
|
||||||
]
|
|
||||||
|
|
||||||
# Allow autofix for all enabled rules (when `--fix`) is provided.
|
# Allow autofix for all enabled rules (when `--fix`) is provided.
|
||||||
fixable = ["ALL"]
|
fixable = ["ALL"]
|
||||||
|
|||||||
+60
-5
@@ -1,8 +1,10 @@
|
|||||||
|
# ruff: noqa: E402
|
||||||
import asyncio
|
import asyncio
|
||||||
|
from time import time
|
||||||
|
|
||||||
import uvloop
|
import uvloop
|
||||||
|
|
||||||
uvloop.install() # noqa
|
uvloop.install()
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import pytest_asyncio
|
import pytest_asyncio
|
||||||
@@ -10,15 +12,28 @@ from fastapi.testclient import TestClient
|
|||||||
from httpx import AsyncClient
|
from httpx import AsyncClient
|
||||||
|
|
||||||
from lnbits.app import create_app
|
from lnbits.app import create_app
|
||||||
from lnbits.core.crud import create_account, create_wallet
|
from lnbits.core.crud import (
|
||||||
|
create_account,
|
||||||
|
create_wallet,
|
||||||
|
get_user,
|
||||||
|
update_payment_status,
|
||||||
|
)
|
||||||
from lnbits.core.models import CreateInvoice
|
from lnbits.core.models import CreateInvoice
|
||||||
from lnbits.core.services import update_wallet_balance
|
from lnbits.core.services import update_wallet_balance
|
||||||
from lnbits.core.views.api import api_payments_create_invoice
|
from lnbits.core.views.api import api_payments_create_invoice
|
||||||
from lnbits.db import Database
|
from lnbits.db import DB_TYPE, SQLITE, Database
|
||||||
from lnbits.settings import settings
|
from lnbits.settings import settings
|
||||||
from tests.helpers import get_hold_invoice, get_random_invoice_data, get_real_invoice
|
from tests.helpers import (
|
||||||
|
clean_database,
|
||||||
|
get_hold_invoice,
|
||||||
|
get_random_invoice_data,
|
||||||
|
get_real_invoice,
|
||||||
|
)
|
||||||
|
|
||||||
# dont install extensions for tests
|
# override settings for tests
|
||||||
|
settings.lnbits_admin_extensions = []
|
||||||
|
settings.lnbits_data_folder = "./tests/data"
|
||||||
|
settings.lnbits_admin_ui = True
|
||||||
settings.lnbits_extensions_default_install = []
|
settings.lnbits_extensions_default_install = []
|
||||||
|
|
||||||
|
|
||||||
@@ -32,6 +47,7 @@ def event_loop():
|
|||||||
# use session scope to run once before and once after all tests
|
# use session scope to run once before and once after all tests
|
||||||
@pytest_asyncio.fixture(scope="session")
|
@pytest_asyncio.fixture(scope="session")
|
||||||
async def app():
|
async def app():
|
||||||
|
clean_database(settings)
|
||||||
app = create_app()
|
app = create_app()
|
||||||
await app.router.startup()
|
await app.router.startup()
|
||||||
yield app
|
yield app
|
||||||
@@ -86,6 +102,20 @@ async def to_user():
|
|||||||
yield user
|
yield user
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def from_super_user(from_user):
|
||||||
|
prev = settings.super_user
|
||||||
|
settings.super_user = from_user.id
|
||||||
|
yield from_user
|
||||||
|
settings.super_user = prev
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture(scope="session")
|
||||||
|
async def superuser():
|
||||||
|
user = await get_user(settings.super_user)
|
||||||
|
yield user
|
||||||
|
|
||||||
|
|
||||||
@pytest_asyncio.fixture(scope="session")
|
@pytest_asyncio.fixture(scope="session")
|
||||||
async def to_wallet(to_user):
|
async def to_wallet(to_user):
|
||||||
user = to_user
|
user = to_user
|
||||||
@@ -157,6 +187,31 @@ async def real_invoice():
|
|||||||
del invoice
|
del invoice
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture(scope="session")
|
||||||
|
async def fake_payments(client, adminkey_headers_from):
|
||||||
|
# Because sqlite only stores timestamps with milliseconds
|
||||||
|
# we have to wait a second to ensure a different timestamp than previous invoices
|
||||||
|
if DB_TYPE == SQLITE:
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
ts = time()
|
||||||
|
|
||||||
|
fake_data = [
|
||||||
|
CreateInvoice(amount=10, memo="aaaa", out=False),
|
||||||
|
CreateInvoice(amount=100, memo="bbbb", out=False),
|
||||||
|
CreateInvoice(amount=1000, memo="aabb", out=False),
|
||||||
|
]
|
||||||
|
|
||||||
|
for invoice in fake_data:
|
||||||
|
response = await client.post(
|
||||||
|
"/api/v1/payments", headers=adminkey_headers_from, json=invoice.dict()
|
||||||
|
)
|
||||||
|
assert response.is_success
|
||||||
|
await update_payment_status(response.json()["checking_id"], pending=False)
|
||||||
|
|
||||||
|
params = {"time[ge]": ts, "time[le]": time()}
|
||||||
|
return fake_data, params
|
||||||
|
|
||||||
|
|
||||||
@pytest_asyncio.fixture(scope="function")
|
@pytest_asyncio.fixture(scope="function")
|
||||||
async def hold_invoice():
|
async def hold_invoice():
|
||||||
invoice = get_hold_invoice(100)
|
invoice = get_hold_invoice(100)
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from lnbits.settings import settings
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_admin_get_settings_permission_denied(client, from_user):
|
||||||
|
response = await client.get(f"/admin/api/v1/settings/?usr={from_user.id}")
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_admin_get_settings(client, superuser):
|
||||||
|
response = await client.get(f"/admin/api/v1/settings/?usr={superuser.id}")
|
||||||
|
assert response.status_code == 200
|
||||||
|
result = response.json()
|
||||||
|
assert "super_user" not in result
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_admin_update_settings(client, superuser):
|
||||||
|
new_site_title = "UPDATED SITETITLE"
|
||||||
|
response = await client.put(
|
||||||
|
f"/admin/api/v1/settings/?usr={superuser.id}",
|
||||||
|
json={"lnbits_site_title": new_site_title},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
result = response.json()
|
||||||
|
assert "status" in result
|
||||||
|
assert result.get("status") == "Success"
|
||||||
|
assert settings.lnbits_site_title == new_site_title
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_admin_update_noneditable_settings(client, superuser):
|
||||||
|
response = await client.put(
|
||||||
|
f"/admin/api/v1/settings/?usr={superuser.id}",
|
||||||
|
json={"super_user": "UPDATED"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 400
|
||||||
+108
-30
@@ -1,23 +1,21 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import hashlib
|
import hashlib
|
||||||
from time import time
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from lnbits import bolt11
|
from lnbits import bolt11
|
||||||
from lnbits.core.crud import get_standalone_payment, update_payment_details
|
from lnbits.core.crud import get_standalone_payment, update_payment_details
|
||||||
from lnbits.core.models import Payment
|
from lnbits.core.models import CreateInvoice, Payment
|
||||||
from lnbits.core.views.admin_api import api_auditor
|
from lnbits.core.views.admin_api import api_auditor
|
||||||
from lnbits.core.views.api import api_payment
|
from lnbits.core.views.api import api_payment
|
||||||
from lnbits.db import DB_TYPE, SQLITE
|
|
||||||
from lnbits.settings import settings
|
from lnbits.settings import settings
|
||||||
from lnbits.wallets import get_wallet_class
|
from lnbits.wallets import get_wallet_class
|
||||||
from tests.conftest import CreateInvoice, api_payments_create_invoice
|
|
||||||
|
|
||||||
from ...helpers import (
|
from ...helpers import (
|
||||||
cancel_invoice,
|
cancel_invoice,
|
||||||
get_random_invoice_data,
|
get_random_invoice_data,
|
||||||
is_fake,
|
is_fake,
|
||||||
|
is_regtest,
|
||||||
pay_real_invoice,
|
pay_real_invoice,
|
||||||
settle_invoice,
|
settle_invoice,
|
||||||
)
|
)
|
||||||
@@ -25,11 +23,51 @@ from ...helpers import (
|
|||||||
WALLET = get_wallet_class()
|
WALLET = get_wallet_class()
|
||||||
|
|
||||||
|
|
||||||
# check if the client is working
|
# create account POST /api/v1/account
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_core_views_generic(client):
|
async def test_create_account(client):
|
||||||
response = await client.get("/")
|
response = await client.post("/api/v1/account", json={"name": "test"})
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
result = response.json()
|
||||||
|
assert "name" in result
|
||||||
|
assert result["name"] == "test"
|
||||||
|
assert "balance_msat" in result
|
||||||
|
assert "id" in result
|
||||||
|
assert "user" in result
|
||||||
|
|
||||||
|
|
||||||
|
# check POST and DELETE /api/v1/wallet with adminkey:
|
||||||
|
# create additional wallet and delete it
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_wallet_and_delete(client, adminkey_headers_to):
|
||||||
|
response = await client.post(
|
||||||
|
"/api/v1/wallet", json={"name": "test"}, headers=adminkey_headers_to
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
result = response.json()
|
||||||
|
assert "name" in result
|
||||||
|
assert result["name"] == "test"
|
||||||
|
assert "balance_msat" in result
|
||||||
|
assert "id" in result
|
||||||
|
assert "adminkey" in result
|
||||||
|
response = await client.delete(
|
||||||
|
"/api/v1/wallet",
|
||||||
|
headers={
|
||||||
|
"X-Api-Key": result["adminkey"],
|
||||||
|
"Content-type": "application/json",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
# get deleted wallet
|
||||||
|
response = await client.get(
|
||||||
|
"/api/v1/wallet",
|
||||||
|
headers={
|
||||||
|
"X-Api-Key": result["adminkey"],
|
||||||
|
"Content-type": "application/json",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
# check GET /api/v1/wallet with inkey: wallet info, no balance
|
# check GET /api/v1/wallet with inkey: wallet info, no balance
|
||||||
@@ -209,6 +247,24 @@ async def test_pay_invoice_wrong_key(client, invoice, adminkey_headers_from):
|
|||||||
assert response.status_code >= 300 # should fail
|
assert response.status_code >= 300 # should fail
|
||||||
|
|
||||||
|
|
||||||
|
# check POST /api/v1/payments: payment with self payment
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_pay_invoice_self_payment(client, adminkey_headers_from):
|
||||||
|
create_invoice = CreateInvoice(out=False, amount=1000, memo="test")
|
||||||
|
response = await client.post(
|
||||||
|
"/api/v1/payments",
|
||||||
|
json=create_invoice.dict(),
|
||||||
|
headers=adminkey_headers_from,
|
||||||
|
)
|
||||||
|
assert response.status_code < 300
|
||||||
|
json_data = response.json()
|
||||||
|
data = {"out": True, "bolt11": json_data["payment_request"]}
|
||||||
|
response = await client.post(
|
||||||
|
"/api/v1/payments", json=data, headers=adminkey_headers_from
|
||||||
|
)
|
||||||
|
assert response.status_code < 300
|
||||||
|
|
||||||
|
|
||||||
# check POST /api/v1/payments: payment with invoice key [should fail]
|
# check POST /api/v1/payments: payment with invoice key [should fail]
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_pay_invoice_invoicekey(client, invoice, inkey_headers_from):
|
async def test_pay_invoice_invoicekey(client, invoice, inkey_headers_from):
|
||||||
@@ -232,29 +288,13 @@ async def test_pay_invoice_adminkey(client, invoice, adminkey_headers_from):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_payments(client, from_wallet, adminkey_headers_from):
|
async def test_get_payments(client, adminkey_headers_from, fake_payments):
|
||||||
# Because sqlite only stores timestamps with milliseconds we have to wait a second
|
fake_data, filters = fake_payments
|
||||||
# to ensure a different timestamp than previous invoices due to this limitation
|
|
||||||
# both payments (normal and paginated) are tested at the same time as they are
|
|
||||||
# almost identical anyways
|
|
||||||
if DB_TYPE == SQLITE:
|
|
||||||
await asyncio.sleep(1)
|
|
||||||
ts = time()
|
|
||||||
|
|
||||||
fake_data = [
|
|
||||||
CreateInvoice(amount=10, memo="aaaa"),
|
|
||||||
CreateInvoice(amount=100, memo="bbbb"),
|
|
||||||
CreateInvoice(amount=1000, memo="aabb"),
|
|
||||||
]
|
|
||||||
|
|
||||||
for invoice in fake_data:
|
|
||||||
await api_payments_create_invoice(invoice, from_wallet)
|
|
||||||
|
|
||||||
async def get_payments(params: dict):
|
async def get_payments(params: dict):
|
||||||
params["time[ge]"] = ts
|
|
||||||
response = await client.get(
|
response = await client.get(
|
||||||
"/api/v1/payments",
|
"/api/v1/payments",
|
||||||
params=params,
|
params=filters | params,
|
||||||
headers=adminkey_headers_from,
|
headers=adminkey_headers_from,
|
||||||
)
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
@@ -280,9 +320,14 @@ async def test_get_payments(client, from_wallet, adminkey_headers_from):
|
|||||||
payments = await get_payments({"amount[gt]": 10000})
|
payments = await get_payments({"amount[gt]": 10000})
|
||||||
assert len(payments) == 2
|
assert len(payments) == 2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_payments_paginated(client, adminkey_headers_from, fake_payments):
|
||||||
|
fake_data, filters = fake_payments
|
||||||
|
|
||||||
response = await client.get(
|
response = await client.get(
|
||||||
"/api/v1/payments/paginated",
|
"/api/v1/payments/paginated",
|
||||||
params={"limit": 2, "time[ge]": ts},
|
params=filters | {"limit": 2},
|
||||||
headers=adminkey_headers_from,
|
headers=adminkey_headers_from,
|
||||||
)
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
@@ -291,6 +336,38 @@ async def test_get_payments(client, from_wallet, adminkey_headers_from):
|
|||||||
assert paginated["total"] == len(fake_data)
|
assert paginated["total"] == len(fake_data)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.skipif(
|
||||||
|
is_regtest, reason="payments wont be confirmed rightaway in regtest"
|
||||||
|
)
|
||||||
|
async def test_get_payments_history(client, adminkey_headers_from, fake_payments):
|
||||||
|
fake_data, filters = fake_payments
|
||||||
|
|
||||||
|
response = await client.get(
|
||||||
|
"/api/v1/payments/history",
|
||||||
|
params=filters,
|
||||||
|
headers=adminkey_headers_from,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert len(data) == 1
|
||||||
|
assert data[0]["spending"] == sum(
|
||||||
|
payment.amount * 1000 for payment in fake_data if payment.out
|
||||||
|
)
|
||||||
|
assert data[0]["income"] == sum(
|
||||||
|
payment.amount * 1000 for payment in fake_data if not payment.out
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await client.get(
|
||||||
|
"/api/v1/payments/history?group=INVALID",
|
||||||
|
params=filters,
|
||||||
|
headers=adminkey_headers_from,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
# check POST /api/v1/payments/decode
|
# check POST /api/v1/payments/decode
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_decode_invoice(client, invoice):
|
async def test_decode_invoice(client, invoice):
|
||||||
@@ -342,8 +419,8 @@ async def test_create_invoice_with_description_hash(client, inkey_headers_to):
|
|||||||
"/api/v1/payments", json=data, headers=inkey_headers_to
|
"/api/v1/payments", json=data, headers=inkey_headers_to
|
||||||
)
|
)
|
||||||
invoice = response.json()
|
invoice = response.json()
|
||||||
invoice_bolt11 = bolt11.decode(invoice["payment_request"])
|
|
||||||
|
|
||||||
|
invoice_bolt11 = bolt11.decode(invoice["payment_request"])
|
||||||
assert invoice_bolt11.description_hash == descr_hash
|
assert invoice_bolt11.description_hash == descr_hash
|
||||||
return invoice
|
return invoice
|
||||||
|
|
||||||
@@ -355,8 +432,9 @@ async def test_create_invoice_with_description_hash(client, inkey_headers_to):
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_create_invoice_with_unhashed_description(client, inkey_headers_to):
|
async def test_create_invoice_with_unhashed_description(client, inkey_headers_to):
|
||||||
data = await get_random_invoice_data()
|
data = await get_random_invoice_data()
|
||||||
descr_hash = hashlib.sha256("asdasdasd".encode()).hexdigest()
|
description = "test description"
|
||||||
data["unhashed_description"] = "asdasdasd".encode().hex()
|
descr_hash = hashlib.sha256(description.encode()).hexdigest()
|
||||||
|
data["unhashed_description"] = description.encode().hex()
|
||||||
|
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
"/api/v1/payments", json=data, headers=inkey_headers_to
|
"/api/v1/payments", json=data, headers=inkey_headers_to
|
||||||
|
|||||||
@@ -7,35 +7,6 @@ async def test_core_views_generic(client):
|
|||||||
assert response.status_code == 200, f"{response.url} {response.status_code}"
|
assert response.status_code == 200, f"{response.url} {response.status_code}"
|
||||||
|
|
||||||
|
|
||||||
# check GET /wallet: wallet info
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_get_wallet(client):
|
|
||||||
response = await client.get("wallet")
|
|
||||||
# redirect not modified
|
|
||||||
assert response.status_code == 307, f"{response.url} {response.status_code}"
|
|
||||||
|
|
||||||
|
|
||||||
# check GET /wallet: do not allow redirects, expect code 307
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_get_wallet_no_redirect(client):
|
|
||||||
response = await client.get("wallet", follow_redirects=False)
|
|
||||||
assert response.status_code == 307, f"{response.url} {response.status_code}"
|
|
||||||
|
|
||||||
# determine the next redirect location
|
|
||||||
request = client.build_request("GET", "wallet")
|
|
||||||
i = 0
|
|
||||||
while request is not None:
|
|
||||||
response = await client.send(request)
|
|
||||||
request = response.next_request
|
|
||||||
if i == 0:
|
|
||||||
# first redirect
|
|
||||||
assert response.status_code == 307, f"{response.url} {response.status_code}"
|
|
||||||
elif i == 1:
|
|
||||||
# then get the actual page
|
|
||||||
assert response.status_code == 200, f"{response.url} {response.status_code}"
|
|
||||||
i += 1
|
|
||||||
|
|
||||||
|
|
||||||
# check GET /wallet: wrong user, expect 400
|
# check GET /wallet: wrong user, expect 400
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_wallet_with_nonexistent_user(client):
|
async def test_get_wallet_with_nonexistent_user(client):
|
||||||
@@ -43,27 +14,6 @@ async def test_get_wallet_with_nonexistent_user(client):
|
|||||||
assert response.status_code == 400, f"{response.url} {response.status_code}"
|
assert response.status_code == 400, f"{response.url} {response.status_code}"
|
||||||
|
|
||||||
|
|
||||||
# check GET /wallet: with user
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_get_wallet_with_user(client, to_user):
|
|
||||||
response = await client.get("wallet", params={"usr": to_user.id})
|
|
||||||
assert response.status_code == 307, f"{response.url} {response.status_code}"
|
|
||||||
|
|
||||||
# determine the next redirect location
|
|
||||||
request = client.build_request("GET", "wallet", params={"usr": to_user.id})
|
|
||||||
i = 0
|
|
||||||
while request is not None:
|
|
||||||
response = await client.send(request)
|
|
||||||
request = response.next_request
|
|
||||||
if i == 0:
|
|
||||||
# first redirect
|
|
||||||
assert response.status_code == 307, f"{response.url} {response.status_code}"
|
|
||||||
elif i == 1:
|
|
||||||
# then get the actual page
|
|
||||||
assert response.status_code == 200, f"{response.url} {response.status_code}"
|
|
||||||
i += 1
|
|
||||||
|
|
||||||
|
|
||||||
# check GET /wallet: wallet and user
|
# check GET /wallet: wallet and user
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_wallet_with_user_and_wallet(client, to_user, to_wallet):
|
async def test_get_wallet_with_user_and_wallet(client, to_user, to_wallet):
|
||||||
@@ -89,7 +39,7 @@ async def test_get_extensions(client, to_user):
|
|||||||
|
|
||||||
# check GET /extensions: extensions list wrong user, expect 400
|
# check GET /extensions: extensions list wrong user, expect 400
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_extensions_wrong_user(client, to_user):
|
async def test_get_extensions_wrong_user(client):
|
||||||
response = await client.get("extensions", params={"usr": "1"})
|
response = await client.get("extensions", params={"usr": "1"})
|
||||||
assert response.status_code == 400, f"{response.url} {response.status_code}"
|
assert response.status_code == 400, f"{response.url} {response.status_code}"
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import asyncio
|
||||||
|
import random
|
||||||
|
from http import HTTPStatus
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pydantic import parse_obj_as
|
||||||
|
|
||||||
|
from lnbits import bolt11
|
||||||
|
from lnbits.nodes.base import ChannelPoint, ChannelState, NodeChannel
|
||||||
|
from tests.conftest import pytest_asyncio, settings
|
||||||
|
|
||||||
|
from ...helpers import (
|
||||||
|
WALLET,
|
||||||
|
get_random_invoice_data,
|
||||||
|
get_unconnected_node_uri,
|
||||||
|
mine_blocks,
|
||||||
|
)
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.skipif(
|
||||||
|
WALLET.__node_cls__ is None, reason="Cant test if node implementation isnt avilable"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture()
|
||||||
|
async def node_client(client, from_super_user):
|
||||||
|
settings.lnbits_node_ui = True
|
||||||
|
settings.lnbits_public_node_ui = False
|
||||||
|
settings.lnbits_node_ui_transactions = True
|
||||||
|
params = client.params
|
||||||
|
client.params = {"usr": from_super_user.id}
|
||||||
|
yield client
|
||||||
|
client.params = params
|
||||||
|
settings.lnbits_node_ui = False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture()
|
||||||
|
async def public_node_client(node_client):
|
||||||
|
settings.lnbits_public_node_ui = True
|
||||||
|
yield node_client
|
||||||
|
settings.lnbits_public_node_ui = False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_node_info_not_found(client):
|
||||||
|
response = await client.get("/node/api/v1/info")
|
||||||
|
assert response.status_code == HTTPStatus.SERVICE_UNAVAILABLE
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_public_node_info_not_found(node_client):
|
||||||
|
response = await node_client.get("/node/public/api/v1/info")
|
||||||
|
assert response.status_code == HTTPStatus.SERVICE_UNAVAILABLE
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_public_node_info(public_node_client):
|
||||||
|
response = await public_node_client.get("/node/public/api/v1/info")
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_node_info(node_client, from_super_user):
|
||||||
|
response = await node_client.get("/node/api/v1/info")
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_node_invoices(inkey_headers_from, node_client):
|
||||||
|
data = await get_random_invoice_data()
|
||||||
|
response = await node_client.post(
|
||||||
|
"/api/v1/payments", json=data, headers=inkey_headers_from
|
||||||
|
)
|
||||||
|
invoice = response.json()
|
||||||
|
|
||||||
|
response = await node_client.get("/node/api/v1/invoices", params={"limit": 1})
|
||||||
|
assert response.status_code == 200
|
||||||
|
invoices = response.json()["data"]
|
||||||
|
assert len(invoices) == 1
|
||||||
|
assert invoices[0]["payment_hash"] == invoice["payment_hash"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_node_payments(node_client, real_invoice, adminkey_headers_from):
|
||||||
|
response = await node_client.post(
|
||||||
|
"/api/v1/payments", json=real_invoice, headers=adminkey_headers_from
|
||||||
|
)
|
||||||
|
assert response.status_code < 300
|
||||||
|
|
||||||
|
response = await node_client.get("/node/api/v1/payments", params={"limit": 1})
|
||||||
|
assert response.status_code == 200
|
||||||
|
payments = response.json()["data"]
|
||||||
|
assert len(payments) == 1
|
||||||
|
assert (
|
||||||
|
payments[0]["payment_hash"]
|
||||||
|
== bolt11.decode(real_invoice["bolt11"]).payment_hash
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_channel_management(node_client):
|
||||||
|
async def get_channels():
|
||||||
|
response = await node_client.get("/node/api/v1/channels")
|
||||||
|
assert response.status_code == 200
|
||||||
|
return parse_obj_as(list[NodeChannel], response.json())
|
||||||
|
|
||||||
|
data = await get_channels()
|
||||||
|
close = random.choice(
|
||||||
|
[channel for channel in data if channel.state == ChannelState.ACTIVE]
|
||||||
|
)
|
||||||
|
assert close, "No active channel found"
|
||||||
|
|
||||||
|
response = await node_client.delete(
|
||||||
|
"/node/api/v1/channels",
|
||||||
|
params={"short_id": close.short_id, **close.point.dict()},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
data = await get_channels()
|
||||||
|
assert any(
|
||||||
|
channel.point == close.point and channel.state == ChannelState.CLOSED
|
||||||
|
for channel in data
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await node_client.post(
|
||||||
|
"/node/api/v1/channels",
|
||||||
|
json={
|
||||||
|
"peer_id": close.peer_id,
|
||||||
|
"funding_amount": 100000,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
created = ChannelPoint(**response.json())
|
||||||
|
data = await get_channels()
|
||||||
|
assert any(
|
||||||
|
channel.point == created and channel.state == ChannelState.PENDING
|
||||||
|
for channel in data
|
||||||
|
)
|
||||||
|
|
||||||
|
# mine some blocks so that the newly created channel eventually
|
||||||
|
# gets confirmed to avoid a situation where no channels are
|
||||||
|
# left for testing
|
||||||
|
mine_blocks(5)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_peer_management(node_client):
|
||||||
|
connect_uri = get_unconnected_node_uri()
|
||||||
|
id = connect_uri.split("@")[0]
|
||||||
|
response = await node_client.post("/node/api/v1/peers", json={"uri": connect_uri})
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
response = await node_client.get("/node/api/v1/peers")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert any(peer["id"] == id for peer in response.json())
|
||||||
|
|
||||||
|
response = await node_client.delete(f"/node/api/v1/peers/{id}")
|
||||||
|
assert response.status_code == 200
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
|
||||||
|
response = await node_client.get("/node/api/v1/peers")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert not any(peer["id"] == id for peer in response.json())
|
||||||
|
|
||||||
|
response = await node_client.delete(f"/node/api/v1/peers/{id}")
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_connect_invalid_uri(node_client):
|
||||||
|
response = await node_client.post("/node/api/v1/peers", json={"uri": "invalid"})
|
||||||
|
assert response.status_code == 400
|
||||||
Binary file not shown.
@@ -7,8 +7,12 @@ import time
|
|||||||
from subprocess import PIPE, Popen, TimeoutExpired
|
from subprocess import PIPE, Popen, TimeoutExpired
|
||||||
from typing import Tuple
|
from typing import Tuple
|
||||||
|
|
||||||
|
import psycopg2
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
from sqlalchemy.engine.url import make_url
|
||||||
|
|
||||||
|
from lnbits import core
|
||||||
|
from lnbits.db import DB_TYPE, POSTGRES
|
||||||
from lnbits.wallets import get_wallet_class, set_wallet_class
|
from lnbits.wallets import get_wallet_class, set_wallet_class
|
||||||
|
|
||||||
|
|
||||||
@@ -49,6 +53,17 @@ docker_bitcoin_cli = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
docker_lightning_unconnected_cli = [
|
||||||
|
"docker",
|
||||||
|
"exec",
|
||||||
|
"lnbits-legend-lnd-2-1",
|
||||||
|
"lncli",
|
||||||
|
"--network",
|
||||||
|
"regtest",
|
||||||
|
"--rpcserver=lnd-2",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def run_cmd(cmd: list) -> str:
|
def run_cmd(cmd: list) -> str:
|
||||||
timeout = 20
|
timeout = 20
|
||||||
process = Popen(cmd, stdout=PIPE, stderr=PIPE)
|
process = Popen(cmd, stdout=PIPE, stderr=PIPE)
|
||||||
@@ -120,6 +135,14 @@ def mine_blocks(blocks: int = 1) -> str:
|
|||||||
return run_cmd(cmd)
|
return run_cmd(cmd)
|
||||||
|
|
||||||
|
|
||||||
|
def get_unconnected_node_uri() -> str:
|
||||||
|
cmd = docker_lightning_unconnected_cli.copy()
|
||||||
|
cmd.append("getinfo")
|
||||||
|
info = run_cmd_json(cmd)
|
||||||
|
pubkey = info["identity_pubkey"]
|
||||||
|
return f"{pubkey}@lnd-2:9735"
|
||||||
|
|
||||||
|
|
||||||
def create_onchain_address(address_type: str = "bech32") -> str:
|
def create_onchain_address(address_type: str = "bech32") -> str:
|
||||||
cmd = docker_bitcoin_cli.copy()
|
cmd = docker_bitcoin_cli.copy()
|
||||||
cmd.extend(["getnewaddress", address_type])
|
cmd.extend(["getnewaddress", address_type])
|
||||||
@@ -131,3 +154,28 @@ def pay_onchain(address: str, sats: int) -> str:
|
|||||||
cmd = docker_bitcoin_cli.copy()
|
cmd = docker_bitcoin_cli.copy()
|
||||||
cmd.extend(["sendtoaddress", address, str(btc)])
|
cmd.extend(["sendtoaddress", address, str(btc)])
|
||||||
return run_cmd(cmd)
|
return run_cmd(cmd)
|
||||||
|
|
||||||
|
|
||||||
|
def clean_database(settings):
|
||||||
|
if DB_TYPE == POSTGRES:
|
||||||
|
db_url = make_url(settings.lnbits_database_url)
|
||||||
|
|
||||||
|
conn = psycopg2.connect(settings.lnbits_database_url)
|
||||||
|
conn.autocommit = True
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
try:
|
||||||
|
cur.execute("DROP DATABASE lnbits_test")
|
||||||
|
except psycopg2.errors.InvalidCatalogName:
|
||||||
|
pass
|
||||||
|
cur.execute("CREATE DATABASE lnbits_test")
|
||||||
|
|
||||||
|
db_url.database = "lnbits_test"
|
||||||
|
settings.lnbits_database_url = str(db_url)
|
||||||
|
|
||||||
|
core.db.__init__("database")
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
else:
|
||||||
|
# FIXME: do this once mock data is removed from test data folder
|
||||||
|
# os.remove(settings.lnbits_data_folder + "/database.sqlite3")
|
||||||
|
pass
|
||||||
|
|||||||
Reference in New Issue
Block a user