Compare commits

...
14 Commits
Author SHA1 Message Date
dni ⚡andGitHub 723d8ba98f chore: update to v1.0.0-rc2 (#2705) 2024-09-24 11:48:28 +02:00
daee2b3418 Check for theme params on the URL (#2678)
---------

Co-authored-by: dni  <office@dnilabs.com>
2024-09-24 11:44:07 +02:00
dni ⚡andGitHub 9d7e54f6b2 refactor: use CreatePayment model instead of a lot of kwargs (#2667)
- refactoring create_payment a bit to use a model instead of 10 kwargs
2024-09-24 11:13:30 +02:00
053ea20508 feat: update to Vue3 (#2677)
* update packages for vue3
* fix make bundle and make checkbundle to include bundle-components
* add lnbits/static/bundle-components.js

---------

Co-authored-by: Pavol Rusnak <pavol@rusnak.io>
2024-09-24 11:06:27 +02:00
dni ⚡andGitHub 04aefc8077 refactor: remove get_key_type decorator (#2676)
* refactor: remove `get_key_type` decorator
breaking change for 1.0.0
2024-09-24 10:56:34 +02:00
21d87adc52 mega chore: update sqlalchemy (#2611)
* update sqlalchemy to 1.4
* async postgres

---------

Co-authored-by: Pavol Rusnak <pavol@rusnak.io>
2024-09-24 10:56:03 +02:00
dni ⚡andGitHub c637e8d31e fix: internal payment can still be pending (#2686)
bug introduced last commit
2024-09-16 20:43:17 +02:00
dni ⚡andGitHub d26e50ec9a refactor: rename is_uncheckable (#2670)
it actually means is_internal and internal payments cant fail so we return success status
2024-09-16 17:34:30 +02:00
ArcandGitHub d229b7a765 fix: bash failed using after install (#2685) 2024-09-14 11:08:25 +02:00
ceb43f384e feat: install lnbits.sh bash script (#2684)
Co-authored-by: arcbtc <ben@arc.wales>
2024-09-12 08:02:47 +02:00
dni ⚡andGitHub 22e6326bce fix: gitignore extensions (#2682) 2024-09-11 19:34:35 +02:00
Vlad StanandGitHub 5f4f1288d7 Fix overlapping redirect paths (#2671) 2024-09-11 12:41:37 +03:00
blackcoffeexbtandGitHub 7a5e7fbd8c feat: UI / UX improvements to Users balance / tx chart (#2672)
* Updates to user manager chart to add axis label, bubble radius depending on balance and bubble labels with wallet info

* Fixed bg colour missing on toggle admin on user manager table
2024-09-11 09:40:41 +02:00
dni ⚡andGitHub 6c8d56e40c chore: update to 1.0.0-rc1 (#2675)
* chore: update to 1.0.0-rc1
2024-09-05 12:28:40 +02:00
74 changed files with 19579 additions and 11182 deletions
+4 -1
View File
@@ -46,7 +46,10 @@ runs:
- name: Install the project dependencies - name: Install the project dependencies
shell: bash shell: bash
run: poetry install run: |
poetry install
# needed for conv tests
poetry add psycopg2-binary
- name: Use Node.js ${{ inputs.node-version }} - name: Use Node.js ${{ inputs.node-version }}
if: ${{ (inputs.npm == 'true') }} if: ${{ (inputs.npm == 'true') }}
+3 -2
View File
@@ -35,6 +35,7 @@ __bundle__
coverage.xml coverage.xml
node_modules node_modules
lnbits/static/bundle.js lnbits/static/bundle.js
lnbits/static/bundle-components.js
lnbits/static/bundle.css lnbits/static/bundle.css
lnbits/static/bundle.min.js.old lnbits/static/bundle.min.js.old
lnbits/static/bundle.min.css.old lnbits/static/bundle.min.css.old
@@ -49,8 +50,8 @@ fly.toml
lnbits-backup.zip lnbits-backup.zip
# Ignore extensions (post installable extension PR) # Ignore extensions (post installable extension PR)
extensions /lnbits/extensions
upgrades/ /upgrades/
# builded python package # builded python package
dist dist
+1
View File
@@ -10,6 +10,7 @@
**/lnbits/static/vendor **/lnbits/static/vendor
**/lnbits/static/bundle.* **/lnbits/static/bundle.*
**/lnbits/static/bundle-components.*
**/lnbits/static/css/* **/lnbits/static/css/*
flake.lock flake.lock
+4 -7
View File
@@ -103,24 +103,21 @@ sass:
bundle: bundle:
npm install npm install
npm run sass npm run bundle
npm run vendor_copy
npm run vendor_json
poetry run ./node_modules/.bin/prettier -w ./lnbits/static/vendor.json poetry run ./node_modules/.bin/prettier -w ./lnbits/static/vendor.json
npm run vendor_bundle_css
npm run vendor_minify_css
npm run vendor_bundle_js
npm run vendor_minify_js
checkbundle: checkbundle:
cp lnbits/static/bundle.min.js lnbits/static/bundle.min.js.old cp lnbits/static/bundle.min.js lnbits/static/bundle.min.js.old
cp lnbits/static/bundle.min.css lnbits/static/bundle.min.css.old cp lnbits/static/bundle.min.css lnbits/static/bundle.min.css.old
cp lnbits/static/bundle-components.min.js lnbits/static/bundle-components.min.js.old
make bundle make bundle
diff -q lnbits/static/bundle.min.js lnbits/static/bundle.min.js.old || exit 1 diff -q lnbits/static/bundle.min.js lnbits/static/bundle.min.js.old || exit 1
diff -q lnbits/static/bundle.min.css lnbits/static/bundle.min.css.old || exit 1 diff -q lnbits/static/bundle.min.css lnbits/static/bundle.min.css.old || exit 1
diff -q lnbits/static/bundle-components.min.js lnbits/static/bundle-components.min.js.old || exit 1
@echo "Bundle is OK" @echo "Bundle is OK"
rm lnbits/static/bundle.min.js.old rm lnbits/static/bundle.min.js.old
rm lnbits/static/bundle.min.css.old rm lnbits/static/bundle.min.css.old
rm lnbits/static/bundle-components.min.js.old
install-pre-commit-hook: install-pre-commit-hook:
@echo "Installing pre-commit hook to git" @echo "Installing pre-commit hook to git"
+20 -17
View File
@@ -10,22 +10,17 @@ The following sections explain how to install LNbits using varions package manag
Note that by default LNbits uses SQLite as its database, which is simple and effective but you can configure it to use PostgreSQL instead which is also described in a section below. Note that by default LNbits uses SQLite as its database, which is simple and effective but you can configure it to use PostgreSQL instead which is also described in a section below.
## Option 1 (recommended): poetry ## Option 1 (recommended): Poetry
Mininum poetry version has is ^1.2, but it is recommended to use latest poetry. (including OSX) It is recommended to use the latest version of Poetry. Make sure you have Python version 3.9 or higher installed.
Make sure you have Python 3.9 or 3.10 installed.
### install python on ubuntu ### Verify Python version
```sh ```sh
# for making sure python 3.9 is installed, skip if installed. To check your installed version: python3 --version python3 --version
sudo apt update
sudo apt install software-properties-common
sudo add-apt-repository ppa:deadsnakes/ppa
sudo apt install python3.9 python3.9-distutils
``` ```
### install poetry ### Install Poetry
```sh ```sh
curl -sSL https://install.python-poetry.org | python3 - curl -sSL https://install.python-poetry.org | python3 -
@@ -38,10 +33,6 @@ git clone https://github.com/lnbits/lnbits.git
cd lnbits cd lnbits
git checkout main git checkout main
# Next command, you can exchange with python3.10 or newer versions.
# Identify your version with python3 --version and specify in the next line
# command is only needed when your default python is not ^3.9 or ^3.10
poetry env use python3.9
poetry install --only main poetry install --only main
cp .env.example .env cp .env.example .env
@@ -69,7 +60,19 @@ poetry install --only main
# Start LNbits with `poetry run lnbits` # Start LNbits with `poetry run lnbits`
``` ```
## Option 2: Nix ## Option 2: Install script (on Debian/Ubuntu)
```sh
wget https://raw.githubusercontent.com/lnbits/lnbits/main/lnbits.sh &&
chmod +x lnbits.sh &&
./lnbits.sh
```
Now visit `0.0.0.0:5000` to make a super-user account.
`export PATH="/home/$USER/.local/bin:$PATH"` then `./lnbits.sh` can be used to run, but for more control `cd lnbits` and use `poetry run lnbits` (see previous option).
## Option 3: Nix
```sh ```sh
# Install nix. If you have installed via another manager, remove and use this install (from https://nixos.org/download) # Install nix. If you have installed via another manager, remove and use this install (from https://nixos.org/download)
@@ -107,7 +110,7 @@ LNBITS_ADMIN_UI=true ./result/bin/lnbits --port 9000
SUPER_USER=be54db7f245346c8833eaa430e1e0405 LNBITS_ADMIN_UI=true ./result/bin/lnbits --port 9000 SUPER_USER=be54db7f245346c8833eaa430e1e0405 LNBITS_ADMIN_UI=true ./result/bin/lnbits --port 9000
``` ```
## Option 3: Docker ## Option 4: Docker
use latest version from docker hub use latest version from docker hub
@@ -129,7 +132,7 @@ mkdir data
docker run --detach --publish 5000:5000 --name lnbits --volume ${PWD}/.env:/app/.env --volume ${PWD}/data/:/app/data lnbits/lnbits docker run --detach --publish 5000:5000 --name lnbits --volume ${PWD}/.env:/app/.env --volume ${PWD}/data/:/app/data lnbits/lnbits
``` ```
## Option 4: Fly.io ## Option 5: Fly.io
Fly.io is a docker container hosting platform that has a generous free tier. You can host LNbits for free on Fly.io for personal use. Fly.io is a docker container hosting platform that has a generous free tier. You can host LNbits for free on Fly.io for personal use.
+1
View File
@@ -30,6 +30,7 @@
meta.rev = self.dirtyRev or self.rev; meta.rev = self.dirtyRev or self.rev;
meta.mainProgram = projectName; meta.mainProgram = projectName;
overrides = pkgs.poetry2nix.overrides.withDefaults (final: prev: { overrides = pkgs.poetry2nix.overrides.withDefaults (final: prev: {
coincurve = prev.coincurve.override { preferWheel = true; };
protobuf = prev.protobuf.override { preferWheel = true; }; protobuf = prev.protobuf.override { preferWheel = true; };
ruff = prev.ruff.override { preferWheel = true; }; ruff = prev.ruff.override { preferWheel = true; };
wallycore = prev.wallycore.override { preferWheel = true; }; wallycore = prev.wallycore.override { preferWheel = true; };
+57
View File
@@ -0,0 +1,57 @@
#!/bin/bash
# Check install has not already run
if [ ! -d lnbits/data ]; then
# Update package list and install prerequisites non-interactively
sudo apt update -y
sudo apt install -y software-properties-common
# Add the deadsnakes PPA repository non-interactively
sudo add-apt-repository -y ppa:deadsnakes/ppa
# Install Python 3.9 and distutils non-interactively
sudo apt install -y python3.9 python3.9-distutils
# Install Poetry
curl -sSL https://install.python-poetry.org | python3.9 -
# Add Poetry to PATH for the current session
export PATH="/home/$USER/.local/bin:$PATH"
if [ ! -d lnbits/wallets ]; then
# Clone the LNbits repository
git clone https://github.com/lnbits/lnbits.git
if [ $? -ne 0 ]; then
echo "Failed to clone the repository ... FAIL"
exit 1
fi
# Ensure we are in the lnbits directory
cd lnbits || { echo "Failed to cd into lnbits ... FAIL"; exit 1; }
fi
git checkout main
# Make data folder
mkdir data
# Copy the .env.example to .env
cp .env.example .env
elif [ ! -d lnbits/wallets ]; then
# cd into lnbits
cd lnbits || { echo "Failed to cd into lnbits ... FAIL"; exit 1; }
fi
# Set path for running after install
export PATH="/home/$USER/.local/bin:$PATH"
# Install the dependencies using Poetry
poetry env use python3.9
poetry install --only main
# Set environment variables for LNbits
export LNBITS_ADMIN_UI=true
export HOST=0.0.0.0
# Run LNbits
poetry run lnbits
+13 -18
View File
@@ -17,10 +17,13 @@ from slowapi.util import get_remote_address
from starlette.middleware.sessions import SessionMiddleware from starlette.middleware.sessions import SessionMiddleware
from lnbits.core.crud import ( from lnbits.core.crud import (
add_installed_extension,
get_dbversions, get_dbversions,
get_installed_extensions, get_installed_extensions,
update_installed_extension_state, update_installed_extension_state,
) )
from lnbits.core.extensions.extension_manager import deactivate_extension
from lnbits.core.extensions.helpers import version_parse
from lnbits.core.helpers import migrate_extension_database from lnbits.core.helpers import migrate_extension_database
from lnbits.core.tasks import ( # watchdog_task from lnbits.core.tasks import ( # watchdog_task
killswitch_task, killswitch_task,
@@ -44,14 +47,8 @@ from lnbits.wallets import get_funding_source, set_funding_source
from .commands import migrate_databases from .commands import migrate_databases
from .core import init_core_routers from .core import init_core_routers
from .core.db import core_app_extra from .core.db import core_app_extra
from .core.extensions.models import Extension, InstallableExtension
from .core.services import check_admin_settings, check_webpush_settings from .core.services import check_admin_settings, check_webpush_settings
from .core.views.extension_api import add_installed_extension
from .extension_manager import (
Extension,
InstallableExtension,
get_valid_extensions,
version_parse,
)
from .middleware import ( from .middleware import (
CustomGZipMiddleware, CustomGZipMiddleware,
ExtensionsRedirectMiddleware, ExtensionsRedirectMiddleware,
@@ -243,6 +240,7 @@ async def check_installed_extensions(app: FastAPI):
) )
except Exception as e: except Exception as e:
logger.warning(e) logger.warning(e)
await deactivate_extension(ext.id)
logger.warning( logger.warning(
f"Failed to re-install extension: {ext.id} ({ext.installed_version})" f"Failed to re-install extension: {ext.id} ({ext.installed_version})"
) )
@@ -317,7 +315,6 @@ async def restore_installed_extension(app: FastAPI, ext: InstallableExtension):
# mount routes for the new version # mount routes for the new version
core_app_extra.register_new_ext_routes(extension) core_app_extra.register_new_ext_routes(extension)
ext.notify_upgrade(extension.upgrade_hash)
def register_custom_extensions_path(): def register_custom_extensions_path():
@@ -380,24 +377,22 @@ def register_ext_routes(app: FastAPI, ext: Extension) -> None:
) )
app.mount(s["path"], StaticFiles(directory=static_dir), s["name"]) app.mount(s["path"], StaticFiles(directory=static_dir), s["name"])
if hasattr(ext_module, f"{ext.code}_redirect_paths"): ext_redirects = (
ext_redirects = getattr(ext_module, f"{ext.code}_redirect_paths") getattr(ext_module, f"{ext.code}_redirect_paths")
settings.lnbits_extensions_redirects = [ if hasattr(ext_module, f"{ext.code}_redirect_paths")
r for r in settings.lnbits_extensions_redirects if r["ext_id"] != ext.code else []
] )
for r in ext_redirects:
r["ext_id"] = ext.code
settings.lnbits_extensions_redirects.append(r)
logger.trace(f"adding route for extension {ext_module}") settings.activate_extension_paths(ext.code, ext.upgrade_hash, ext_redirects)
logger.trace(f"Adding route for extension {ext_module}.")
prefix = f"/upgrades/{ext.upgrade_hash}" if ext.upgrade_hash != "" else "" prefix = f"/upgrades/{ext.upgrade_hash}" if ext.upgrade_hash != "" else ""
app.include_router(router=ext_route, prefix=prefix) app.include_router(router=ext_route, prefix=prefix)
async def check_and_register_extensions(app: FastAPI): async def check_and_register_extensions(app: FastAPI):
await check_installed_extensions(app) await check_installed_extensions(app)
for ext in get_valid_extensions(False): for ext in Extension.get_valid_extensions(False):
try: try:
register_ext_routes(app, ext) register_ext_routes(app, ext)
except Exception as exc: except Exception as exc:
+8 -8
View File
@@ -25,18 +25,18 @@ from lnbits.core.crud import (
remove_deleted_wallets, remove_deleted_wallets,
update_payment_status, update_payment_status,
) )
from lnbits.core.extensions.models import (
CreateExtension,
ExtensionRelease,
InstallableExtension,
)
from lnbits.core.helpers import migrate_databases from lnbits.core.helpers import migrate_databases
from lnbits.core.models import Payment, PaymentState, User from lnbits.core.models import Payment, PaymentState
from lnbits.core.services import check_admin_settings from lnbits.core.services import check_admin_settings
from lnbits.core.views.extension_api import ( from lnbits.core.views.extension_api import (
api_install_extension, api_install_extension,
api_uninstall_extension, api_uninstall_extension,
) )
from lnbits.extension_manager import (
CreateExtension,
ExtensionRelease,
InstallableExtension,
)
from lnbits.settings import settings from lnbits.settings import settings
from lnbits.wallets.base import Wallet from lnbits.wallets.base import Wallet
@@ -611,7 +611,7 @@ async def _call_install_extension(
) )
resp.raise_for_status() resp.raise_for_status()
else: else:
await api_install_extension(data, User(id="mock_id")) await api_install_extension(data)
async def _call_uninstall_extension( async def _call_uninstall_extension(
@@ -625,7 +625,7 @@ async def _call_uninstall_extension(
) )
resp.raise_for_status() resp.raise_for_status()
else: else:
await api_uninstall_extension(extension, User(id="mock_id")) await api_uninstall_extension(extension)
async def _can_run_operation(url) -> bool: async def _can_run_operation(url) -> bool:
+279 -278
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,93 @@
import asyncio
import importlib
from loguru import logger
from lnbits.core.crud import (
add_installed_extension,
delete_installed_extension,
get_dbversions,
get_installed_extension,
update_installed_extension_state,
)
from lnbits.core.db import core_app_extra
from lnbits.core.helpers import migrate_extension_database
from lnbits.settings import settings
from .models import Extension, InstallableExtension
async def install_extension(ext_info: InstallableExtension) -> Extension:
extension = Extension.from_installable_ext(ext_info)
installed_ext = await get_installed_extension(ext_info.id)
ext_info.payments = installed_ext.payments if installed_ext else []
await ext_info.download_archive()
ext_info.extract_archive()
db_version = (await get_dbversions()).get(ext_info.id, 0)
await migrate_extension_database(extension, db_version)
await add_installed_extension(ext_info)
if extension.is_upgrade_extension:
# call stop while the old routes are still active
await stop_extension_background_work(ext_info.id)
return extension
async def uninstall_extension(ext_id: str):
await stop_extension_background_work(ext_id)
settings.deactivate_extension_paths(ext_id)
extension = await get_installed_extension(ext_id)
if extension:
extension.clean_extension_files()
await delete_installed_extension(ext_id=ext_id)
async def activate_extension(ext: Extension):
core_app_extra.register_new_ext_routes(ext)
await update_installed_extension_state(ext_id=ext.code, active=True)
async def deactivate_extension(ext_id: str):
settings.deactivate_extension_paths(ext_id)
await update_installed_extension_state(ext_id=ext_id, active=False)
async def stop_extension_background_work(ext_id: str) -> bool:
"""
Stop background work for extension (like asyncio.Tasks, WebSockets, etc).
Extensions SHOULD expose a `api_stop()` function.
"""
upgrade_hash = settings.lnbits_upgraded_extensions.get(ext_id, "")
ext = Extension(ext_id, True, False, upgrade_hash=upgrade_hash)
try:
logger.info(f"Stopping background work for extension '{ext.module_name}'.")
old_module = importlib.import_module(ext.module_name)
# Extensions must expose an `{ext_id}_stop()` function at the module level
# The `api_stop()` function is for backwards compatibility (will be deprecated)
stop_fns = [f"{ext_id}_stop", "api_stop"]
stop_fn_name = next((fn for fn in stop_fns if hasattr(old_module, fn)), None)
assert stop_fn_name, "No stop function found for '{ext.module_name}'"
stop_fn = getattr(old_module, stop_fn_name)
if stop_fn:
if asyncio.iscoroutinefunction(stop_fn):
await stop_fn()
else:
stop_fn()
logger.info(f"Stopped background work for extension '{ext.module_name}'.")
except Exception as ex:
logger.warning(f"Failed to stop background work for '{ext.module_name}'.")
logger.warning(ex)
return False
return True
+56
View File
@@ -0,0 +1,56 @@
import hashlib
from typing import Any, Optional
from urllib import request
import httpx
from loguru import logger
from packaging import version
from lnbits.settings import settings
def version_parse(v: str):
"""
Wrapper for version.parse() that does not throw if the version is invalid.
Instead it return the lowest possible version ("0.0.0")
"""
try:
return version.parse(v)
except Exception:
return version.parse("0.0.0")
async def github_api_get(url: str, error_msg: Optional[str]) -> Any:
headers = {"User-Agent": settings.user_agent}
if settings.lnbits_ext_github_token:
headers["Authorization"] = f"Bearer {settings.lnbits_ext_github_token}"
async with httpx.AsyncClient(headers=headers) as client:
resp = await client.get(url)
if resp.status_code != 200:
logger.warning(f"{error_msg} ({url}): {resp.text}")
resp.raise_for_status()
return resp.json()
def download_url(url, save_path):
with request.urlopen(url, timeout=60) as dl_file:
with open(save_path, "wb") as out_file:
out_file.write(dl_file.read())
def file_hash(filename):
h = hashlib.sha256()
b = bytearray(128 * 1024)
mv = memoryview(b)
with open(filename, "rb", buffering=0) as f:
while n := f.readinto(mv):
h.update(mv[:n])
return h.hexdigest()
def icon_to_github_url(source_repo: str, path: Optional[str]) -> str:
if not path:
return ""
_, _, *rest = path.split("/")
tail = "/".join(rest)
return f"https://github.com/{source_repo}/raw/main/{tail}"
@@ -1,3 +1,5 @@
from __future__ import annotations
import asyncio import asyncio
import hashlib import hashlib
import json import json
@@ -6,16 +8,22 @@ import shutil
import sys import sys
import zipfile import zipfile
from pathlib import Path from pathlib import Path
from typing import Any, List, NamedTuple, Optional, Tuple from typing import Any, NamedTuple, Optional
from urllib import request
import httpx import httpx
from loguru import logger from loguru import logger
from packaging import version
from pydantic import BaseModel from pydantic import BaseModel
from lnbits.settings import settings from lnbits.settings import settings
from .helpers import (
download_url,
file_hash,
github_api_get,
icon_to_github_url,
version_parse,
)
class ExplicitRelease(BaseModel): class ExplicitRelease(BaseModel):
id: str id: str
@@ -23,7 +31,7 @@ class ExplicitRelease(BaseModel):
version: str version: str
archive: str archive: str
hash: str hash: str
dependencies: List[str] = [] dependencies: list[str] = []
repo: Optional[str] repo: Optional[str]
icon: Optional[str] icon: Optional[str]
short_description: Optional[str] short_description: Optional[str]
@@ -48,9 +56,9 @@ class GitHubRelease(BaseModel):
class Manifest(BaseModel): class Manifest(BaseModel):
featured: List[str] = [] featured: list[str] = []
extensions: List["ExplicitRelease"] = [] extensions: list[ExplicitRelease] = []
repos: List["GitHubRelease"] = [] repos: list[GitHubRelease] = []
class GitHubRepoRelease(BaseModel): class GitHubRepoRelease(BaseModel):
@@ -81,6 +89,17 @@ class ExtensionConfig(BaseModel):
return True return True
return version_parse(self.min_lnbits_version) <= version_parse(settings.version) return version_parse(self.min_lnbits_version) <= version_parse(settings.version)
@classmethod
async def fetch_github_release_config(
cls, org: str, repo: str, tag_name: str
) -> Optional[ExtensionConfig]:
config_url = (
f"https://raw.githubusercontent.com/{org}/{repo}/{tag_name}/config.json"
)
error_msg = "Cannot fetch GitHub extension config"
config = await github_api_get(config_url, error_msg)
return ExtensionConfig.parse_obj(config)
class ReleasePaymentInfo(BaseModel): class ReleasePaymentInfo(BaseModel):
amount: Optional[int] = None amount: Optional[int] = None
@@ -112,7 +131,7 @@ class UserExtension(BaseModel):
return self.extra.paid_to_enable is True return self.extra.paid_to_enable is True
@classmethod @classmethod
def from_row(cls, data: dict) -> "UserExtension": def from_row(cls, data: dict) -> UserExtension:
ext = UserExtension(**data) ext = UserExtension(**data)
ext.extra = ( ext.extra = (
UserExtensionInfo(**json.loads(data["_extra"] or "{}")) UserExtensionInfo(**json.loads(data["_extra"] or "{}"))
@@ -122,124 +141,6 @@ class UserExtension(BaseModel):
return ext return ext
def download_url(url, save_path):
with request.urlopen(url, timeout=60) as dl_file:
with open(save_path, "wb") as out_file:
out_file.write(dl_file.read())
def file_hash(filename):
h = hashlib.sha256()
b = bytearray(128 * 1024)
mv = memoryview(b)
with open(filename, "rb", buffering=0) as f:
while n := f.readinto(mv):
h.update(mv[:n])
return h.hexdigest()
async def fetch_github_repo_info(
org: str, repository: str
) -> Tuple[GitHubRepo, GitHubRepoRelease, ExtensionConfig]:
repo_url = f"https://api.github.com/repos/{org}/{repository}"
error_msg = "Cannot fetch extension repo"
repo = await github_api_get(repo_url, error_msg)
github_repo = GitHubRepo.parse_obj(repo)
lates_release_url = (
f"https://api.github.com/repos/{org}/{repository}/releases/latest"
)
error_msg = "Cannot fetch extension releases"
latest_release: Any = await github_api_get(lates_release_url, error_msg)
config_url = f"https://raw.githubusercontent.com/{org}/{repository}/{github_repo.default_branch}/config.json"
error_msg = "Cannot fetch config for extension"
config = await github_api_get(config_url, error_msg)
return (
github_repo,
GitHubRepoRelease.parse_obj(latest_release),
ExtensionConfig.parse_obj(config),
)
async def fetch_manifest(url) -> Manifest:
error_msg = "Cannot fetch extensions manifest"
manifest = await github_api_get(url, error_msg)
return Manifest.parse_obj(manifest)
async def fetch_github_releases(org: str, repo: str) -> List[GitHubRepoRelease]:
releases_url = f"https://api.github.com/repos/{org}/{repo}/releases"
error_msg = "Cannot fetch extension releases"
releases = await github_api_get(releases_url, error_msg)
return [GitHubRepoRelease.parse_obj(r) for r in releases]
async def fetch_github_release_config(
org: str, repo: str, tag_name: str
) -> Optional[ExtensionConfig]:
config_url = (
f"https://raw.githubusercontent.com/{org}/{repo}/{tag_name}/config.json"
)
error_msg = "Cannot fetch GitHub extension config"
config = await github_api_get(config_url, error_msg)
return ExtensionConfig.parse_obj(config)
async def github_api_get(url: str, error_msg: Optional[str]) -> Any:
headers = {"User-Agent": settings.user_agent}
if settings.lnbits_ext_github_token:
headers["Authorization"] = f"Bearer {settings.lnbits_ext_github_token}"
async with httpx.AsyncClient(headers=headers) as client:
resp = await client.get(url)
if resp.status_code != 200:
logger.warning(f"{error_msg} ({url}): {resp.text}")
resp.raise_for_status()
return resp.json()
async def fetch_release_payment_info(
url: str, amount: Optional[int] = None
) -> Optional[ReleasePaymentInfo]:
if amount:
url = f"{url}?amount={amount}"
try:
async with httpx.AsyncClient() as client:
resp = await client.get(url)
resp.raise_for_status()
return ReleasePaymentInfo(**resp.json())
except Exception as e:
logger.warning(e)
return None
async def fetch_release_details(details_link: str) -> Optional[dict]:
try:
async with httpx.AsyncClient() as client:
resp = await client.get(details_link)
resp.raise_for_status()
data = resp.json()
if "description_md" in data:
resp = await client.get(data["description_md"])
if not resp.is_error:
data["description_md"] = resp.text
return data
except Exception as e:
logger.warning(e)
return None
def icon_to_github_url(source_repo: str, path: Optional[str]) -> str:
if not path:
return ""
_, _, *rest = path.split("/")
tail = "/".join(rest)
return f"https://github.com/{source_repo}/raw/main/{tail}"
class Extension(NamedTuple): class Extension(NamedTuple):
code: str code: str
is_valid: bool is_valid: bool
@@ -247,7 +148,7 @@ class Extension(NamedTuple):
name: Optional[str] = None name: Optional[str] = None
short_description: Optional[str] = None short_description: Optional[str] = None
tile: Optional[str] = None tile: Optional[str] = None
contributors: Optional[List[str]] = None contributors: Optional[list[str]] = None
hidden: bool = False hidden: bool = False
migration_module: Optional[str] = None migration_module: Optional[str] = None
db_name: Optional[str] = None db_name: Optional[str] = None
@@ -269,7 +170,7 @@ class Extension(NamedTuple):
return self.upgrade_hash != "" return self.upgrade_hash != ""
@classmethod @classmethod
def from_installable_ext(cls, ext_info: "InstallableExtension") -> "Extension": def from_installable_ext(cls, ext_info: InstallableExtension) -> Extension:
return Extension( return Extension(
code=ext_info.id, code=ext_info.id,
is_valid=True, is_valid=True,
@@ -278,22 +179,43 @@ class Extension(NamedTuple):
upgrade_hash=ext_info.hash if ext_info.module_installed else "", upgrade_hash=ext_info.hash if ext_info.module_installed else "",
) )
@classmethod
def get_valid_extensions(
cls, include_deactivated: Optional[bool] = True
) -> list[Extension]:
valid_extensions = [
extension for extension in cls._extensions() if extension.is_valid
]
# All subdirectories in the current directory, not recursive. if include_deactivated:
return valid_extensions
if settings.lnbits_extensions_deactivate_all:
return []
class ExtensionManager: return [
def __init__(self) -> None: e
for e in valid_extensions
if e.code not in settings.lnbits_deactivated_extensions
]
@classmethod
def get_valid_extension(
cls, ext_id: str, include_deactivated: Optional[bool] = True
) -> Optional[Extension]:
all_extensions = cls.get_valid_extensions(include_deactivated)
return next((e for e in all_extensions if e.code == ext_id), None)
@classmethod
def _extensions(cls) -> list[Extension]:
p = Path(settings.lnbits_extensions_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()] extension_folders: list[Path] = [f for f in p.iterdir() if f.is_dir()]
@property
def extensions(self) -> List[Extension]:
# todo: remove this property somehow, it is too expensive # todo: remove this property somehow, it is too expensive
output: List[Extension] = [] output: list[Extension] = []
for extension_folder in self._extension_folders: for extension_folder in extension_folders:
extension_code = extension_folder.parts[-1] extension_code = extension_folder.parts[-1]
try: try:
with open(extension_folder / "config.json") as json_file: with open(extension_folder / "config.json") as json_file:
@@ -356,13 +278,27 @@ class ExtensionRelease(BaseModel):
if not self.pay_link: if not self.pay_link:
return return
payment_info = await fetch_release_payment_info(self.pay_link) payment_info = await self.fetch_release_payment_info()
self.cost_sats = payment_info.amount if payment_info else None self.cost_sats = payment_info.amount if payment_info else None
async def fetch_release_payment_info(
self, amount: Optional[int] = None
) -> Optional[ReleasePaymentInfo]:
url = f"{self.pay_link}?amount={amount}" if amount else self.pay_link
assert url, "Missing URL for payment info."
try:
async with httpx.AsyncClient() as client:
resp = await client.get(url)
resp.raise_for_status()
return ReleasePaymentInfo(**resp.json())
except Exception as e:
logger.warning(e)
return None
@classmethod @classmethod
def from_github_release( def from_github_release(
cls, source_repo: str, r: "GitHubRepoRelease" cls, source_repo: str, r: GitHubRepoRelease
) -> "ExtensionRelease": ) -> ExtensionRelease:
return ExtensionRelease( return ExtensionRelease(
name=r.name, name=r.name,
description=r.name, description=r.name,
@@ -377,8 +313,8 @@ class ExtensionRelease(BaseModel):
@classmethod @classmethod
def from_explicit_release( def from_explicit_release(
cls, source_repo: str, e: "ExplicitRelease" cls, source_repo: str, e: ExplicitRelease
) -> "ExtensionRelease": ) -> ExtensionRelease:
return ExtensionRelease( return ExtensionRelease(
name=e.name, name=e.name,
version=e.version, version=e.version,
@@ -397,9 +333,9 @@ class ExtensionRelease(BaseModel):
) )
@classmethod @classmethod
async def get_github_releases(cls, org: str, repo: str) -> List["ExtensionRelease"]: async def get_github_releases(cls, org: str, repo: str) -> list[ExtensionRelease]:
try: try:
github_releases = await fetch_github_releases(org, repo) github_releases = await cls.fetch_github_releases(org, repo)
return [ return [
ExtensionRelease.from_github_release(f"{org}/{repo}", r) ExtensionRelease.from_github_release(f"{org}/{repo}", r)
for r in github_releases for r in github_releases
@@ -408,6 +344,33 @@ class ExtensionRelease(BaseModel):
logger.warning(e) logger.warning(e)
return [] return []
@classmethod
async def fetch_github_releases(
cls, org: str, repo: str
) -> list[GitHubRepoRelease]:
releases_url = f"https://api.github.com/repos/{org}/{repo}/releases"
error_msg = "Cannot fetch extension releases"
releases = await github_api_get(releases_url, error_msg)
return [GitHubRepoRelease.parse_obj(r) for r in releases]
@classmethod
async def fetch_release_details(cls, details_link: str) -> Optional[dict]:
try:
async with httpx.AsyncClient() as client:
resp = await client.get(details_link)
resp.raise_for_status()
data = resp.json()
if "description_md" in data:
resp = await client.get(data["description_md"])
if not resp.is_error:
data["description_md"] = resp.text
return data
except Exception as e:
logger.warning(e)
return None
class InstallableExtension(BaseModel): class InstallableExtension(BaseModel):
id: str id: str
@@ -415,13 +378,13 @@ class InstallableExtension(BaseModel):
active: Optional[bool] = False active: Optional[bool] = False
short_description: Optional[str] = None short_description: Optional[str] = None
icon: Optional[str] = None icon: Optional[str] = None
dependencies: List[str] = [] dependencies: list[str] = []
is_admin_only: bool = False is_admin_only: bool = False
stars: int = 0 stars: int = 0
featured = False featured = False
latest_release: Optional[ExtensionRelease] = None latest_release: Optional[ExtensionRelease] = None
installed_release: Optional[ExtensionRelease] = None installed_release: Optional[ExtensionRelease] = None
payments: List[ReleasePaymentInfo] = [] payments: list[ReleasePaymentInfo] = []
pay_to_enable: Optional[PayToEnableInfo] = None pay_to_enable: Optional[PayToEnableInfo] = None
archive: Optional[str] = None archive: Optional[str] = None
@@ -546,16 +509,6 @@ class InstallableExtension(BaseModel):
shutil.copytree(Path(self.ext_upgrade_dir), Path(self.ext_dir)) shutil.copytree(Path(self.ext_upgrade_dir), Path(self.ext_dir))
logger.success(f"Extension {self.name} ({self.installed_version}) installed.") logger.success(f"Extension {self.name} ({self.installed_version}) installed.")
def notify_upgrade(self, upgrade_hash: Optional[str]) -> None:
"""
Update the list of upgraded extensions. The middleware will perform
redirects based on this
"""
if upgrade_hash:
settings.lnbits_upgraded_extensions.add(f"{self.hash}/{self.id}")
settings.lnbits_all_extensions_ids.add(self.id)
def clean_extension_files(self): def clean_extension_files(self):
# remove downloaded archive # remove downloaded archive
if self.zip_path.is_file(): if self.zip_path.is_file():
@@ -610,7 +563,7 @@ class InstallableExtension(BaseModel):
self.payments.append(payment_info) self.payments.append(payment_info)
@classmethod @classmethod
def from_row(cls, data: dict) -> "InstallableExtension": def from_row(cls, data: dict) -> InstallableExtension:
meta = json.loads(data["meta"]) meta = json.loads(data["meta"])
ext = InstallableExtension(**data) ext = InstallableExtension(**data)
if "installed_release" in meta: if "installed_release" in meta:
@@ -623,9 +576,7 @@ class InstallableExtension(BaseModel):
return ext return ext
@classmethod @classmethod
def from_rows( def from_rows(cls, rows: Optional[list[Any]] = None) -> list[InstallableExtension]:
cls, rows: Optional[List[Any]] = None
) -> List["InstallableExtension"]:
if rows is None: if rows is None:
rows = [] rows = []
return [InstallableExtension.from_row(row) for row in rows] return [InstallableExtension.from_row(row) for row in rows]
@@ -633,9 +584,9 @@ class InstallableExtension(BaseModel):
@classmethod @classmethod
async def from_github_release( async def from_github_release(
cls, github_release: GitHubRelease cls, github_release: GitHubRelease
) -> Optional["InstallableExtension"]: ) -> Optional[InstallableExtension]:
try: try:
repo, latest_release, config = await fetch_github_repo_info( repo, latest_release, config = await cls.fetch_github_repo_info(
github_release.organisation, github_release.repository github_release.organisation, github_release.repository
) )
source_repo = f"{github_release.organisation}/{github_release.repository}" source_repo = f"{github_release.organisation}/{github_release.repository}"
@@ -657,7 +608,7 @@ class InstallableExtension(BaseModel):
return None return None
@classmethod @classmethod
def from_explicit_release(cls, e: ExplicitRelease) -> "InstallableExtension": def from_explicit_release(cls, e: ExplicitRelease) -> InstallableExtension:
return InstallableExtension( return InstallableExtension(
id=e.id, id=e.id,
name=e.name, name=e.name,
@@ -670,13 +621,13 @@ class InstallableExtension(BaseModel):
@classmethod @classmethod
async def get_installable_extensions( async def get_installable_extensions(
cls, cls,
) -> List["InstallableExtension"]: ) -> list[InstallableExtension]:
extension_list: List[InstallableExtension] = [] extension_list: list[InstallableExtension] = []
extension_id_list: List[str] = [] extension_id_list: list[str] = []
for url in settings.lnbits_extensions_manifests: for url in settings.lnbits_extensions_manifests:
try: try:
manifest = await fetch_manifest(url) manifest = await cls.fetch_manifest(url)
for r in manifest.repos: for r in manifest.repos:
ext = await InstallableExtension.from_github_release(r) ext = await InstallableExtension.from_github_release(r)
@@ -712,12 +663,12 @@ class InstallableExtension(BaseModel):
return extension_list return extension_list
@classmethod @classmethod
async def get_extension_releases(cls, ext_id: str) -> List["ExtensionRelease"]: async def get_extension_releases(cls, ext_id: str) -> list[ExtensionRelease]:
extension_releases: List[ExtensionRelease] = [] extension_releases: list[ExtensionRelease] = []
for url in settings.lnbits_extensions_manifests: for url in settings.lnbits_extensions_manifests:
try: try:
manifest = await fetch_manifest(url) manifest = await cls.fetch_manifest(url)
for r in manifest.repos: for r in manifest.repos:
if r.id != ext_id: if r.id != ext_id:
continue continue
@@ -741,8 +692,8 @@ class InstallableExtension(BaseModel):
@classmethod @classmethod
async def get_extension_release( async def get_extension_release(
cls, ext_id: str, source_repo: str, archive: str, version: str cls, ext_id: str, source_repo: str, archive: str, version: str
) -> Optional["ExtensionRelease"]: ) -> Optional[ExtensionRelease]:
all_releases: List[ExtensionRelease] = ( all_releases: list[ExtensionRelease] = (
await InstallableExtension.get_extension_releases(ext_id) await InstallableExtension.get_extension_releases(ext_id)
) )
selected_release = [ selected_release = [
@@ -755,6 +706,37 @@ class InstallableExtension(BaseModel):
return selected_release[0] if len(selected_release) != 0 else None return selected_release[0] if len(selected_release) != 0 else None
@classmethod
async def fetch_github_repo_info(
cls, org: str, repository: str
) -> tuple[GitHubRepo, GitHubRepoRelease, ExtensionConfig]:
repo_url = f"https://api.github.com/repos/{org}/{repository}"
error_msg = "Cannot fetch extension repo"
repo = await github_api_get(repo_url, error_msg)
github_repo = GitHubRepo.parse_obj(repo)
lates_release_url = (
f"https://api.github.com/repos/{org}/{repository}/releases/latest"
)
error_msg = "Cannot fetch extension releases"
latest_release: Any = await github_api_get(lates_release_url, error_msg)
config_url = f"https://raw.githubusercontent.com/{org}/{repository}/{github_repo.default_branch}/config.json"
error_msg = "Cannot fetch config for extension"
config = await github_api_get(config_url, error_msg)
return (
github_repo,
GitHubRepoRelease.parse_obj(latest_release),
ExtensionConfig.parse_obj(config),
)
@classmethod
async def fetch_manifest(cls, url) -> Manifest:
error_msg = "Cannot fetch extensions manifest"
manifest = await github_api_get(url, error_msg)
return Manifest.parse_obj(manifest)
class CreateExtension(BaseModel): class CreateExtension(BaseModel):
ext_id: str ext_id: str
@@ -769,32 +751,3 @@ class ExtensionDetailsRequest(BaseModel):
ext_id: str ext_id: str
source_repo: str source_repo: str
version: str version: str
def get_valid_extensions(include_deactivated: Optional[bool] = True) -> List[Extension]:
valid_extensions = [
extension for extension in ExtensionManager().extensions if extension.is_valid
]
if include_deactivated:
return valid_extensions
if settings.lnbits_extensions_deactivate_all:
return []
return [
e
for e in valid_extensions
if e.code not in settings.lnbits_deactivated_extensions
]
def version_parse(v: str):
"""
Wrapper for version.parse() that does not throw if the version is invalid.
Instead it return the lowest possible version ("0.0.0")
"""
try:
return version.parse(v)
except Exception:
return version.parse("0.0.0")
+4 -68
View File
@@ -1,9 +1,8 @@
import importlib import importlib
import re import re
from typing import Any, Optional from typing import Any
from uuid import UUID from uuid import UUID
import httpx
from loguru import logger from loguru import logger
from lnbits.core import migrations as core_migrations from lnbits.core import migrations as core_migrations
@@ -13,11 +12,10 @@ from lnbits.core.crud import (
update_migration_version, update_migration_version,
) )
from lnbits.core.db import db as core_db from lnbits.core.db import db as core_db
from lnbits.db import COCKROACH, POSTGRES, SQLITE, Connection from lnbits.core.extensions.models import (
from lnbits.extension_manager import (
Extension, Extension,
get_valid_extensions,
) )
from lnbits.db import COCKROACH, POSTGRES, SQLITE, Connection
from lnbits.settings import settings from lnbits.settings import settings
@@ -55,68 +53,6 @@ async def run_migration(
await update_migration_version(conn, db_name, version) await update_migration_version(conn, db_name, version)
async def stop_extension_background_work(
ext_id: str, user: str, access_token: Optional[str] = None
):
"""
Stop background work for extension (like asyncio.Tasks, WebSockets, etc).
Extensions SHOULD expose a `api_stop()` function and/or a DELETE enpoint
at the root level of their API.
"""
stopped = await _stop_extension_background_work(ext_id)
if not stopped:
# fallback to REST API call
await _stop_extension_background_work_via_api(ext_id, user, access_token)
async def _stop_extension_background_work(ext_id) -> bool:
upgrade_hash = settings.extension_upgrade_hash(ext_id) or ""
ext = Extension(ext_id, True, False, upgrade_hash=upgrade_hash)
try:
logger.info(f"Stopping background work for extension '{ext.module_name}'.")
old_module = importlib.import_module(ext.module_name)
# Extensions must expose an `{ext_id}_stop()` function at the module level
# The `api_stop()` function is for backwards compatibility (will be deprecated)
stop_fns = [f"{ext_id}_stop", "api_stop"]
stop_fn_name = next((fn for fn in stop_fns if hasattr(old_module, fn)), None)
assert stop_fn_name, "No stop function found for '{ext.module_name}'"
stop_fn = getattr(old_module, stop_fn_name)
if stop_fn:
await stop_fn()
logger.info(f"Stopped background work for extension '{ext.module_name}'.")
except Exception as ex:
logger.warning(f"Failed to stop background work for '{ext.module_name}'.")
logger.warning(ex)
return False
return True
async def _stop_extension_background_work_via_api(ext_id, user, access_token):
logger.info(
f"Stopping background work for extension '{ext_id}' using the REST API."
)
async with httpx.AsyncClient() as client:
try:
url = f"http://{settings.host}:{settings.port}/{ext_id}/api/v1?usr={user}"
headers = (
{"Authorization": "Bearer " + access_token} if access_token else None
)
resp = await client.delete(url=url, headers=headers)
resp.raise_for_status()
logger.info(f"Stopped background work for extension '{ext_id}'.")
except Exception as ex:
logger.warning(
f"Failed to stop background work for '{ext_id}' using the REST API."
)
logger.warning(ex)
def to_valid_user_id(user_id: str) -> UUID: def to_valid_user_id(user_id: str) -> UUID:
if len(user_id) < 32: if len(user_id) < 32:
raise ValueError("User ID must have at least 128 bits") raise ValueError("User ID must have at least 128 bits")
@@ -161,7 +97,7 @@ async def migrate_databases():
await load_disabled_extension_list() await load_disabled_extension_list()
# todo: revisit, use installed extensions # todo: revisit, use installed extensions
for ext in get_valid_extensions(False): for ext in Extension.get_valid_extensions(False):
current_version = current_versions.get(ext.code, 0) current_version = current_versions.get(ext.code, 0)
try: try:
await migrate_extension_database(ext, current_version) await migrate_extension_database(ext, current_version)
+46 -51
View File
@@ -1,4 +1,3 @@
import datetime
from time import time from time import time
from loguru import logger from loguru import logger
@@ -102,7 +101,7 @@ async def m002_add_fields_to_apipayments(db):
import json import json
rows = await (await db.execute("SELECT * FROM apipayments")).fetchall() rows = await db.fetchall("SELECT * FROM apipayments")
for row in rows: for row in rows:
if not row["memo"] or not row["memo"].startswith("#"): if not row["memo"] or not row["memo"].startswith("#"):
continue continue
@@ -113,15 +112,15 @@ async def m002_add_fields_to_apipayments(db):
new = row["memo"][len(prefix) :] new = row["memo"][len(prefix) :]
await db.execute( await db.execute(
""" """
UPDATE apipayments SET extra = ?, memo = ? UPDATE apipayments SET extra = :extra, memo = :memo1
WHERE checking_id = ? AND memo = ? WHERE checking_id = :checking_id AND memo = :memo2
""", """,
( {
json.dumps({"tag": ext}), "extra": json.dumps({"tag": ext}),
new, "memo1": new,
row["checking_id"], "checking_id": row["checking_id"],
row["memo"], "memo2": row["memo"],
), },
) )
break break
except OperationalError: except OperationalError:
@@ -212,19 +211,17 @@ async def m007_set_invoice_expiries(db):
Precomputes invoice expiry for existing pending incoming payments. Precomputes invoice expiry for existing pending incoming payments.
""" """
try: try:
rows = await ( rows = await db.fetchall(
await db.execute( f"""
f""" SELECT bolt11, checking_id
SELECT bolt11, checking_id FROM apipayments
FROM apipayments WHERE pending = true
WHERE pending = true AND amount > 0
AND amount > 0 AND bolt11 IS NOT NULL
AND bolt11 IS NOT NULL AND expiry IS NULL
AND expiry IS NULL AND time < {db.timestamp_now}
AND time < {db.timestamp_now} """
""" )
)
).fetchall()
if len(rows): if len(rows):
logger.info(f"Migration: Checking expiry of {len(rows)} invoices") logger.info(f"Migration: Checking expiry of {len(rows)} invoices")
for i, ( for i, (
@@ -236,22 +233,17 @@ async def m007_set_invoice_expiries(db):
if invoice.expiry is None: if invoice.expiry is None:
continue continue
expiration_date = datetime.datetime.fromtimestamp( expiration_date = invoice.date + invoice.expiry
invoice.date + invoice.expiry
)
logger.info( logger.info(
f"Migration: {i+1}/{len(rows)} setting expiry of invoice" f"Migration: {i+1}/{len(rows)} setting expiry of invoice"
f" {invoice.payment_hash} to {expiration_date}" f" {invoice.payment_hash} to {expiration_date}"
) )
await db.execute( await db.execute(
""" f"""
UPDATE apipayments SET expiry = ? UPDATE apipayments SET expiry = {db.timestamp_placeholder('expiry')}
WHERE checking_id = ? AND amount > 0 WHERE checking_id = :checking_id AND amount > 0
""", """,
( {"expiry": expiration_date, "checking_id": checking_id},
db.datetime_to_timestamp(expiration_date),
checking_id,
),
) )
except Exception: except Exception:
continue continue
@@ -347,17 +339,15 @@ async def m014_set_deleted_wallets(db):
Sets deleted column to wallets. Sets deleted column to wallets.
""" """
try: try:
rows = await ( rows = await db.fetchall(
await db.execute( """
""" SELECT *
SELECT * FROM wallets
FROM wallets WHERE user LIKE 'del:%'
WHERE user LIKE 'del:%' AND adminkey LIKE 'del:%'
AND adminkey LIKE 'del:%' AND inkey LIKE 'del:%'
AND inkey LIKE 'del:%' """
""" )
)
).fetchall()
for row in rows: for row in rows:
try: try:
@@ -367,10 +357,15 @@ async def m014_set_deleted_wallets(db):
await db.execute( await db.execute(
""" """
UPDATE wallets SET UPDATE wallets SET
"user" = ?, adminkey = ?, inkey = ?, deleted = true "user" = :user, adminkey = :adminkey, inkey = :inkey, deleted = true
WHERE id = ? WHERE id = :wallet
""", """,
(user, adminkey, inkey, row[0]), {
"user": user,
"adminkey": adminkey,
"inkey": inkey,
"wallet": row.get("id"),
},
) )
except Exception: except Exception:
continue continue
@@ -456,17 +451,17 @@ async def m017_add_timestamp_columns_to_accounts_and_wallets(db):
now = int(time()) now = int(time())
await db.execute( await db.execute(
f""" f"""
UPDATE wallets SET created_at = {db.timestamp_placeholder} UPDATE wallets SET created_at = {db.timestamp_placeholder('now')}
WHERE created_at IS NULL WHERE created_at IS NULL
""", """,
(now,), {"now": now},
) )
await db.execute( await db.execute(
f""" f"""
UPDATE accounts SET created_at = {db.timestamp_placeholder} UPDATE accounts SET created_at = {db.timestamp_placeholder('now')}
WHERE created_at IS NULL WHERE created_at IS NULL
""", """,
(now,), {"now": now},
) )
except OperationalError as exc: except OperationalError as exc:
+24 -6
View File
@@ -7,7 +7,6 @@ import json
import time import time
from dataclasses import dataclass from dataclasses import dataclass
from enum import Enum from enum import Enum
from sqlite3 import Row
from typing import Callable, Optional from typing import Callable, Optional
from ecdsa import SECP256k1, SigningKey from ecdsa import SECP256k1, SigningKey
@@ -21,8 +20,10 @@ from lnbits.settings import settings
from lnbits.utils.exchange_rates import allowed_currencies from lnbits.utils.exchange_rates import allowed_currencies
from lnbits.wallets import get_funding_source from lnbits.wallets import get_funding_source
from lnbits.wallets.base import ( from lnbits.wallets.base import (
PaymentFailedStatus,
PaymentPendingStatus, PaymentPendingStatus,
PaymentStatus, PaymentStatus,
PaymentSuccessStatus,
) )
@@ -211,6 +212,19 @@ class PaymentState(str, Enum):
return self.value return self.value
class CreatePayment(BaseModel):
wallet_id: str
payment_request: str
payment_hash: str
amount: int
memo: str
preimage: Optional[str] = None
expiry: Optional[datetime.datetime] = None
extra: Optional[dict] = None
webhook: Optional[str] = None
fee: int = 0
class Payment(FromRowModel): class Payment(FromRowModel):
status: str status: str
# TODO should be removed in the future, backward compatibility # TODO should be removed in the future, backward compatibility
@@ -224,7 +238,7 @@ class Payment(FromRowModel):
preimage: str preimage: str
payment_hash: str payment_hash: str
expiry: Optional[float] expiry: Optional[float]
extra: dict = {} extra: Optional[dict]
wallet_id: str wallet_id: str
webhook: Optional[str] webhook: Optional[str]
webhook_status: Optional[int] webhook_status: Optional[int]
@@ -238,7 +252,7 @@ class Payment(FromRowModel):
return self.status == PaymentState.FAILED.value return self.status == PaymentState.FAILED.value
@classmethod @classmethod
def from_row(cls, row: Row): def from_row(cls, row: dict):
return cls( return cls(
checking_id=row["checking_id"], checking_id=row["checking_id"],
payment_hash=row["hash"] or "0" * 64, payment_hash=row["hash"] or "0" * 64,
@@ -285,11 +299,15 @@ class Payment(FromRowModel):
return self.expiry < time.time() if self.expiry else False return self.expiry < time.time() if self.expiry else False
@property @property
def is_uncheckable(self) -> bool: def is_internal(self) -> bool:
return self.checking_id.startswith("internal_") return self.checking_id.startswith("internal_")
async def check_status(self) -> PaymentStatus: async def check_status(self) -> PaymentStatus:
if self.is_uncheckable: if self.is_internal:
if self.success:
return PaymentSuccessStatus()
if self.failed:
return PaymentFailedStatus()
return PaymentPendingStatus() return PaymentPendingStatus()
funding_source = get_funding_source() funding_source = get_funding_source()
if self.is_out: if self.is_out:
@@ -341,7 +359,7 @@ class TinyURL(BaseModel):
time: float time: float
@classmethod @classmethod
def from_row(cls, row: Row): def from_row(cls, row: dict):
return cls(**dict(row)) return cls(**dict(row))
+45 -39
View File
@@ -1,10 +1,9 @@
import asyncio import asyncio
import datetime
import json import json
import time import time
from io import BytesIO from io import BytesIO
from pathlib import Path from pathlib import Path
from typing import Dict, List, Optional, Tuple, TypedDict from typing import Optional
from urllib.parse import parse_qs, urlparse from urllib.parse import parse_qs, urlparse
from uuid import UUID, uuid4 from uuid import UUID, uuid4
@@ -68,16 +67,24 @@ from .crud import (
update_user_extension, update_user_extension,
) )
from .helpers import to_valid_user_id from .helpers import to_valid_user_id
from .models import BalanceDelta, Payment, PaymentState, User, UserConfig, Wallet from .models import (
BalanceDelta,
CreatePayment,
Payment,
PaymentState,
User,
UserConfig,
Wallet,
)
async def calculate_fiat_amounts( async def calculate_fiat_amounts(
amount: float, amount: float,
wallet_id: str, wallet_id: str,
currency: Optional[str] = None, currency: Optional[str] = None,
extra: Optional[Dict] = None, extra: Optional[dict] = None,
conn: Optional[Connection] = None, conn: Optional[Connection] = None,
) -> Tuple[int, Optional[Dict]]: ) -> tuple[int, Optional[dict]]:
wallet = await get_wallet(wallet_id, conn=conn) wallet = await get_wallet(wallet_id, conn=conn)
assert wallet, "invalid wallet_id" assert wallet, "invalid wallet_id"
wallet_currency = wallet.currency or settings.lnbits_default_accounting_currency wallet_currency = wallet.currency or settings.lnbits_default_accounting_currency
@@ -118,11 +125,11 @@ async def create_invoice(
description_hash: Optional[bytes] = None, description_hash: Optional[bytes] = None,
unhashed_description: Optional[bytes] = None, unhashed_description: Optional[bytes] = None,
expiry: Optional[int] = None, expiry: Optional[int] = None,
extra: Optional[Dict] = None, extra: Optional[dict] = None,
webhook: Optional[str] = None, webhook: Optional[str] = None,
internal: Optional[bool] = False, internal: Optional[bool] = False,
conn: Optional[Connection] = None, conn: Optional[Connection] = None,
) -> Tuple[str, str]: ) -> tuple[str, str]:
if not amount > 0: if not amount > 0:
raise InvoiceError("Amountless invoices not supported.", status="failed") raise InvoiceError("Amountless invoices not supported.", status="failed")
@@ -167,17 +174,20 @@ async def create_invoice(
invoice = bolt11_decode(payment_request) invoice = bolt11_decode(payment_request)
amount_msat = 1000 * amount_sat create_payment_model = CreatePayment(
await create_payment(
wallet_id=wallet_id, wallet_id=wallet_id,
checking_id=checking_id,
payment_request=payment_request, payment_request=payment_request,
payment_hash=invoice.payment_hash, payment_hash=invoice.payment_hash,
amount=amount_msat, amount=amount_sat * 1000,
expiry=invoice.expiry_date, expiry=invoice.expiry_date,
memo=memo, memo=memo,
extra=extra, extra=extra,
webhook=webhook, webhook=webhook,
)
await create_payment(
checking_id=checking_id,
data=create_payment_model,
conn=conn, conn=conn,
) )
@@ -189,7 +199,7 @@ async def pay_invoice(
wallet_id: str, wallet_id: str,
payment_request: str, payment_request: str,
max_sat: Optional[int] = None, max_sat: Optional[int] = None,
extra: Optional[Dict] = None, extra: Optional[dict] = None,
description: str = "", description: str = "",
conn: Optional[Connection] = None, conn: Optional[Connection] = None,
) -> str: ) -> str:
@@ -223,17 +233,7 @@ async def pay_invoice(
invoice.amount_msat / 1000, wallet_id, extra=extra, conn=conn invoice.amount_msat / 1000, wallet_id, extra=extra, conn=conn
) )
# put all parameters that don't change here create_payment_model = CreatePayment(
class PaymentKwargs(TypedDict):
wallet_id: str
payment_request: str
payment_hash: str
amount: int
memo: str
expiry: Optional[datetime.datetime]
extra: Optional[Dict]
payment_kwargs: PaymentKwargs = PaymentKwargs(
wallet_id=wallet_id, wallet_id=wallet_id,
payment_request=payment_request, payment_request=payment_request,
payment_hash=invoice.payment_hash, payment_hash=invoice.payment_hash,
@@ -252,9 +252,6 @@ async def pay_invoice(
# (pending only) # (pending only)
internal_checking_id = await check_internal(invoice.payment_hash, conn=conn) internal_checking_id = await check_internal(invoice.payment_hash, conn=conn)
if internal_checking_id: if internal_checking_id:
fee_reserve_total_msat = fee_reserve_total(
invoice.amount_msat, internal=True
)
# perform additional checks on the internal payment # perform additional checks on the internal payment
# the payment hash is not enough to make sure that this is the same invoice # the payment hash is not enough to make sure that this is the same invoice
internal_invoice = await get_standalone_payment( internal_invoice = await get_standalone_payment(
@@ -269,16 +266,23 @@ async def pay_invoice(
logger.debug(f"creating temporary internal payment with id {internal_id}") logger.debug(f"creating temporary internal payment with id {internal_id}")
# create a new payment from this wallet # create a new payment from this wallet
fee_reserve_total_msat = fee_reserve_total(
invoice.amount_msat, internal=True
)
create_payment_model.fee = abs(fee_reserve_total_msat)
new_payment = await create_payment( new_payment = await create_payment(
checking_id=internal_id, checking_id=internal_id,
fee=0 + abs(fee_reserve_total_msat), data=create_payment_model,
status=PaymentState.SUCCESS, status=PaymentState.SUCCESS,
conn=conn, conn=conn,
**payment_kwargs,
) )
else: else:
new_payment = await _create_external_payment( new_payment = await _create_external_payment(
temp_id, invoice.amount_msat, conn=conn, **payment_kwargs temp_id=temp_id,
amount_msat=invoice.amount_msat,
data=create_payment_model,
conn=conn,
) )
# do the balance check # do the balance check
@@ -377,14 +381,16 @@ async def pay_invoice(
# credit service fee wallet # credit service fee wallet
if settings.lnbits_service_fee_wallet and service_fee_msat: if settings.lnbits_service_fee_wallet and service_fee_msat:
new_payment = await create_payment( create_payment_model = CreatePayment(
wallet_id=settings.lnbits_service_fee_wallet, wallet_id=settings.lnbits_service_fee_wallet,
fee=0,
amount=abs(service_fee_msat),
memo="Service fee",
checking_id="service_fee" + temp_id,
payment_request=payment_request, payment_request=payment_request,
payment_hash=invoice.payment_hash, payment_hash=invoice.payment_hash,
amount=abs(service_fee_msat),
memo="Service fee",
)
new_payment = await create_payment(
checking_id=f"service_fee_{temp_id}",
data=create_payment_model,
status=PaymentState.SUCCESS, status=PaymentState.SUCCESS,
) )
return invoice.payment_hash return invoice.payment_hash
@@ -393,8 +399,8 @@ async def pay_invoice(
async def _create_external_payment( async def _create_external_payment(
temp_id: str, temp_id: str,
amount_msat: MilliSatoshi, amount_msat: MilliSatoshi,
data: CreatePayment,
conn: Optional[Connection], conn: Optional[Connection],
**payment_kwargs,
) -> Payment: ) -> Payment:
fee_reserve_total_msat = fee_reserve_total(amount_msat, internal=False) fee_reserve_total_msat = fee_reserve_total(amount_msat, internal=False)
@@ -428,11 +434,11 @@ async def _create_external_payment(
# create a temporary payment here so we can check if # create a temporary payment here so we can check if
# the balance is enough in the next step # the balance is enough in the next step
try: try:
data.fee = -abs(fee_reserve_total_msat)
new_payment = await create_payment( new_payment = await create_payment(
checking_id=temp_id, checking_id=temp_id,
fee=-abs(fee_reserve_total_msat), data=data,
conn=conn, conn=conn,
**payment_kwargs,
) )
return new_payment return new_payment
except Exception as exc: except Exception as exc:
@@ -514,7 +520,7 @@ async def redeem_lnurl_withdraw(
wallet_id: str, wallet_id: str,
lnurl_request: str, lnurl_request: str,
memo: Optional[str] = None, memo: Optional[str] = None,
extra: Optional[Dict] = None, extra: Optional[dict] = None,
wait_seconds: int = 0, wait_seconds: int = 0,
conn: Optional[Connection] = None, conn: Optional[Connection] = None,
) -> None: ) -> None:
@@ -853,7 +859,7 @@ async def create_user_account(
class WebsocketConnectionManager: class WebsocketConnectionManager:
def __init__(self) -> None: def __init__(self) -> None:
self.active_connections: List[WebSocket] = [] self.active_connections: list[WebSocket] = []
async def connect(self, websocket: WebSocket, item_id: str): async def connect(self, websocket: WebSocket, item_id: str):
logger.debug(f"Websocket connected to {item_id}") logger.debug(f"Websocket connected to {item_id}")
+1 -1
View File
@@ -901,7 +901,7 @@
</q-dialog> </q-dialog>
{% endblock %} {% block scripts %} {{ window_vars(user) }} {% endblock %} {% block scripts %} {{ window_vars(user) }}
<script> <script>
new Vue({ window.app = Vue.createApp({
el: '#vue', el: '#vue',
data: function () { data: function () {
+2 -2
View File
@@ -154,10 +154,10 @@
<q-card> <q-card>
<q-card-section class="text-center"> <q-card-section class="text-center">
<p v-text="$t('export_to_phone_desc')"></p> <p v-text="$t('export_to_phone_desc')"></p>
<qrcode <qrcode-vue
:value="'{{request.base_url}}wallet?usr={{user.id}}&wal={{wallet.id}}'" :value="'{{request.base_url}}wallet?usr={{user.id}}&wal={{wallet.id}}'"
:options="{ width: 256 }" :options="{ width: 256 }"
></qrcode> ></qrcode-vue>
</q-card-section> </q-card-section>
<q-card-actions class="flex-center q-pb-md"> <q-card-actions class="flex-center q-pb-md">
<q-btn <q-btn
@@ -51,11 +51,11 @@
> >
<a :href="'lightning:' + transactionDetailsDialog.data.bolt11"> <a :href="'lightning:' + transactionDetailsDialog.data.bolt11">
<q-responsive :ratio="1" class="q-mx-xl"> <q-responsive :ratio="1" class="q-mx-xl">
<qrcode <qrcode-vue
:value="'lightning:' + transactionDetailsDialog.data.bolt11.toUpperCase()" :value="'lightning:' + transactionDetailsDialog.data.bolt11.toUpperCase()"
:options="{width: 340}" :options="{width: 340}"
class="rounded-borders" class="rounded-borders"
></qrcode> ></qrcode-vue>
</q-responsive> </q-responsive>
</a> </a>
<q-btn <q-btn
@@ -138,11 +138,11 @@
> >
<a :href="'lightning:' + props.row.bolt11"> <a :href="'lightning:' + props.row.bolt11">
<q-responsive :ratio="1" class="q-mx-xl"> <q-responsive :ratio="1" class="q-mx-xl">
<qrcode <qrcode-vue
:value="'lightning:' + props.row.bolt11.toUpperCase()" :value="'lightning:' + props.row.bolt11.toUpperCase()"
:options="{width: 340}" :options="{width: 340}"
class="rounded-borders" class="rounded-borders"
></qrcode> ></qrcode-vue>
</q-responsive> </q-responsive>
</a> </a>
</div> </div>
+4 -4
View File
@@ -7,7 +7,7 @@ include "users/_createWalletDialog.html" %}
<div class="row q-col-gutter-md justify-center"> <div class="row q-col-gutter-md justify-center">
<div class="col q-gutter-y-md" style="width: 300px"> <div class="col q-gutter-y-md" style="width: 300px">
<div style="width: 600px"> <div style="width: 100%; max-width: 2000px">
<canvas ref="chart1"></canvas> <canvas ref="chart1"></canvas>
</div> </div>
</div> </div>
@@ -24,8 +24,8 @@ include "users/_createWalletDialog.html" %}
</q-btn> </q-btn>
</div> </div>
<q-table <q-table
:data="users" row-key="id"
:row-key="usersTableRowKey" :rows="users"
:columns="usersTable.columns" :columns="usersTable.columns"
:pagination.sync="usersTable.pagination" :pagination.sync="usersTable.pagination"
:no-data-label="$t('no_users')" :no-data-label="$t('no_users')"
@@ -70,7 +70,7 @@ include "users/_createWalletDialog.html" %}
v-if="!props.row.is_super_user" v-if="!props.row.is_super_user"
icon="build" icon="build"
size="sm" size="sm"
:color="props.row.is_admin ? 'primary' : ''" :color="props.row.is_admin ? 'primary' : 'grey'"
@click="toggleAdmin(props.row.id)" @click="toggleAdmin(props.row.id)"
> >
<q-tooltip>Toggle Admin</q-tooltip> <q-tooltip>Toggle Admin</q-tooltip>
+59 -100
View File
@@ -1,8 +1,6 @@
import sys
from http import HTTPStatus from http import HTTPStatus
from typing import ( from typing import (
List, List,
Optional,
) )
from bolt11 import decode as bolt11_decode from bolt11 import decode as bolt11_decode
@@ -13,10 +11,21 @@ from fastapi import (
) )
from loguru import logger from loguru import logger
from lnbits.core.db import core_app_extra from lnbits.core.extensions.extension_manager import (
from lnbits.core.helpers import ( activate_extension,
migrate_extension_database, deactivate_extension,
stop_extension_background_work, install_extension,
uninstall_extension,
)
from lnbits.core.extensions.models import (
CreateExtension,
Extension,
ExtensionConfig,
ExtensionRelease,
InstallableExtension,
PayToEnableInfo,
ReleasePaymentInfo,
UserExtensionInfo,
) )
from lnbits.core.models import ( from lnbits.core.models import (
SimpleStatus, SimpleStatus,
@@ -24,36 +33,18 @@ from lnbits.core.models import (
) )
from lnbits.core.services import check_transaction_status, create_invoice from lnbits.core.services import check_transaction_status, create_invoice
from lnbits.decorators import ( from lnbits.decorators import (
check_access_token,
check_admin, check_admin,
check_user_exists, check_user_exists,
) )
from lnbits.extension_manager import (
CreateExtension,
Extension,
ExtensionRelease,
InstallableExtension,
PayToEnableInfo,
ReleasePaymentInfo,
UserExtensionInfo,
fetch_github_release_config,
fetch_release_details,
fetch_release_payment_info,
get_valid_extensions,
)
from lnbits.settings import settings
from ..crud import ( from ..crud import (
add_installed_extension,
delete_dbversion, delete_dbversion,
delete_installed_extension,
drop_extension_db, drop_extension_db,
get_dbversions, get_dbversions,
get_installed_extension, get_installed_extension,
get_installed_extensions, get_installed_extensions,
get_user_extension, get_user_extension,
update_extension_pay_to_enable, update_extension_pay_to_enable,
update_installed_extension_state,
update_user_extension, update_user_extension,
update_user_extension_extra, update_user_extension_extra,
) )
@@ -64,12 +55,8 @@ extension_router = APIRouter(
) )
@extension_router.post("") @extension_router.post("", dependencies=[Depends(check_admin)])
async def api_install_extension( async def api_install_extension(data: CreateExtension):
data: CreateExtension,
user: User = Depends(check_admin),
access_token: Optional[str] = Depends(check_access_token),
):
release = await InstallableExtension.get_extension_release( release = await InstallableExtension.get_extension_release(
data.ext_id, data.source_repo, data.archive, data.version data.ext_id, data.source_repo, data.archive, data.version
) )
@@ -89,43 +76,36 @@ async def api_install_extension(
) )
try: try:
installed_ext = await get_installed_extension(data.ext_id) extension = await install_extension(ext_info)
ext_info.payments = installed_ext.payments if installed_ext else []
await ext_info.download_archive()
ext_info.extract_archive()
extension = Extension.from_installable_ext(ext_info)
db_version = (await get_dbversions()).get(data.ext_id, 0)
await migrate_extension_database(extension, db_version)
ext_info.active = True
await add_installed_extension(ext_info)
if extension.is_upgrade_extension:
# call stop while the old routes are still active
await stop_extension_background_work(data.ext_id, user.id, access_token)
# mount routes for the new version
core_app_extra.register_new_ext_routes(extension)
ext_info.notify_upgrade(extension.upgrade_hash)
settings.lnbits_deactivated_extensions.discard(data.ext_id)
return extension
except AssertionError as exc:
raise HTTPException(HTTPStatus.BAD_REQUEST, str(exc)) from exc
except Exception as exc: except Exception as exc:
logger.warning(exc) logger.warning(exc)
ext_info.clean_extension_files() ext_info.clean_extension_files()
detail = (
str(exc)
if isinstance(exc, AssertionError)
else f"Failed to install extension '{ext_info.id}'."
f"({ext_info.installed_version})."
)
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=( detail=detail,
f"Failed to install extension {ext_info.id} " ) from exc
f"({ext_info.installed_version})."
), try:
await activate_extension(extension)
return extension
except Exception as exc:
logger.warning(exc)
await deactivate_extension(extension.code)
detail = (
str(exc)
if isinstance(exc, AssertionError)
else f"Extension `{extension.code}` installed, but activation failed."
)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=detail,
) from exc ) from exc
@@ -143,7 +123,7 @@ async def api_extension_details(
) )
assert release, "Details not found for release" assert release, "Details not found for release"
release_details = await fetch_release_details(details_link) release_details = await ExtensionRelease.fetch_release_details(details_link)
assert release_details, "Cannot fetch details for release" assert release_details, "Cannot fetch details for release"
release_details["icon"] = release.icon release_details["icon"] = release.icon
release_details["repo"] = release.repo release_details["repo"] = release.repo
@@ -186,7 +166,7 @@ async def api_update_pay_to_enable(
async def api_enable_extension( async def api_enable_extension(
ext_id: str, user: User = Depends(check_user_exists) ext_id: str, user: User = Depends(check_user_exists)
) -> SimpleStatus: ) -> SimpleStatus:
if ext_id not in [e.code for e in get_valid_extensions()]: if ext_id not in [e.code for e in Extension.get_valid_extensions()]:
raise HTTPException( raise HTTPException(
HTTPStatus.NOT_FOUND, f"Extension '{ext_id}' doesn't exist." HTTPStatus.NOT_FOUND, f"Extension '{ext_id}' doesn't exist."
) )
@@ -249,7 +229,7 @@ async def api_enable_extension(
async def api_disable_extension( async def api_disable_extension(
ext_id: str, user: User = Depends(check_user_exists) ext_id: str, user: User = Depends(check_user_exists)
) -> SimpleStatus: ) -> SimpleStatus:
if ext_id not in [e.code for e in get_valid_extensions()]: if ext_id not in [e.code for e in Extension.get_valid_extensions()]:
raise HTTPException( raise HTTPException(
HTTPStatus.BAD_REQUEST, f"Extension '{ext_id}' doesn't exist." HTTPStatus.BAD_REQUEST, f"Extension '{ext_id}' doesn't exist."
) )
@@ -270,20 +250,14 @@ async def api_activate_extension(ext_id: str) -> SimpleStatus:
try: try:
logger.info(f"Activating extension: '{ext_id}'.") logger.info(f"Activating extension: '{ext_id}'.")
all_extensions = get_valid_extensions() ext = Extension.get_valid_extension(ext_id)
ext = next((e for e in all_extensions if e.code == ext_id), None)
assert ext, f"Extension '{ext_id}' doesn't exist." assert ext, f"Extension '{ext_id}' doesn't exist."
# if extension never loaded (was deactivated on server startup)
if ext_id not in sys.modules.keys():
# run extension start-up routine
core_app_extra.register_new_ext_routes(ext)
settings.lnbits_deactivated_extensions.discard(ext_id) await activate_extension(ext)
await update_installed_extension_state(ext_id=ext_id, active=True)
return SimpleStatus(success=True, message=f"Extension '{ext_id}' activated.") return SimpleStatus(success=True, message=f"Extension '{ext_id}' activated.")
except Exception as exc: except Exception as exc:
logger.warning(exc) logger.warning(exc)
await deactivate_extension(ext_id)
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=(f"Failed to activate '{ext_id}'."), detail=(f"Failed to activate '{ext_id}'."),
@@ -295,13 +269,10 @@ async def api_deactivate_extension(ext_id: str) -> SimpleStatus:
try: try:
logger.info(f"Deactivating extension: '{ext_id}'.") logger.info(f"Deactivating extension: '{ext_id}'.")
all_extensions = get_valid_extensions() ext = Extension.get_valid_extension(ext_id)
ext = next((e for e in all_extensions if e.code == ext_id), None)
assert ext, f"Extension '{ext_id}' doesn't exist." assert ext, f"Extension '{ext_id}' doesn't exist."
settings.lnbits_deactivated_extensions.add(ext_id) await deactivate_extension(ext_id)
await update_installed_extension_state(ext_id=ext_id, active=False)
return SimpleStatus(success=True, message=f"Extension '{ext_id}' deactivated.") return SimpleStatus(success=True, message=f"Extension '{ext_id}' deactivated.")
except Exception as exc: except Exception as exc:
logger.warning(exc) logger.warning(exc)
@@ -311,23 +282,19 @@ async def api_deactivate_extension(ext_id: str) -> SimpleStatus:
) from exc ) from exc
@extension_router.delete("/{ext_id}") @extension_router.delete("/{ext_id}", dependencies=[Depends(check_admin)])
async def api_uninstall_extension( async def api_uninstall_extension(ext_id: str) -> SimpleStatus:
ext_id: str,
user: User = Depends(check_admin),
access_token: Optional[str] = Depends(check_access_token),
) -> SimpleStatus:
installed_extensions = await get_installed_extensions()
extensions = [e for e in installed_extensions if e.id == ext_id] extension = await get_installed_extension(ext_id)
if len(extensions) == 0: if not extension:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, status_code=HTTPStatus.NOT_FOUND,
detail=f"Unknown extension id: {ext_id}", detail=f"Unknown extension id: {ext_id}",
) )
installed_extensions = await get_installed_extensions()
# check that other extensions do not depend on this one # check that other extensions do not depend on this one
for valid_ext_id in [ext.code for ext in get_valid_extensions()]: for valid_ext_id in [ext.code for ext in Extension.get_valid_extensions()]:
installed_ext = next( installed_ext = next(
(ext for ext in installed_extensions if ext.id == valid_ext_id), None (ext for ext in installed_extensions if ext.id == valid_ext_id), None
) )
@@ -341,14 +308,7 @@ async def api_uninstall_extension(
) )
try: try:
# call stop while the old routes are still active await uninstall_extension(ext_id)
await stop_extension_background_work(ext_id, user.id, access_token)
settings.lnbits_deactivated_extensions.add(ext_id)
for ext_info in extensions:
ext_info.clean_extension_files()
await delete_installed_extension(ext_id=ext_info.id)
logger.success(f"Extension '{ext_id}' uninstalled.") logger.success(f"Extension '{ext_id}' uninstalled.")
return SimpleStatus(success=True, message=f"Extension '{ext_id}' uninstalled.") return SimpleStatus(success=True, message=f"Extension '{ext_id}' uninstalled.")
@@ -397,9 +357,8 @@ async def get_pay_to_install_invoice(
assert release, "Release not found." assert release, "Release not found."
assert release.pay_link, "Pay link not found for release." assert release.pay_link, "Pay link not found for release."
payment_info = await fetch_release_payment_info( payment_info = await release.fetch_release_payment_info(data.cost_sats)
release.pay_link, data.cost_sats
)
assert payment_info and payment_info.payment_request, "Cannot request invoice." assert payment_info and payment_info.payment_request, "Cannot request invoice."
invoice = bolt11_decode(payment_info.payment_request) invoice = bolt11_decode(payment_info.payment_request)
@@ -474,7 +433,7 @@ async def get_pay_to_enable_invoice(
) )
async def get_extension_release(org: str, repo: str, tag_name: str): async def get_extension_release(org: str, repo: str, tag_name: str):
try: try:
config = await fetch_github_release_config(org, repo, tag_name) config = await ExtensionConfig.fetch_github_release_config(org, repo, tag_name)
if not config: if not config:
return {} return {}
+2 -2
View File
@@ -12,6 +12,7 @@ from lnurl import decode as lnurl_decode
from loguru import logger from loguru import logger
from pydantic.types import UUID4 from pydantic.types import UUID4
from lnbits.core.extensions.models import Extension, InstallableExtension
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.core.services import create_invoice from lnbits.core.services import create_invoice
@@ -20,7 +21,6 @@ from lnbits.helpers import template_renderer
from lnbits.settings import settings from lnbits.settings import settings
from lnbits.wallets import get_funding_source from lnbits.wallets import get_funding_source
from ...extension_manager import InstallableExtension, get_valid_extensions
from ...utils.exchange_rates import allowed_currencies, currencies from ...utils.exchange_rates import allowed_currencies, currencies
from ..crud import ( from ..crud import (
create_account, create_account,
@@ -104,7 +104,7 @@ async def extensions(request: Request, user: User = Depends(check_user_exists)):
installed_exts_ids = [] installed_exts_ids = []
try: try:
all_ext_ids = [ext.code for ext in get_valid_extensions()] all_ext_ids = [ext.code for ext in Extension.get_valid_extensions()]
inactive_extensions = [ inactive_extensions = [
e.id for e in await get_installed_extensions(active=False) e.id for e in await get_installed_extensions(active=False)
] ]
+11 -12
View File
@@ -35,7 +35,6 @@ from lnbits.core.models import (
from lnbits.db import Filters, Page from lnbits.db import Filters, Page
from lnbits.decorators import ( from lnbits.decorators import (
WalletTypeInfo, WalletTypeInfo,
get_key_type,
parse_filters, parse_filters,
require_admin_key, require_admin_key,
require_invoice_key, require_invoice_key,
@@ -73,12 +72,12 @@ payment_router = APIRouter(prefix="/api/v1/payments", tags=["Payments"])
openapi_extra=generate_filter_params_openapi(PaymentFilters), openapi_extra=generate_filter_params_openapi(PaymentFilters),
) )
async def api_payments( async def api_payments(
wallet: WalletTypeInfo = Depends(get_key_type), key_info: WalletTypeInfo = Depends(require_invoice_key),
filters: Filters = Depends(parse_filters(PaymentFilters)), filters: Filters = Depends(parse_filters(PaymentFilters)),
): ):
await update_pending_payments(wallet.wallet.id) await update_pending_payments(key_info.wallet.id)
return await get_payments( return await get_payments(
wallet_id=wallet.wallet.id, wallet_id=key_info.wallet.id,
pending=True, pending=True,
complete=True, complete=True,
filters=filters, filters=filters,
@@ -92,12 +91,12 @@ async def api_payments(
openapi_extra=generate_filter_params_openapi(PaymentFilters), openapi_extra=generate_filter_params_openapi(PaymentFilters),
) )
async def api_payments_history( async def api_payments_history(
wallet: WalletTypeInfo = Depends(get_key_type), key_info: WalletTypeInfo = Depends(require_invoice_key),
group: DateTrunc = Query("day"), group: DateTrunc = Query("day"),
filters: Filters[PaymentFilters] = Depends(parse_filters(PaymentFilters)), filters: Filters[PaymentFilters] = Depends(parse_filters(PaymentFilters)),
): ):
await update_pending_payments(wallet.wallet.id) await update_pending_payments(key_info.wallet.id)
return await get_payments_history(wallet.wallet.id, group, filters) return await get_payments_history(key_info.wallet.id, group, filters)
@payment_router.get( @payment_router.get(
@@ -109,12 +108,12 @@ async def api_payments_history(
openapi_extra=generate_filter_params_openapi(PaymentFilters), openapi_extra=generate_filter_params_openapi(PaymentFilters),
) )
async def api_payments_paginated( async def api_payments_paginated(
wallet: WalletTypeInfo = Depends(get_key_type), key_info: WalletTypeInfo = Depends(require_invoice_key),
filters: Filters = Depends(parse_filters(PaymentFilters)), filters: Filters = Depends(parse_filters(PaymentFilters)),
): ):
await update_pending_payments(wallet.wallet.id) await update_pending_payments(key_info.wallet.id)
page = await get_payments_paginated( page = await get_payments_paginated(
wallet_id=wallet.wallet.id, wallet_id=key_info.wallet.id,
pending=True, pending=True,
complete=True, complete=True,
filters=filters, filters=filters,
@@ -378,10 +377,10 @@ async def subscribe_wallet_invoices(request: Request, wallet: Wallet):
@payment_router.get("/sse") @payment_router.get("/sse")
async def api_payments_sse( async def api_payments_sse(
request: Request, wallet: WalletTypeInfo = Depends(get_key_type) request: Request, key_info: WalletTypeInfo = Depends(require_invoice_key)
): ):
return EventSourceResponse( return EventSourceResponse(
subscribe_wallet_invoices(request, wallet.wallet), subscribe_wallet_invoices(request, key_info.wallet),
ping=20, ping=20,
media_type="text/event-stream", media_type="text/event-stream",
) )
+8 -9
View File
@@ -13,8 +13,8 @@ from lnbits.core.models import (
) )
from lnbits.decorators import ( from lnbits.decorators import (
WalletTypeInfo, WalletTypeInfo,
get_key_type,
require_admin_key, require_admin_key,
require_invoice_key,
) )
from ..crud import ( from ..crud import (
@@ -27,15 +27,14 @@ wallet_router = APIRouter(prefix="/api/v1/wallet", tags=["Wallet"])
@wallet_router.get("") @wallet_router.get("")
async def api_wallet(wallet: WalletTypeInfo = Depends(get_key_type)): async def api_wallet(wallet: WalletTypeInfo = Depends(require_invoice_key)):
res = {
"name": wallet.wallet.name,
"balance": wallet.wallet.balance_msat,
}
if wallet.key_type == KeyType.admin: if wallet.key_type == KeyType.admin:
return { res["id"] = wallet.wallet.id
"id": wallet.wallet.id, return res
"name": wallet.wallet.name,
"balance": wallet.wallet.balance_msat,
}
else:
return {"name": wallet.wallet.name, "balance": wallet.wallet.balance_msat}
@wallet_router.put("/{new_name}") @wallet_router.put("/{new_name}")
+124 -119
View File
@@ -7,14 +7,13 @@ import re
import time import time
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from enum import Enum from enum import Enum
from sqlite3 import Row
from typing import Any, Generic, Literal, Optional, TypeVar from typing import Any, Generic, Literal, Optional, TypeVar
from loguru import logger from loguru import logger
from pydantic import BaseModel, ValidationError, root_validator from pydantic import BaseModel, ValidationError, root_validator
from sqlalchemy import create_engine from sqlalchemy import event
from sqlalchemy_aio.base import AsyncConnection from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine, create_async_engine
from sqlalchemy_aio.strategy import ASYNCIO_STRATEGY from sqlalchemy.sql import text
from lnbits.settings import settings from lnbits.settings import settings
@@ -24,31 +23,15 @@ SQLITE = "SQLITE"
if settings.lnbits_database_url: if settings.lnbits_database_url:
database_uri = settings.lnbits_database_url database_uri = settings.lnbits_database_url
if database_uri.startswith("cockroachdb://"): if database_uri.startswith("cockroachdb://"):
DB_TYPE = COCKROACH DB_TYPE = COCKROACH
else: else:
if not database_uri.startswith("postgres://"):
raise ValueError(
"Please use the 'postgres://...' " "format for the database URL."
)
DB_TYPE = POSTGRES DB_TYPE = POSTGRES
from psycopg2.extensions import DECIMAL, new_type, register_type
def _parse_timestamp(value, _):
if value is None:
return None
f = "%Y-%m-%d %H:%M:%S.%f"
if "." not in value:
f = "%Y-%m-%d %H:%M:%S"
return time.mktime(datetime.datetime.strptime(value, f).timetuple())
register_type(
new_type(
DECIMAL.values,
"DEC2FLOAT",
lambda value, curs: float(value) if value is not None else None,
)
)
register_type(new_type((1184, 1114), "TIMESTAMP2INT", _parse_timestamp))
else: else:
if not os.path.isdir(settings.lnbits_data_folder): if not os.path.isdir(settings.lnbits_data_folder):
os.mkdir(settings.lnbits_data_folder) os.mkdir(settings.lnbits_data_folder)
@@ -56,21 +39,21 @@ else:
DB_TYPE = SQLITE DB_TYPE = SQLITE
def compat_timestamp_placeholder(): def compat_timestamp_placeholder(key: str):
if DB_TYPE == POSTGRES: if DB_TYPE == POSTGRES:
return "to_timestamp(?)" return f"to_timestamp(:{key})"
elif DB_TYPE == COCKROACH: elif DB_TYPE == COCKROACH:
return "cast(? AS timestamp)" return f"cast(:{key} AS timestamp)"
else: else:
return "?" return f":{key}"
def get_placeholder(model: Any, field: str) -> str: def get_placeholder(model: Any, field: str) -> str:
type_ = model.__fields__[field].type_ type_ = model.__fields__[field].type_
if type_ == datetime.datetime: if type_ == datetime.datetime:
return compat_timestamp_placeholder() return compat_timestamp_placeholder(field)
else: else:
return "?" return f":{field}"
class Compat: class Compat:
@@ -127,15 +110,13 @@ class Compat:
return "BIGINT" return "BIGINT"
return "INT" return "INT"
@property def timestamp_placeholder(self, key: str) -> str:
def timestamp_placeholder(self) -> str: return compat_timestamp_placeholder(key)
return compat_timestamp_placeholder()
class Connection(Compat): class Connection(Compat):
def __init__(self, conn: AsyncConnection, txn, typ, name, schema): def __init__(self, conn: AsyncConnection, typ, name, schema):
self.conn = conn self.conn = conn
self.txn = txn
self.type = typ self.type = typ
self.name = name self.name = name
self.schema = schema self.schema = schema
@@ -146,45 +127,42 @@ class Connection(Compat):
query = query.replace("?", "%s") query = query.replace("?", "%s")
return query return query
def rewrite_values(self, values): def rewrite_values(self, values: dict) -> dict:
# strip html # strip html
clean_regex = re.compile("<.*?>|&([a-z0-9]+|#[0-9]{1,6}|#x[0-9a-f]{1,6});") clean_regex = re.compile("<.*?>|&([a-z0-9]+|#[0-9]{1,6}|#x[0-9a-f]{1,6});")
clean_values: dict = {}
# tuple to list and back to tuple for key, raw_value in values.items():
raw_values = [values] if isinstance(values, str) else list(values)
values = []
for raw_value in raw_values:
if isinstance(raw_value, str): if isinstance(raw_value, str):
values.append(re.sub(clean_regex, "", raw_value)) clean_values[key] = re.sub(clean_regex, "", raw_value)
elif isinstance(raw_value, datetime.datetime): elif isinstance(raw_value, datetime.datetime):
ts = raw_value.timestamp() ts = raw_value.timestamp()
if self.type == SQLITE: if self.type == SQLITE:
values.append(int(ts)) clean_values[key] = int(ts)
else: else:
values.append(ts) clean_values[key] = ts
else: else:
values.append(raw_value) clean_values[key] = raw_value
return tuple(values) return clean_values
async def fetchall(self, query: str, values: tuple = ()) -> list: async def fetchall(self, query: str, values: Optional[dict] = None) -> list[dict]:
result = await self.conn.execute( params = self.rewrite_values(values) if values else {}
self.rewrite_query(query), self.rewrite_values(values) result = await self.conn.execute(text(self.rewrite_query(query)), params)
) row = result.mappings().all()
return await result.fetchall() result.close()
return row
async def fetchone(self, query: str, values: tuple = ()): async def fetchone(self, query: str, values: Optional[dict] = None) -> dict:
result = await self.conn.execute( params = self.rewrite_values(values) if values else {}
self.rewrite_query(query), self.rewrite_values(values) result = await self.conn.execute(text(self.rewrite_query(query)), params)
) row = result.mappings().first()
row = await result.fetchone() result.close()
await result.close()
return row return row
async def fetch_page( async def fetch_page(
self, self,
query: str, query: str,
where: Optional[list[str]] = None, where: Optional[list[str]] = None,
values: Optional[list[str]] = None, values: Optional[dict] = None,
filters: Optional[Filters] = None, filters: Optional[Filters] = None,
model: Optional[type[TRowModel]] = None, model: Optional[type[TRowModel]] = None,
group_by: Optional[list[str]] = None, group_by: Optional[list[str]] = None,
@@ -211,14 +189,14 @@ class Connection(Compat):
{filters.order_by()} {filters.order_by()}
{filters.pagination()} {filters.pagination()}
""", """,
parsed_values, self.rewrite_values(parsed_values),
) )
if rows: if rows:
# no need for extra query if no pagination is specified # no need for extra query if no pagination is specified
if filters.offset or filters.limit: if filters.offset or filters.limit:
count = await self.fetchone( result = await self.fetchone(
f""" f"""
SELECT COUNT(*) FROM ( SELECT COUNT(*) as count FROM (
{query} {query}
{clause} {clause}
{group_by_string} {group_by_string}
@@ -226,21 +204,22 @@ class Connection(Compat):
""", """,
parsed_values, parsed_values,
) )
count = int(count[0]) count = int(result.get("count", 0))
else: else:
count = len(rows) count = len(rows)
else: else:
count = 0 count = 0
return Page( return Page(
data=[model.from_row(row) for row in rows] if model else rows, data=[model.from_row(row) for row in rows] if model else [],
total=count, total=count,
) )
async def execute(self, query: str, values: tuple = ()): async def execute(self, query: str, values: Optional[dict] = None):
return await self.conn.execute( params = self.rewrite_values(values) if values else {}
self.rewrite_query(query), self.rewrite_values(values) result = await self.conn.execute(text(self.rewrite_query(query)), params)
) await self.conn.commit()
return result
class Database(Compat): class Database(Compat):
@@ -253,18 +232,44 @@ class Database(Compat):
self.path = os.path.join( self.path = os.path.join(
settings.lnbits_data_folder, f"{self.name}.sqlite3" settings.lnbits_data_folder, f"{self.name}.sqlite3"
) )
database_uri = f"sqlite:///{self.path}" database_uri = f"sqlite+aiosqlite:///{self.path}"
else: else:
database_uri = settings.lnbits_database_url database_uri = settings.lnbits_database_url.replace(
"postgres://", "postgresql+asyncpg://"
)
if self.name.startswith("ext_"): if self.name.startswith("ext_"):
self.schema = self.name[4:] self.schema = self.name[4:]
else: else:
self.schema = None self.schema = None
self.engine = create_engine( self.engine: AsyncEngine = create_async_engine(
database_uri, strategy=ASYNCIO_STRATEGY, echo=settings.debug_database database_uri, echo=settings.debug_database
) )
if self.type in {POSTGRES, COCKROACH}:
@event.listens_for(self.engine.sync_engine, "connect")
def register_custom_types(dbapi_connection, *_):
def _parse_timestamp(value):
if value is None:
return None
f = "%Y-%m-%d %H:%M:%S.%f"
if "." not in value:
f = "%Y-%m-%d %H:%M:%S"
return int(
time.mktime(datetime.datetime.strptime(value, f).timetuple())
)
dbapi_connection.run_async(
lambda connection: connection.set_type_codec(
"TIMESTAMP",
encoder=datetime.datetime,
decoder=_parse_timestamp,
schema="pg_catalog",
)
)
self.lock = asyncio.Lock() self.lock = asyncio.Lock()
logger.trace(f"database {self.type} added for {self.name}") logger.trace(f"database {self.type} added for {self.name}")
@@ -273,41 +278,37 @@ class Database(Compat):
async def connect(self): async def connect(self):
await self.lock.acquire() await self.lock.acquire()
try: try:
async with self.engine.connect() as conn: # type: ignore async with self.engine.connect() as conn:
async with conn.begin() as txn: if not conn:
wconn = Connection(conn, txn, self.type, self.name, self.schema) raise Exception("Could not connect to the database")
if self.schema: wconn = Connection(conn, self.type, self.name, self.schema)
if self.type in {POSTGRES, COCKROACH}:
await wconn.execute(
f"CREATE SCHEMA IF NOT EXISTS {self.schema}"
)
elif self.type == SQLITE:
await wconn.execute(
f"ATTACH '{self.path}' AS {self.schema}"
)
yield wconn if self.schema:
if self.type in {POSTGRES, COCKROACH}:
await wconn.execute(
f"CREATE SCHEMA IF NOT EXISTS {self.schema}"
)
elif self.type == SQLITE:
await wconn.execute(f"ATTACH '{self.path}' AS {self.schema}")
yield wconn
finally: finally:
self.lock.release() self.lock.release()
async def fetchall(self, query: str, values: tuple = ()) -> list: async def fetchall(self, query: str, values: Optional[dict] = None) -> list[dict]:
async with self.connect() as conn: async with self.connect() as conn:
result = await conn.execute(query, values) return await conn.fetchall(query, values)
return await result.fetchall()
async def fetchone(self, query: str, values: tuple = ()): async def fetchone(self, query: str, values: Optional[dict] = None) -> dict:
async with self.connect() as conn: async with self.connect() as conn:
result = await conn.execute(query, values) return await conn.fetchone(query, values)
row = await result.fetchone()
await result.close()
return row
async def fetch_page( async def fetch_page(
self, self,
query: str, query: str,
where: Optional[list[str]] = None, where: Optional[list[str]] = None,
values: Optional[list[str]] = None, values: Optional[dict] = None,
filters: Optional[Filters] = None, filters: Optional[Filters] = None,
model: Optional[type[TRowModel]] = None, model: Optional[type[TRowModel]] = None,
group_by: Optional[list[str]] = None, group_by: Optional[list[str]] = None,
@@ -315,7 +316,7 @@ class Database(Compat):
async with self.connect() as conn: async with self.connect() as conn:
return await conn.fetch_page(query, where, values, filters, model, group_by) return await conn.fetch_page(query, where, values, filters, model, group_by)
async def execute(self, query: str, values: tuple = ()): async def execute(self, query: str, values: Optional[dict] = None):
async with self.connect() as conn: async with self.connect() as conn:
return await conn.execute(query, values) return await conn.execute(query, values)
@@ -373,8 +374,8 @@ class Operator(Enum):
class FromRowModel(BaseModel): class FromRowModel(BaseModel):
@classmethod @classmethod
def from_row(cls, row: Row): def from_row(cls, row: dict):
return cls(**dict(row)) return cls(**row)
class FilterModel(BaseModel): class FilterModel(BaseModel):
@@ -396,12 +397,13 @@ class Page(BaseModel, Generic[T]):
class Filter(BaseModel, Generic[TFilterModel]): class Filter(BaseModel, Generic[TFilterModel]):
field: str field: str
op: Operator = Operator.EQ op: Operator = Operator.EQ
values: list[Any]
model: Optional[type[TFilterModel]] model: Optional[type[TFilterModel]]
values: Optional[dict] = None
@classmethod @classmethod
def parse_query(cls, key: str, raw_values: list[Any], model: type[TFilterModel]): def parse_query(
cls, key: str, raw_values: list[Any], model: type[TFilterModel], i: int = 0
):
# Key format: # Key format:
# key[operator] # key[operator]
# e.g. name[eq] # e.g. name[eq]
@@ -417,12 +419,12 @@ class Filter(BaseModel, Generic[TFilterModel]):
if field in model.__fields__: if field in model.__fields__:
compare_field = model.__fields__[field] compare_field = model.__fields__[field]
values = [] values: dict = {}
for raw_value in raw_values: for raw_value in raw_values:
validated, errors = compare_field.validate(raw_value, {}, loc="none") validated, errors = compare_field.validate(raw_value, {}, loc="none")
if errors: if errors:
raise ValidationError(errors=[errors], model=model) raise ValidationError(errors=[errors], model=model)
values.append(validated) values[f"{field}__{i}"] = validated
else: else:
raise ValueError("Unknown filter field") raise ValueError("Unknown filter field")
@@ -430,13 +432,17 @@ class Filter(BaseModel, Generic[TFilterModel]):
@property @property
def statement(self): def statement(self):
assert self.model, "Model is required for statement generation" stmt = []
placeholder = get_placeholder(self.model, self.field) for key in self.values.keys() if self.values else []:
if self.op in (Operator.INCLUDE, Operator.EXCLUDE): clean_key = key.split("__")[0]
placeholders = ", ".join([placeholder] * len(self.values)) if (
stmt = [f"{self.field} {self.op.as_sql} ({placeholders})"] self.model
else: and self.model.__fields__[clean_key].type_ == datetime.datetime
stmt = [f"{self.field} {self.op.as_sql} {placeholder}"] * len(self.values) ):
placeholder = compat_timestamp_placeholder(key)
else:
placeholder = f":{key}"
stmt.append(f"{clean_key} {self.op.as_sql} {placeholder}")
return " OR ".join(stmt) return " OR ".join(stmt)
@@ -487,14 +493,11 @@ class Filters(BaseModel, Generic[TFilterModel]):
for page_filter in self.filters: for page_filter in self.filters:
where_stmts.append(page_filter.statement) where_stmts.append(page_filter.statement)
if self.search and self.model: if self.search and self.model:
fields = self.model.__search_fields__
if DB_TYPE == POSTGRES: if DB_TYPE == POSTGRES:
where_stmts.append( where_stmts.append(f"lower(concat({', '.join(fields)})) LIKE :search")
f"lower(concat({', '.join(self.model.__search_fields__)})) LIKE ?"
)
elif DB_TYPE == SQLITE: elif DB_TYPE == SQLITE:
where_stmts.append( where_stmts.append(f"lower({'||'.join(fields)}) LIKE :search")
f"lower({'||'.join(self.model.__search_fields__)}) LIKE ?"
)
if where_stmts: if where_stmts:
return "WHERE " + " AND ".join(where_stmts) return "WHERE " + " AND ".join(where_stmts)
return "" return ""
@@ -504,12 +507,14 @@ class Filters(BaseModel, Generic[TFilterModel]):
return f"ORDER BY {self.sortby} {self.direction or 'asc'}" return f"ORDER BY {self.sortby} {self.direction or 'asc'}"
return "" return ""
def values(self, values: Optional[list[str]] = None) -> tuple: def values(self, values: Optional[dict] = None) -> dict:
if not values: if not values:
values = [] values = {}
if self.filters: if self.filters:
for page_filter in self.filters: for page_filter in self.filters:
values.extend(page_filter.values) if page_filter.values:
for key, value in page_filter.values.items():
values[key] = value
if self.search and self.model: if self.search and self.model:
values.append(f"%{self.search}%") values["search"] = f"%{self.search}%"
return tuple(values) return values
+2 -11
View File
@@ -95,15 +95,6 @@ class KeyChecker(SecurityBase):
return WalletTypeInfo(key_type, wallet) return WalletTypeInfo(key_type, wallet)
async def get_key_type(
request: Request,
api_key_header: str = Security(api_key_header),
api_key_query: str = Security(api_key_query),
) -> WalletTypeInfo:
check: KeyChecker = KeyChecker(api_key=api_key_header or api_key_query)
return await check(request)
async def require_admin_key( async def require_admin_key(
request: Request, request: Request,
api_key_header: str = Security(api_key_header), api_key_header: str = Security(api_key_header),
@@ -204,9 +195,9 @@ def parse_filters(model: Type[TFilterModel]):
): ):
params = request.query_params params = request.query_params
filters = [] filters = []
for key in params.keys(): for i, key in enumerate(params.keys()):
try: try:
filters.append(Filter.parse_query(key, params.getlist(key), model)) filters.append(Filter.parse_query(key, params.getlist(key), model, i))
except ValueError: except ValueError:
continue continue
+8 -4
View File
@@ -10,6 +10,7 @@ import shortuuid
from pydantic import BaseModel from pydantic import BaseModel
from pydantic.schema import field_schema from pydantic.schema import field_schema
from lnbits.core.extensions.models import Extension
from lnbits.db import get_placeholder from lnbits.db import get_placeholder
from lnbits.jinja2_templating import Jinja2Templates from lnbits.jinja2_templating import Jinja2Templates
from lnbits.nodes import get_node_class from lnbits.nodes import get_node_class
@@ -18,7 +19,6 @@ from lnbits.settings import settings
from lnbits.utils.crypto import AESCipher from lnbits.utils.crypto import AESCipher
from .db import FilterModel from .db import FilterModel
from .extension_manager import get_valid_extensions
def get_db_vendor_name(): def get_db_vendor_name():
@@ -93,19 +93,21 @@ def template_renderer(additional_folders: Optional[List] = None) -> Jinja2Templa
settings.lnbits_node_ui and get_node_class() is not None 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["LNBITS_NODE_UI_AVAILABLE"] = get_node_class() is not None
t.env.globals["EXTENSIONS"] = get_valid_extensions(False) t.env.globals["EXTENSIONS"] = Extension.get_valid_extensions(False)
if settings.lnbits_custom_logo: if settings.lnbits_custom_logo:
t.env.globals["USE_CUSTOM_LOGO"] = settings.lnbits_custom_logo t.env.globals["USE_CUSTOM_LOGO"] = settings.lnbits_custom_logo
if settings.bundle_assets: if settings.bundle_assets:
t.env.globals["INCLUDED_JS"] = ["bundle.min.js"] t.env.globals["INCLUDED_JS"] = ["bundle.min.js"]
t.env.globals["INCLUDED_CSS"] = ["bundle.min.css"] t.env.globals["INCLUDED_CSS"] = ["bundle.min.css"]
t.env.globals["INCLUDED_COMPONENTS"] = ["bundle-components.min.js"]
else: else:
vendor_filepath = Path(settings.lnbits_path, "static", "vendor.json") vendor_filepath = Path(settings.lnbits_path, "static", "vendor.json")
with open(vendor_filepath) as vendor_file: with open(vendor_filepath) as vendor_file:
vendor_files = json.loads(vendor_file.read()) vendor_files = json.loads(vendor_file.read())
t.env.globals["INCLUDED_JS"] = vendor_files["js"] t.env.globals["INCLUDED_JS"] = vendor_files["js"]
t.env.globals["INCLUDED_CSS"] = vendor_files["css"] t.env.globals["INCLUDED_CSS"] = vendor_files["css"]
t.env.globals["INCLUDED_COMPONENTS"] = vendor_files["components"]
t.env.globals["WEBPUSH_PUBKEY"] = settings.lnbits_webpush_pubkey t.env.globals["WEBPUSH_PUBKEY"] = settings.lnbits_webpush_pubkey
@@ -187,12 +189,14 @@ def insert_query(table_name: str, model: BaseModel) -> str:
return f"INSERT INTO {table_name} ({fields}) VALUES ({values})" return f"INSERT INTO {table_name} ({fields}) VALUES ({values})"
def update_query(table_name: str, model: BaseModel, where: str = "WHERE id = ?") -> str: def update_query(
table_name: str, model: BaseModel, where: str = "WHERE id = :id"
) -> str:
""" """
Generate an update query with placeholders for a given table and model Generate an update query with placeholders for a given table and model
:param table_name: Name of the table :param table_name: Name of the table
:param model: Pydantic model :param model: Pydantic model
:param where: Where string, default to `WHERE id = ?` :param where: Where string, default to `WHERE id = :id`
""" """
fields = [] fields = []
for field in model.dict().keys(): for field in model.dict().keys():
+7 -72
View File
@@ -1,5 +1,5 @@
from http import HTTPStatus from http import HTTPStatus
from typing import Any, List, Tuple, Union from typing import Any, List, Union
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
@@ -45,16 +45,11 @@ class InstalledExtensionMiddleware:
await self.app(scope, receive, send) await self.app(scope, receive, send)
return return
upgrade_path = next(
(
e
for e in settings.lnbits_upgraded_extensions
if e.endswith(f"/{top_path}")
),
None,
)
# re-route all trafic if the extension has been upgraded # re-route all trafic if the extension has been upgraded
if upgrade_path: if top_path in settings.lnbits_upgraded_extensions:
upgrade_path = (
f"""{settings.lnbits_upgraded_extensions[top_path]}/{top_path}"""
)
tail = "/".join(rest) tail = "/".join(rest)
scope["path"] = f"/upgrades/{upgrade_path}/{tail}" scope["path"] = f"/upgrades/{upgrade_path}/{tail}"
@@ -118,72 +113,12 @@ class ExtensionsRedirectMiddleware:
return return
req_headers = scope["headers"] if "headers" in scope else [] req_headers = scope["headers"] if "headers" in scope else []
redirect = self._find_redirect(scope["path"], req_headers) redirect = settings.find_extension_redirect(scope["path"], req_headers)
if redirect: if redirect:
scope["path"] = self._new_path(redirect, scope["path"]) scope["path"] = redirect.new_path_from(scope["path"])
await self.app(scope, receive, send) await self.app(scope, receive, send)
def _find_redirect(self, path: str, req_headers: List[Tuple[bytes, bytes]]):
return next(
(
r
for r in settings.lnbits_extensions_redirects
if self._redirect_matches(r, path, req_headers)
),
None,
)
def _redirect_matches(
self, redirect: dict, path: str, req_headers: List[Tuple[bytes, bytes]]
) -> bool:
if "from_path" not in redirect:
return False
header_filters = (
redirect["header_filters"] if "header_filters" in redirect else {}
)
return self._has_common_path(redirect["from_path"], path) and self._has_headers(
header_filters, req_headers
)
def _has_headers(
self, filter_headers: dict, req_headers: List[Tuple[bytes, bytes]]
) -> bool:
for h in filter_headers:
if not self._has_header(req_headers, (str(h), str(filter_headers[h]))):
return False
return True
def _has_header(
self, req_headers: List[Tuple[bytes, bytes]], header: Tuple[str, str]
) -> bool:
for h in req_headers:
if (
h[0].decode().lower() == header[0].lower()
and h[1].decode() == header[1]
):
return True
return False
def _has_common_path(self, redirect_path: str, req_path: str) -> bool:
redirect_path_elements = redirect_path.split("/")
req_path_elements = req_path.split("/")
if len(redirect_path) > len(req_path):
return False
sub_path = req_path_elements[: len(redirect_path_elements)]
return redirect_path == "/".join(sub_path)
def _new_path(self, redirect: dict, req_path: str) -> str:
from_path = redirect["from_path"].split("/")
redirect_to = redirect["redirect_to_path"].split("/")
req_tail_path = req_path.split("/")[len(from_path) :]
elements = [
e for e in ([redirect["ext_id"], *redirect_to, *req_tail_path]) if e != ""
]
return "/" + "/".join(elements)
def add_ratelimit_middleware(app: FastAPI): def add_ratelimit_middleware(app: FastAPI):
core_app_extra.register_new_ratelimiter() core_app_extra.register_new_ratelimiter()
+113 -7
View File
@@ -62,26 +62,132 @@ class ExtensionsInstallSettings(LNbitsSettings):
lnbits_ext_github_token: str = Field(default="") lnbits_ext_github_token: str = Field(default="")
class RedirectPath(BaseModel):
ext_id: str
from_path: str
redirect_to_path: str
header_filters: dict = {}
def in_conflict(self, other: RedirectPath) -> bool:
if self.ext_id == other.ext_id:
return False
return self.redirect_matches(
other.from_path, list(other.header_filters.items())
) or other.redirect_matches(self.from_path, list(self.header_filters.items()))
def find_in_conflict(self, others: list[RedirectPath]) -> Optional[RedirectPath]:
for other in others:
if self.in_conflict(other):
return other
return None
def new_path_from(self, req_path: str) -> str:
from_path = self.from_path.split("/")
redirect_to = self.redirect_to_path.split("/")
req_tail_path = req_path.split("/")[len(from_path) :]
elements = [e for e in ([self.ext_id, *redirect_to, *req_tail_path]) if e != ""]
return "/" + "/".join(elements)
def redirect_matches(self, path: str, req_headers: list[tuple[str, str]]) -> bool:
return self._has_common_path(path) and self._has_headers(req_headers)
def _has_common_path(self, req_path: str) -> bool:
if len(self.from_path) > len(req_path):
return False
redirect_path_elements = self.from_path.split("/")
req_path_elements = req_path.split("/")
sub_path = req_path_elements[: len(redirect_path_elements)]
return self.from_path == "/".join(sub_path)
def _has_headers(self, req_headers: list[tuple[str, str]]) -> bool:
for h in self.header_filters:
if not self._has_header(req_headers, (str(h), str(self.header_filters[h]))):
return False
return True
def _has_header(
self, req_headers: list[tuple[str, str]], header: tuple[str, str]
) -> bool:
for h in req_headers:
if h[0].lower() == header[0].lower() and h[1].lower() == header[1].lower():
return True
return False
class InstalledExtensionsSettings(LNbitsSettings): class InstalledExtensionsSettings(LNbitsSettings):
# installed extensions that have been deactivated # installed extensions that have been deactivated
lnbits_deactivated_extensions: set[str] = Field(default=[]) lnbits_deactivated_extensions: set[str] = Field(default=[])
# upgraded extensions that require API redirects # upgraded extensions that require API redirects
lnbits_upgraded_extensions: set[str] = Field(default=[]) lnbits_upgraded_extensions: dict[str, str] = Field(default={})
# list of redirects that extensions want to perform # list of redirects that extensions want to perform
lnbits_extensions_redirects: list[Any] = Field(default=[]) lnbits_extensions_redirects: list[RedirectPath] = Field(default=[])
# list of all extension ids # list of all extension ids
lnbits_all_extensions_ids: set[Any] = Field(default=[]) lnbits_all_extensions_ids: set[Any] = Field(default=[])
def extension_upgrade_path(self, ext_id: str) -> Optional[str]: def find_extension_redirect(
self, path: str, req_headers: list[tuple[bytes, bytes]]
) -> Optional[RedirectPath]:
headers = [(k.decode(), v.decode()) for k, v in req_headers]
return next( return next(
(e for e in self.lnbits_upgraded_extensions if e.endswith(f"/{ext_id}")), (
r
for r in self.lnbits_extensions_redirects
if r.redirect_matches(path, headers)
),
None, None,
) )
def extension_upgrade_hash(self, ext_id: str) -> Optional[str]: def activate_extension_paths(
path = settings.extension_upgrade_path(ext_id) self,
return path.split("/")[0] if path else None ext_id: str,
upgrade_hash: Optional[str] = None,
ext_redirects: Optional[list[dict]] = None,
):
self.lnbits_deactivated_extensions.discard(ext_id)
"""
Update the list of upgraded extensions. The middleware will perform
redirects based on this
"""
if upgrade_hash:
self.lnbits_upgraded_extensions[ext_id] = upgrade_hash
if ext_redirects:
self._activate_extension_redirects(ext_id, ext_redirects)
self.lnbits_all_extensions_ids.add(ext_id)
def deactivate_extension_paths(self, ext_id: str):
self.lnbits_deactivated_extensions.add(ext_id)
self._remove_extension_redirects(ext_id)
def _activate_extension_redirects(self, ext_id: str, ext_redirects: list[dict]):
ext_redirect_paths = [
RedirectPath(**{"ext_id": ext_id, **er}) for er in ext_redirects
]
existing_redirects = {
r.ext_id
for r in self.lnbits_extensions_redirects
if r.find_in_conflict(ext_redirect_paths)
}
assert len(existing_redirects) == 0, (
f"Cannot redirect for extension '{ext_id}'."
f" Already mapped by {existing_redirects}."
)
self._remove_extension_redirects(ext_id)
self.lnbits_extensions_redirects += ext_redirect_paths
def _remove_extension_redirects(self, ext_id: str):
self.lnbits_extensions_redirects = [
er for er in self.lnbits_extensions_redirects if er.ext_id != ext_id
]
class ThemesSettings(LNbitsSettings): class ThemesSettings(LNbitsSettings):
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+33 -45
View File
File diff suppressed because one or more lines are too long
+2 -16
View File
@@ -1,6 +1,6 @@
new Vue({ window.app = Vue.createApp({
el: '#vue', el: '#vue',
mixins: [windowMixin], mixins: [window.windowMixin],
data: function () { data: function () {
return { return {
user: null, user: null,
@@ -80,20 +80,6 @@ new Vue({
this.applyGradient() this.applyGradient()
} }
}, },
setColors: function () {
this.$q.localStorage.set(
'lnbits.primaryColor',
LNbits.utils.getPaletteColor('primary')
)
this.$q.localStorage.set(
'lnbits.secondaryColor',
LNbits.utils.getPaletteColor('secondary')
)
this.$q.localStorage.set(
'lnbits.darkBgColor',
LNbits.utils.getPaletteColor('dark')
)
},
updateAccount: async function () { updateAccount: async function () {
try { try {
const {data} = await LNbits.api.request( const {data} = await LNbits.api.request(
+1 -1
View File
@@ -1,4 +1,4 @@
new Vue({ window.app = Vue.createApp({
el: '#vue', el: '#vue',
mixins: [windowMixin], mixins: [windowMixin],
data: function () { data: function () {
+75 -18
View File
@@ -1,15 +1,10 @@
/* globals crypto, moment, Vue, axios, Quasar, _ */
Vue.use(VueI18n)
window.LOCALE = 'en' window.LOCALE = 'en'
window.i18n = new VueI18n({ window.i18n = new VueI18n.createI18n({
locale: window.LOCALE, locale: window.LOCALE,
fallbackLocale: window.LOCALE, fallbackLocale: window.LOCALE,
messages: window.localisation messages: window.localisation
}) })
window.EventHub = new Vue()
window.LNbits = { window.LNbits = {
api: { api: {
request: function (method, url, apiKey, data) { request: function (method, url, apiKey, data) {
@@ -264,12 +259,12 @@ window.LNbits = {
fiat_currency: data.fiat_currency fiat_currency: data.fiat_currency
} }
obj.date = Quasar.utils.date.formatDate( obj.date = Quasar.date.formatDate(
new Date(obj.time * 1000), new Date(obj.time * 1000),
'YYYY-MM-DD HH:mm' 'YYYY-MM-DD HH:mm'
) )
obj.dateFrom = moment(obj.date).fromNow() obj.dateFrom = moment(obj.date).fromNow()
obj.expirydate = Quasar.utils.date.formatDate( obj.expirydate = Quasar.date.formatDate(
new Date(obj.expiry * 1000), new Date(obj.expiry * 1000),
'YYYY-MM-DD HH:mm' 'YYYY-MM-DD HH:mm'
) )
@@ -294,7 +289,7 @@ window.LNbits = {
}, },
utils: { utils: {
confirmDialog: function (msg) { confirmDialog: function (msg) {
return Quasar.plugins.Dialog.create({ return Quasar.Dialog.create({
message: msg, message: msg,
ok: { ok: {
flat: true, flat: true,
@@ -411,14 +406,14 @@ window.LNbits = {
) )
.join('\r\n') .join('\r\n')
var status = Quasar.utils.exportFile( var status = Quasar.exportFile(
`${fileName || 'table-export'}.csv`, `${fileName || 'table-export'}.csv`,
content, content,
'text/csv' 'text/csv'
) )
if (status !== true) { if (status !== true) {
Quasar.plugins.Notify.create({ Quasar.Notify.create({
message: 'Browser denied file download...', message: 'Browser denied file download...',
color: 'negative', color: 'negative',
icon: null icon: null
@@ -432,16 +427,16 @@ window.LNbits = {
return converter.makeHtml(text) return converter.makeHtml(text)
}, },
hexToRgb: function (hex) { hexToRgb: function (hex) {
return Quasar.utils.colors.hexToRgb(hex) return Quasar.colors.hexToRgb(hex)
}, },
hexDarken: function (hex, percent) { hexDarken: function (hex, percent) {
return Quasar.utils.colors.lighten(hex, percent) return Quasar.colors.lighten(hex, percent)
}, },
hexAlpha: function (hex, alpha) { hexAlpha: function (hex, alpha) {
return Quasar.utils.colors.changeAlpha(hex, alpha) return Quasar.colors.changeAlpha(hex, alpha)
}, },
getPaletteColor: function (color) { getPaletteColor: function (color) {
return Quasar.utils.colors.getPaletteColor(color) return Quasar.colors.getPaletteColor(color)
} }
} }
} }
@@ -475,6 +470,7 @@ window.windowMixin = {
}, },
applyGradient: function () { applyGradient: function () {
if (this.$q.localStorage.getItem('lnbits.gradientBg')) { if (this.$q.localStorage.getItem('lnbits.gradientBg')) {
this.setColors()
darkBgColor = this.$q.localStorage.getItem('lnbits.darkBgColor') darkBgColor = this.$q.localStorage.getItem('lnbits.darkBgColor')
primaryColor = this.$q.localStorage.getItem('lnbits.primaryColor') primaryColor = this.$q.localStorage.getItem('lnbits.primaryColor')
const gradientStyle = `linear-gradient(to bottom right, ${LNbits.utils.hexDarken(String(primaryColor), -70)}, #0a0a0a)` const gradientStyle = `linear-gradient(to bottom right, ${LNbits.utils.hexDarken(String(primaryColor), -70)}, #0a0a0a)`
@@ -492,9 +488,23 @@ window.windowMixin = {
document.head.appendChild(style) document.head.appendChild(style)
} }
}, },
setColors: function () {
this.$q.localStorage.set(
'lnbits.primaryColor',
LNbits.utils.getPaletteColor('primary')
)
this.$q.localStorage.set(
'lnbits.secondaryColor',
LNbits.utils.getPaletteColor('secondary')
)
this.$q.localStorage.set(
'lnbits.darkBgColor',
LNbits.utils.getPaletteColor('dark')
)
},
copyText: function (text, message, position) { copyText: function (text, message, position) {
var notify = this.$q.notify var notify = this.$q.notify
Quasar.utils.copyToClipboard(text).then(function () { Quasar.copyToClipboard(text).then(function () {
notify({ notify({
message: message || 'Copied to clipboard!', message: message || 'Copied to clipboard!',
position: position || 'bottom' position: position || 'bottom'
@@ -541,6 +551,52 @@ window.windowMixin = {
LNbits.utils.notifyApiError(e) LNbits.utils.notifyApiError(e)
} }
}) })
},
themeParams() {
const url = new URL(window.location.href)
const params = new URLSearchParams(window.location.search)
const fields = ['theme', 'dark', 'gradient']
const toBoolean = value =>
value.trim().toLowerCase() === 'true' || value === '1'
// Check if any of the relevant parameters ('theme', 'dark', 'gradient') are present in the URL.
if (fields.some(param => params.has(param))) {
const theme = params.get('theme')
const darkMode = params.get('dark')
const gradient = params.get('gradient')
if (
theme &&
this.g.allowedThemes.includes(theme.trim().toLowerCase())
) {
const normalizedTheme = theme.trim().toLowerCase()
document.body.setAttribute('data-theme', normalizedTheme)
this.$q.localStorage.set('lnbits.theme', normalizedTheme)
}
if (darkMode) {
const isDark = toBoolean(darkMode)
this.$q.localStorage.set('lnbits.darkMode', isDark)
if (!isDark) {
this.$q.localStorage.set('lnbits.gradientBg', false)
}
}
if (gradient) {
const isGradient = toBoolean(gradient)
this.$q.localStorage.set('lnbits.gradientBg', isGradient)
if (isGradient) {
this.$q.localStorage.set('lnbits.darkMode', true)
}
}
// Remove processed parameters
fields.forEach(param => params.delete(param))
window.history.replaceState(null, null, url.pathname)
}
this.setColors()
} }
}, },
created: async function () { created: async function () {
@@ -555,8 +611,6 @@ window.windowMixin = {
this.reactionChoice = this.reactionChoice =
this.$q.localStorage.getItem('lnbits.reactions') || 'confettiBothSides' this.$q.localStorage.getItem('lnbits.reactions') || 'confettiBothSides'
this.applyGradient()
this.g.allowedThemes = window.allowedThemes ?? ['bitcoin'] this.g.allowedThemes = window.allowedThemes ?? ['bitcoin']
let locale = this.$q.localStorage.getItem('lnbits.lang') let locale = this.$q.localStorage.getItem('lnbits.lang')
@@ -595,6 +649,8 @@ window.windowMixin = {
) )
} }
this.applyGradient()
if (window.user) { if (window.user) {
this.g.user = Object.freeze(window.LNbits.map.user(window.user)) this.g.user = Object.freeze(window.LNbits.map.user(window.user))
} }
@@ -633,6 +689,7 @@ window.windowMixin = {
this.g.extensions = extensions this.g.extensions = extensions
} }
await this.checkUsrInUrl() await this.checkUsrInUrl()
this.themeParams()
} }
} }
+24 -25
View File
@@ -1,6 +1,6 @@
/* global _, Vue, moment, LNbits, EventHub, decryptLnurlPayAES */ window.app.component(QrcodeVue)
Vue.component('lnbits-fsat', { window.app.component('lnbits-fsat', {
props: { props: {
amount: { amount: {
type: Number, type: Number,
@@ -15,12 +15,13 @@ Vue.component('lnbits-fsat', {
} }
}) })
Vue.component('lnbits-wallet-list', { window.app.component('lnbits-wallet-list', {
props: ['balance'],
data: function () { data: function () {
return { return {
user: null, user: null,
activeWallet: null, activeWallet: null,
activeBalance: [], balance: 0,
showForm: false, showForm: false,
walletName: '', walletName: '',
LNBITS_DENOMINATION: LNBITS_DENOMINATION LNBITS_DENOMINATION: LNBITS_DENOMINATION
@@ -74,7 +75,7 @@ Vue.component('lnbits-wallet-list', {
`, `,
computed: { computed: {
wallets: function () { wallets: function () {
var bal = this.activeBalance var bal = this.balance
return this.user.wallets.map(function (obj) { return this.user.wallets.map(function (obj) {
obj.live_fsat = obj.live_fsat =
bal.length && bal[0] === obj.id bal.length && bal[0] === obj.id
@@ -87,9 +88,6 @@ Vue.component('lnbits-wallet-list', {
methods: { methods: {
createWallet: function () { createWallet: function () {
LNbits.api.createWallet(this.user.wallets[0], this.walletName) LNbits.api.createWallet(this.user.wallets[0], this.walletName)
},
updateWalletBalance: function (payload) {
this.activeBalance = payload
} }
}, },
created: function () { created: function () {
@@ -99,11 +97,11 @@ Vue.component('lnbits-wallet-list', {
if (window.wallet) { if (window.wallet) {
this.activeWallet = LNbits.map.wallet(window.wallet) this.activeWallet = LNbits.map.wallet(window.wallet)
} }
EventHub.$on('update-wallet-balance', this.updateWalletBalance) document.addEventListener('updateWalletBalance', this.updateWalletBalance)
} }
}) })
Vue.component('lnbits-extension-list', { window.app.component('lnbits-extension-list', {
data: function () { data: function () {
return { return {
extensions: [], extensions: [],
@@ -169,7 +167,7 @@ Vue.component('lnbits-extension-list', {
} }
}) })
Vue.component('lnbits-manage', { window.app.component('lnbits-manage', {
props: ['showAdmin', 'showNode', 'showExtensions', 'showUsers'], props: ['showAdmin', 'showNode', 'showExtensions', 'showUsers'],
methods: { methods: {
isActive: function (path) { isActive: function (path) {
@@ -229,9 +227,9 @@ Vue.component('lnbits-manage', {
} }
}) })
Vue.component('lnbits-payment-details', { window.app.component('lnbits-payment-details', {
props: ['payment'], props: ['payment'],
mixins: [windowMixin], mixins: [window.windowMixin],
data: function () { data: function () {
return { return {
LNBITS_DENOMINATION: LNBITS_DENOMINATION LNBITS_DENOMINATION: LNBITS_DENOMINATION
@@ -345,7 +343,7 @@ Vue.component('lnbits-payment-details', {
} }
}) })
Vue.component('lnbits-lnurlpay-success-action', { window.app.component('lnbits-lnurlpay-success-action', {
props: ['payment', 'success_action'], props: ['payment', 'success_action'],
data() { data() {
return { return {
@@ -374,10 +372,12 @@ Vue.component('lnbits-lnurlpay-success-action', {
} }
}) })
Vue.component('lnbits-qrcode', { window.app.component('lnbits-qrcode', {
mixins: [windowMixin], mixins: [window.windowMixin],
components: {
QrcodeVue
},
props: ['value'], props: ['value'],
components: {[VueQrcode.name]: VueQrcode},
data() { data() {
return { return {
logo: LNBITS_QR_LOGO logo: LNBITS_QR_LOGO
@@ -385,15 +385,14 @@ Vue.component('lnbits-qrcode', {
}, },
template: ` template: `
<div class="qrcode__wrapper"> <div class="qrcode__wrapper">
<qrcode :value="value" <qrcode-vue :value="value" size="350" class="rounded-borders"></qrcode-vue>
:options="{errorCorrectionLevel: 'Q', width: 800}" class="rounded-borders"></qrcode>
<img class="qrcode__image" :src="logo" alt="..." /> <img class="qrcode__image" :src="logo" alt="..." />
</div> </div>
` `
}) })
Vue.component('lnbits-notifications-btn', { window.app.component('lnbits-notifications-btn', {
mixins: [windowMixin], mixins: [window.windowMixin],
props: ['pubkey'], props: ['pubkey'],
data() { data() {
return { return {
@@ -605,8 +604,8 @@ Vue.component('lnbits-notifications-btn', {
} }
}) })
Vue.component('lnbits-dynamic-fields', { window.app.component('lnbits-dynamic-fields', {
mixins: [windowMixin], mixins: [window.windowMixin],
props: ['options', 'value'], props: ['options', 'value'],
data() { data() {
return { return {
@@ -742,8 +741,8 @@ Vue.component('lnbits-dynamic-fields', {
} }
}) })
Vue.component('lnbits-update-balance', { window.app.component('lnbits-update-balance', {
mixins: [windowMixin], mixins: [window.windowMixin],
props: ['wallet_id', 'callback'], props: ['wallet_id', 'callback'],
computed: { computed: {
denomination() { denomination() {
@@ -1,4 +1,4 @@
Vue.component('lnbits-extension-rating', { window.app.component('lnbits-extension-rating', {
name: 'lnbits-extension-rating', name: 'lnbits-extension-rating',
props: ['rating'], props: ['rating'],
template: ` template: `
@@ -1,10 +1,10 @@
Vue.component('lnbits-extension-settings-form', { window.app.component('lnbits-extension-settings-form', {
name: 'lnbits-extension-settings-form', name: 'lnbits-extension-settings-form',
props: ['options', 'adminkey', 'endpoint'], props: ['options', 'adminkey', 'endpoint'],
methods: { methods: {
updateSettings: async function () { async updateSettings() {
if (!this.settings) { if (!this.settings) {
return Quasar.plugins.Notify.create({ return this.$q.notify({
message: 'No settings to update', message: 'No settings to update',
type: 'negative' type: 'negative'
}) })
@@ -66,7 +66,7 @@ Vue.component('lnbits-extension-settings-form', {
} }
}) })
Vue.component('lnbits-extension-settings-btn-dialog', { window.app.component('lnbits-extension-settings-btn-dialog', {
name: 'lnbits-extension-settings-btn-dialog', name: 'lnbits-extension-settings-btn-dialog',
props: ['options', 'adminkey', 'endpoint'], props: ['options', 'adminkey', 'endpoint'],
template: ` template: `
@@ -1,5 +1,5 @@
Vue.component('lnbits-funding-sources', { window.app.component('lnbits-funding-sources', {
mixins: [windowMixin], mixins: [window.windowMixin],
props: ['form-data', 'allowed-funding-sources'], props: ['form-data', 'allowed-funding-sources'],
methods: { methods: {
getFundingSourceLabel(item) { getFundingSourceLabel(item) {
+2 -2
View File
@@ -80,10 +80,10 @@ function generateChart(canvas, rawData) {
}) })
} }
Vue.component('payment-chart', { window.app.component('payment-chart', {
name: 'payment-chart', name: 'payment-chart',
props: ['wallet'], props: ['wallet'],
mixins: [windowMixin], mixins: [window.windowMixin],
data: function () { data: function () {
return { return {
paymentsChart: { paymentsChart: {
+3 -3
View File
@@ -1,7 +1,7 @@
Vue.component('payment-list', { window.app.component('payment-list', {
name: 'payment-list', name: 'payment-list',
props: ['update', 'wallet', 'mobileSimple', 'lazy'], props: ['update', 'wallet', 'mobileSimple', 'lazy'],
mixins: [windowMixin], mixins: [window.windowMixin],
data: function () { data: function () {
return { return {
denomination: LNBITS_DENOMINATION, denomination: LNBITS_DENOMINATION,
@@ -313,7 +313,7 @@ Vue.component('payment-list', {
<q-table <q-table
dense dense
flat flat
:data="paymentsOmitter" :rows="paymentsOmitter"
:row-key="paymentTableRowKey" :row-key="paymentTableRowKey"
:columns="paymentsTable.columns" :columns="paymentsTable.columns"
:pagination.sync="paymentsTable.pagination" :pagination.sync="paymentsTable.pagination"
+2 -3
View File
@@ -1,6 +1,6 @@
new Vue({ window.app = Vue.createApp({
el: '#vue', el: '#vue',
mixins: [windowMixin], mixins: [window.windowMixin],
data: function () { data: function () {
return { return {
disclaimerDialog: { disclaimerDialog: {
@@ -93,7 +93,6 @@ new Vue({
}, },
created() { created() {
this.description = SITE_DESCRIPTION this.description = SITE_DESCRIPTION
this.isUserAuthorized = !!this.$q.cookies.get('is_lnbits_user_authorized') this.isUserAuthorized = !!this.$q.cookies.get('is_lnbits_user_authorized')
if (this.isUserAuthorized) { if (this.isUserAuthorized) {
window.location.href = '/wallet' window.location.href = '/wallet'
+4
View File
@@ -0,0 +1,4 @@
window.app.use(VueQrcodeReader)
window.app.use(Quasar)
window.app.use(window.i18n)
window.app.mount('#vue')
+12 -12
View File
@@ -4,7 +4,7 @@ function shortenNodeId(nodeId) {
: '...' : '...'
} }
Vue.component('lnbits-node-ranks', { window.app.component('lnbits-node-ranks', {
props: ['ranks'], props: ['ranks'],
data: function () { data: function () {
return { return {
@@ -35,7 +35,7 @@ Vue.component('lnbits-node-ranks', {
` `
}) })
Vue.component('lnbits-channel-stats', { window.app.component('lnbits-channel-stats', {
props: ['stats'], props: ['stats'],
data: function () { data: function () {
return { return {
@@ -71,7 +71,7 @@ Vue.component('lnbits-channel-stats', {
} }
}) })
Vue.component('lnbits-stat', { window.app.component('lnbits-stat', {
props: ['title', 'amount', 'msat', 'btc'], props: ['title', 'amount', 'msat', 'btc'],
computed: { computed: {
value: function () { value: function () {
@@ -99,20 +99,20 @@ Vue.component('lnbits-stat', {
` `
}) })
Vue.component('lnbits-node-qrcode', { window.app.component('lnbits-node-qrcode', {
props: ['info'], props: ['info'],
mixins: [windowMixin], mixins: [window.windowMixin],
template: ` template: `
<q-card class="my-card"> <q-card class="my-card">
<q-card-section> <q-card-section>
<div class="text-h6"> <div class="text-h6">
<div style="text-align: center"> <div style="text-align: center">
<qrcode <vue-qrcode
:value="info.addresses[0]" :value="info.addresses[0]"
:options="{width: 250}" :options="{width: 250}"
v-if='info.addresses[0]' v-if='info.addresses[0]'
class="rounded-borders" class="rounded-borders"
></qrcode> ></vue-qrcode>
<div v-else class='text-subtitle1'> <div v-else class='text-subtitle1'>
No addresses available No addresses available
</div> </div>
@@ -132,14 +132,14 @@ Vue.component('lnbits-node-qrcode', {
` `
}) })
Vue.component('lnbits-node-info', { window.app.component('lnbits-node-info', {
props: ['info'], props: ['info'],
data() { data() {
return { return {
showDialog: false showDialog: false
} }
}, },
mixins: [windowMixin], mixins: [window.windowMixin],
methods: { methods: {
shortenNodeId shortenNodeId
}, },
@@ -177,7 +177,7 @@ Vue.component('lnbits-node-info', {
` `
}) })
Vue.component('lnbits-stat', { window.app.component('lnbits-stat', {
props: ['title', 'amount', 'msat', 'btc'], props: ['title', 'amount', 'msat', 'btc'],
computed: { computed: {
value: function () { value: function () {
@@ -205,7 +205,7 @@ Vue.component('lnbits-stat', {
` `
}) })
Vue.component('lnbits-channel-balance', { window.app.component('lnbits-channel-balance', {
props: ['balance', 'color'], props: ['balance', 'color'],
methods: { methods: {
formatMsat: function (msat) { formatMsat: function (msat) {
@@ -246,7 +246,7 @@ Vue.component('lnbits-channel-balance', {
` `
}) })
Vue.component('lnbits-date', { window.app.component('lnbits-date', {
props: ['ts'], props: ['ts'],
computed: { computed: {
date: function () { date: function () {
+49 -7
View File
@@ -1,6 +1,6 @@
new Vue({ window.app = Vue.createApp({
el: '#vue', el: '#vue',
mixins: [windowMixin], mixins: [window.windowMixin],
data: function () { data: function () {
return { return {
isSuperUser: false, isSuperUser: false,
@@ -164,6 +164,41 @@ new Vue({
this.chart1 = new Chart(this.$refs.chart1.getContext('2d'), { this.chart1 = new Chart(this.$refs.chart1.getContext('2d'), {
type: 'bubble', type: 'bubble',
options: { options: {
scales: {
xAxes: [
{
type: 'linear',
ticks: {
beginAtZero: true
},
scaleLabel: {
display: true,
labelString: 'Tx count'
}
}
],
yAxes: [
{
type: 'linear',
ticks: {
beginAtZero: true
},
scaleLabel: {
display: true,
labelString: 'User balance in million sats'
}
}
]
},
tooltips: {
callbacks: {
label: function (tooltipItem, data) {
const dataset = data.datasets[tooltipItem.datasetIndex]
const dataPoint = dataset.data[tooltipItem.index]
return dataPoint.customLabel || ''
}
}
},
layout: { layout: {
padding: 10 padding: 10
} }
@@ -171,7 +206,7 @@ new Vue({
data: { data: {
datasets: [ datasets: [
{ {
label: 'Balance - TX Count in million sats', label: 'Wallet balance vs transaction count',
backgroundColor: 'rgb(255, 99, 132)', backgroundColor: 'rgb(255, 99, 132)',
data: [] data: []
} }
@@ -183,9 +218,6 @@ new Vue({
formatSat: function (value) { formatSat: function (value) {
return LNbits.utils.formatSat(Math.floor(value / 1000)) return LNbits.utils.formatSat(Math.floor(value / 1000))
}, },
usersTableRowKey: function (row) {
return row.id
},
createUser() { createUser() {
LNbits.api LNbits.api
.request('POST', '/users/api/v1/user', null, this.createUserDialog.data) .request('POST', '/users/api/v1/user', null, this.createUserDialog.data)
@@ -291,10 +323,20 @@ new Vue({
}) })
const data = filtered.map(user => { const data = filtered.map(user => {
const labelUsername = `${user.username ? 'User: ' + user.username + '. ' : ''}`
const userBalanceSats = Math.floor(
user.balance_msat / 1000
).toLocaleString()
return { return {
x: user.transaction_count, x: user.transaction_count,
y: user.balance_msat / 1000000000, y: user.balance_msat / 1000000000,
r: 3 r: 4,
customLabel:
labelUsername +
'Balance: ' +
userBalanceSats +
' sats. Tx count: ' +
user.transaction_count
} }
}) })
this.chart1.data.datasets[0].data = data this.chart1.data.datasets[0].data = data
+8 -12
View File
@@ -1,11 +1,6 @@
/* globals windowMixin, decode, Vue, VueQrcodeReader, VueQrcode, Quasar, LNbits, _, EventHub, decryptLnurlPayAES */ window.app = Vue.createApp({
Vue.component(VueQrcode.name, VueQrcode)
Vue.use(VueQrcodeReader)
new Vue({
el: '#vue', el: '#vue',
mixins: [windowMixin], mixins: [window.windowMixin],
data: function () { data: function () {
return { return {
updatePayments: false, updatePayments: false,
@@ -321,7 +316,7 @@ new Vue({
var expireDate = new Date( var expireDate = new Date(
(invoice.data.time_stamp + tag.value) * 1000 (invoice.data.time_stamp + tag.value) * 1000
) )
cleanInvoice.expireDate = Quasar.utils.date.formatDate( cleanInvoice.expireDate = this.$q.utils.date.formatDate(
expireDate, expireDate,
'YYYY-MM-DDTHH:mm:ss.SSSZ' 'YYYY-MM-DDTHH:mm:ss.SSSZ'
) )
@@ -514,10 +509,11 @@ new Vue({
fetchBalance: function () { fetchBalance: function () {
LNbits.api.getWallet(this.g.wallet).then(response => { LNbits.api.getWallet(this.g.wallet).then(response => {
this.balance = Math.floor(response.data.balance / 1000) this.balance = Math.floor(response.data.balance / 1000)
EventHub.$emit('update-wallet-balance', [ document.dispatchEvent(
this.g.wallet.id, new CustomEvent('updateWalletBalance', {
this.balance detail: [this.g.wallet.id, this.balance]
]) })
)
}) })
if (this.g.wallet.currency) { if (this.g.wallet.currency) {
this.updateFiatBalance() this.updateFiatBalance()
+15 -13
View File
@@ -3,15 +3,14 @@
"vendor/moment.js", "vendor/moment.js",
"vendor/underscore.js", "vendor/underscore.js",
"vendor/axios.js", "vendor/axios.js",
"vendor/vue.js", "vendor/vue.global.prod.js",
"vendor/vue-router.js", "vendor/quasar.umd.prod.js",
"vendor/VueQrcodeReader.umd.js", "vendor/vuex.global.js",
"vendor/vue-qrcode.js", "vendor/vue-i18n.global.prod.js",
"vendor/vuex.js", "vendor/vue-router.global.js",
"vendor/quasar.ie.polyfills.umd.min.js", "vendor/vue-qrcode-reader.umd.js",
"vendor/quasar.umd.js", "vendor/qrcode.vue.browser.js",
"vendor/Chart.bundle.js", "vendor/chart.umd.js",
"vendor/vue-i18n.js",
"vendor/showdown.js", "vendor/showdown.js",
"i18n/i18n.js", "i18n/i18n.js",
"i18n/de.js", "i18n/de.js",
@@ -34,14 +33,17 @@
"i18n/kr.js", "i18n/kr.js",
"i18n/fi.js", "i18n/fi.js",
"js/base.js", "js/base.js",
"js/components.js", "js/event-reactions.js",
"js/bolt11-decoder.js"
],
"components": [
"js/components/lnbits-funding-sources.js", "js/components/lnbits-funding-sources.js",
"js/components/extension-settings.js", "js/components/extension-settings.js",
"js/components/extension-rating.js", "js/components/extension-rating.js",
"js/components/payment-list.js", "js/components/payment-list.js",
"js/components/payment-chart.js", "js/components/payment-chart.js",
"js/event-reactions.js", "js/components.js",
"js/bolt11-decoder.js" "js/init-app.js"
], ],
"css": ["vendor/quasar.css", "vendor/Chart.css", "css/base.css"] "css": ["vendor/quasar.css", "css/base.css"]
} }
+179 -131
View File
@@ -1,4 +1,4 @@
// Axios v1.7.5 Copyright (c) 2024 Matt Zabriskie and contributors // Axios v1.7.7 Copyright (c) 2024 Matt Zabriskie and contributors
(function (global, factory) { (function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() : typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() :
typeof define === 'function' && define.amd ? define(factory) : typeof define === 'function' && define.amd ? define(factory) :
@@ -3093,38 +3093,42 @@
}; };
var composeSignals = function composeSignals(signals, timeout) { var composeSignals = function composeSignals(signals, timeout) {
var controller = new AbortController(); var _signals = signals = signals ? signals.filter(Boolean) : [],
var aborted; length = _signals.length;
var onabort = function onabort(cancel) { if (timeout || length) {
if (!aborted) { var controller = new AbortController();
aborted = true; var aborted;
unsubscribe(); var onabort = function onabort(reason) {
var err = cancel instanceof Error ? cancel : this.reason; if (!aborted) {
controller.abort(err instanceof AxiosError ? err : new CanceledError(err instanceof Error ? err.message : err)); aborted = true;
} unsubscribe();
}; var err = reason instanceof Error ? reason : this.reason;
var timer = timeout && setTimeout(function () { controller.abort(err instanceof AxiosError ? err : new CanceledError(err instanceof Error ? err.message : err));
onabort(new AxiosError("timeout ".concat(timeout, " of ms exceeded"), AxiosError.ETIMEDOUT)); }
}, timeout); };
var unsubscribe = function unsubscribe() { var timer = timeout && setTimeout(function () {
if (signals) {
timer && clearTimeout(timer);
timer = null; timer = null;
signals.forEach(function (signal) { onabort(new AxiosError("timeout ".concat(timeout, " of ms exceeded"), AxiosError.ETIMEDOUT));
signal && (signal.removeEventListener ? signal.removeEventListener('abort', onabort) : signal.unsubscribe(onabort)); }, timeout);
}); var unsubscribe = function unsubscribe() {
signals = null; if (signals) {
} timer && clearTimeout(timer);
}; timer = null;
signals.forEach(function (signal) { signals.forEach(function (signal) {
return signal && signal.addEventListener && signal.addEventListener('abort', onabort); signal.unsubscribe ? signal.unsubscribe(onabort) : signal.removeEventListener('abort', onabort);
}); });
var signal = controller.signal; signals = null;
signal.unsubscribe = unsubscribe; }
return [signal, function () { };
timer && clearTimeout(timer); signals.forEach(function (signal) {
timer = null; return signal.addEventListener('abort', onabort);
}]; });
var signal = controller.signal;
signal.unsubscribe = function () {
return utils$1.asap(unsubscribe);
};
return signal;
}
}; };
var composeSignals$1 = composeSignals; var composeSignals$1 = composeSignals;
@@ -3163,7 +3167,7 @@
}, streamChunk); }, streamChunk);
}); });
var readBytes = /*#__PURE__*/function () { var readBytes = /*#__PURE__*/function () {
var _ref = _wrapAsyncGenerator( /*#__PURE__*/_regeneratorRuntime().mark(function _callee(iterable, chunkSize, encode) { var _ref = _wrapAsyncGenerator( /*#__PURE__*/_regeneratorRuntime().mark(function _callee(iterable, chunkSize) {
var _iteratorAbruptCompletion, _didIteratorError, _iteratorError, _iterator, _step, chunk; var _iteratorAbruptCompletion, _didIteratorError, _iteratorError, _iterator, _step, chunk;
return _regeneratorRuntime().wrap(function _callee$(_context2) { return _regeneratorRuntime().wrap(function _callee$(_context2) {
while (1) switch (_context2.prev = _context2.next) { while (1) switch (_context2.prev = _context2.next) {
@@ -3171,82 +3175,111 @@
_iteratorAbruptCompletion = false; _iteratorAbruptCompletion = false;
_didIteratorError = false; _didIteratorError = false;
_context2.prev = 2; _context2.prev = 2;
_iterator = _asyncIterator(iterable); _iterator = _asyncIterator(readStream(iterable));
case 4: case 4:
_context2.next = 6; _context2.next = 6;
return _awaitAsyncGenerator(_iterator.next()); return _awaitAsyncGenerator(_iterator.next());
case 6: case 6:
if (!(_iteratorAbruptCompletion = !(_step = _context2.sent).done)) { if (!(_iteratorAbruptCompletion = !(_step = _context2.sent).done)) {
_context2.next = 27; _context2.next = 12;
break; break;
} }
chunk = _step.value; chunk = _step.value;
_context2.t0 = _asyncGeneratorDelegate; return _context2.delegateYield(_asyncGeneratorDelegate(_asyncIterator(streamChunk(chunk, chunkSize))), "t0", 9);
_context2.t1 = _asyncIterator; case 9:
_context2.t2 = streamChunk;
if (!ArrayBuffer.isView(chunk)) {
_context2.next = 15;
break;
}
_context2.t3 = chunk;
_context2.next = 18;
break;
case 15:
_context2.next = 17;
return _awaitAsyncGenerator(encode(String(chunk)));
case 17:
_context2.t3 = _context2.sent;
case 18:
_context2.t4 = _context2.t3;
_context2.t5 = chunkSize;
_context2.t6 = (0, _context2.t2)(_context2.t4, _context2.t5);
_context2.t7 = (0, _context2.t1)(_context2.t6);
_context2.t8 = _awaitAsyncGenerator;
return _context2.delegateYield((0, _context2.t0)(_context2.t7, _context2.t8), "t9", 24);
case 24:
_iteratorAbruptCompletion = false; _iteratorAbruptCompletion = false;
_context2.next = 4; _context2.next = 4;
break; break;
case 27: case 12:
_context2.next = 33; _context2.next = 18;
break; break;
case 29: case 14:
_context2.prev = 29; _context2.prev = 14;
_context2.t10 = _context2["catch"](2); _context2.t1 = _context2["catch"](2);
_didIteratorError = true; _didIteratorError = true;
_iteratorError = _context2.t10; _iteratorError = _context2.t1;
case 33: case 18:
_context2.prev = 33; _context2.prev = 18;
_context2.prev = 34; _context2.prev = 19;
if (!(_iteratorAbruptCompletion && _iterator["return"] != null)) { if (!(_iteratorAbruptCompletion && _iterator["return"] != null)) {
_context2.next = 38; _context2.next = 23;
break; break;
} }
_context2.next = 38; _context2.next = 23;
return _awaitAsyncGenerator(_iterator["return"]()); return _awaitAsyncGenerator(_iterator["return"]());
case 38: case 23:
_context2.prev = 38; _context2.prev = 23;
if (!_didIteratorError) { if (!_didIteratorError) {
_context2.next = 41; _context2.next = 26;
break; break;
} }
throw _iteratorError; throw _iteratorError;
case 41: case 26:
return _context2.finish(38); return _context2.finish(23);
case 42: case 27:
return _context2.finish(33); return _context2.finish(18);
case 43: case 28:
case "end": case "end":
return _context2.stop(); return _context2.stop();
} }
}, _callee, null, [[2, 29, 33, 43], [34,, 38, 42]]); }, _callee, null, [[2, 14, 18, 28], [19,, 23, 27]]);
})); }));
return function readBytes(_x, _x2, _x3) { return function readBytes(_x, _x2) {
return _ref.apply(this, arguments); return _ref.apply(this, arguments);
}; };
}(); }();
var trackStream = function trackStream(stream, chunkSize, onProgress, onFinish, encode) { var readStream = /*#__PURE__*/function () {
var iterator = readBytes(stream, chunkSize, encode); var _ref2 = _wrapAsyncGenerator( /*#__PURE__*/_regeneratorRuntime().mark(function _callee2(stream) {
var reader, _yield$_awaitAsyncGen, done, value;
return _regeneratorRuntime().wrap(function _callee2$(_context3) {
while (1) switch (_context3.prev = _context3.next) {
case 0:
if (!stream[Symbol.asyncIterator]) {
_context3.next = 3;
break;
}
return _context3.delegateYield(_asyncGeneratorDelegate(_asyncIterator(stream)), "t0", 2);
case 2:
return _context3.abrupt("return");
case 3:
reader = stream.getReader();
_context3.prev = 4;
case 5:
_context3.next = 7;
return _awaitAsyncGenerator(reader.read());
case 7:
_yield$_awaitAsyncGen = _context3.sent;
done = _yield$_awaitAsyncGen.done;
value = _yield$_awaitAsyncGen.value;
if (!done) {
_context3.next = 12;
break;
}
return _context3.abrupt("break", 16);
case 12:
_context3.next = 14;
return value;
case 14:
_context3.next = 5;
break;
case 16:
_context3.prev = 16;
_context3.next = 19;
return _awaitAsyncGenerator(reader.cancel());
case 19:
return _context3.finish(16);
case 20:
case "end":
return _context3.stop();
}
}, _callee2, null, [[4,, 16, 20]]);
}));
return function readStream(_x3) {
return _ref2.apply(this, arguments);
};
}();
var trackStream = function trackStream(stream, chunkSize, onProgress, onFinish) {
var iterator = readBytes(stream, chunkSize);
var bytes = 0; var bytes = 0;
var done; var done;
var _onFinish = function _onFinish(e) { var _onFinish = function _onFinish(e) {
@@ -3257,25 +3290,25 @@
}; };
return new ReadableStream({ return new ReadableStream({
pull: function pull(controller) { pull: function pull(controller) {
return _asyncToGenerator( /*#__PURE__*/_regeneratorRuntime().mark(function _callee2() { return _asyncToGenerator( /*#__PURE__*/_regeneratorRuntime().mark(function _callee3() {
var _yield$iterator$next, _done, value, len, loadedBytes; var _yield$iterator$next, _done, value, len, loadedBytes;
return _regeneratorRuntime().wrap(function _callee2$(_context3) { return _regeneratorRuntime().wrap(function _callee3$(_context4) {
while (1) switch (_context3.prev = _context3.next) { while (1) switch (_context4.prev = _context4.next) {
case 0: case 0:
_context3.prev = 0; _context4.prev = 0;
_context3.next = 3; _context4.next = 3;
return iterator.next(); return iterator.next();
case 3: case 3:
_yield$iterator$next = _context3.sent; _yield$iterator$next = _context4.sent;
_done = _yield$iterator$next.done; _done = _yield$iterator$next.done;
value = _yield$iterator$next.value; value = _yield$iterator$next.value;
if (!_done) { if (!_done) {
_context3.next = 10; _context4.next = 10;
break; break;
} }
_onFinish(); _onFinish();
controller.close(); controller.close();
return _context3.abrupt("return"); return _context4.abrupt("return");
case 10: case 10:
len = value.byteLength; len = value.byteLength;
if (onProgress) { if (onProgress) {
@@ -3283,18 +3316,18 @@
onProgress(loadedBytes); onProgress(loadedBytes);
} }
controller.enqueue(new Uint8Array(value)); controller.enqueue(new Uint8Array(value));
_context3.next = 19; _context4.next = 19;
break; break;
case 15: case 15:
_context3.prev = 15; _context4.prev = 15;
_context3.t0 = _context3["catch"](0); _context4.t0 = _context4["catch"](0);
_onFinish(_context3.t0); _onFinish(_context4.t0);
throw _context3.t0; throw _context4.t0;
case 19: case 19:
case "end": case "end":
return _context3.stop(); return _context4.stop();
} }
}, _callee2, null, [[0, 15]]); }, _callee3, null, [[0, 15]]);
}))(); }))();
}, },
cancel: function cancel(reason) { cancel: function cancel(reason) {
@@ -3377,6 +3410,7 @@
}(new Response()); }(new Response());
var getBodyLength = /*#__PURE__*/function () { var getBodyLength = /*#__PURE__*/function () {
var _ref2 = _asyncToGenerator( /*#__PURE__*/_regeneratorRuntime().mark(function _callee2(body) { var _ref2 = _asyncToGenerator( /*#__PURE__*/_regeneratorRuntime().mark(function _callee2(body) {
var _request;
return _regeneratorRuntime().wrap(function _callee2$(_context2) { return _regeneratorRuntime().wrap(function _callee2$(_context2) {
while (1) switch (_context2.prev = _context2.next) { while (1) switch (_context2.prev = _context2.next) {
case 0: case 0:
@@ -3393,32 +3427,36 @@
return _context2.abrupt("return", body.size); return _context2.abrupt("return", body.size);
case 4: case 4:
if (!utils$1.isSpecCompliantForm(body)) { if (!utils$1.isSpecCompliantForm(body)) {
_context2.next = 8; _context2.next = 9;
break; break;
} }
_context2.next = 7; _request = new Request(platform.origin, {
return new Request(body).arrayBuffer(); method: 'POST',
case 7: body: body
return _context2.abrupt("return", _context2.sent.byteLength); });
_context2.next = 8;
return _request.arrayBuffer();
case 8: case 8:
return _context2.abrupt("return", _context2.sent.byteLength);
case 9:
if (!(utils$1.isArrayBufferView(body) || utils$1.isArrayBuffer(body))) { if (!(utils$1.isArrayBufferView(body) || utils$1.isArrayBuffer(body))) {
_context2.next = 10; _context2.next = 11;
break; break;
} }
return _context2.abrupt("return", body.byteLength); return _context2.abrupt("return", body.byteLength);
case 10: case 11:
if (utils$1.isURLSearchParams(body)) { if (utils$1.isURLSearchParams(body)) {
body = body + ''; body = body + '';
} }
if (!utils$1.isString(body)) { if (!utils$1.isString(body)) {
_context2.next = 15; _context2.next = 16;
break; break;
} }
_context2.next = 14; _context2.next = 15;
return encodeText(body); return encodeText(body);
case 14:
return _context2.abrupt("return", _context2.sent.byteLength);
case 15: case 15:
return _context2.abrupt("return", _context2.sent.byteLength);
case 16:
case "end": case "end":
return _context2.stop(); return _context2.stop();
} }
@@ -3448,18 +3486,15 @@
}(); }();
var fetchAdapter = isFetchSupported && ( /*#__PURE__*/function () { var fetchAdapter = isFetchSupported && ( /*#__PURE__*/function () {
var _ref4 = _asyncToGenerator( /*#__PURE__*/_regeneratorRuntime().mark(function _callee4(config) { var _ref4 = _asyncToGenerator( /*#__PURE__*/_regeneratorRuntime().mark(function _callee4(config) {
var _resolveConfig, url, method, data, signal, cancelToken, timeout, onDownloadProgress, onUploadProgress, responseType, headers, _resolveConfig$withCr, withCredentials, fetchOptions, _ref5, _ref6, composedSignal, stopTimeout, finished, request, onFinish, requestContentLength, _request, contentTypeHeader, _progressEventDecorat, _progressEventDecorat2, onProgress, flush, isCredentialsSupported, response, isStreamResponse, options, responseContentLength, _ref7, _ref8, _onProgress, _flush, responseData; var _resolveConfig, url, method, data, signal, cancelToken, timeout, onDownloadProgress, onUploadProgress, responseType, headers, _resolveConfig$withCr, withCredentials, fetchOptions, composedSignal, request, unsubscribe, requestContentLength, _request, contentTypeHeader, _progressEventDecorat, _progressEventDecorat2, onProgress, flush, isCredentialsSupported, response, isStreamResponse, options, responseContentLength, _ref5, _ref6, _onProgress, _flush, responseData;
return _regeneratorRuntime().wrap(function _callee4$(_context4) { return _regeneratorRuntime().wrap(function _callee4$(_context4) {
while (1) switch (_context4.prev = _context4.next) { while (1) switch (_context4.prev = _context4.next) {
case 0: case 0:
_resolveConfig = resolveConfig(config), url = _resolveConfig.url, method = _resolveConfig.method, data = _resolveConfig.data, signal = _resolveConfig.signal, cancelToken = _resolveConfig.cancelToken, timeout = _resolveConfig.timeout, onDownloadProgress = _resolveConfig.onDownloadProgress, onUploadProgress = _resolveConfig.onUploadProgress, responseType = _resolveConfig.responseType, headers = _resolveConfig.headers, _resolveConfig$withCr = _resolveConfig.withCredentials, withCredentials = _resolveConfig$withCr === void 0 ? 'same-origin' : _resolveConfig$withCr, fetchOptions = _resolveConfig.fetchOptions; _resolveConfig = resolveConfig(config), url = _resolveConfig.url, method = _resolveConfig.method, data = _resolveConfig.data, signal = _resolveConfig.signal, cancelToken = _resolveConfig.cancelToken, timeout = _resolveConfig.timeout, onDownloadProgress = _resolveConfig.onDownloadProgress, onUploadProgress = _resolveConfig.onUploadProgress, responseType = _resolveConfig.responseType, headers = _resolveConfig.headers, _resolveConfig$withCr = _resolveConfig.withCredentials, withCredentials = _resolveConfig$withCr === void 0 ? 'same-origin' : _resolveConfig$withCr, fetchOptions = _resolveConfig.fetchOptions;
responseType = responseType ? (responseType + '').toLowerCase() : 'text'; responseType = responseType ? (responseType + '').toLowerCase() : 'text';
_ref5 = signal || cancelToken || timeout ? composeSignals$1([signal, cancelToken], timeout) : [], _ref6 = _slicedToArray(_ref5, 2), composedSignal = _ref6[0], stopTimeout = _ref6[1]; composedSignal = composeSignals$1([signal, cancelToken && cancelToken.toAbortSignal()], timeout);
onFinish = function onFinish() { unsubscribe = composedSignal && composedSignal.unsubscribe && function () {
!finished && setTimeout(function () { composedSignal.unsubscribe();
composedSignal && composedSignal.unsubscribe();
});
finished = true;
}; };
_context4.prev = 4; _context4.prev = 4;
_context4.t0 = onUploadProgress && supportsRequestStream && method !== 'get' && method !== 'head'; _context4.t0 = onUploadProgress && supportsRequestStream && method !== 'get' && method !== 'head';
@@ -3487,7 +3522,7 @@
} }
if (_request.body) { if (_request.body) {
_progressEventDecorat = progressEventDecorator(requestContentLength, progressEventReducer(asyncDecorator(onUploadProgress))), _progressEventDecorat2 = _slicedToArray(_progressEventDecorat, 2), onProgress = _progressEventDecorat2[0], flush = _progressEventDecorat2[1]; _progressEventDecorat = progressEventDecorator(requestContentLength, progressEventReducer(asyncDecorator(onUploadProgress))), _progressEventDecorat2 = _slicedToArray(_progressEventDecorat, 2), onProgress = _progressEventDecorat2[0], flush = _progressEventDecorat2[1];
data = trackStream(_request.body, DEFAULT_CHUNK_SIZE, onProgress, flush, encodeText); data = trackStream(_request.body, DEFAULT_CHUNK_SIZE, onProgress, flush);
} }
case 15: case 15:
if (!utils$1.isString(withCredentials)) { if (!utils$1.isString(withCredentials)) {
@@ -3510,26 +3545,25 @@
case 20: case 20:
response = _context4.sent; response = _context4.sent;
isStreamResponse = supportsResponseStream && (responseType === 'stream' || responseType === 'response'); isStreamResponse = supportsResponseStream && (responseType === 'stream' || responseType === 'response');
if (supportsResponseStream && (onDownloadProgress || isStreamResponse)) { if (supportsResponseStream && (onDownloadProgress || isStreamResponse && unsubscribe)) {
options = {}; options = {};
['status', 'statusText', 'headers'].forEach(function (prop) { ['status', 'statusText', 'headers'].forEach(function (prop) {
options[prop] = response[prop]; options[prop] = response[prop];
}); });
responseContentLength = utils$1.toFiniteNumber(response.headers.get('content-length')); responseContentLength = utils$1.toFiniteNumber(response.headers.get('content-length'));
_ref7 = onDownloadProgress && progressEventDecorator(responseContentLength, progressEventReducer(asyncDecorator(onDownloadProgress), true)) || [], _ref8 = _slicedToArray(_ref7, 2), _onProgress = _ref8[0], _flush = _ref8[1]; _ref5 = onDownloadProgress && progressEventDecorator(responseContentLength, progressEventReducer(asyncDecorator(onDownloadProgress), true)) || [], _ref6 = _slicedToArray(_ref5, 2), _onProgress = _ref6[0], _flush = _ref6[1];
response = new Response(trackStream(response.body, DEFAULT_CHUNK_SIZE, _onProgress, function () { response = new Response(trackStream(response.body, DEFAULT_CHUNK_SIZE, _onProgress, function () {
_flush && _flush(); _flush && _flush();
isStreamResponse && onFinish(); unsubscribe && unsubscribe();
}, encodeText), options); }), options);
} }
responseType = responseType || 'text'; responseType = responseType || 'text';
_context4.next = 26; _context4.next = 26;
return resolvers[utils$1.findKey(resolvers, responseType) || 'text'](response, config); return resolvers[utils$1.findKey(resolvers, responseType) || 'text'](response, config);
case 26: case 26:
responseData = _context4.sent; responseData = _context4.sent;
!isStreamResponse && onFinish(); !isStreamResponse && unsubscribe && unsubscribe();
stopTimeout && stopTimeout(); _context4.next = 30;
_context4.next = 31;
return new Promise(function (resolve, reject) { return new Promise(function (resolve, reject) {
settle(resolve, reject, { settle(resolve, reject, {
data: responseData, data: responseData,
@@ -3540,26 +3574,26 @@
request: request request: request
}); });
}); });
case 31: case 30:
return _context4.abrupt("return", _context4.sent); return _context4.abrupt("return", _context4.sent);
case 34: case 33:
_context4.prev = 34; _context4.prev = 33;
_context4.t2 = _context4["catch"](4); _context4.t2 = _context4["catch"](4);
onFinish(); unsubscribe && unsubscribe();
if (!(_context4.t2 && _context4.t2.name === 'TypeError' && /fetch/i.test(_context4.t2.message))) { if (!(_context4.t2 && _context4.t2.name === 'TypeError' && /fetch/i.test(_context4.t2.message))) {
_context4.next = 39; _context4.next = 38;
break; break;
} }
throw Object.assign(new AxiosError('Network Error', AxiosError.ERR_NETWORK, config, request), { throw Object.assign(new AxiosError('Network Error', AxiosError.ERR_NETWORK, config, request), {
cause: _context4.t2.cause || _context4.t2 cause: _context4.t2.cause || _context4.t2
}); });
case 39: case 38:
throw AxiosError.from(_context4.t2, _context4.t2 && _context4.t2.code, config, request); throw AxiosError.from(_context4.t2, _context4.t2 && _context4.t2.code, config, request);
case 40: case 39:
case "end": case "end":
return _context4.stop(); return _context4.stop();
} }
}, _callee4, null, [[4, 34]]); }, _callee4, null, [[4, 33]]);
})); }));
return function (_x5) { return function (_x5) {
return _ref4.apply(this, arguments); return _ref4.apply(this, arguments);
@@ -3683,7 +3717,7 @@
}); });
} }
var VERSION = "1.7.5"; var VERSION = "1.7.7";
var validators$1 = {}; var validators$1 = {};
@@ -4064,6 +4098,20 @@
this._listeners.splice(index, 1); this._listeners.splice(index, 1);
} }
} }
}, {
key: "toAbortSignal",
value: function toAbortSignal() {
var _this = this;
var controller = new AbortController();
var abort = function abort(err) {
controller.abort(err);
};
this.subscribe(abort);
controller.signal.unsubscribe = function () {
return _this.unsubscribe(abort);
};
return controller.signal;
}
/** /**
* Returns an object that contains a new `CancelToken` and a function that, when called, * Returns an object that contains a new `CancelToken` and a function that, when called,
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
+3124 -3165
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+13 -8
View File
@@ -7,13 +7,13 @@
exports.noConflict = function () { global._ = current; return exports; }; exports.noConflict = function () { global._ = current; return exports; };
}())); }()));
}(this, (function () { }(this, (function () {
// Underscore.js 1.13.6 // Underscore.js 1.13.7
// https://underscorejs.org // https://underscorejs.org
// (c) 2009-2022 Jeremy Ashkenas, Julian Gonggrijp, and DocumentCloud and Investigative Reporters & Editors // (c) 2009-2024 Jeremy Ashkenas, Julian Gonggrijp, and DocumentCloud and Investigative Reporters & Editors
// Underscore may be freely distributed under the MIT license. // Underscore may be freely distributed under the MIT license.
// Current version. // Current version.
var VERSION = '1.13.6'; var VERSION = '1.13.7';
// Establish the root object, `window` (`self`) in the browser, `global` // Establish the root object, `window` (`self`) in the browser, `global`
// on the server, or `this` in some virtual machines. We use `self` // on the server, or `this` in some virtual machines. We use `self`
@@ -150,8 +150,11 @@
// In IE 10 - Edge 13, `DataView` has string tag `'[object Object]'`. // In IE 10 - Edge 13, `DataView` has string tag `'[object Object]'`.
// In IE 11, the most common among them, this problem also applies to // In IE 11, the most common among them, this problem also applies to
// `Map`, `WeakMap` and `Set`. // `Map`, `WeakMap` and `Set`.
var hasStringTagBug = ( // Also, there are cases where an application can override the native
supportsDataView && hasObjectTag(new DataView(new ArrayBuffer(8))) // `DataView` object, in cases like that we can't use the constructor
// safely and should just rely on alternate `DataView` checks
var hasDataViewBug = (
supportsDataView && (!/\[native code\]/.test(String(DataView)) || hasObjectTag(new DataView(new ArrayBuffer(8))))
), ),
isIE11 = (typeof Map !== 'undefined' && hasObjectTag(new Map)); isIE11 = (typeof Map !== 'undefined' && hasObjectTag(new Map));
@@ -159,11 +162,13 @@
// In IE 10 - Edge 13, we need a different heuristic // In IE 10 - Edge 13, we need a different heuristic
// to determine whether an object is a `DataView`. // to determine whether an object is a `DataView`.
function ie10IsDataView(obj) { // Also, in cases where the native `DataView` is
// overridden we can't rely on the tag itself.
function alternateIsDataView(obj) {
return obj != null && isFunction$1(obj.getInt8) && isArrayBuffer(obj.buffer); return obj != null && isFunction$1(obj.getInt8) && isArrayBuffer(obj.buffer);
} }
var isDataView$1 = (hasStringTagBug ? ie10IsDataView : isDataView); var isDataView$1 = (hasDataViewBug ? alternateIsDataView : isDataView);
// Is a given value an array? // Is a given value an array?
// Delegates to ECMA5's native `Array.isArray`. // Delegates to ECMA5's native `Array.isArray`.
@@ -376,7 +381,7 @@
var className = toString.call(a); var className = toString.call(a);
if (className !== toString.call(b)) return false; if (className !== toString.call(b)) return false;
// Work around a bug in IE 10 - Edge 13. // Work around a bug in IE 10 - Edge 13.
if (hasStringTagBug && className == '[object Object]' && isDataView$1(a)) { if (hasDataViewBug && className == '[object Object]' && isDataView$1(a)) {
if (!isDataView$1(b)) return false; if (!isDataView$1(b)) return false;
className = tagDataView; className = tagDataView;
} }
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+112 -5508
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
+188 -184
View File
@@ -32,196 +32,198 @@
</head> </head>
<body data-theme="bitcoin"> <body data-theme="bitcoin">
<q-layout id="vue" view="hHh lpR lfr" v-cloak> <div id="vue">
<q-header bordered class="bg-marginal-bg"> <q-layout view="hHh lpR lfr" v-cloak>
<q-toolbar> <q-header bordered class="bg-marginal-bg">
{% block drawer_toggle %} <q-toolbar>
<q-btn {% block drawer_toggle %}
dense <q-btn
flat dense
round flat
icon="menu" round
@click="g.visibleDrawer = !g.visibleDrawer" icon="menu"
></q-btn> @click="g.visibleDrawer = !g.visibleDrawer"
{% endblock %} ></q-btn>
<q-toolbar-title> {% endblock %}
{% block toolbar_title %} <q-toolbar-title>
<q-btn flat no-caps dense size="lg" type="a" href="/" {% block toolbar_title %}
>{% if USE_CUSTOM_LOGO %} <q-btn flat no-caps dense size="lg" type="a" href="/"
<img height="30px" alt="Logo" src="{{ USE_CUSTOM_LOGO }}" /> >{% if USE_CUSTOM_LOGO %}
{%else%} {% if SITE_TITLE != 'LNbits' %} {{ SITE_TITLE }} {% else <img height="30px" alt="Logo" src="{{ USE_CUSTOM_LOGO }}" />
%} {%else%} {% if SITE_TITLE != 'LNbits' %} {{ SITE_TITLE }} {%
<span><strong>LN</strong>bits</span> {% endif %} {%endif%} </q-btn else %}
>{% endblock %} {% block toolbar_subtitle %}{%if user and <span><strong>LN</strong>bits</span> {% endif %} {%endif%} </q-btn
user.super_user%} >{% endblock %} {% block toolbar_subtitle %}{%if user and
<q-badge align="middle">Super User</q-badge> user.super_user%}
{% elif user and user.admin %} <q-badge align="middle">Super User</q-badge>
<q-badge align="middle">Admin User</q-badge> {% elif user and user.admin %}
{%endif%}{% endblock %} <q-badge align="middle">Admin User</q-badge>
</q-toolbar-title> {%endif%}{% endblock %}
{% block beta %} {% if VOIDWALLET %} </q-toolbar-title>
<q-badge {% block beta %} {% if VOIDWALLET %}
v-text="$t('voidwallet_active')" <q-badge
color="red" v-text="$t('voidwallet_active')"
class="q-mr-md gt-md" color="red"
> class="q-mr-md gt-md"
</q-badge> >
{%endif%} </q-badge>
<q-badge
v-if="'{{LNBITS_CUSTOM_BADGE}}' && '{{LNBITS_CUSTOM_BADGE}}' != 'None'"
v-show="$q.screen.gt.sm"
color="{{ LNBITS_CUSTOM_BADGE_COLOR }}"
class="q-mr-md"
label="{{LNBITS_CUSTOM_BADGE}}"
>
</q-badge>
{% if LNBITS_SERVICE_FEE > 0 %}
<q-badge
v-show="$q.screen.gt.sm"
v-if="g.user"
color="green"
class="q-mr-md"
>
{% if LNBITS_SERVICE_FEE_MAX > 0 %}
<span
v-text='$t("service_fee_max", { amount: "{{ LNBITS_SERVICE_FEE }}", max: "{{ LNBITS_SERVICE_FEE_MAX }}"})'
></span>
{%else%}
<span
v-text='$t("service_fee", { amount: "{{ LNBITS_SERVICE_FEE }}" })'
></span>
{%endif%} {%endif%}
<q-tooltip <q-badge
><span v-text='$t("service_fee_tooltip")'></span v-if="'{{LNBITS_CUSTOM_BADGE}}' && '{{LNBITS_CUSTOM_BADGE}}' != 'None'"
></q-tooltip> v-show="$q.screen.gt.sm"
</q-badge> color="{{ LNBITS_CUSTOM_BADGE_COLOR }}"
class="q-mr-md"
label="{{LNBITS_CUSTOM_BADGE}}"
>
</q-badge>
{% if LNBITS_SERVICE_FEE > 0 %}
<q-badge
v-show="$q.screen.gt.sm"
v-if="g.user"
color="green"
class="q-mr-md"
>
{% if LNBITS_SERVICE_FEE_MAX > 0 %}
<span
v-text='$t("service_fee_max", { amount: "{{ LNBITS_SERVICE_FEE }}", max: "{{ LNBITS_SERVICE_FEE_MAX }}"})'
></span>
{%else%}
<span
v-text='$t("service_fee", { amount: "{{ LNBITS_SERVICE_FEE }}" })'
></span>
{%endif%}
<q-tooltip
><span v-text='$t("service_fee_tooltip")'></span
></q-tooltip>
</q-badge>
{%endif%} {% endblock %} {%endif%} {% endblock %}
<q-badge v-if="g.offline" color="red" class="q-mr-md"> <q-badge v-if="g.offline" color="red" class="q-mr-md">
<span>OFFLINE</span> <span>OFFLINE</span>
</q-badge> </q-badge>
<q-btn-dropdown <q-btn-dropdown
v-if="isUserAuthorized" v-if="isUserAuthorized"
dense dense
flat flat
round round
size="sm" size="sm"
class="q-pl-sm" class="q-pl-sm"
> >
<template v-slot:label> <template v-slot:label>
<div> <div>
{%if user and user.config and user.config.picture%} {%if user and user.config and user.config.picture%}
<img src="{{user.config.picture}}" style="max-width: 32px" /> <img src="{{user.config.picture}}" style="max-width: 32px" />
{%else%} {%else%}
<q-icon name="account_circle" /> <q-icon name="account_circle" />
{%endif%} {%endif%}
</div> </div>
</template> </template>
<q-list> <q-list>
<q-item tag="a" href="/account" clickable v-close-popup <q-item tag="a" href="/account" clickable v-close-popup
><q-item-section> ><q-item-section>
<q-icon name="person" /> <q-icon name="person" />
</q-item-section> </q-item-section>
<q-item-section> <q-item-section>
<q-item-label> <q-item-label>
<span v-text="$t('my_account')"></span> <span v-text="$t('my_account')"></span>
</q-item-label> </q-item-label>
</q-item-section> </q-item-section>
<q-item-section> <q-item-section>
<q-item-label> </q-item-label> <q-item-label> </q-item-label>
</q-item-section> </q-item-section>
</q-item> </q-item>
<q-separator></q-separator> <q-separator></q-separator>
<q-item clickable v-close-popup @click="logout" <q-item clickable v-close-popup @click="logout"
><q-item-section> ><q-item-section>
<q-icon name="logout" /> <q-icon name="logout" />
</q-item-section> </q-item-section>
<q-item-section> <q-item-section>
<q-item-label> <q-item-label>
<span v-text="$t('logout')"></span> <span v-text="$t('logout')"></span>
</q-item-label> </q-item-label>
</q-item-section> </q-item-section>
<q-item-section> <q-item-section>
<q-item-label> </q-item-label> <q-item-label> </q-item-label>
</q-item-section> </q-item-section>
</q-item> </q-item>
</q-list> </q-list>
</q-btn-dropdown> </q-btn-dropdown>
</q-toolbar> </q-toolbar>
</q-header> </q-header>
{% block drawer %} {% block drawer %}
<q-drawer <q-drawer
v-model="g.visibleDrawer" v-model="g.visibleDrawer"
side="left" side="left"
:width="($q.screen.lt.md) ? 260 : 230" :width="($q.screen.lt.md) ? 260 : 230"
show-if-above show-if-above
:elevated="$q.screen.lt.md" :elevated="$q.screen.lt.md"
> >
<lnbits-wallet-list></lnbits-wallet-list> <lnbits-wallet-list :balance="balance"></lnbits-wallet-list>
<lnbits-manage <lnbits-manage
:show-admin="'{{LNBITS_ADMIN_UI}}' == 'True'" :show-admin="'{{LNBITS_ADMIN_UI}}' == 'True'"
:show-users="'{{LNBITS_ADMIN_UI}}' == 'True'" :show-users="'{{LNBITS_ADMIN_UI}}' == 'True'"
:show-node="'{{LNBITS_NODE_UI}}' == 'True'" :show-node="'{{LNBITS_NODE_UI}}' == 'True'"
:show-extensions="'{{LNBITS_EXTENSIONS_DEACTIVATE_ALL}}' == 'False'" :show-extensions="'{{LNBITS_EXTENSIONS_DEACTIVATE_ALL}}' == 'False'"
></lnbits-manage> ></lnbits-manage>
<lnbits-extension-list class="q-pb-xl"></lnbits-extension-list> <lnbits-extension-list class="q-pb-xl"></lnbits-extension-list>
</q-drawer> </q-drawer>
{% endblock %} {% block page_container %} {% endblock %} {% block page_container %}
<q-page-container> <q-page-container>
<q-page class="q-px-md q-py-lg" :class="{'q-px-lg': $q.screen.gt.xs}"> <q-page class="q-px-md q-py-lg" :class="{'q-px-lg': $q.screen.gt.xs}">
{% block page %}{% endblock %} {% block page %}{% endblock %}
</q-page> </q-page>
</q-page-container> </q-page-container>
{% endblock %} {% block footer %} {% endblock %} {% block footer %}
<q-footer <q-footer
class="bg-transparent q-px-lg q-py-md" class="bg-transparent q-px-lg q-py-md"
:class="{'text-dark': !$q.dark.isActive}" :class="{'text-dark': !$q.dark.isActive}"
> >
<q-space class="q-py-lg lt-md"></q-space> <q-space class="q-py-lg lt-md"></q-space>
<q-toolbar class="gt-sm"> <q-toolbar class="gt-sm">
<q-toolbar-title class="text-caption"> <q-toolbar-title class="text-caption">
{{ SITE_TITLE }}, {{SITE_TAGLINE}} {{ SITE_TITLE }}, {{SITE_TAGLINE}}
<br /> <br />
<small <small
v-text="$t('lnbits_version') + ': {{LNBITS_VERSION}}'" v-text="$t('lnbits_version') + ': {{LNBITS_VERSION}}'"
></small> ></small>
</q-toolbar-title> </q-toolbar-title>
<q-space></q-space> <q-space></q-space>
<q-btn <q-btn
flat flat
dense dense
:color="($q.dark.isActive) ? 'white' : 'primary'" :color="($q.dark.isActive) ? 'white' : 'primary'"
type="a" type="a"
href="/docs" href="/docs"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
> >
<span v-text="$t('api_docs')"></span> <span v-text="$t('api_docs')"></span>
<q-tooltip <q-tooltip
><span v-text="$t('view_swagger_docs')"></span ><span v-text="$t('view_swagger_docs')"></span
></q-tooltip> ></q-tooltip>
</q-btn> </q-btn>
<q-btn <q-btn
flat flat
dense dense
:color="($q.dark.isActive) ? 'white' : 'primary'" :color="($q.dark.isActive) ? 'white' : 'primary'"
icon="code" icon="code"
type="a" type="a"
href="https://github.com/lnbits/lnbits" href="https://github.com/lnbits/lnbits"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
> >
<q-tooltip><span v-text="$t('view_github')"></span></q-tooltip> <q-tooltip><span v-text="$t('view_github')"></span></q-tooltip>
</q-btn> </q-btn>
</q-toolbar> </q-toolbar>
</q-footer> </q-footer>
{% endblock %} {% endblock %}
</q-layout> </q-layout>
</div>
{% block vue_templates %}{% endblock %} {% block vue_templates %}{% endblock %}
<!----> <!---->
@@ -258,6 +260,8 @@
{ value: 'fi', label: 'Suomi', display: '🇫🇮 FI' } { value: 'fi', label: 'Suomi', display: '🇫🇮 FI' }
] ]
</script> </script>
{% block scripts %}{% endblock %} {% block scripts %}{% endblock %} {% for url in INCLUDED_COMPONENTS %}
<script src="{{ static_url_for('static', url) }}"></script>
{% endfor %}
</body> </body>
</html> </html>
+4 -4
View File
@@ -74,10 +74,10 @@ def configure_logger() -> None:
logging.getLogger("uvicorn.error").propagate = False logging.getLogger("uvicorn.error").propagate = False
logging.getLogger("sqlalchemy").handlers = [InterceptHandler()] logging.getLogger("sqlalchemy").handlers = [InterceptHandler()]
logging.getLogger("sqlalchemy.engine.base").handlers = [InterceptHandler()] logging.getLogger("sqlalchemy.engine").handlers = [InterceptHandler()]
logging.getLogger("sqlalchemy.engine.base").propagate = False logging.getLogger("sqlalchemy.engine").propagate = False
logging.getLogger("sqlalchemy.engine.base.Engine").handlers = [InterceptHandler()] logging.getLogger("sqlalchemy.engine.Engine").handlers = [InterceptHandler()]
logging.getLogger("sqlalchemy.engine.base.Engine").propagate = False logging.getLogger("sqlalchemy.engine.Engine").propagate = False
class Formatter: class Formatter:
+408 -417
View File
File diff suppressed because it is too large Load Diff
+37 -35
View File
@@ -6,45 +6,46 @@
"vendor_json": "node -e \"require('fs').writeFileSync('./lnbits/static/vendor.json', JSON.stringify(require('./package.json').bundle))\"", "vendor_json": "node -e \"require('fs').writeFileSync('./lnbits/static/vendor.json', JSON.stringify(require('./package.json').bundle))\"",
"vendor_bundle_css": "node -e \"require('concat')(require('./package.json').bundle.css.map(a => 'lnbits/static/'+a), './lnbits/static/bundle.css')\"", "vendor_bundle_css": "node -e \"require('concat')(require('./package.json').bundle.css.map(a => 'lnbits/static/'+a), './lnbits/static/bundle.css')\"",
"vendor_bundle_js": "node -e \"require('concat')(require('./package.json').bundle.js.map(a => 'lnbits/static/'+a),'./lnbits/static/bundle.js')\"", "vendor_bundle_js": "node -e \"require('concat')(require('./package.json').bundle.js.map(a => 'lnbits/static/'+a),'./lnbits/static/bundle.js')\"",
"vendor_bundle_components": "node -e \"require('concat')(require('./package.json').bundle.components.map(a => 'lnbits/static/'+a), './lnbits/static/bundle-components.js')\"",
"vendor_minify_css": "./node_modules/.bin/minify ./lnbits/static/bundle.css > ./lnbits/static/bundle.min.css", "vendor_minify_css": "./node_modules/.bin/minify ./lnbits/static/bundle.css > ./lnbits/static/bundle.min.css",
"vendor_minify_js": "./node_modules/.bin/minify ./lnbits/static/bundle.js > ./lnbits/static/bundle.min.js" "vendor_minify_js": "./node_modules/.bin/minify ./lnbits/static/bundle.js > ./lnbits/static/bundle.min.js",
"vendor_minify_components": "./node_modules/.bin/minify ./lnbits/static/bundle-components.js > ./lnbits/static/bundle-components.min.js",
"bundle": "npm run sass && npm run vendor_copy && npm run vendor_json && npm run vendor_bundle_css && npm run vendor_bundle_js && npm run vendor_bundle_components && npm run vendor_minify_css && npm run vendor_minify_js && npm run vendor_minify_components"
}, },
"devDependencies": { "devDependencies": {
"concat": "^1.0.3", "concat": "^1.0.3",
"minify": "^9.2.0", "minify": "^9.2.0",
"prettier": "^3.3.3", "prettier": "^3.3.3",
"pyright": "1.1.289", "pyright": "1.1.289",
"sass": "^1.60.0" "sass": "^1.78.0"
}, },
"dependencies": { "dependencies": {
"@chenfengyuan/vue-qrcode": "1.0.2", "axios": "^1.7.7",
"axios": "^1.7.5", "chart.js": "^4.4.4",
"chart.js": "^2.9.4",
"moment": "^2.30.1", "moment": "^2.30.1",
"quasar": "1.13.2", "qrcode.vue": "^3.4.1",
"quasar": "2.16.10",
"showdown": "^2.1.0", "showdown": "^2.1.0",
"underscore": "^1.13.6", "underscore": "^1.13.7",
"vue": "2.6.12", "vue": "3.5.2",
"vue-i18n": "^8.28.2", "vue-i18n": "^9.14.0",
"vue-qrcode-reader": "^2.3.18", "vue-qrcode-reader": "^5.5.7",
"vue-router": "3.4.3", "vue-router": "4.4.3",
"vuex": "3.5.1" "vuex": "4.1.0"
}, },
"vendor": [ "vendor": [
"./node_modules/moment/moment.js", "./node_modules/moment/moment.js",
"./node_modules/underscore/underscore.js", "./node_modules/underscore/underscore.js",
"./node_modules/axios/dist/axios.js", "./node_modules/axios/dist/axios.js",
"./node_modules/vue/dist/vue.js", "./node_modules/vue/dist/vue.global.prod.js",
"./node_modules/vue-router/dist/vue-router.js", "./node_modules/quasar/dist/quasar.umd.prod.js",
"./node_modules/vue-qrcode-reader/dist/VueQrcodeReader.umd.js", "./node_modules/vuex/dist/vuex.global.js",
"./node_modules/@chenfengyuan/vue-qrcode/dist/vue-qrcode.js", "./node_modules/vue-i18n/dist/vue-i18n.global.prod.js",
"./node_modules/vuex/dist/vuex.js", "./node_modules/vue-router/dist/vue-router.global.js",
"./node_modules/quasar/dist/quasar.ie.polyfills.umd.min.js", "./node_modules/vue-qrcode-reader/dist/vue-qrcode-reader.umd.js",
"./node_modules/quasar/dist/quasar.umd.js", "./node_modules/qrcode.vue/dist/qrcode.vue.browser.js",
"./node_modules/chart.js/dist/Chart.bundle.js", "./node_modules/chart.js/dist/chart.umd.js",
"./node_modules/quasar/dist/quasar.css", "./node_modules/quasar/dist/quasar.css",
"./node_modules/chart.js/dist/Chart.css",
"./node_modules/vue-i18n/dist/vue-i18n.js",
"./node_modules/showdown/dist/showdown.js" "./node_modules/showdown/dist/showdown.js"
], ],
"bundle": { "bundle": {
@@ -52,15 +53,14 @@
"vendor/moment.js", "vendor/moment.js",
"vendor/underscore.js", "vendor/underscore.js",
"vendor/axios.js", "vendor/axios.js",
"vendor/vue.js", "vendor/vue.global.prod.js",
"vendor/vue-router.js", "vendor/quasar.umd.prod.js",
"vendor/VueQrcodeReader.umd.js", "vendor/vuex.global.js",
"vendor/vue-qrcode.js", "vendor/vue-i18n.global.prod.js",
"vendor/vuex.js", "vendor/vue-router.global.js",
"vendor/quasar.ie.polyfills.umd.min.js", "vendor/vue-qrcode-reader.umd.js",
"vendor/quasar.umd.js", "vendor/qrcode.vue.browser.js",
"vendor/Chart.bundle.js", "vendor/chart.umd.js",
"vendor/vue-i18n.js",
"vendor/showdown.js", "vendor/showdown.js",
"i18n/i18n.js", "i18n/i18n.js",
"i18n/de.js", "i18n/de.js",
@@ -83,18 +83,20 @@
"i18n/kr.js", "i18n/kr.js",
"i18n/fi.js", "i18n/fi.js",
"js/base.js", "js/base.js",
"js/components.js", "js/event-reactions.js",
"js/bolt11-decoder.js"
],
"components": [
"js/components/lnbits-funding-sources.js", "js/components/lnbits-funding-sources.js",
"js/components/extension-settings.js", "js/components/extension-settings.js",
"js/components/extension-rating.js", "js/components/extension-rating.js",
"js/components/payment-list.js", "js/components/payment-list.js",
"js/components/payment-chart.js", "js/components/payment-chart.js",
"js/event-reactions.js", "js/components.js",
"js/bolt11-decoder.js" "js/init-app.js"
], ],
"css": [ "css": [
"vendor/quasar.css", "vendor/quasar.css",
"vendor/Chart.css",
"css/base.css" "css/base.css"
] ]
} }
Generated
+531 -466
View File
File diff suppressed because it is too large Load Diff
+13 -15
View File
@@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "lnbits" name = "lnbits"
version = "0.12.11" version = "1.0.0-rc2"
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>"]
readme = "README.md" readme = "README.md"
@@ -16,25 +16,25 @@ python = "^3.12 | ^3.11 | ^3.10 | ^3.9"
bech32 = "1.2.0" bech32 = "1.2.0"
click = "8.1.7" click = "8.1.7"
ecdsa = "0.19.0" ecdsa = "0.19.0"
fastapi = "0.112.0" fastapi = "0.113.0"
httpx = "0.27.0" httpx = "0.27.0"
jinja2 = "3.1.4" jinja2 = "3.1.4"
lnurl = "0.5.3" lnurl = "0.5.3"
psycopg2-binary = "2.9.9" pydantic = "1.10.18"
pydantic = "1.10.17"
pyqrcode = "1.2.1" pyqrcode = "1.2.1"
shortuuid = "1.0.13" shortuuid = "1.0.13"
sqlalchemy = "1.3.24"
sqlalchemy-aio = "0.17.0"
sse-starlette = "1.8.2" sse-starlette = "1.8.2"
typing-extensions = "4.12.2" typing-extensions = "4.12.2"
uvicorn = "0.30.5" uvicorn = "0.30.6"
sqlalchemy = "1.4.54"
aiosqlite = "0.20.0"
asyncpg = "0.29.0"
uvloop = "0.19.0" uvloop = "0.19.0"
websockets = "11.0.3" websockets = "11.0.3"
loguru = "0.7.2" loguru = "0.7.2"
grpcio = "1.65.5" grpcio = "1.66.1"
protobuf = "5.27.3" protobuf = "5.28.0"
pyln-client = "24.5" pyln-client = "24.8.1"
pywebpush = "1.14.1" pywebpush = "1.14.1"
slowapi = "0.1.9" slowapi = "0.1.9"
websocket-client = "1.8.0" websocket-client = "1.8.0"
@@ -70,11 +70,11 @@ black = "^24.8.0"
pytest-asyncio = "^0.21.2" pytest-asyncio = "^0.21.2"
pytest = "^8.3.2" pytest = "^8.3.2"
pytest-cov = "^4.1.0" pytest-cov = "^4.1.0"
mypy = "^1.11.1" mypy = "^1.11.2"
types-protobuf = "^5.27.0.20240626" types-protobuf = "^5.27.0.20240626"
pre-commit = "^3.8.0" pre-commit = "^3.8.0"
openapi-spec-validator = "^0.7.1" openapi-spec-validator = "^0.7.1"
ruff = "^0.5.7" ruff = "^0.6.4"
types-passlib = "^1.7.7.20240327" types-passlib = "^1.7.7.20240327"
openai = "^1.39.0" openai = "^1.39.0"
json5 = "^0.9.25" json5 = "^0.9.25"
@@ -84,7 +84,7 @@ pytest-httpserver = "^1.1.0"
pytest-mock = "^3.14.0" pytest-mock = "^3.14.0"
types-mock = "^5.1.0.20240425" types-mock = "^5.1.0.20240425"
mock = "^5.1.0" mock = "^5.1.0"
grpcio-tools = "^1.65.5" grpcio-tools = "^1.66.1"
[build-system] [build-system]
requires = ["poetry-core>=1.0.0"] requires = ["poetry-core>=1.0.0"]
@@ -126,7 +126,6 @@ module = [
"secp256k1.*", "secp256k1.*",
"uvicorn.*", "uvicorn.*",
"sqlalchemy.*", "sqlalchemy.*",
"sqlalchemy_aio.*",
"websocket.*", "websocket.*",
"websockets.*", "websockets.*",
"pyqrcode.*", "pyqrcode.*",
@@ -136,7 +135,6 @@ module = [
"bolt11.*", "bolt11.*",
"bitstring.*", "bitstring.*",
"ecdsa.*", "ecdsa.*",
"psycopg2.*",
"pyngrok.*", "pyngrok.*",
"pyln.client.*", "pyln.client.*",
"py_vapid.*", "py_vapid.*",
+4 -4
View File
@@ -367,11 +367,11 @@ async def test_get_payments_history(client, adminkey_headers_from, fake_payments
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
assert len(data) == 1 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( assert data[0]["income"] == sum(
payment.amount * 1000 for payment in fake_data if not payment.out [int(payment.amount * 1000) for payment in fake_data if not payment.out]
)
assert data[0]["spending"] == sum(
[int(payment.amount * 1000) for payment in fake_data if payment.out]
) )
response = await client.get( response = await client.get(
+3 -5
View File
@@ -25,7 +25,6 @@ from lnbits.core.views.payment_api import api_payments_create_invoice
from lnbits.db import DB_TYPE, SQLITE, Database from lnbits.db import DB_TYPE, SQLITE, Database
from lnbits.settings import settings from lnbits.settings import settings
from tests.helpers import ( from tests.helpers import (
clean_database,
get_random_invoice_data, get_random_invoice_data,
) )
@@ -47,7 +46,6 @@ 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()
async with LifespanManager(app) as manager: async with LifespanManager(app) as manager:
settings.first_install = False settings.first_install = False
@@ -199,9 +197,9 @@ async def fake_payments(client, adminkey_headers_from):
"/api/v1/payments", headers=adminkey_headers_from, json=invoice.dict() "/api/v1/payments", headers=adminkey_headers_from, json=invoice.dict()
) )
assert response.is_success assert response.is_success
await update_payment_status( data = response.json()
response.json()["checking_id"], status=PaymentState.SUCCESS assert data["checking_id"]
) await update_payment_status(data["checking_id"], status=PaymentState.SUCCESS)
params = {"time[ge]": ts, "time[le]": time()} params = {"time[ge]": ts, "time[le]": time()}
return fake_data, params return fake_data, params
+1 -23
View File
@@ -2,11 +2,7 @@ import random
import string import string
from typing import Optional from typing import Optional
from psycopg2 import connect from lnbits.db import FromRowModel
from psycopg2.errors import InvalidCatalogName
from lnbits import core
from lnbits.db import DB_TYPE, POSTGRES, FromRowModel
from lnbits.wallets import get_funding_source, set_funding_source from lnbits.wallets import get_funding_source, set_funding_source
@@ -35,21 +31,3 @@ set_funding_source()
funding_source = get_funding_source() funding_source = get_funding_source()
is_fake: bool = funding_source.__class__.__name__ == "FakeWallet" is_fake: bool = funding_source.__class__.__name__ == "FakeWallet"
is_regtest: bool = not is_fake is_regtest: bool = not is_fake
def clean_database(settings):
if DB_TYPE == POSTGRES:
conn = connect(settings.lnbits_database_url)
conn.autocommit = True
with conn.cursor() as cur:
try:
cur.execute("DROP DATABASE lnbits_test")
except InvalidCatalogName:
pass
cur.execute("CREATE DATABASE lnbits_test")
core.db.__init__("database")
conn.close()
else:
# TODO: do this once mock data is removed from test data folder
# os.remove(settings.lnbits_data_folder + "/database.sqlite3")
pass
+2 -2
View File
@@ -14,8 +14,8 @@ from lnbits.db import POSTGRES
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_date_conversion(db): async def test_date_conversion(db):
if db.type == POSTGRES: if db.type == POSTGRES:
row = await db.fetchone("SELECT now()::date") row = await db.fetchone("SELECT now()::date as now")
assert row and isinstance(row[0], date) assert row and isinstance(row.get("now"), date)
# make test to create wallet and delete wallet # make test to create wallet and delete wallet
+9 -2
View File
@@ -12,10 +12,17 @@ test = DbTestModel(id=1, name="test", value="yes")
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_helpers_insert_query(): async def test_helpers_insert_query():
q = insert_query("test_helpers_query", test) q = insert_query("test_helpers_query", test)
assert q == "INSERT INTO test_helpers_query (id, name, value) VALUES (?, ?, ?)" assert (
q == "INSERT INTO test_helpers_query (id, name, value) "
"VALUES (:id, :name, :value)"
)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_helpers_update_query(): async def test_helpers_update_query():
q = update_query("test_helpers_query", test) q = update_query("test_helpers_query", test)
assert q == "UPDATE test_helpers_query SET id = ?, name = ?, value = ? WHERE id = ?" assert (
q == "UPDATE test_helpers_query "
"SET id = :id, name = :name, value = :value "
"WHERE id = :id"
)
+168
View File
@@ -0,0 +1,168 @@
import pytest
from lnbits.settings import RedirectPath
lnurlp_redirect_path = {
"from_path": "/.well-known/lnurlp",
"redirect_to_path": "/api/v1/well-known",
}
lnurlp_redirect_path_with_headers = {
"from_path": "/.well-known/lnurlp",
"redirect_to_path": "/api/v1/well-known",
"header_filters": {"accept": "application/nostr+json"},
}
lnaddress_redirect_path = {
"from_path": "/.well-known/lnurlp",
"redirect_to_path": "/api/v1/well-known",
}
nostrrelay_redirect_path = {
"from_path": "/",
"redirect_to_path": "/api/v1/relay-info",
"header_filters": {"accept": "application/nostr+json"},
}
@pytest.fixture()
def lnurlp():
return RedirectPath(ext_id="lnurlp", **lnurlp_redirect_path)
@pytest.fixture()
def lnurlp_with_headers():
return RedirectPath(
ext_id="lnurlp_with_headers", **lnurlp_redirect_path_with_headers
)
@pytest.fixture()
def lnaddress():
return RedirectPath(ext_id="lnaddress", **lnaddress_redirect_path)
@pytest.fixture()
def nostrrelay():
return RedirectPath(ext_id="nostrrelay", **nostrrelay_redirect_path)
def test_redirect_path_self_not_in_conflict(
lnurlp: RedirectPath, lnaddress: RedirectPath, nostrrelay: RedirectPath
):
assert not lnurlp.in_conflict(lnurlp), "Path is not in conflict with itself."
assert not lnaddress.in_conflict(lnaddress), "Path is not in conflict with itself."
assert not nostrrelay.in_conflict(
nostrrelay
), "Path is not in conflict with itself."
assert not lnurlp.in_conflict(nostrrelay)
assert not nostrrelay.in_conflict(lnurlp)
def test_redirect_path_not_in_conflict(
lnurlp: RedirectPath, lnaddress: RedirectPath, nostrrelay: RedirectPath
):
assert not lnurlp.in_conflict(nostrrelay)
assert not nostrrelay.in_conflict(lnurlp)
assert not lnaddress.in_conflict(nostrrelay)
assert not nostrrelay.in_conflict(lnaddress)
def test_redirect_path_in_conflict(lnurlp: RedirectPath, lnaddress: RedirectPath):
assert lnurlp.in_conflict(lnaddress)
assert lnaddress.in_conflict(lnurlp)
def test_redirect_path_find_conflict(
lnurlp: RedirectPath, lnaddress: RedirectPath, nostrrelay: RedirectPath
):
assert lnurlp.find_in_conflict([nostrrelay, lnaddress])
assert lnurlp.find_in_conflict([lnaddress, nostrrelay])
assert lnaddress.find_in_conflict([nostrrelay, lnurlp])
assert lnaddress.find_in_conflict([lnurlp, nostrrelay])
def test_redirect_path_find_no_conflict(
lnurlp: RedirectPath, lnaddress: RedirectPath, nostrrelay: RedirectPath
):
assert not nostrrelay.find_in_conflict([lnurlp, lnaddress])
assert not lnurlp.find_in_conflict([nostrrelay])
assert not lnaddress.find_in_conflict([nostrrelay])
def test_redirect_path_in_conflict_with_headers(
lnurlp: RedirectPath, lnurlp_with_headers: RedirectPath
):
assert lnurlp.in_conflict(lnurlp_with_headers)
assert lnurlp_with_headers.in_conflict(lnurlp)
def test_redirect_path_matches_with_headers(
lnurlp: RedirectPath, lnurlp_with_headers: RedirectPath
):
headers_list = list(lnurlp_with_headers.header_filters.items())
assert lnurlp.redirect_matches(
path=lnurlp_with_headers.from_path,
req_headers=headers_list,
)
assert lnurlp_with_headers.redirect_matches(
path=lnurlp_redirect_path["from_path"],
req_headers=[("ACCEPT", "APPlication/nostr+json")],
)
assert lnurlp_with_headers.redirect_matches(
path=lnurlp_redirect_path["from_path"],
req_headers=[("accept", "application/nostr+json"), ("my_header", "my_value")],
)
assert not lnurlp_with_headers.redirect_matches(
path=lnurlp_redirect_path["from_path"], req_headers=[]
)
assert not lnurlp_with_headers.redirect_matches(
path=lnurlp_redirect_path["from_path"],
req_headers=[("accept", "application/json")],
)
assert not lnurlp_with_headers.redirect_matches(path="/random/path", req_headers=[])
assert not lnurlp_with_headers.redirect_matches(path="/random_path", req_headers=[])
assert not lnurlp_with_headers.redirect_matches(
path="/.well-known/lnurlp", req_headers=[]
)
assert lnurlp.redirect_matches(path="/.well-known/lnurlp", req_headers=[])
assert lnurlp.redirect_matches(
path="/.well-known/lnurlp/some/other/path", req_headers=[]
)
assert lnurlp.redirect_matches(
path="/.well-known/lnurlp/some/other/path",
req_headers=headers_list,
)
assert not lnurlp_with_headers.redirect_matches(
path="/.well-known/lnurlp", req_headers=[]
)
assert not lnurlp_with_headers.redirect_matches(
path="/.well-known/lnurlp/some/other/path", req_headers=[]
)
assert lnurlp_with_headers.redirect_matches(
path="/.well-known/lnurlp/some/other/path",
req_headers=headers_list,
)
def test_redirect_path_new_path_from(lnurlp: RedirectPath):
assert lnurlp.new_path_from("") == "/lnurlp/api/v1/well-known"
assert lnurlp.new_path_from("/") == "/lnurlp/api/v1/well-known"
assert lnurlp.new_path_from("/path") == "/lnurlp/api/v1/well-known"
assert lnurlp.new_path_from("/path/more") == "/lnurlp/api/v1/well-known"
assert lnurlp.new_path_from("/.well-known/lnurlp") == "/lnurlp/api/v1/well-known"
assert (
lnurlp.new_path_from("/.well-known/lnurlp/path")
== "/lnurlp/api/v1/well-known/path"
)
assert (
lnurlp.new_path_from("/.well-known/lnurlp/path/more")
== "/lnurlp/api/v1/well-known/path/more"
)
+9 -5
View File
@@ -1,5 +1,5 @@
# Python script to migrate an LNbits SQLite DB to Postgres # Python script to migrate an LNbits SQLite DB to Postgres
# All credits to @Fritz446 for the awesome work # credits to @Fritz446 for the awesome work
# pip install psycopg2 OR psycopg2-binary # pip install psycopg2 OR psycopg2-binary
@@ -9,10 +9,14 @@ import sqlite3
import sys import sys
from typing import List, Optional from typing import List, Optional
import psycopg2
from lnbits.settings import settings from lnbits.settings import settings
try:
import psycopg2 # type: ignore
except ImportError:
print("Please install psycopg2")
sys.exit(1)
sqfolder = settings.lnbits_data_folder sqfolder = settings.lnbits_data_folder
db_url = settings.lnbits_database_url db_url = settings.lnbits_database_url
@@ -55,8 +59,8 @@ def check_db_versions(sqdb):
version = dbpost[key] version = dbpost[key]
if value != version: if value != version:
raise Exception( raise Exception(
f"sqlite database version ({value}) of {key} doesn't match postgres" f"sqlite database version ({value}) of {key} doesn't match "
f" database version {version}" f"postgres database version {version}"
) )
connection = postgres.connection connection = postgres.connection